@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/git.js ADDED
@@ -0,0 +1,83 @@
1
+ // The only child process Inkan ever spawns: git, with a fixed argument
2
+ // array. Never `shell: true`, never a user-supplied command string.
3
+
4
+ import { spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+
10
+ function run(args, { cwd, env }) {
11
+ return spawnSync('git', args, { cwd, env, encoding: 'utf8' });
12
+ }
13
+
14
+ /** The full sha `ref` resolves to, or null if it does not resolve here. */
15
+ export function revParse(cwd, ref) {
16
+ const result = run(['rev-parse', ref], { cwd, env: process.env });
17
+ if (result.status !== 0) return null;
18
+ return result.stdout.trim();
19
+ }
20
+
21
+ /** The HEAD sha, or null outside a worktree or before the first commit. */
22
+ export function head(cwd) {
23
+ return revParse(cwd, 'HEAD');
24
+ }
25
+
26
+ export function isWorktree(cwd) {
27
+ const result = run(['rev-parse', '--is-inside-work-tree'], { cwd, env: process.env });
28
+ return result.status === 0 && result.stdout.trim() === 'true';
29
+ }
30
+
31
+ /** The abbreviated form of `sha`, or `sha` itself if git cannot shorten it. */
32
+ export function shortSha(cwd, sha) {
33
+ const result = run(['rev-parse', '--short', sha], { cwd, env: process.env });
34
+ return result.status === 0 ? result.stdout.trim() : sha;
35
+ }
36
+
37
+ /** Values of `commit`'s `Inkan-Outcome` trailers, in order; empty if none or if `commit` does not resolve. */
38
+ export function trailerValues(cwd, commit) {
39
+ const result = run(['log', '-1', '--format=%(trailers:key=Inkan-Outcome,valueonly)', commit], { cwd, env: process.env });
40
+ if (result.status !== 0) return [];
41
+ return result.stdout.split('\n').filter((line) => line.length > 0);
42
+ }
43
+
44
+ /** The content of `filePath` as it exists in `commit`'s tree, or null if it is not there. */
45
+ export function showFile(cwd, commit, filePath) {
46
+ const result = run(['show', `${commit}:${filePath}`], { cwd, env: process.env });
47
+ return result.status === 0 ? result.stdout : null;
48
+ }
49
+
50
+ /** Whether `recordedTree` matches `commit`'s tree, `.inkan/outcomes` excluded. */
51
+ export function treeMatchesCommit(cwd, recordedTree, commit) {
52
+ const result = run(
53
+ ['diff-tree', '-r', '--quiet', recordedTree, `${commit}^{tree}`, '--', '.', ':(exclude).inkan/outcomes'],
54
+ { cwd, env: process.env }
55
+ );
56
+ return result.status === 0;
57
+ }
58
+
59
+ /**
60
+ * The tree hash of the working tree, `.inkan/outcomes` excluded, via the
61
+ * temporary-index recipe from decision 0006. Returns null outside a worktree.
62
+ * The temporary index file is always cleaned up.
63
+ */
64
+ export function treeHash(cwd) {
65
+ if (!isWorktree(cwd)) return null;
66
+ const tmp = path.join(os.tmpdir(), `inkan-index-${process.pid}-${crypto.randomUUID()}`);
67
+ const env = { ...process.env, GIT_INDEX_FILE: tmp };
68
+ try {
69
+ let result = run(['read-tree', '--empty'], { cwd, env });
70
+ if (result.status !== 0) throw new Error(`git read-tree failed: ${result.stderr}`);
71
+ result = run(['add', '-A', '--', '.', ':(exclude).inkan/outcomes'], { cwd, env });
72
+ if (result.status !== 0) throw new Error(`git add failed: ${result.stderr}`);
73
+ result = run(['write-tree'], { cwd, env });
74
+ if (result.status !== 0) throw new Error(`git write-tree failed: ${result.stderr}`);
75
+ return result.stdout.trim();
76
+ } finally {
77
+ try {
78
+ fs.unlinkSync(tmp);
79
+ } catch {
80
+ // already gone
81
+ }
82
+ }
83
+ }
package/src/store.js ADDED
@@ -0,0 +1,167 @@
1
+ // Storage primitives: seal root discovery, outcome id generation, and durable
2
+ // reads/writes of per-outcome event files. No knowledge of event semantics
3
+ // lives here; that is fold.js.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import crypto from 'node:crypto';
8
+
9
+ export const DIR_NAME = '.inkan';
10
+
11
+ /**
12
+ * Walk up from `startDir` looking for a `.inkan` directory. Returns the
13
+ * directory that contains it, or null if none is found before the
14
+ * filesystem root.
15
+ */
16
+ export function findRoot(startDir) {
17
+ let dir = path.resolve(startDir);
18
+ for (;;) {
19
+ if (fs.existsSync(path.join(dir, DIR_NAME))) return dir;
20
+ const parent = path.dirname(dir);
21
+ if (parent === dir) return null;
22
+ dir = parent;
23
+ }
24
+ }
25
+
26
+ export function outcomesDir(root) {
27
+ return path.join(root, DIR_NAME, 'outcomes');
28
+ }
29
+
30
+ export function decisionsDir(root) {
31
+ return path.join(root, DIR_NAME, 'decisions');
32
+ }
33
+
34
+ export function outcomeFile(root, id) {
35
+ return path.join(outcomesDir(root), `${id}.jsonl`);
36
+ }
37
+
38
+ // Crockford base32, lowercase, with the vowels (a, e) also removed on top of
39
+ // Crockford's own exclusions (i, l, o, u) so an id never spells a word.
40
+ const ID_ALPHABET = '0123456789bcdfghjkmnpqrstvwxyz';
41
+
42
+ /**
43
+ * A fresh `YYYY-MM-DD-HHMM-xxxx` id (UTC date, UTC hour and minute, four
44
+ * random base32 characters), sorting chronologically to the minute with
45
+ * random tie-breaking. `existingIds` dodges same-minute collisions; a true
46
+ * cross-clone collision is left for `doctor` (M3) to report. Readers also
47
+ * accept the earlier `YYYY-MM-DD-xxxx` form (see OUTCOME_ID_RE in cli.js).
48
+ */
49
+ export function newOutcomeId(existingIds = []) {
50
+ const existing = new Set(existingIds);
51
+ const now = new Date().toISOString();
52
+ const date = now.slice(0, 10);
53
+ const hhmm = now.slice(11, 13) + now.slice(14, 16);
54
+ for (;;) {
55
+ let suffix = '';
56
+ for (let i = 0; i < 4; i++) {
57
+ suffix += ID_ALPHABET[crypto.randomInt(ID_ALPHABET.length)];
58
+ }
59
+ const id = `${date}-${hhmm}-${suffix}`;
60
+ if (!existing.has(id)) return id;
61
+ }
62
+ }
63
+
64
+ /** Sort key for an outcome id: a legacy `YYYY-MM-DD-xxxx` id (missing the
65
+ * `HHMM` segment `newOutcomeId` adds) sorts as though that segment were
66
+ * `0000`, interleaving it chronologically instead of always sorting last. */
67
+ export function idSortKey(id) {
68
+ const parts = id.split('-');
69
+ return parts.length === 4 ? `${parts[0]}-${parts[1]}-${parts[2]}-0000-${parts[3]}` : id;
70
+ }
71
+
72
+ /** Ascending comparator for outcome ids, built on idSortKey. */
73
+ export function compareOutcomeIds(a, b) {
74
+ return idSortKey(a) < idSortKey(b) ? -1 : idSortKey(a) > idSortKey(b) ? 1 : 0;
75
+ }
76
+
77
+ export function listOutcomeIds(root) {
78
+ const dir = outcomesDir(root);
79
+ if (!fs.existsSync(dir)) return [];
80
+ return fs
81
+ .readdirSync(dir)
82
+ .filter((name) => name.endsWith('.jsonl'))
83
+ .map((name) => name.slice(0, -'.jsonl'.length))
84
+ .sort(compareOutcomeIds);
85
+ }
86
+
87
+ /** Parse one outcome file's raw text into events, `label` naming it in error messages. */
88
+ export function parseOutcomeEvents(raw, label) {
89
+ return raw
90
+ .split('\n')
91
+ .filter((line) => line.length > 0)
92
+ .map((line, i) => {
93
+ try {
94
+ return JSON.parse(line);
95
+ } catch {
96
+ throw new Error(`${label}:${i + 1}: not valid JSON`);
97
+ }
98
+ });
99
+ }
100
+
101
+ export function readOutcomeEvents(root, id) {
102
+ const file = outcomeFile(root, id);
103
+ return parseOutcomeEvents(fs.readFileSync(file, 'utf8'), file);
104
+ }
105
+
106
+ function fsyncDir(dir) {
107
+ let fd;
108
+ try {
109
+ fd = fs.openSync(dir, 'r');
110
+ fs.fsyncSync(fd);
111
+ } catch (err) {
112
+ // Some platforms refuse to open or fsync a directory; that is not fatal.
113
+ if (!['EINVAL', 'ENOTSUP', 'EBADF', 'EPERM', 'EISDIR'].includes(err.code)) throw err;
114
+ } finally {
115
+ if (fd !== undefined) fs.closeSync(fd);
116
+ }
117
+ }
118
+
119
+ /** Append one event line to an existing outcome file, fsynced before return. */
120
+ export function appendEvent(root, id, event) {
121
+ const file = outcomeFile(root, id);
122
+ const line = Buffer.from(`${JSON.stringify(event)}\n`, 'utf8');
123
+ const fd = fs.openSync(file, 'a');
124
+ try {
125
+ fs.writeSync(fd, line);
126
+ fs.fsyncSync(fd);
127
+ } finally {
128
+ fs.closeSync(fd);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Create a new outcome file containing exactly one event, failing if the
134
+ * file already exists. Writes to a temporary file first and fsyncs it, then
135
+ * links it into place, so the target name never appears with partial
136
+ * content.
137
+ */
138
+ export function createOutcomeFile(root, id, event) {
139
+ const dir = outcomesDir(root);
140
+ fs.mkdirSync(dir, { recursive: true });
141
+ const target = outcomeFile(root, id);
142
+ const temp = path.join(dir, `.${id}.${process.pid}.${crypto.randomUUID()}.tmp`);
143
+ const line = `${JSON.stringify(event)}\n`;
144
+ let fd;
145
+ try {
146
+ fd = fs.openSync(temp, 'wx', 0o644);
147
+ fs.writeFileSync(fd, line, 'utf8');
148
+ fs.fsyncSync(fd);
149
+ fs.closeSync(fd);
150
+ fd = undefined;
151
+ fs.linkSync(temp, target);
152
+ } finally {
153
+ if (fd !== undefined) {
154
+ try {
155
+ fs.closeSync(fd);
156
+ } catch {
157
+ // already closed
158
+ }
159
+ }
160
+ try {
161
+ fs.unlinkSync(temp);
162
+ } catch {
163
+ // already gone
164
+ }
165
+ }
166
+ fsyncDir(dir);
167
+ }