@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/api.js ADDED
@@ -0,0 +1,696 @@
1
+ // The library layer. One function per command, each taking a plain options
2
+ // object plus `root`, returning a plain serializable result or throwing
3
+ // InkanError with a user-facing message. Nothing here reads process.argv or
4
+ // writes to stdout; src/cli.js is the only thing that parses and prints.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import * as store from './store.js';
10
+ import { fold, computeContractHash } from './fold.js';
11
+ import * as git from './git.js';
12
+ import * as decisions from './decisions.js';
13
+
14
+ export class InkanError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'InkanError';
18
+ }
19
+ }
20
+
21
+ // Bundled skill directory, whether running from source or an installed package.
22
+ const SKILL_SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'use-inkan');
23
+
24
+ const DECISION_ID_RE = /^\d{4}$/;
25
+ const LANG_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
26
+
27
+ function resolveRoot(root) {
28
+ const found = store.findRoot(root);
29
+ if (!found) throw new InkanError('not an Inkan repository (no .inkan found); run "inkan init" first');
30
+ return found;
31
+ }
32
+
33
+ function loadRecord(root, id) {
34
+ let events;
35
+ try {
36
+ events = store.readOutcomeEvents(root, id);
37
+ } catch (err) {
38
+ if (err.code === 'ENOENT') throw new InkanError(`unknown outcome "${id}"`);
39
+ throw err;
40
+ }
41
+ return fold(events, store.outcomeFile(root, id));
42
+ }
43
+
44
+ function allRecords(root) {
45
+ return store.listOutcomeIds(root).map((id) => loadRecord(root, id));
46
+ }
47
+
48
+ function openRecords(root) {
49
+ return allRecords(root).filter((r) => !r.closed);
50
+ }
51
+
52
+ /** The decision file whose filename starts with `<id>-`, or null. */
53
+ function findDecisionFile(root, id) {
54
+ const dir = store.decisionsDir(root);
55
+ if (!fs.existsSync(dir)) return null;
56
+ const match = fs.readdirSync(dir).find((name) => name.startsWith(`${id}-`) && name.endsWith('.md'));
57
+ return match ? path.join(dir, match) : null;
58
+ }
59
+
60
+ function validateDecisionIds(root, ids) {
61
+ for (const id of ids) {
62
+ if (!DECISION_ID_RE.test(id)) throw new InkanError(`malformed decision id "${id}" (expected four digits)`);
63
+ if (!findDecisionFile(root, id)) throw new InkanError(`unknown decision "${id}"; add it first with "inkan decision add"`);
64
+ }
65
+ }
66
+
67
+ /** Resolves `2`, `02`, or `0002` to the file whose name starts with the canonical id. */
68
+ function requireDecisionFile(root, rawId) {
69
+ if (!/^\d{1,4}$/.test(String(rawId))) throw new InkanError(`malformed decision id "${rawId}" (expected digits, e.g. 2, 02, or 0002)`);
70
+ const id = String(Number(rawId)).padStart(4, '0');
71
+ const file = findDecisionFile(root, id);
72
+ if (!file) throw new InkanError(`unknown decision "${id}"`);
73
+ return { id, file };
74
+ }
75
+
76
+ /** `{ id, status }` for each linked decision id, `status: null` when the file is missing. */
77
+ function resolveDecisionLinks(root, ids) {
78
+ return ids.map((id) => {
79
+ const file = findDecisionFile(root, id);
80
+ if (!file) return { id, status: null };
81
+ return { id, status: decisions.parse(fs.readFileSync(file, 'utf8'), file).status };
82
+ });
83
+ }
84
+
85
+ function requireStatus(status) {
86
+ const normalized = String(status).toLowerCase();
87
+ if (!decisions.STATUSES.includes(normalized)) throw new InkanError(`malformed status "${status}" (expected one of ${decisions.STATUSES.join(', ')})`);
88
+ return normalized;
89
+ }
90
+
91
+ /** The single open outcome, or null; refuses ambiguity naming `flagHint` in the message. */
92
+ function singleOpenOrNull(root, flagHint) {
93
+ const open = openRecords(root);
94
+ if (open.length > 1) {
95
+ throw new InkanError(`more than one outcome is open (${open.map((r) => r.id).join(', ')}); specify which with ${flagHint}`);
96
+ }
97
+ return open[0] ?? null;
98
+ }
99
+
100
+ /** The single open outcome, or the one named by `id`. Refuses ambiguity. */
101
+ function resolveTarget(root, id) {
102
+ if (id) {
103
+ const record = loadRecord(root, id);
104
+ if (record.closed) throw new InkanError(`outcome "${id}" is closed; new work is a new outcome`);
105
+ return record;
106
+ }
107
+ const open = singleOpenOrNull(root, 'an id');
108
+ if (!open) throw new InkanError('no outcome is open');
109
+ return open;
110
+ }
111
+
112
+ export function begin({ root, outcome, accept = [], decision = [], lane }) {
113
+ const resolvedRoot = resolveRoot(root);
114
+ if (!outcome || !outcome.trim()) throw new InkanError('an outcome is required');
115
+ validateDecisionIds(resolvedRoot, decision);
116
+ // Other open outcomes belong to whoever began them. They are reported,
117
+ // never closed or otherwise touched here (decision 0013).
118
+ const openAlongside = openRecords(resolvedRoot).map((r) => ({ id: r.id, outcome: r.outcome }));
119
+ const head = git.head(resolvedRoot);
120
+ const existingIds = store.listOutcomeIds(resolvedRoot);
121
+ const resolvedLane = lane ?? null;
122
+
123
+ for (let attempt = 0; attempt < 5; attempt++) {
124
+ const id = store.newOutcomeId(existingIds);
125
+ const event = {
126
+ v: 1,
127
+ type: 'begin',
128
+ id,
129
+ ts: new Date().toISOString(),
130
+ outcome,
131
+ criteria: accept,
132
+ decisions: decision,
133
+ lane: resolvedLane,
134
+ head,
135
+ };
136
+ try {
137
+ store.createOutcomeFile(resolvedRoot, id, event);
138
+ return { id, outcome, criteria: accept, decisions: decision, lane: resolvedLane, head, openAlongside };
139
+ } catch (err) {
140
+ existingIds.push(id);
141
+ if (attempt === 4) throw err;
142
+ }
143
+ }
144
+ /* unreachable */
145
+ }
146
+
147
+ export function amend({ root, id, reason, addition, accept = [], withdraw = [], decision = [] }) {
148
+ const resolvedRoot = resolveRoot(root);
149
+ if (!reason || !reason.trim()) throw new InkanError('amend requires --reason');
150
+ validateDecisionIds(resolvedRoot, decision);
151
+ const record = resolveTarget(resolvedRoot, id);
152
+
153
+ const withdrawIndexes = withdraw.map((raw) => {
154
+ const n = Number(raw);
155
+ if (!Number.isInteger(n) || n < 1) throw new InkanError(`malformed criterion index "${raw}"`);
156
+ return n;
157
+ });
158
+ for (const n of withdrawIndexes) {
159
+ const criterion = record.criteria[n - 1];
160
+ if (!criterion || criterion.withdrawn) throw new InkanError(`cannot withdraw unknown or already-withdrawn criterion ${n}`);
161
+ }
162
+ const head = git.head(resolvedRoot);
163
+ store.appendEvent(resolvedRoot, record.id, {
164
+ v: 1,
165
+ type: 'amend',
166
+ id: record.id,
167
+ ts: new Date().toISOString(),
168
+ reason,
169
+ addition: addition ?? null,
170
+ criteria: accept,
171
+ withdraw: withdrawIndexes,
172
+ decisions: decision,
173
+ head,
174
+ });
175
+ const updated = loadRecord(resolvedRoot, record.id);
176
+ return { id: record.id, contractHash: computeContractHash(updated) };
177
+ }
178
+
179
+ const DISPOSITION_RE = /^(\d+)\s*(?::\s*([\s\S]*))?$/;
180
+
181
+ function parseDisposition(raw, met) {
182
+ const match = String(raw).match(DISPOSITION_RE);
183
+ if (!match) throw new InkanError(`malformed disposition "${raw}"`);
184
+ const note = match[2] && match[2].trim().length > 0 ? match[2].trim() : undefined;
185
+ return { criterion: Number(match[1]), met, note };
186
+ }
187
+
188
+ export function end({ root, id, met = [], unmet = [], status, note }) {
189
+ const resolvedRoot = resolveRoot(root);
190
+ if (!note || !note.trim()) throw new InkanError('end requires --note');
191
+ if (status !== undefined && status !== 'abandoned') {
192
+ throw new InkanError(`malformed status "${status}" (only "abandoned" may be set explicitly)`);
193
+ }
194
+ const record = resolveTarget(resolvedRoot, id);
195
+ const seen = new Set();
196
+ const dispositions = [];
197
+ for (const raw of [...met.map((r) => [r, true]), ...unmet.map((r) => [r, false])]) {
198
+ const d = parseDisposition(raw[0], raw[1]);
199
+ if (seen.has(d.criterion)) throw new InkanError(`duplicate disposition for criterion ${d.criterion}`);
200
+ seen.add(d.criterion);
201
+ dispositions.push(d);
202
+ }
203
+ for (const d of dispositions) {
204
+ const criterion = record.criteria[d.criterion - 1];
205
+ if (!criterion) throw new InkanError(`unknown criterion ${d.criterion}`);
206
+ if (criterion.withdrawn) throw new InkanError(`criterion ${d.criterion} was withdrawn`);
207
+ }
208
+ let finalStatus = status;
209
+ if (finalStatus !== 'abandoned') {
210
+ for (const c of record.criteria) {
211
+ if (!c.withdrawn && !seen.has(c.index)) throw new InkanError(`criterion ${c.index} needs a disposition (--met or --unmet)`);
212
+ }
213
+ finalStatus = dispositions.some((d) => !d.met) ? 'partial' : 'completed';
214
+ }
215
+ const contractHash = computeContractHash(record);
216
+ const tree = git.treeHash(resolvedRoot);
217
+ const head = git.head(resolvedRoot);
218
+ store.appendEvent(resolvedRoot, record.id, {
219
+ v: 1,
220
+ type: 'end',
221
+ id: record.id,
222
+ ts: new Date().toISOString(),
223
+ status: finalStatus,
224
+ dispositions,
225
+ note,
226
+ contractHash,
227
+ tree,
228
+ head,
229
+ });
230
+
231
+ return { id: record.id, status: finalStatus };
232
+ }
233
+
234
+ export function status({ root }) {
235
+ const resolvedRoot = resolveRoot(root);
236
+ const open = openRecords(resolvedRoot)
237
+ .sort((a, b) => store.compareOutcomeIds(a.id, b.id))
238
+ .map((r) => ({ ...r, decisionLinks: resolveDecisionLinks(resolvedRoot, r.decisions) }));
239
+ return { open };
240
+ }
241
+
242
+ const LOG_STATUSES = new Set(['open', 'completed', 'partial', 'abandoned']);
243
+ const SINCE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.]+Z?)?$/;
244
+
245
+ function parseSince(raw) {
246
+ if (!SINCE_RE.test(raw) || Number.isNaN(Date.parse(raw))) {
247
+ throw new InkanError(`malformed --since "${raw}" (expected an ISO date or datetime)`);
248
+ }
249
+ return Date.parse(raw);
250
+ }
251
+
252
+ /** Normalizes a decision id filter (accepts `2`, `02`, `0002`) without requiring the file to exist. */
253
+ function normalizeDecisionFilter(raw) {
254
+ if (!/^\d{1,4}$/.test(String(raw))) throw new InkanError(`malformed decision id "${raw}" (expected digits, e.g. 2, 02, or 0002)`);
255
+ return String(Number(raw)).padStart(4, '0');
256
+ }
257
+
258
+ function matchesGrep(record, re) {
259
+ if (re.test(record.outcome)) return true;
260
+ if (record.criteria.some((c) => re.test(c.text))) return true;
261
+ if (record.amendments.some((a) => re.test(a.reason) || (a.addition && re.test(a.addition)))) return true;
262
+ return record.closed && Boolean(record.note) && re.test(record.note);
263
+ }
264
+
265
+ export function log({ root, n, lane, since, grep, status, decision, id }) {
266
+ const resolvedRoot = resolveRoot(root);
267
+ if (id) {
268
+ const record = loadRecord(resolvedRoot, id);
269
+ return { record: { ...record, decisionLinks: resolveDecisionLinks(resolvedRoot, record.decisions) } };
270
+ }
271
+ const limit = n ?? 20;
272
+
273
+ // Only these filters require reading past the newest few files; the plain
274
+ // `-n` case sorts the directory listing and folds nothing else. See
275
+ // decision 0009. listOutcomeIds already sorts
276
+ // ascending by store.compareOutcomeIds, so reversing it is enough.
277
+ const filtered = [lane, since, grep, status, decision].some((v) => v !== undefined);
278
+ if (!filtered) {
279
+ const ids = store.listOutcomeIds(resolvedRoot).reverse().slice(0, limit);
280
+ return { records: ids.map((rid) => loadRecord(resolvedRoot, rid)) };
281
+ }
282
+
283
+ if (status !== undefined && !LOG_STATUSES.has(status)) {
284
+ throw new InkanError(`malformed status "${status}" (expected one of ${[...LOG_STATUSES].join(', ')})`);
285
+ }
286
+ const sinceMs = since !== undefined ? parseSince(since) : null;
287
+ const grepRe = grep !== undefined ? new RegExp(grep, 'i') : null;
288
+ const decisionId = decision !== undefined ? normalizeDecisionFilter(decision) : null;
289
+
290
+ let records = store.listOutcomeIds(resolvedRoot).reverse().map((rid) => loadRecord(resolvedRoot, rid));
291
+ if (lane) records = records.filter((r) => r.lane === lane);
292
+ if (sinceMs !== null) records = records.filter((r) => Date.parse(r.sealedAt) >= sinceMs);
293
+ if (status !== undefined) records = records.filter((r) => (r.closed ? r.status : 'open') === status);
294
+ if (decisionId) records = records.filter((r) => r.decisions.includes(decisionId));
295
+ if (grepRe) records = records.filter((r) => matchesGrep(r, grepRe));
296
+ return { records: records.slice(0, limit) };
297
+ }
298
+
299
+ // --- check / doctor ---------------------------------------------------------
300
+
301
+ const OUTCOME_FILE_PREFIX = '.inkan/outcomes/';
302
+
303
+ /** The four facts for one `Inkan-Outcome` trailer value, per decision 0006. */
304
+ function checkTrailer(root, sha, id) {
305
+ const filePath = `${OUTCOME_FILE_PREFIX}${id}.jsonl`;
306
+ const raw = git.showFile(root, sha, filePath);
307
+ if (raw === null) return { id, lines: ['outcome: missing from commit'], ok: false };
308
+
309
+ let events;
310
+ try {
311
+ events = store.parseOutcomeEvents(raw, `${sha}:${filePath}`);
312
+ } catch (err) {
313
+ const firstLine = String(err.message).split('\n')[0];
314
+ return { id, lines: [`outcome: present, unreadable (${firstLine})`], ok: false };
315
+ }
316
+ const endEvent = events.find((e) => e && e.type === 'end');
317
+ if (!endEvent) return { id, lines: ['outcome: present, open'], ok: false };
318
+
319
+ let hashOk = true;
320
+ try {
321
+ fold(events, `${sha}:${filePath}`);
322
+ } catch {
323
+ hashOk = false;
324
+ }
325
+ const lines = [`outcome: present, closed (${endEvent.status})`, hashOk ? 'hash: matches refold' : 'hash: does not match refold'];
326
+
327
+ let treeOk = true;
328
+ if (endEvent.tree == null) {
329
+ lines.push('tree: not recorded');
330
+ } else if (git.treeMatchesCommit(root, endEvent.tree, sha)) {
331
+ lines.push('tree: matches commit tree');
332
+ } else {
333
+ lines.push('tree: differs from commit tree');
334
+ treeOk = false;
335
+ }
336
+
337
+ return { id, lines, ok: hashOk && treeOk };
338
+ }
339
+
340
+ /** Read-only report on whether a commit's `Inkan-Outcome` trailers stay faithful to what it recorded. */
341
+ export function check({ root, commit }) {
342
+ const resolvedRoot = resolveRoot(root);
343
+ const ref = commit ?? 'HEAD';
344
+ const sha = git.revParse(resolvedRoot, ref);
345
+ if (!sha) throw new InkanError(`unknown commit "${ref}"`);
346
+ const shortSha = git.shortSha(resolvedRoot, sha);
347
+ const trailerIds = git.trailerValues(resolvedRoot, sha);
348
+ if (trailerIds.length === 0) return { shortSha, noTrailer: true };
349
+ const reports = trailerIds.map((id) => checkTrailer(resolvedRoot, sha, id));
350
+ return { shortSha, reports, consistent: reports.every((r) => r.ok) };
351
+ }
352
+
353
+ /** Read-only report: folds every outcome, parses every decision, and cross-checks ids and links. */
354
+ export function doctor({ root }) {
355
+ const resolvedRoot = resolveRoot(root);
356
+ const problems = [];
357
+
358
+ const outcomeIds = store.listOutcomeIds(resolvedRoot);
359
+ const records = [];
360
+ for (const id of outcomeIds) {
361
+ try {
362
+ const events = store.readOutcomeEvents(resolvedRoot, id);
363
+ if (events[0]?.id !== undefined && events[0].id !== id) {
364
+ problems.push(`outcome ${id}: begin id "${events[0].id}" does not match the file name`);
365
+ }
366
+ records.push(fold(events, store.outcomeFile(resolvedRoot, id)));
367
+ } catch (err) {
368
+ problems.push(`outcome ${id}: ${err.message}`);
369
+ }
370
+ }
371
+
372
+ const dir = store.decisionsDir(resolvedRoot);
373
+ const decisionNames = fs.existsSync(dir) ? fs.readdirSync(dir).filter((n) => n.endsWith('.md')) : [];
374
+ const decisionOwners = new Map();
375
+ for (const name of decisionNames) {
376
+ const file = path.join(dir, name);
377
+ try {
378
+ const record = decisions.parse(fs.readFileSync(file, 'utf8'), file);
379
+ const owner = decisionOwners.get(record.id);
380
+ if (owner) problems.push(`decision ${record.id}: duplicate id (${owner}, ${name})`);
381
+ else decisionOwners.set(record.id, name);
382
+ } catch (err) {
383
+ problems.push(`decision ${name}: ${err.message}`);
384
+ }
385
+ }
386
+
387
+ for (const record of records) {
388
+ for (const decisionId of record.decisions) {
389
+ if (!decisionOwners.has(decisionId)) problems.push(`outcome ${record.id}: dangling decision link "${decisionId}"`);
390
+ }
391
+ }
392
+
393
+ return { outcomeCount: outcomeIds.length, decisionCount: decisionNames.length, problems };
394
+ }
395
+
396
+ // --- decisions ------------------------------------------------------------
397
+
398
+ export function decisionAdd({ root, title, context, decision, driver = [], option = [], consequence = [], status }) {
399
+ const resolvedRoot = resolveRoot(root);
400
+ if (!title || !title.trim()) throw new InkanError('decision add requires a title');
401
+ if (!context || !context.trim()) throw new InkanError('decision add requires --context');
402
+ if (!decision || !decision.trim()) throw new InkanError('decision add requires --decision');
403
+ const resolvedStatus = requireStatus(status ?? 'accepted');
404
+ const id = decisions.nextId(resolvedRoot);
405
+ const dir = store.decisionsDir(resolvedRoot);
406
+ fs.mkdirSync(dir, { recursive: true });
407
+ const file = path.join(dir, `${id}-${decisions.slugify(title)}.md`);
408
+ const date = new Date().toISOString().slice(0, 10);
409
+ const sections = { context, drivers: driver, options: option, outcome: decision, consequences: consequence };
410
+ const content = decisions.render({ id, title, date, status: resolvedStatus, sections });
411
+ fs.writeFileSync(file, content, { encoding: 'utf8', flag: 'wx' });
412
+ return { id, file };
413
+ }
414
+
415
+ export function decisionShow({ root, id }) {
416
+ const resolvedRoot = resolveRoot(root);
417
+ const { id: resolvedId, file } = requireDecisionFile(resolvedRoot, id);
418
+ return { id: resolvedId, file, content: fs.readFileSync(file, 'utf8') };
419
+ }
420
+
421
+ export function decisionList({ root, status }) {
422
+ const resolvedRoot = resolveRoot(root);
423
+ let records = decisions.list(resolvedRoot);
424
+ if (status !== undefined) {
425
+ const normalized = requireStatus(status);
426
+ records = records.filter((r) => r.status === normalized);
427
+ }
428
+ return { records };
429
+ }
430
+
431
+ export function decisionUpdate({ root, id, status, reason, outcome }) {
432
+ const resolvedRoot = resolveRoot(root);
433
+ if (status === undefined) throw new InkanError('decision update requires --status');
434
+ const resolvedStatus = requireStatus(status);
435
+ if (!reason || !reason.trim()) throw new InkanError('decision update requires --reason');
436
+ const { id: resolvedId, file } = requireDecisionFile(resolvedRoot, id);
437
+ let outcomeId;
438
+ if (outcome !== undefined) {
439
+ const record = loadRecord(resolvedRoot, outcome);
440
+ if (record.closed) throw new InkanError(`outcome "${outcome}" is closed`);
441
+ outcomeId = record.id;
442
+ } else {
443
+ outcomeId = singleOpenOrNull(resolvedRoot, '--outcome <id>')?.id;
444
+ }
445
+ const from = decisions.appendHistory(file, { ts: new Date().toISOString(), outcomeId, to: resolvedStatus, reason });
446
+ return { id: resolvedId, from, to: resolvedStatus };
447
+ }
448
+
449
+ const AGENTS_FILENAME = 'AGENTS.md';
450
+ const CLAUDE_FILENAME = 'CLAUDE.md';
451
+ const START_MARKER = '<!-- inkan -->';
452
+ const END_MARKER = '<!-- /inkan -->';
453
+ const DEFAULT_LANG = 'en';
454
+
455
+ // Protocol 1, frozen verbatim so `init` can recognise a block it generated
456
+ // earlier and upgrade it. Never edit; a protocol change is a new version.
457
+ function protocolBlockV1(lang) {
458
+ return `${START_MARKER}
459
+ <!-- inkan-protocol: 1 -->
460
+ <!-- inkan-lang: ${lang} -->
461
+
462
+ ## Agent protocol: sealed outcomes
463
+
464
+ This repository uses Inkan (\`inkan\`, alias \`ink\`). Inkan keeps a trustworthy record of what the work was meant to deliver and what was declared at close. It does not run tests and does not judge the result; the repository's own checks do that. Write outcome prose in ${lang}.
465
+
466
+ 1. **Seal before durable changes.** Before changing code, configuration, documentation, or dependencies, run \`inkan begin "<outcome>" --accept "<observable criterion>"\`. Repeat \`--accept\` per criterion. Add \`--decision <id>\` for each decision record this work is bound by. Add \`--lane <tag>\` only when the repository already files outcomes by lane.
467
+ 2. **The seal is a fact.** Deliver what it says. If circumstances change, do not reinterpret it: run \`inkan amend --reason "<what changed>"\` with the added or withdrawn criteria. The original text stays. Never question why the outcome was sealed the way it was at the time.
468
+ 3. **Close with dispositions, then commit.** Run \`inkan end --met <n>... [--unmet <n>...] --note "<what happened>"\`. Every criterion gets a disposition. Put the printed \`Inkan-Outcome: <id>\` trailer in the commit message that lands the work. Never report success without closing the outcome.
469
+ 4. **Re-anchor after context loss.** Run \`inkan status\` and \`inkan log -n 3\`. The open outcome is the task; continue it. To stop it, close it with a note. Do not begin over it.
470
+ 5. **Closed outcomes are final.** Reviewing the log is reading, not re-checking. Never re-verify, re-attest, or re-close a closed outcome. If a past declaration now looks wrong, that is a new outcome with its own seal.
471
+
472
+ Decision records live in \`.inkan/decisions/\`. Their Context and Decision sections record the scenario at the time and are never edited. To challenge one, run \`inkan decision update <id> --status <status> --reason "<what changed>"\` or add a new record that supersedes it.
473
+
474
+ Outcome log: \`.inkan/outcomes/<id>.jsonl\`, one append-only file per outcome. Commit \`.inkan/\` with the code. Do not edit these files by hand.
475
+ ${END_MARKER}`;
476
+ }
477
+
478
+ // Protocol 2, frozen verbatim for the same reason. Never edit.
479
+ function protocolBlockV2(lang) {
480
+ return `${START_MARKER}
481
+ <!-- inkan-protocol: 2 -->
482
+ <!-- inkan-lang: ${lang} -->
483
+
484
+ ## Agent protocol: sealed outcomes
485
+
486
+ This repository uses Inkan (\`inkan\`, alias \`ink\`). Inkan keeps a trustworthy record of what the work was meant to deliver and what was declared at close. It does not run tests and does not judge the result; the repository's own checks do that. Write outcome prose in ${lang}.
487
+
488
+ 1. **Seal before durable changes.** Before changing code, configuration, documentation, or dependencies, run \`inkan begin "<outcome>" --accept "<observable criterion>"\`. Repeat \`--accept\` per criterion. Add \`--decision <id>\` for each decision record this work is bound by. Add \`--lane <tag>\` only when the repository already files outcomes by lane.
489
+ 2. **The seal is a fact.** Deliver what it says. If circumstances change, do not reinterpret it: run \`inkan amend --reason "<what changed>"\` with the added or withdrawn criteria. The original text stays. Never question why the outcome was sealed the way it was at the time.
490
+ 3. **Close with dispositions, then commit.** Run \`inkan end --met <n>... [--unmet <n>...] --note "<what happened>"\`. Every criterion gets a disposition. Put the printed \`Inkan-Outcome: <id>\` trailer in the commit message that lands the work: in the last paragraph of the message, next to any other trailers, with no blank line between them, because git reads trailers only from that final paragraph. Never report success without closing the outcome.
491
+ 4. **Re-anchor after context loss.** Run \`inkan status\` and \`inkan log -n 3\`. The open outcome is the task; continue it. To stop it, close it with a note. Do not begin over it.
492
+ 5. **Closed outcomes are final.** Reviewing the log is reading, not re-checking. Never re-verify, re-attest, or re-close a closed outcome. If a past declaration now looks wrong, that is a new outcome with its own seal.
493
+
494
+ Decision records live in \`.inkan/decisions/\`. Their Context and Decision sections record the scenario at the time and are never edited. To challenge one, run \`inkan decision update <id> --status <status> --reason "<what changed>"\` or add a new record that supersedes it.
495
+
496
+ Outcome log: \`.inkan/outcomes/<id>.jsonl\`, one append-only file per outcome. Commit \`.inkan/\` with the code. Do not edit these files by hand.
497
+ ${END_MARKER}`;
498
+ }
499
+
500
+ // Protocol 3, frozen verbatim for the same reason. Never edit.
501
+ function protocolBlockV3(lang) {
502
+ return `${START_MARKER}
503
+ <!-- inkan-protocol: 3 -->
504
+ <!-- inkan-lang: ${lang} -->
505
+
506
+ ## Agent protocol: sealed outcomes
507
+
508
+ This repository uses Inkan (\`inkan\`, alias \`ink\`). Inkan keeps a trustworthy record of what the work was meant to deliver and what was declared at close. It does not run tests and does not judge the result; the repository's own checks do that. Write outcome prose in ${lang}.
509
+
510
+ 1. **Seal before durable changes.** Before changing code, configuration, documentation, or dependencies, run \`inkan begin "<outcome>" --accept "<observable criterion>"\`. Repeat \`--accept\` per criterion. Add \`--decision <id>\` for each decision record this work is bound by. Add \`--lane <tag>\` only when the repository already files outcomes by lane.
511
+ 2. **The seal is a fact.** Deliver what it says. If circumstances change, do not reinterpret it: run \`inkan amend --reason "<what changed>"\` with the added or withdrawn criteria. The original text stays. Never question why the outcome was sealed the way it was at the time.
512
+ 3. **Close with dispositions, then commit.** Run \`inkan end --met <n>... [--unmet <n>...] --note "<what happened>"\`. Every criterion gets a disposition. Put the printed \`Inkan-Outcome: <id>\` trailer in the commit message that lands the work: in the last paragraph of the message, next to any other trailers, with no blank line between them, because git reads trailers only from that final paragraph. Never report success without closing the outcome.
513
+ 4. **Re-anchor after context loss.** Run \`inkan status\` and \`inkan log -n 3\`. An open outcome that is the work you were asked to do is your task: continue it, or close it with a note. An open outcome that is not your work belongs to another session: leave it alone. Never close, amend, or abandon an outcome you did not work on, and do not judge why it is still open; begin your own outcome beside it.
514
+ 5. **Closed outcomes are final.** Reviewing the log is reading, not re-checking. Never re-verify, re-attest, or re-close a closed outcome. If a past declaration now looks wrong, that is a new outcome with its own seal.
515
+
516
+ Decision records live in \`.inkan/decisions/\`. Their Context and Decision sections record the scenario at the time and are never edited. To challenge one, run \`inkan decision update <id> --status <status> --reason "<what changed>"\` or add a new record that supersedes it.
517
+
518
+ Outcome log: \`.inkan/outcomes/<id>.jsonl\`, one append-only file per outcome. Commit \`.inkan/\` with the code. Do not edit these files by hand.
519
+ ${END_MARKER}`;
520
+ }
521
+
522
+ function protocolBlockV4(lang) {
523
+ return `${START_MARKER}
524
+ <!-- inkan-protocol: 4 -->
525
+ <!-- inkan-lang: ${lang} -->
526
+
527
+ ## Agent protocol: sealed outcomes
528
+
529
+ This repository uses Inkan (\`inkan\`, alias \`ink\`). Inkan keeps a trustworthy record of what the work was meant to deliver and what was declared at close. It does not run tests and does not judge the result; the repository's own checks do that. Write outcome prose in ${lang}.
530
+
531
+ 1. **Seal before durable changes.** Before changing code, configuration, documentation, or dependencies, run \`inkan status\`; if it shows an open outcome that is not your work, follow rule 4 first. Then run \`inkan begin "<outcome>" --accept "<observable criterion>"\`. Repeat \`--accept\` per criterion. Add \`--decision <id>\` for each decision record this work is bound by. Add \`--lane <tag>\` only when the repository already files outcomes by lane.
532
+ 2. **The seal is a fact.** Deliver what it says. If circumstances change, do not reinterpret it: run \`inkan amend --reason "<what changed>"\` with the added or withdrawn criteria. The original text stays. Never question why the outcome was sealed the way it was at the time.
533
+ 3. **Close with dispositions, then commit.** Run \`inkan end --met <n>... [--unmet <n>...] --note "<what happened>"\`. Every criterion gets a disposition. Put the printed \`Inkan-Outcome: <id>\` trailer in the commit message that lands the work: in the last paragraph of the message, next to any other trailers, with no blank line between them, because git reads trailers only from that final paragraph. Never report success without closing the outcome.
534
+ 4. **Re-anchor after context loss.** Run \`inkan status\` and \`inkan log -n 3\`. An open outcome that is the work you were asked to do is your task: continue it, or close it with a note. An open outcome that is not your work belongs to another session: leave it alone. Never close, amend, or abandon an outcome you did not work on, and do not judge why it is still open. Before beginning your own outcome beside it, stop and tell the person it is there, and ask whether your work should run in its own git worktree, because two sessions in one checkout record each other's files.
535
+ 5. **Closed outcomes are final.** Reviewing the log is reading, not re-checking. Never re-verify, re-attest, or re-close a closed outcome. If a past declaration now looks wrong, that is a new outcome with its own seal.
536
+
537
+ Decision records live in \`.inkan/decisions/\`. Their Context and Decision sections record the scenario at the time and are never edited. To challenge one, run \`inkan decision update <id> --status <status> --reason "<what changed>"\` or add a new record that supersedes it.
538
+
539
+ Outcome log: \`.inkan/outcomes/<id>.jsonl\`, one append-only file per outcome. Commit \`.inkan/\` with the code. Do not edit these files by hand.
540
+ ${END_MARKER}`;
541
+ }
542
+
543
+ const PROTOCOL_VERSION = 4;
544
+
545
+ /**
546
+ * The managed block for `lang` at protocol `version`, current by default.
547
+ * Earlier versions stay available verbatim so `init` can tell a block it
548
+ * generated before from a hand edit (decision 0008).
549
+ */
550
+ export function protocolBlock(lang, version = PROTOCOL_VERSION) {
551
+ if (version === 1) return protocolBlockV1(lang);
552
+ if (version === 2) return protocolBlockV2(lang);
553
+ if (version === 3) return protocolBlockV3(lang);
554
+ if (version === 4) return protocolBlockV4(lang);
555
+ throw new InkanError(`unknown protocol version ${version}`);
556
+ }
557
+
558
+ /** Blocks compare equal ignoring only the parts `--lang` is allowed to change. */
559
+ function blockKey(block) {
560
+ return block
561
+ .replace(/<!-- inkan-lang: [^>]*-->/, '<!-- inkan-lang: LANG -->')
562
+ .replace(/Write outcome prose in [^.]*\./, 'Write outcome prose in LANG.');
563
+ }
564
+
565
+ function extractBlock(content) {
566
+ const start = content.indexOf(START_MARKER);
567
+ if (start === -1) return null;
568
+ const end = content.indexOf(END_MARKER, start);
569
+ if (end === -1) throw new InkanError(`${AGENTS_FILENAME} has an unterminated inkan block`);
570
+ const stop = end + END_MARKER.length;
571
+ return { start, end: stop, text: content.slice(start, stop) };
572
+ }
573
+
574
+ export function init({ root, lang, claude = false }) {
575
+ if (lang !== undefined && !LANG_RE.test(lang)) throw new InkanError(`malformed --lang "${lang}"`);
576
+
577
+ const dir = path.resolve(root);
578
+ const protocol = writeProtocol(dir, lang);
579
+ const linked = claude ? linkClaudeFile(dir) : false;
580
+ return {
581
+ root: dir,
582
+ agentsFile: protocol.agentsFile,
583
+ changed: protocol.changed || linked,
584
+ claudeFile: claude ? path.join(dir, CLAUDE_FILENAME) : null,
585
+ };
586
+ }
587
+
588
+ /**
589
+ * CLAUDE.md as a relative symlink to AGENTS.md, so Claude Code reads the one
590
+ * policy rather than a copy (decision 0014). Returns whether anything
591
+ * changed. An existing CLAUDE.md that is not that symlink is refused, never
592
+ * replaced.
593
+ */
594
+ function linkClaudeFile(dir) {
595
+ const file = path.join(dir, CLAUDE_FILENAME);
596
+ let stat = null;
597
+ try {
598
+ stat = fs.lstatSync(file);
599
+ } catch {
600
+ // absent
601
+ }
602
+ if (stat) {
603
+ if (stat.isSymbolicLink() && fs.readlinkSync(file) === AGENTS_FILENAME) return false;
604
+ throw new InkanError(`${CLAUDE_FILENAME} already exists and is not a symlink to ${AGENTS_FILENAME}; refusing to replace it`);
605
+ }
606
+ fs.symlinkSync(AGENTS_FILENAME, file);
607
+ return true;
608
+ }
609
+
610
+ /** Write or upgrade the managed block; see decision 0008. */
611
+ function writeProtocol(dir, lang) {
612
+ fs.mkdirSync(store.outcomesDir(dir), { recursive: true });
613
+ fs.mkdirSync(store.decisionsDir(dir), { recursive: true });
614
+
615
+ const agentsFile = path.join(dir, AGENTS_FILENAME);
616
+ const existing = fs.existsSync(agentsFile) ? fs.readFileSync(agentsFile, 'utf8') : null;
617
+
618
+ if (existing === null) {
619
+ fs.writeFileSync(agentsFile, `# Agent instructions\n\n${protocolBlock(lang ?? DEFAULT_LANG)}\n`, 'utf8');
620
+ return { root: dir, agentsFile, changed: true };
621
+ }
622
+
623
+ const found = extractBlock(existing);
624
+ if (!found) {
625
+ const separator = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
626
+ fs.writeFileSync(agentsFile, `${existing}${separator}${protocolBlock(lang ?? DEFAULT_LANG)}\n`, 'utf8');
627
+ return { root: dir, agentsFile, changed: true };
628
+ }
629
+
630
+ const currentLang = found.text.match(/<!-- inkan-lang: ([^>]*)-->/);
631
+ const resolvedLang = lang ?? (currentLang ? currentLang[1].trim() : DEFAULT_LANG);
632
+ const generated = protocolBlock(resolvedLang);
633
+
634
+ if (found.text === generated) return { root: dir, agentsFile, changed: false };
635
+ // A block this tool generated under any protocol so far, differing at most
636
+ // in language, is upgraded in place. Anything else was edited by hand.
637
+ const foundKey = blockKey(found.text);
638
+ let known = false;
639
+ for (let v = 1; v <= PROTOCOL_VERSION; v += 1) {
640
+ if (foundKey === blockKey(protocolBlock(resolvedLang, v))) known = true;
641
+ }
642
+ if (known) {
643
+ const content = existing.slice(0, found.start) + generated + existing.slice(found.end);
644
+ fs.writeFileSync(agentsFile, content, 'utf8');
645
+ return { root: dir, agentsFile, changed: true };
646
+ }
647
+ throw new InkanError(`${AGENTS_FILENAME} inkan block was edited by hand; refusing to overwrite it`);
648
+ }
649
+
650
+ // --- skill install ----------------------------------------------------------
651
+
652
+ /** Every file under `dir`, as paths relative to `dir`, sorted. */
653
+ function listFilesRecursive(dir) {
654
+ const out = [];
655
+ for (const name of fs.readdirSync(dir)) {
656
+ const full = path.join(dir, name);
657
+ if (fs.statSync(full).isDirectory()) out.push(...listFilesRecursive(full).map((f) => path.join(name, f)));
658
+ else out.push(name);
659
+ }
660
+ return out.sort();
661
+ }
662
+
663
+ /** Whether `a` and `b` are directories with byte-identical file trees. */
664
+ function sameTree(a, b) {
665
+ if (!fs.existsSync(a) || !fs.existsSync(b)) return false;
666
+ const filesA = listFilesRecursive(a);
667
+ const filesB = listFilesRecursive(b);
668
+ if (filesA.length !== filesB.length || filesA.some((f, i) => f !== filesB[i])) return false;
669
+ return filesA.every((rel) => fs.readFileSync(path.join(a, rel)).equals(fs.readFileSync(path.join(b, rel))));
670
+ }
671
+
672
+ /** Copies the bundled `skills/use-inkan/` to `<target>/use-inkan/`; refuses,
673
+ * with no overwrite flag, when the destination exists and differs. */
674
+ export function skillInstall({ root, target, claude = false }) {
675
+ if (target !== undefined && claude) throw new InkanError('skill install takes --claude or --target <dir>, not both');
676
+ let base;
677
+ if (target !== undefined) {
678
+ if (!target.trim()) throw new InkanError('skill install --target needs a directory');
679
+ base = path.resolve(target);
680
+ } else {
681
+ // The default and --claude are project installs under the Inkan root:
682
+ // .agents/skills is what most hosts read, .claude/skills is Claude
683
+ // Code's (decision 0014).
684
+ base = path.join(resolveRoot(root), claude ? '.claude' : '.agents', 'skills');
685
+ }
686
+ const dest = path.join(base, 'use-inkan');
687
+ if (fs.existsSync(dest)) {
688
+ if (!sameTree(SKILL_SOURCE, dest)) {
689
+ throw new InkanError(`${dest} already exists and differs from the bundled skill; remove it by hand first`);
690
+ }
691
+ return { dest };
692
+ }
693
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
694
+ fs.cpSync(SKILL_SOURCE, dest, { recursive: true });
695
+ return { dest };
696
+ }