@rowan-hiro/inkan 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/src/cli.js ADDED
@@ -0,0 +1,351 @@
1
+ // Argument parsing, printing, and exit codes. Nothing here holds business
2
+ // logic; that all lives in src/api.js. Exit codes: 0 success, 1 refusal or
3
+ // error.
4
+
5
+ import { parseArgs } from 'node:util';
6
+ import { readFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import path from 'node:path';
9
+ import * as api from './api.js';
10
+ import { InkanError } from './api.js';
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ const pkg = JSON.parse(readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
14
+
15
+ // Matches the current YYYY-MM-DD-HHMM-xxxx id form and the legacy YYYY-MM-DD-xxxx form.
16
+ const OUTCOME_ID_RE = /^\d{4}-\d{2}-\d{2}-(?:\d{4}-)?[0-9a-z]{4}$/;
17
+
18
+ const HELP = `Usage: inkan <command> [options]
19
+
20
+ Commands:
21
+ init [--lang <tag>] [--claude]
22
+ Create .inkan/ and write the agent protocol block into AGENTS.md.
23
+ --claude also links CLAUDE.md to AGENTS.md.
24
+ begin "<outcome>" [--accept <text>]... [--decision <id>]... [--lane <tag>]
25
+ Seal a new outcome; prints its id.
26
+ amend --reason <text> [<addition>] [--accept <text>]... [--withdraw <n>]...
27
+ [--decision <id>]... [<id>]
28
+ Append an amendment to the open outcome; prints the new contract hash.
29
+ end [<id>] [--met <n>]... [--unmet <n>]... [-s abandoned] --note <text>
30
+ Record dispositions and close an outcome.
31
+ status
32
+ Print every open outcome.
33
+ log [-n <count>] [--since <date>] [--grep <regex>] [--status <s>]
34
+ [--decision <id>] [--lane <tag>] [<id>]
35
+ Print the outcome log, newest first; <id> prints one outcome in full.
36
+ Filters combine.
37
+ check [<commit>]
38
+ Read-only. Reports whether a commit's Inkan-Outcome trailers still
39
+ match what was recorded. Exit 0 consistent, 1 mismatch, 2 no trailer.
40
+ doctor
41
+ Read-only. Reports corrupt outcomes, id mismatches, duplicate
42
+ decision ids, and dangling decision links. Exit 0 clean, 1 problems.
43
+ decision add "<title>" --context <text> --decision <text> [--driver <text>]...
44
+ [--option <text>]... [--consequence <text>]... [-s <status>]
45
+ Write a numbered MADR record; prints its file path.
46
+ decision show <id>
47
+ Print one decision record verbatim. <id> accepts 2, 02, or 0002.
48
+ decision list [-s <status>]
49
+ One line per record, ascending by id.
50
+ decision update <id> --status <status> --reason <text> [--outcome <id>]
51
+ Append a dated history entry and set the new status.
52
+ skill install [--claude | --target <dir>]
53
+ Copy the bundled use-inkan skill to .agents/skills/use-inkan, or to
54
+ .claude/skills/use-inkan with --claude, or to <dir>/use-inkan; prints
55
+ the path.
56
+
57
+ help, --help, -h show this help
58
+ --version, -v print the version
59
+
60
+ 'ink' is an alias for 'inkan'; both accept identical arguments.`;
61
+
62
+ function fail(message) {
63
+ process.stderr.write(`inkan: ${message}\n`);
64
+ process.exitCode = 1;
65
+ }
66
+
67
+ function splitAmendPositionals(positionals) {
68
+ let id;
69
+ let addition;
70
+ for (const p of positionals) {
71
+ if (OUTCOME_ID_RE.test(p) && id === undefined) {
72
+ id = p;
73
+ } else if (addition === undefined) {
74
+ addition = p;
75
+ } else {
76
+ throw new InkanError('usage: inkan amend --reason <text> [<addition>] [<id>]');
77
+ }
78
+ }
79
+ return { id, addition };
80
+ }
81
+
82
+ function printCriterionLine(c, dispositionByIndex) {
83
+ let line = ` ${c.index}. ${c.text}`;
84
+ if (c.withdrawn) {
85
+ line += ' (withdrawn)';
86
+ } else {
87
+ const d = dispositionByIndex.get(c.index);
88
+ if (d) line += d.met ? ' (met)' : ` (unmet${d.note ? `: ${d.note}` : ''})`;
89
+ }
90
+ console.log(line);
91
+ }
92
+
93
+ /** Shared body for `status` and `log <id>`: the open-outcome shape, plus the
94
+ * closed-only fields when the record has an end event. */
95
+ function printRecord(record) {
96
+ console.log(`[${record.id}] ${record.closed ? record.status : 'open'}`);
97
+ console.log(` sealed: ${record.sealedAt}`);
98
+ console.log(` hash: ${record.contractHash}`);
99
+ if (record.lane) console.log(` lane: ${record.lane}`);
100
+ console.log(` outcome: ${record.outcome}`);
101
+
102
+ const dispositionByIndex = new Map((record.dispositions ?? []).map((d) => [d.criterion, d]));
103
+ for (const c of record.criteria) printCriterionLine(c, dispositionByIndex);
104
+ for (const a of record.amendments) {
105
+ console.log(` amend ${a.ts}: ${a.reason}`);
106
+ if (a.addition) console.log(` ${a.addition}`);
107
+ }
108
+ if (record.decisionLinks.length > 0) {
109
+ const links = record.decisionLinks.map((d) => `${d.id} (${d.status ?? 'missing'})`);
110
+ console.log(` decisions: ${links.join(', ')}`);
111
+ }
112
+
113
+ if (record.closed) {
114
+ console.log(` closed: ${record.closedAt}`);
115
+ console.log(` status: ${record.status}`);
116
+ console.log(` note: ${record.note}`);
117
+ console.log(` tree: ${record.tree ?? 'none'}`);
118
+ console.log(` head: ${record.head ?? 'none'}`);
119
+ }
120
+ }
121
+
122
+ function printStatus(open) {
123
+ if (open.length === 0) {
124
+ console.log('no outcome open');
125
+ return;
126
+ }
127
+ for (const record of open) printRecord(record);
128
+ }
129
+
130
+ function printCheck(result) {
131
+ if (result.noTrailer) {
132
+ console.log(`${result.shortSha} no Inkan-Outcome trailer`);
133
+ process.exitCode = 2;
134
+ return;
135
+ }
136
+ for (const r of result.reports) {
137
+ console.log(`${result.shortSha} Inkan-Outcome: ${r.id}`);
138
+ for (const line of r.lines) console.log(` ${line}`);
139
+ }
140
+ if (result.consistent) {
141
+ console.log('consistent');
142
+ } else {
143
+ console.log('mismatch');
144
+ console.log('a mismatch is a fact about this commit; it is recorded, not repaired');
145
+ process.exitCode = 1;
146
+ }
147
+ }
148
+
149
+ function printDoctor(result) {
150
+ if (result.problems.length === 0) {
151
+ console.log(`ok: ${result.outcomeCount} outcomes, ${result.decisionCount} decisions`);
152
+ return;
153
+ }
154
+ for (const p of result.problems) console.log(p);
155
+ process.exitCode = 1;
156
+ }
157
+
158
+ function printLogLine(record) {
159
+ const parts = [record.id, record.closed ? record.status : 'open'];
160
+ if (record.lane) parts.push(`[${record.lane}]`);
161
+ parts.push(record.outcome);
162
+ if (record.closed && record.status !== 'abandoned') {
163
+ const live = record.criteria.filter((c) => !c.withdrawn).length;
164
+ const met = (record.dispositions ?? []).filter((d) => d.met).length;
165
+ parts.push(`(${met}/${live} met)`);
166
+ }
167
+ console.log(parts.join(' '));
168
+ }
169
+
170
+ const DECISION_ADD_USAGE =
171
+ 'usage: inkan decision add "<title>" --context <text> --decision <text> ' +
172
+ '[--driver <text>]... [--option <text>]... [--consequence <text>]... [-s <status>]';
173
+
174
+ function runDecision(root, rest) {
175
+ const [sub, ...subRest] = rest;
176
+ switch (sub) {
177
+ case 'add': {
178
+ const opts = {
179
+ context: { type: 'string' },
180
+ decision: { type: 'string' },
181
+ driver: { type: 'string', multiple: true, default: [] },
182
+ option: { type: 'string', multiple: true, default: [] },
183
+ consequence: { type: 'string', multiple: true, default: [] },
184
+ status: { type: 'string', short: 's' },
185
+ };
186
+ const { values, positionals } = parseArgs({ args: subRest, options: opts, allowPositionals: true });
187
+ if (positionals.length !== 1) throw new InkanError(DECISION_ADD_USAGE);
188
+ console.log(api.decisionAdd({ root, title: positionals[0], ...values }).file);
189
+ break;
190
+ }
191
+ case 'show': {
192
+ const { positionals } = parseArgs({ args: subRest, allowPositionals: true });
193
+ if (positionals.length !== 1) throw new InkanError('usage: inkan decision show <id>');
194
+ process.stdout.write(api.decisionShow({ root, id: positionals[0] }).content);
195
+ break;
196
+ }
197
+ case 'list': {
198
+ const { values } = parseArgs({ args: subRest, options: { status: { type: 'string', short: 's' } } });
199
+ for (const r of api.decisionList({ root, status: values.status }).records) {
200
+ console.log(`${r.id} ${r.status} ${r.title}`);
201
+ }
202
+ break;
203
+ }
204
+ case 'update': {
205
+ const opts = { status: { type: 'string' }, reason: { type: 'string' }, outcome: { type: 'string' } };
206
+ const { values, positionals } = parseArgs({ args: subRest, options: opts, allowPositionals: true });
207
+ if (positionals.length !== 1) {
208
+ throw new InkanError('usage: inkan decision update <id> --status <status> --reason <text> [--outcome <id>]');
209
+ }
210
+ const result = api.decisionUpdate({ root, id: positionals[0], ...values });
211
+ console.log(`${result.id} ${result.from} -> ${result.to}`);
212
+ break;
213
+ }
214
+ default:
215
+ throw new InkanError(`unknown decision subcommand "${sub}"`);
216
+ }
217
+ }
218
+
219
+ function run(argv) {
220
+ const [command, ...rest] = argv;
221
+
222
+ if (command === undefined || command === 'help' || command === '--help' || command === '-h') {
223
+ console.log(HELP);
224
+ return;
225
+ }
226
+ if (command === '--version' || command === '-v') {
227
+ console.log(pkg.version);
228
+ return;
229
+ }
230
+
231
+ const root = process.cwd();
232
+
233
+ try {
234
+ switch (command) {
235
+ case 'init': {
236
+ const { values } = parseArgs({
237
+ args: rest,
238
+ options: { lang: { type: 'string' }, claude: { type: 'boolean', default: false } },
239
+ });
240
+ const result = api.init({ root, lang: values.lang, claude: values.claude });
241
+ const verb = result.changed ? 'Initialized' : 'Already initialized';
242
+ console.log(`${verb} Inkan in ${result.root}`);
243
+ if (result.claudeFile) console.log('CLAUDE.md -> AGENTS.md');
244
+ break;
245
+ }
246
+ case 'begin': {
247
+ const opts = {
248
+ accept: { type: 'string', multiple: true, default: [] },
249
+ decision: { type: 'string', multiple: true, default: [] },
250
+ lane: { type: 'string' },
251
+ };
252
+ const { values, positionals } = parseArgs({ args: rest, options: opts, allowPositionals: true });
253
+ if (positionals.length !== 1) throw new InkanError('usage: inkan begin "<outcome>" [--accept <text>]...');
254
+ const result = api.begin({ root, outcome: positionals[0], ...values });
255
+ // Other open outcomes belong to whoever began them; they are named,
256
+ // never touched (decision 0013).
257
+ for (const other of result.openAlongside) {
258
+ process.stderr.write(`inkan: also open: ${other.id} ${other.outcome}\n`);
259
+ }
260
+ console.log(result.id);
261
+ break;
262
+ }
263
+ case 'amend': {
264
+ const opts = {
265
+ reason: { type: 'string' },
266
+ accept: { type: 'string', multiple: true, default: [] },
267
+ withdraw: { type: 'string', multiple: true, default: [] },
268
+ decision: { type: 'string', multiple: true, default: [] },
269
+ };
270
+ const { values, positionals } = parseArgs({ args: rest, options: opts, allowPositionals: true });
271
+ const { id, addition } = splitAmendPositionals(positionals);
272
+ const result = api.amend({ root, id, addition, ...values });
273
+ console.log(result.contractHash);
274
+ break;
275
+ }
276
+ case 'end': {
277
+ const opts = {
278
+ met: { type: 'string', multiple: true, default: [] },
279
+ unmet: { type: 'string', multiple: true, default: [] },
280
+ status: { type: 'string', short: 's' },
281
+ note: { type: 'string' },
282
+ };
283
+ const { values, positionals } = parseArgs({ args: rest, options: opts, allowPositionals: true });
284
+ if (positionals.length > 1) {
285
+ throw new InkanError('usage: inkan end [<id>] [--met <n>]... [--unmet <n>]... [-s abandoned] --note <text>');
286
+ }
287
+ const result = api.end({ root, id: positionals[0], ...values });
288
+ console.log(`${result.id} ${result.status}`);
289
+ console.log(`Inkan-Outcome: ${result.id}`);
290
+ break;
291
+ }
292
+ case 'status': {
293
+ parseArgs({ args: rest });
294
+ printStatus(api.status({ root }).open);
295
+ break;
296
+ }
297
+ case 'log': {
298
+ const opts = {
299
+ n: { type: 'string', short: 'n' },
300
+ lane: { type: 'string' },
301
+ since: { type: 'string' },
302
+ grep: { type: 'string' },
303
+ status: { type: 'string' },
304
+ decision: { type: 'string' },
305
+ };
306
+ const { values, positionals } = parseArgs({ args: rest, options: opts, allowPositionals: true });
307
+ if (positionals.length > 1) {
308
+ throw new InkanError(
309
+ 'usage: inkan log [-n <count>] [--since <date>] [--grep <regex>] [--status <s>] [--decision <id>] [--lane <tag>] [<id>]'
310
+ );
311
+ }
312
+ const n = values.n !== undefined ? Number(values.n) : undefined;
313
+ const result = api.log({ root, ...values, n, id: positionals[0] });
314
+ if (result.record) printRecord(result.record);
315
+ else for (const record of result.records) printLogLine(record);
316
+ break;
317
+ }
318
+ case 'check': {
319
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true });
320
+ if (positionals.length > 1) throw new InkanError('usage: inkan check [<commit>]');
321
+ printCheck(api.check({ root, commit: positionals[0] }));
322
+ break;
323
+ }
324
+ case 'doctor': {
325
+ parseArgs({ args: rest });
326
+ printDoctor(api.doctor({ root }));
327
+ break;
328
+ }
329
+ case 'decision': {
330
+ runDecision(root, rest);
331
+ break;
332
+ }
333
+ case 'skill': {
334
+ const [sub, ...subRest] = rest;
335
+ if (sub !== 'install') throw new InkanError(`unknown skill subcommand "${sub}"`);
336
+ const { values } = parseArgs({
337
+ args: subRest,
338
+ options: { target: { type: 'string' }, claude: { type: 'boolean', default: false } },
339
+ });
340
+ console.log(api.skillInstall({ root, target: values.target, claude: values.claude }).dest);
341
+ break;
342
+ }
343
+ default:
344
+ fail(`unknown command "${command}"`);
345
+ }
346
+ } catch (err) {
347
+ fail(err instanceof InkanError ? err.message : (err.message ?? String(err)));
348
+ }
349
+ }
350
+
351
+ run(process.argv.slice(2));
@@ -0,0 +1,169 @@
1
+ // MADR decision records under .inkan/decisions/NNNN-slug.md. `render` and
2
+ // `parse` are exact inverses for a well-formed file: every section is raw
3
+ // text, so reassembling them reproduces the original bytes. `appendHistory`
4
+ // is the only writer once a file exists, touching only Status and History.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import { decisionsDir } from './store.js';
10
+
11
+ export const STATUSES = ['proposed', 'accepted', 'rejected', 'deferred', 'deprecated', 'superseded'];
12
+
13
+ const HEADINGS = [
14
+ ['status', 'Status'],
15
+ ['context', 'Context and Problem Statement'],
16
+ ['drivers', 'Decision Drivers'],
17
+ ['options', 'Considered Options'],
18
+ ['outcome', 'Decision Outcome'],
19
+ ['consequences', 'Consequences'],
20
+ ['history', 'Decision History'],
21
+ ];
22
+
23
+ function capitalizeStatus(status) {
24
+ return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
25
+ }
26
+
27
+ /** Locate every `## Heading` line, in file order, with the offset where its body starts. */
28
+ function findHeadings(content) {
29
+ const headingRe = /^## (.+?)\r?\n/gm;
30
+ const found = [];
31
+ let m;
32
+ while ((m = headingRe.exec(content)) !== null) {
33
+ found.push({ name: m[1], start: m.index, bodyStart: m.index + m[0].length });
34
+ }
35
+ return found;
36
+ }
37
+
38
+ /** Strip the blank-line padding a heading's body is wrapped in. */
39
+ function unwrap(raw) {
40
+ let s = raw;
41
+ if (s.startsWith('\n')) s = s.slice(1);
42
+ if (s.endsWith('\n\n')) s = s.slice(0, -2);
43
+ else if (s.endsWith('\n')) s = s.slice(0, -1);
44
+ return s;
45
+ }
46
+
47
+ /** Parse one MADR record into `{ id, title, date, status, file, sections }`
48
+ * (`sections` holds each heading's raw text). Throws naming `file` when a
49
+ * heading is missing or the status token is unknown. */
50
+ export function parse(content, file) {
51
+ const h1 = content.match(/^# (\d+)\.\s+(.+?)\r?\n/);
52
+ if (!h1) throw new Error(`${file}: missing "# N. Title" heading`);
53
+ const title = h1[2];
54
+ const id = h1[1].padStart(4, '0');
55
+ const dateMatch = content.match(/^Date:\s*(\S+)\s*$/m);
56
+ if (!dateMatch) throw new Error(`${file}: missing "Date: YYYY-MM-DD" line`);
57
+ const date = dateMatch[1];
58
+ const found = findHeadings(content);
59
+ const sections = {};
60
+ let status;
61
+ for (let i = 0; i < HEADINGS.length; i++) {
62
+ const [key, name] = HEADINGS[i];
63
+ const entry = found[i];
64
+ if (!entry || entry.name !== name) {
65
+ throw new Error(`${file}: missing required heading "## ${name}"`);
66
+ }
67
+ const bodyEnd = i + 1 < found.length ? found[i + 1].start : content.length;
68
+ const body = unwrap(content.slice(entry.bodyStart, bodyEnd));
69
+ if (key === 'status') {
70
+ status = body.trim().toLowerCase();
71
+ if (!STATUSES.includes(status)) throw new Error(`${file}: unknown status "${body.trim()}"`);
72
+ } else {
73
+ sections[key] = body;
74
+ }
75
+ }
76
+
77
+ return { id, title, date, status, file, sections };
78
+ }
79
+
80
+ function wrap(raw, last) {
81
+ if (raw === '') return '';
82
+ return last ? `\n${raw}\n` : `\n${raw}\n\n`;
83
+ }
84
+
85
+ /** `* item1\n* item2...`, or `raw` itself if it is already a string (as `parse` returns it). */
86
+ function bullets(raw) {
87
+ return Array.isArray(raw) ? raw.map((item) => `* ${item}`).join('\n') : raw ?? '';
88
+ }
89
+
90
+ /** Render `{ id, title, date, status, sections }` back into MADR text. Each
91
+ * `sections` value is the raw per-heading text, or an array of bullet items. */
92
+ export function render({ id, title, date, status, sections = {} }) {
93
+ let out = `# ${Number(id)}. ${title}\n\nDate: ${date}\n\n## Status\n${wrap(capitalizeStatus(status))}`;
94
+ for (const [key, name] of HEADINGS.slice(1)) {
95
+ out += `## ${name}\n${wrap(bullets(sections[key]), key === 'history')}`;
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** Every decision record under `.inkan/decisions/`, parsed and sorted by id. */
101
+ export function list(root) {
102
+ const dir = decisionsDir(root);
103
+ if (!fs.existsSync(dir)) return [];
104
+ return fs
105
+ .readdirSync(dir)
106
+ .filter((name) => name.endsWith('.md'))
107
+ .map((name) => {
108
+ const file = path.join(dir, name);
109
+ return parse(fs.readFileSync(file, 'utf8'), file);
110
+ })
111
+ .sort((a, b) => (a.id < b.id ? -1 : 1));
112
+ }
113
+
114
+ /** One more than the highest decision id present, as a zero-padded string. */
115
+ export function nextId(root) {
116
+ const max = list(root).reduce((acc, r) => Math.max(acc, Number(r.id)), 0);
117
+ return String(max + 1).padStart(4, '0');
118
+ }
119
+
120
+ /** Lowercase, non-alphanumeric runs collapsed to one hyphen, trimmed, capped at 80 chars. */
121
+ export function slugify(title) {
122
+ const slug = title
123
+ .toLowerCase()
124
+ .replace(/[^a-z0-9]+/g, '-')
125
+ .replace(/^-+|-+$/g, '');
126
+ return slug.slice(0, 80).replace(/-+$/, '');
127
+ }
128
+
129
+ function atomicWrite(file, content) {
130
+ const dir = path.dirname(file);
131
+ const temp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
132
+ const fd = fs.openSync(temp, 'w', 0o644);
133
+ try {
134
+ fs.writeFileSync(fd, content, 'utf8');
135
+ fs.fsyncSync(fd);
136
+ } finally {
137
+ fs.closeSync(fd);
138
+ }
139
+ fs.renameSync(temp, file);
140
+ }
141
+
142
+ /** Append a dated history entry and set the new status; every other byte in
143
+ * the file is preserved. `outcomeId` names the heading when given, else the
144
+ * heading is a bare `### <ts>`. Returns the previous status. */
145
+ export function appendHistory(file, { ts, outcomeId, to, reason }) {
146
+ const content = fs.readFileSync(file, 'utf8');
147
+ const before = parse(content, file); // validates shape and the new status token below
148
+ if (!STATUSES.includes(to)) throw new Error(`${file}: unknown status "${to}"`);
149
+ const found = findHeadings(content);
150
+ const statusEntry = found[0];
151
+ const historyEntry = found[found.length - 1];
152
+ const statusBodyEnd = found[1] ? found[1].start : content.length;
153
+ const statusRaw = content.slice(statusEntry.bodyStart, statusBodyEnd);
154
+ const token = statusRaw.match(/\S+/);
155
+ const newStatusRaw =
156
+ statusRaw.slice(0, token.index) + capitalizeStatus(to) + statusRaw.slice(token.index + token[0].length);
157
+ const historyRaw = content.slice(historyEntry.bodyStart);
158
+ const heading = outcomeId ? `### ${ts}, outcome ${outcomeId}` : `### ${ts}`;
159
+ const newHistoryRaw = `${historyRaw}\n${heading}\n\nStatus: ${before.status} -> ${to}\n\n${reason}\n`;
160
+
161
+ const newContent =
162
+ content.slice(0, statusEntry.bodyStart) +
163
+ newStatusRaw +
164
+ content.slice(statusBodyEnd, historyEntry.bodyStart) +
165
+ newHistoryRaw;
166
+
167
+ atomicWrite(file, newContent);
168
+ return before.status;
169
+ }
package/src/fold.js ADDED
@@ -0,0 +1,173 @@
1
+ // Event validation and the fold of one outcome's events into a record.
2
+ // A file that violates the rules throws, naming the file, the line, and the
3
+ // rule. Never repaired, here or anywhere else.
4
+
5
+ import crypto from 'node:crypto';
6
+
7
+ const EVENT_TYPES = new Set(['begin', 'amend', 'end']);
8
+ const STATUSES = new Set(['completed', 'partial', 'abandoned']);
9
+
10
+ function corrupt(where, message) {
11
+ return new Error(`${where}: ${message}`);
12
+ }
13
+
14
+ /** Deterministic JSON with sorted object keys, for hashing. */
15
+ export function canonicalJSON(value) {
16
+ if (Array.isArray(value)) return `[${value.map(canonicalJSON).join(',')}]`;
17
+ if (value !== null && typeof value === 'object') {
18
+ const keys = Object.keys(value).sort();
19
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJSON(value[k])}`).join(',')}}`;
20
+ }
21
+ return JSON.stringify(value);
22
+ }
23
+
24
+ /**
25
+ * sha256 over canonical JSON of { outcome, criteria with withdrawn flags,
26
+ * decisions, amendments as [reason, addition] }. The lane tag is excluded on
27
+ * purpose: it is a filing label, not part of what was promised.
28
+ */
29
+ export function computeContractHash({ outcome, criteria, decisions, amendments }) {
30
+ const payload = {
31
+ outcome,
32
+ criteria: criteria.map((c) => ({ text: c.text, withdrawn: c.withdrawn })),
33
+ decisions,
34
+ amendments: amendments.map((a) => [a.reason, a.addition ?? null]),
35
+ };
36
+ return crypto.createHash('sha256').update(canonicalJSON(payload)).digest('hex');
37
+ }
38
+
39
+ function positiveInteger(value) {
40
+ const n = Number(value);
41
+ return Number.isInteger(n) && n >= 1 ? n : null;
42
+ }
43
+
44
+ /**
45
+ * Fold one outcome's events into a plain record. `file` is used only to
46
+ * label errors. Throws on any violation of the rules in decision 0005: `begin`
47
+ * first and unique; `amend`/`end` only while open; exactly one `end`;
48
+ * `completed` requires every live criterion met; `partial` requires every
49
+ * live criterion to have a disposition and at least one unmet; `abandoned`
50
+ * requires a note and needs no dispositions.
51
+ */
52
+ export function fold(events, file) {
53
+ if (!Array.isArray(events) || events.length === 0) throw corrupt(file, 'no events');
54
+
55
+ const record = {
56
+ id: null,
57
+ outcome: null,
58
+ lane: null,
59
+ decisions: [],
60
+ criteria: [],
61
+ amendments: [],
62
+ sealedAt: null,
63
+ beginHead: null,
64
+ closed: false,
65
+ };
66
+ let began = false;
67
+ let closed = false;
68
+
69
+ events.forEach((event, i) => {
70
+ const where = `${file}:${i + 1}`;
71
+ if (event === null || typeof event !== 'object') throw corrupt(where, 'event is not an object');
72
+ if (event.v !== 1) throw corrupt(where, `unsupported event version ${event.v}`);
73
+ if (!EVENT_TYPES.has(event.type)) throw corrupt(where, `unknown event type "${event.type}"`);
74
+
75
+ if (event.type === 'begin') {
76
+ if (i !== 0 || began) throw corrupt(where, 'begin must be first and unique');
77
+ began = true;
78
+ if (!event.outcome || typeof event.outcome !== 'string') {
79
+ throw corrupt(where, 'begin missing an outcome');
80
+ }
81
+ record.id = event.id;
82
+ record.outcome = event.outcome;
83
+ record.lane = event.lane ?? null;
84
+ record.sealedAt = event.ts;
85
+ record.beginHead = event.head ?? null;
86
+ for (const text of event.criteria ?? []) {
87
+ record.criteria.push({ index: record.criteria.length + 1, text, withdrawn: false });
88
+ }
89
+ for (const d of event.decisions ?? []) {
90
+ if (!record.decisions.includes(d)) record.decisions.push(d);
91
+ }
92
+ return;
93
+ }
94
+
95
+ if (!began) throw corrupt(where, 'begin must be first and unique');
96
+ if (closed) throw corrupt(where, `${event.type} after outcome closed`);
97
+
98
+ if (event.type === 'amend') {
99
+ if (!event.reason || typeof event.reason !== 'string') throw corrupt(where, 'amend missing reason');
100
+ for (const raw of event.withdraw ?? []) {
101
+ const n = positiveInteger(raw);
102
+ const criterion = n === null ? null : record.criteria[n - 1];
103
+ if (!criterion || criterion.withdrawn) {
104
+ throw corrupt(where, `withdraw of unknown or already-withdrawn criterion ${raw}`);
105
+ }
106
+ criterion.withdrawn = true;
107
+ }
108
+ for (const text of event.criteria ?? []) {
109
+ record.criteria.push({ index: record.criteria.length + 1, text, withdrawn: false });
110
+ }
111
+ for (const d of event.decisions ?? []) {
112
+ if (!record.decisions.includes(d)) record.decisions.push(d);
113
+ }
114
+ record.amendments.push({
115
+ ts: event.ts,
116
+ reason: event.reason,
117
+ addition: event.addition ?? null,
118
+ head: event.head ?? null,
119
+ });
120
+ return;
121
+ }
122
+
123
+ // event.type === 'end'
124
+ if (!STATUSES.has(event.status)) throw corrupt(where, `unknown status "${event.status}"`);
125
+ if (!event.note || typeof event.note !== 'string') throw corrupt(where, 'end missing note');
126
+
127
+ const seen = new Set();
128
+ const dispositions = [];
129
+ for (const d of event.dispositions ?? []) {
130
+ const n = positiveInteger(d?.criterion);
131
+ const criterion = n === null ? null : record.criteria[n - 1];
132
+ if (!criterion) throw corrupt(where, `disposition for unknown criterion ${d?.criterion}`);
133
+ if (criterion.withdrawn) throw corrupt(where, `disposition for withdrawn criterion ${n}`);
134
+ if (seen.has(n)) throw corrupt(where, `duplicate disposition for criterion ${n}`);
135
+ seen.add(n);
136
+ dispositions.push({ criterion: n, met: Boolean(d.met), note: d.note ?? null });
137
+ }
138
+
139
+ if (event.status !== 'abandoned') {
140
+ for (const c of record.criteria) {
141
+ if (!c.withdrawn && !seen.has(c.index)) {
142
+ throw corrupt(where, `missing disposition for criterion ${c.index}`);
143
+ }
144
+ }
145
+ const anyUnmet = dispositions.some((d) => !d.met);
146
+ if (event.status === 'completed' && anyUnmet) {
147
+ throw corrupt(where, 'status "completed" but a live criterion is unmet');
148
+ }
149
+ if (event.status === 'partial' && !anyUnmet) {
150
+ throw corrupt(where, 'status "partial" but no live criterion is unmet');
151
+ }
152
+ }
153
+
154
+ const expectedHash = computeContractHash(record);
155
+ if (event.contractHash !== expectedHash) {
156
+ throw corrupt(where, 'contract hash does not match the folded record (tampering or corruption)');
157
+ }
158
+
159
+ record.closed = true;
160
+ record.closedAt = event.ts;
161
+ record.status = event.status;
162
+ record.dispositions = dispositions;
163
+ record.note = event.note;
164
+ record.contractHash = event.contractHash;
165
+ record.tree = event.tree ?? null;
166
+ record.head = event.head ?? null;
167
+ closed = true;
168
+ });
169
+
170
+ if (!began) throw corrupt(file, 'begin must be first and unique');
171
+ if (!record.closed) record.contractHash = computeContractHash(record);
172
+ return record;
173
+ }