@zyaiting/keelson 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +101 -0
  3. package/README_CN.md +101 -0
  4. package/bin/keelson.js +15 -0
  5. package/hooks/codebuddy-session.mjs +67 -0
  6. package/hooks/opencode-session.mjs +65 -0
  7. package/hooks/prompt-state.mjs +66 -0
  8. package/hooks/session-start.mjs +94 -0
  9. package/package.json +64 -0
  10. package/registry/models.json +118 -0
  11. package/registry/platforms.json +92 -0
  12. package/skills/keelson/SKILL.md +44 -0
  13. package/skills/keelson/references/build.md +61 -0
  14. package/skills/keelson/references/context.md +34 -0
  15. package/skills/keelson/references/debug.md +46 -0
  16. package/skills/keelson/references/design-lenses.md +78 -0
  17. package/skills/keelson/references/discover.md +70 -0
  18. package/skills/keelson/references/engineer.md +110 -0
  19. package/skills/keelson/references/frontend-delivery.md +38 -0
  20. package/skills/keelson/references/frontend-interaction.md +31 -0
  21. package/skills/keelson/references/frontend-review.md +33 -0
  22. package/skills/keelson/references/frontend-visual.md +31 -0
  23. package/skills/keelson/references/frontend.md +33 -0
  24. package/skills/keelson/references/handoff.md +43 -0
  25. package/skills/keelson/references/harness.md +54 -0
  26. package/skills/keelson/references/interview.md +120 -0
  27. package/skills/keelson/references/land.md +47 -0
  28. package/skills/keelson/references/model.md +29 -0
  29. package/skills/keelson/references/plan.md +106 -0
  30. package/skills/keelson/references/reconcile.md +61 -0
  31. package/skills/keelson/references/shape.md +86 -0
  32. package/skills/keelson/references/verify.md +64 -0
  33. package/skills/keelson/templates/GLOSSARY.md +5 -0
  34. package/skills/keelson/templates/INTENT.md +22 -0
  35. package/skills/keelson/templates/NOW.md +9 -0
  36. package/skills/keelson/templates/README.md +60 -0
  37. package/skills/keelson/templates/ROADMAP.md +12 -0
  38. package/skills/keelson/templates/change-quick.md +16 -0
  39. package/skills/keelson/templates/change.md +32 -0
  40. package/skills/keelson/templates/delta-spec.md +12 -0
  41. package/skills/keelson/templates/handoff.md +27 -0
  42. package/skills/keelson/templates/ledger.md +3 -0
  43. package/skills/keelson/templates/resident-block.md +7 -0
  44. package/skills/keelson/templates/rules-general.md +10 -0
  45. package/skills/keelson/templates/rules-index.md +5 -0
  46. package/skills/keelson/templates/spec.md +14 -0
  47. package/skills/keelson/templates/tasks.md +9 -0
  48. package/skills/keelson/templates/workflow.md +18 -0
  49. package/skills/zh/keelson/SKILL.md +46 -0
  50. package/skills/zh/keelson/references/build.md +61 -0
  51. package/skills/zh/keelson/references/context.md +34 -0
  52. package/skills/zh/keelson/references/debug.md +46 -0
  53. package/skills/zh/keelson/references/design-lenses.md +78 -0
  54. package/skills/zh/keelson/references/discover.md +70 -0
  55. package/skills/zh/keelson/references/engineer.md +110 -0
  56. package/skills/zh/keelson/references/frontend-delivery.md +38 -0
  57. package/skills/zh/keelson/references/frontend-interaction.md +31 -0
  58. package/skills/zh/keelson/references/frontend-review.md +33 -0
  59. package/skills/zh/keelson/references/frontend-visual.md +31 -0
  60. package/skills/zh/keelson/references/frontend.md +33 -0
  61. package/skills/zh/keelson/references/handoff.md +43 -0
  62. package/skills/zh/keelson/references/harness.md +54 -0
  63. package/skills/zh/keelson/references/interview.md +120 -0
  64. package/skills/zh/keelson/references/land.md +47 -0
  65. package/skills/zh/keelson/references/model.md +29 -0
  66. package/skills/zh/keelson/references/plan.md +106 -0
  67. package/skills/zh/keelson/references/reconcile.md +61 -0
  68. package/skills/zh/keelson/references/shape.md +86 -0
  69. package/skills/zh/keelson/references/verify.md +64 -0
  70. package/skills/zh/keelson/templates/GLOSSARY.md +5 -0
  71. package/skills/zh/keelson/templates/INTENT.md +22 -0
  72. package/skills/zh/keelson/templates/NOW.md +9 -0
  73. package/skills/zh/keelson/templates/README.md +60 -0
  74. package/skills/zh/keelson/templates/ROADMAP.md +12 -0
  75. package/skills/zh/keelson/templates/change-quick.md +16 -0
  76. package/skills/zh/keelson/templates/change.md +32 -0
  77. package/skills/zh/keelson/templates/delta-spec.md +12 -0
  78. package/skills/zh/keelson/templates/handoff.md +27 -0
  79. package/skills/zh/keelson/templates/ledger.md +3 -0
  80. package/skills/zh/keelson/templates/resident-block.md +7 -0
  81. package/skills/zh/keelson/templates/rules-general.md +10 -0
  82. package/skills/zh/keelson/templates/rules-index.md +5 -0
  83. package/skills/zh/keelson/templates/spec.md +14 -0
  84. package/skills/zh/keelson/templates/tasks.md +9 -0
  85. package/skills/zh/keelson/templates/workflow.md +18 -0
  86. package/src/cli.js +87 -0
  87. package/src/commands/ablate.js +96 -0
  88. package/src/commands/ask.js +64 -0
  89. package/src/commands/attest.js +71 -0
  90. package/src/commands/check.js +127 -0
  91. package/src/commands/context.js +95 -0
  92. package/src/commands/design.js +63 -0
  93. package/src/commands/doctor.js +157 -0
  94. package/src/commands/focus.js +84 -0
  95. package/src/commands/guide.js +59 -0
  96. package/src/commands/handoff.js +41 -0
  97. package/src/commands/hook.js +23 -0
  98. package/src/commands/impact.js +58 -0
  99. package/src/commands/init.js +289 -0
  100. package/src/commands/land.js +258 -0
  101. package/src/commands/models.js +62 -0
  102. package/src/commands/new.js +70 -0
  103. package/src/commands/platforms.js +39 -0
  104. package/src/commands/retro.js +114 -0
  105. package/src/commands/status.js +115 -0
  106. package/src/commands/uninstall.js +30 -0
  107. package/src/commands/validate.js +117 -0
  108. package/src/lib/args.js +30 -0
  109. package/src/lib/changes.js +114 -0
  110. package/src/lib/check-activity.js +29 -0
  111. package/src/lib/config.js +102 -0
  112. package/src/lib/decisions.js +59 -0
  113. package/src/lib/evidence.js +127 -0
  114. package/src/lib/fs.js +126 -0
  115. package/src/lib/git.js +353 -0
  116. package/src/lib/glob.js +54 -0
  117. package/src/lib/health.js +113 -0
  118. package/src/lib/lifecycle.js +120 -0
  119. package/src/lib/maintenance.js +66 -0
  120. package/src/lib/markdown.js +438 -0
  121. package/src/lib/models.js +195 -0
  122. package/src/lib/out.js +13 -0
  123. package/src/lib/paths.js +82 -0
  124. package/src/lib/rules.js +27 -0
  125. package/src/lib/runtime-path.js +22 -0
  126. package/src/lib/session.js +100 -0
  127. package/src/lib/specs.js +345 -0
  128. package/src/lib/transaction.js +93 -0
  129. package/src/platforms/index.js +3 -0
  130. package/src/platforms/integration.js +384 -0
  131. package/src/platforms/registry.js +46 -0
  132. package/src/platforms/runtime.js +249 -0
@@ -0,0 +1,127 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { append, exists, read, readJson, walk, withLock, write } from './fs.js';
6
+ import { runtimeDir } from './runtime-path.js';
7
+ import { loadConfig, checkEntries } from './config.js';
8
+
9
+ const { version } = createRequire(import.meta.url)('../../package.json');
10
+ export const PAYLOAD_TYPE = 'application/vnd.in-toto+json';
11
+ export const VERIFICATION_TYPE = 'https://github.com/Atingaii/keelson/verification/v1';
12
+ export const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
13
+ const pae = (type, payload) => Buffer.concat([Buffer.from(`DSSEv1 ${Buffer.byteLength(type)} ${type} ${payload.length} `), payload]);
14
+
15
+ export function contractFingerprint(root, changeDir) {
16
+ const cfgPath = path.join(root, '.keelson', 'config.yaml');
17
+ const cfg = loadConfig(cfgPath);
18
+ const locations = [
19
+ ['config', cfgPath], ['intent', path.join(root, '.keelson', 'INTENT.md')],
20
+ ['specs', path.resolve(root, cfg.paths.specs)], ['rules', path.join(root, '.keelson', 'rules')],
21
+ ['change', path.join(changeDir, 'change.md')], ['delta', path.join(changeDir, 'specs')],
22
+ ['decisions', path.join(changeDir, 'decisions.json')],
23
+ ];
24
+ const hash = crypto.createHash('sha256');
25
+ for (const [label, loc] of locations) {
26
+ hash.update(label).update('\0');
27
+ if (!exists(loc)) { hash.update('missing\0'); continue; }
28
+ const stat = fs.lstatSync(loc);
29
+ if (stat.isSymbolicLink()) throw new Error(`contract path must not be a symlink: ${loc}`);
30
+ const files = stat.isDirectory() ? walk(loc) : [''];
31
+ for (const rel of files) {
32
+ const file = rel ? path.join(loc, rel) : loc;
33
+ if (fs.lstatSync(file).isSymbolicLink()) throw new Error(`contract path must not be a symlink: ${file}`);
34
+ hash.update(rel).update('\0').update(fs.readFileSync(file)).update('\0');
35
+ }
36
+ }
37
+ return hash.digest('hex');
38
+ }
39
+
40
+ function signingKey(root) {
41
+ const keyFile = path.join(runtimeDir(root), 'attestation-key.pem');
42
+ return withLock(keyFile, () => {
43
+ if (!exists(keyFile)) {
44
+ const { privateKey } = crypto.generateKeyPairSync('ed25519');
45
+ write(keyFile, privateKey.export({ type: 'pkcs8', format: 'pem' }));
46
+ fs.chmodSync(keyFile, 0o600);
47
+ }
48
+ return crypto.createPrivateKey(read(keyFile));
49
+ });
50
+ }
51
+
52
+ export function recordStatement(root, dir, statement) {
53
+ const privateKey = signingKey(root);
54
+ const publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
55
+ const payload = Buffer.from(JSON.stringify(statement));
56
+ const envelope = { payloadType: PAYLOAD_TYPE, payload: payload.toString('base64'), signatures: [{ keyid: sha256(publicKey), sig: crypto.sign(null, pae(PAYLOAD_TYPE, payload), privateKey).toString('base64') }] };
57
+ // Exported keys permit independent cryptographic checking, not automatic trust.
58
+ write(path.join(dir, 'evidence', 'public-key.pem'), publicKey);
59
+ write(path.join(dir, 'evidence', 'keys', `${sha256(publicKey)}.pem`), publicKey);
60
+ append(path.join(dir, 'ledger.jsonl'), `${JSON.stringify(envelope)}\n`);
61
+ return envelope;
62
+ }
63
+
64
+ export function verificationStatement({ tree, contract, results, claim, startedOn, unchanged, complete, model = null, host = null }) {
65
+ return {
66
+ _type: 'https://in-toto.io/Statement/v1',
67
+ subject: [{ name: 'worktree', digest: { [tree.length === 40 ? 'gitTree' : 'sha256']: tree } }, { name: 'contract', digest: { sha256: contract } }],
68
+ predicateType: VERIFICATION_TYPE,
69
+ predicate: { claim, startedOn, finishedOn: new Date().toISOString(), unchanged, complete, runs: results, runner: { keelson: version, host, model, attribution: 'caller-supplied' } },
70
+ };
71
+ }
72
+
73
+ /** Fail closed, using the last record (never search backwards for a passing run). */
74
+ export function inspectEvidence(root, dir) {
75
+ const file = path.join(dir, 'ledger.jsonl');
76
+ if (!exists(file)) return { state: 'not-run', detail: 'no structured record; run `keelson check --record`' };
77
+ try {
78
+ const lines = read(file).trim().split('\n');
79
+ const envelopes = lines.map((line) => JSON.parse(line));
80
+ const keyFile = path.join(runtimeDir(root), 'attestation-key.pem');
81
+ if (!exists(keyFile)) return { state: 'untrusted', detail: 'record is from another machine; re-run `keelson check --record` here' };
82
+ const key = crypto.createPublicKey(crypto.createPrivateKey(read(keyFile)));
83
+ const keyid = sha256(key.export({ type: 'spki', format: 'pem' }));
84
+ let statement;
85
+ let locallyTrusted = false;
86
+ for (const envelope of envelopes) {
87
+ const payload = Buffer.from(envelope.payload ?? '', 'base64');
88
+ const valid = envelope.payloadType === PAYLOAD_TYPE && envelope.signatures?.filter((sig) => {
89
+ if (!/^[a-f0-9]{64}$/.test(sig.keyid)) return false;
90
+ const exported = path.join(dir, 'evidence', 'keys', `${sig.keyid}.pem`);
91
+ const publicKey = sig.keyid === keyid ? key : exists(exported) ? crypto.createPublicKey(read(exported)) : null;
92
+ return publicKey && sha256(publicKey.export({ type: 'spki', format: 'pem' })) === sig.keyid && crypto.verify(null, pae(PAYLOAD_TYPE, payload), publicKey, Buffer.from(sig.sig, 'base64'));
93
+ });
94
+ if (!valid?.length) throw new Error('signature mismatch');
95
+ const parsed = JSON.parse(payload);
96
+ if (parsed._type !== 'https://in-toto.io/Statement/v1') throw new Error('unknown statement schema');
97
+ if (parsed.predicateType === VERIFICATION_TYPE) { statement = parsed; locallyTrusted = valid.some((sig) => sig.keyid === keyid); }
98
+ }
99
+ if (!statement) return { state: 'not-run', detail: 'no verification statement' };
100
+ if (!locallyTrusted) return { state: 'untrusted', detail: 'latest verification was signed elsewhere; re-run checks locally' };
101
+ const p = statement.predicate;
102
+ const tree = Object.values(statement.subject.find((s) => s.name === 'worktree')?.digest ?? {})[0];
103
+ const contract = statement.subject.find((s) => s.name === 'contract')?.digest?.sha256;
104
+ if (!tree || !contract || !Array.isArray(p.runs) || !p.runs.length) throw new Error('incomplete statement');
105
+ for (const run of p.runs) {
106
+ if (!/^[a-f0-9]{64}$/.test(run.logDigest)) throw new Error('invalid log digest');
107
+ const log = path.join(dir, 'evidence', `${run.logDigest}.log`);
108
+ if (!exists(log) || fs.lstatSync(log).isSymbolicLink() || sha256(fs.readFileSync(log)) !== run.logDigest) throw new Error('missing or modified evidence log');
109
+ }
110
+ if (p.runs.some((r) => r.exit !== 0)) return { state: 'failed', detail: 'at least one recorded check failed', tree, contract };
111
+ if (!p.unchanged) return { state: 'stale', detail: 'worktree or contract changed during checking', tree, contract };
112
+ const configured = checkEntries(loadConfig(path.join(root, '.keelson', 'config.yaml'))).map((e) => e.command);
113
+ if (!p.complete || JSON.stringify(p.runs.map((r) => r.cmd)) !== JSON.stringify(configured)) return { state: 'partial', detail: 'record does not cover the configured check suite', tree, contract };
114
+ if (contract !== contractFingerprint(root, dir)) return { state: 'stale', detail: 'contract changed after verification', tree, contract };
115
+ return { state: 'passed', detail: 'locally signed record and log digests verified', tree, contract, statement };
116
+ } catch (error) { return { state: 'invalid', detail: `${error.message}; restore the intact evidence bundle, then re-run checks` }; }
117
+ }
118
+
119
+ export function trustCommands(root, commands, trusted = false) {
120
+ const file = path.join(runtimeDir(root), 'trusted-checks.json');
121
+ const digest = sha256(JSON.stringify(commands));
122
+ return withLock(file, () => {
123
+ if (readJson(file, null)?.digest === digest) return;
124
+ if (trusted) { write(file, JSON.stringify({ digest }) + '\n'); return; }
125
+ throw Object.assign(new Error(`check commands require local trust:\n${commands.map((c) => ` ${c}`).join('\n')}\nReview these commands, then run keelson check --trust --record. Trust is invalidated when commands change.`), { exitCode: 4 });
126
+ });
127
+ }
package/src/lib/fs.js ADDED
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ export const exists = (p) => fs.existsSync(p);
6
+ export const isDir = (p) => exists(p) && fs.statSync(p).isDirectory();
7
+ export const read = (p) => fs.readFileSync(p, 'utf8');
8
+ export const readOr = (p, fallback = '') => (exists(p) ? read(p) : fallback);
9
+ export const write = (p, s) => {
10
+ fs.mkdirSync(path.dirname(p), { recursive: true });
11
+ const tmp = `${p}.${process.pid}.${crypto.randomUUID()}.tmp`;
12
+ try {
13
+ const mode = exists(p) ? fs.statSync(p).mode & 0o777 : 0o600;
14
+ const fd = fs.openSync(tmp, 'wx', mode);
15
+ try { fs.writeFileSync(fd, s); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
16
+ const deadline = Date.now() + 1000;
17
+ for (;;) {
18
+ try { fs.renameSync(tmp, p); break; }
19
+ catch (error) {
20
+ // Windows can briefly deny replacement while another process has the
21
+ // destination open. Keep the old file intact and retry the same atomic
22
+ // rename; never emulate replacement by unlinking the destination.
23
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || Date.now() >= deadline) throw error;
24
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
25
+ }
26
+ }
27
+ } finally { fs.rmSync(tmp, { force: true }); }
28
+ };
29
+ export const mkdirp = (p) => fs.mkdirSync(p, { recursive: true });
30
+ export const listDirs = (p) =>
31
+ isDir(p) ? fs.readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort() : [];
32
+ export const listFiles = (p) =>
33
+ isDir(p) ? fs.readdirSync(p, { withFileTypes: true }).filter((d) => d.isFile()).map((d) => d.name).sort() : [];
34
+ export const rmrf = (p) => fs.rmSync(p, { recursive: true, force: true });
35
+ export const copyDir = (from, to, opts = {}) => fs.cpSync(from, to, { recursive: true, ...opts });
36
+
37
+ /**
38
+ * Replace a generated directory without deleting the last good copy first.
39
+ * A fixed sibling backup makes an interrupted replacement recoverable on the next run.
40
+ */
41
+ export function replaceDirSafe(dest, populate) {
42
+ const tmp = `${dest}.keelson-tmp`;
43
+ const bak = `${dest}.keelson-bak`;
44
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
45
+
46
+ if (!exists(dest) && exists(bak)) fs.renameSync(bak, dest);
47
+ if (exists(tmp)) rmrf(tmp);
48
+ if (exists(bak) && exists(dest)) rmrf(bak);
49
+
50
+ fs.mkdirSync(tmp, { recursive: true });
51
+ try {
52
+ populate(tmp);
53
+ if (exists(dest)) fs.renameSync(dest, bak);
54
+ fs.renameSync(tmp, dest);
55
+ if (exists(bak)) rmrf(bak);
56
+ } catch (error) {
57
+ if (exists(tmp)) rmrf(tmp);
58
+ if (!exists(dest) && exists(bak)) fs.renameSync(bak, dest);
59
+ throw error;
60
+ }
61
+ }
62
+ export const writeJson = (p, obj) => write(p, JSON.stringify(obj, null, 2) + '\n');
63
+ export const readJson = (p, fallback = null, { strict = true } = {}) => {
64
+ if (!exists(p)) return fallback;
65
+ try {
66
+ return JSON.parse(read(p));
67
+ } catch (cause) {
68
+ if (strict) throw Object.assign(new Error(`cannot parse ${p}: ${cause.message}. Repair the file; it has not been overwritten.`), { exitCode: 5 });
69
+ return fallback;
70
+ }
71
+ };
72
+
73
+ /** Serialize a synchronous read/modify/write. A dead process lock is never guessed stale. */
74
+ export function withLock(file, fn, { timeout = 10000 } = {}) {
75
+ const lock = `${file}.lock`;
76
+ mkdirp(path.dirname(lock));
77
+ const start = Date.now();
78
+ let fd;
79
+ for (;;) {
80
+ try { fd = fs.openSync(lock, 'wx', 0o600); break; }
81
+ catch (error) {
82
+ // A Windows lock file being closed/deleted may report a sharing error
83
+ // instead of EEXIST. Wait within the same bound; never remove its lock.
84
+ if (error.code !== 'EEXIST' && !(process.platform === 'win32' && ['EPERM', 'EACCES', 'EBUSY'].includes(error.code))) throw error;
85
+ if (Date.now() - start >= timeout) throw new Error(`lock timeout: ${lock}. Check for an active writer before removing an abandoned lock.`);
86
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
87
+ }
88
+ }
89
+ try { fs.writeFileSync(fd, `${process.pid}\n`); return fn(); }
90
+ finally {
91
+ fs.closeSync(fd);
92
+ const deadline = Date.now() + 1000;
93
+ for (;;) {
94
+ try { fs.unlinkSync(lock); break; }
95
+ catch (error) {
96
+ if (error.code === 'ENOENT') break;
97
+ // A competing Windows opener can briefly deny deletion too. Retry
98
+ // only removal of our acquired lock, never the completed callback.
99
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || Date.now() >= deadline) throw error;
100
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ export function append(file, text) {
107
+ return withLock(file, () => {
108
+ const fd = fs.openSync(file, 'a', 0o600);
109
+ try { fs.writeFileSync(fd, text); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
110
+ });
111
+ }
112
+
113
+ /** Recursively list files under dir, returning relative posix paths. */
114
+ export function walk(dir, { ignore = ['node_modules', '.git'] } = {}) {
115
+ const out = [];
116
+ const visit = (d, rel) => {
117
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
118
+ if (ignore.includes(e.name)) continue;
119
+ const r = rel ? `${rel}/${e.name}` : e.name;
120
+ if (e.isDirectory()) visit(path.join(d, e.name), r);
121
+ else out.push(r);
122
+ }
123
+ };
124
+ if (isDir(dir)) visit(dir, '');
125
+ return out.sort();
126
+ }
package/src/lib/git.js ADDED
@@ -0,0 +1,353 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { isUtf8 } from 'node:buffer';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { walk } from './fs.js';
8
+
9
+ export function git(root, args, { allowFail = true, env = {} } = {}) {
10
+ try {
11
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, ...env } }).trim();
12
+ } catch (e) {
13
+ if (allowFail) return null;
14
+ throw e;
15
+ }
16
+ }
17
+
18
+ function gitBytes(root, args) {
19
+ return execFileSync('git', args, {
20
+ cwd: root,
21
+ maxBuffer: 32 * 1024 * 1024,
22
+ stdio: ['ignore', 'pipe', 'pipe'],
23
+ // The fallback below deliberately recognizes only Git's stable C locale
24
+ // diagnostic for an ordinary directory. Other Git failures are evidence
25
+ // that the input set cannot be established and must fail closed.
26
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C', LANGUAGE: 'C' },
27
+ });
28
+ }
29
+
30
+ export const isGitRepo = (root) => git(root, ['rev-parse', '--is-inside-work-tree']) === 'true';
31
+ export const headSha = (root) => git(root, ['rev-parse', '--short=10', 'HEAD']);
32
+ export const currentBranch = (root) => git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
33
+ export const gitUserName = (root) => git(root, ['config', 'user.name']) || os.userInfo().username;
34
+
35
+ export function gitStatusShort(root) {
36
+ const s = git(root, ['status', '--porcelain']);
37
+ return s === null ? null : s.split('\n').filter(Boolean);
38
+ }
39
+
40
+ export function recentCommits(root, n = 5) {
41
+ const s = git(root, ['log', `-${n}`, '--pretty=%h %s']);
42
+ return s ? s.split('\n') : [];
43
+ }
44
+
45
+ const KEELSON_DIR = Buffer.from('.keelson');
46
+ const LENGTH_BUFFER = Buffer.allocUnsafe(8);
47
+
48
+ function nulRecords(bytes) {
49
+ const records = [];
50
+ for (let start = 0; start < bytes.length;) {
51
+ const end = bytes.indexOf(0, start);
52
+ if (end === -1) break;
53
+ records.push(bytes.subarray(start, end));
54
+ start = end + 1;
55
+ }
56
+ return records;
57
+ }
58
+
59
+ function isKeelsonPath(file) {
60
+ return file.equals(KEELSON_DIR) || (file.length > KEELSON_DIR.length && file.subarray(0, KEELSON_DIR.length).equals(KEELSON_DIR) && file[KEELSON_DIR.length] === 0x2f);
61
+ }
62
+
63
+ function fingerprintPathResolver(root) {
64
+ const base = path.resolve(root);
65
+ const prefix = base.endsWith(path.sep) ? base : `${base}${path.sep}`;
66
+ const separators = path.sep === '\\' ? /[\\/]/ : /\//;
67
+ return (bytes) => {
68
+ if (!isUtf8(bytes)) throw new Error('cannot fingerprint a Git path that is not valid UTF-8');
69
+ const rel = bytes.toString('utf8');
70
+ // Git emits canonical relative names. Reject any escape component, then
71
+ // concatenate with the already-resolved root without a path.relative call
72
+ // for every entry in a large repository.
73
+ if (!rel || path.isAbsolute(rel) || rel.split(separators).includes('..')) {
74
+ throw new Error('cannot fingerprint a Git path outside the project root');
75
+ }
76
+ return `${prefix}${rel}`;
77
+ };
78
+ }
79
+
80
+ export function resolveFingerprintPath(root, bytes) {
81
+ return fingerprintPathResolver(root)(bytes);
82
+ }
83
+
84
+ function addGitEntries(entries, root, args, kind) {
85
+ const output = gitBytes(root, args);
86
+ for (const record of nulRecords(output)) {
87
+ const tab = kind === 'tracked' ? record.indexOf(0x09) : -1;
88
+ const metadata = tab === -1 ? null : record.subarray(0, tab).toString('ascii').split(' ');
89
+ const rawFile = tab === -1 ? record : record.subarray(tab + 1);
90
+ if (!rawFile.length || isKeelsonPath(rawFile)) continue;
91
+ entries.push({ kind, rawFile, indexMode: metadata ? Number.parseInt(metadata[0], 8) : null });
92
+ }
93
+ }
94
+
95
+ function hasGitMetadata(root) {
96
+ const device = fs.lstatSync(path.resolve(root)).dev;
97
+ const crossFilesystem = process.env.GIT_DISCOVERY_ACROSS_FILESYSTEM === '1';
98
+ for (let current = path.resolve(root);;) {
99
+ try {
100
+ fs.lstatSync(path.join(current, '.git'));
101
+ return true;
102
+ } catch (error) {
103
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
104
+ }
105
+ const parent = path.dirname(current);
106
+ if (parent === current) return false;
107
+ if (!crossFilesystem && fs.lstatSync(parent).dev !== device) return false;
108
+ current = parent;
109
+ }
110
+ }
111
+
112
+ function isOrdinaryNonGitDirectory(error, root) {
113
+ if (hasGitMetadata(root) || process.env.GIT_DIR) return false;
114
+ const stderr = Buffer.isBuffer(error?.stderr) ? error.stderr.toString('utf8') : String(error?.stderr ?? '');
115
+ const lines = stderr.trimEnd().split(/\r?\n/);
116
+ const ordinaryDirectory = /^fatal: not a git repository \(or any of the parent directories\): \.git$/;
117
+ const mountBoundary = /^fatal: not a git repository \(or any parent up to mount point .+\)$/;
118
+ const boundary = 'Stopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).';
119
+ return lines.some((line) => ordinaryDirectory.test(line) || mountBoundary.test(line))
120
+ && lines.every((line) => ordinaryDirectory.test(line) || mountBoundary.test(line) || line === boundary);
121
+ }
122
+
123
+ function updateField(hash, value) {
124
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(String(value));
125
+ LENGTH_BUFFER.writeBigUInt64BE(BigInt(bytes.length));
126
+ hash.update(LENGTH_BUFFER).update(bytes);
127
+ }
128
+
129
+ function contentDigest(value) {
130
+ return crypto.createHash('sha256').update(value).digest();
131
+ }
132
+
133
+ function prepareEntry(resolvePath, entry) {
134
+ const file = resolvePath(entry.rawFile);
135
+ if (!file) return null;
136
+ let stat;
137
+ try {
138
+ stat = fs.lstatSync(file);
139
+ } catch (error) {
140
+ // A deleted tracked path is absent from the current worktree tree.
141
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
142
+ throw error;
143
+ }
144
+ entry.file = file;
145
+ entry.mode = stat.mode;
146
+ entry.type = stat.isDirectory() ? 'gitlink' : stat.isSymbolicLink() ? 'symlink' : stat.isFile() ? 'file' : 'other';
147
+ return entry;
148
+ }
149
+
150
+ function gitObjectDigests(root, entries) {
151
+ const digests = new Map();
152
+ const batched = [];
153
+ for (const entry of entries) {
154
+ // `--stdin-paths` is newline-delimited. Keep the rare newline names on
155
+ // the direct path so they cannot be split or silently omitted.
156
+ if (entry.file.includes('\n')) {
157
+ digests.set(entry, contentDigest(fs.readFileSync(entry.file)));
158
+ } else {
159
+ batched.push(entry);
160
+ }
161
+ }
162
+ if (!batched.length) return digests;
163
+ const input = Buffer.from(`${batched.map((entry) => entry.file).join('\n')}\n`);
164
+ const output = execFileSync('git', ['hash-object', '--no-filters', '--stdin-paths'], {
165
+ cwd: root,
166
+ input,
167
+ encoding: 'buffer',
168
+ maxBuffer: 32 * 1024 * 1024,
169
+ stdio: ['pipe', 'pipe', 'ignore'],
170
+ });
171
+ const lines = output.subarray(0, output.length && output[output.length - 1] === 0x0a ? -1 : output.length).toString('ascii').split('\n');
172
+ if (lines.length !== batched.length || lines.some((line) => !/^[a-f0-9]{40,64}$/.test(line))) {
173
+ throw new Error('git hash-object returned an invalid digest list');
174
+ }
175
+ for (let index = 0; index < batched.length; index++) {
176
+ // Git reads the current bytes once for this content-addressed blob ID;
177
+ // the outer worktree SHA-256 frames that digest with every path/mode.
178
+ digests.set(batched[index], Buffer.from(lines[index], 'ascii'));
179
+ }
180
+ return digests;
181
+ }
182
+
183
+ function hashGitEntries(hash, root, entries) {
184
+ const resolvePath = fingerprintPathResolver(root);
185
+ const prepared = entries.map((entry) => prepareEntry(resolvePath, entry)).filter(Boolean);
186
+ const regular = prepared.filter((entry) => entry.type === 'file');
187
+ const digests = gitObjectDigests(root, regular);
188
+ const slab = Buffer.allocUnsafe(512 * 1024);
189
+ const cachedStrings = new Map();
190
+ let offset = 0;
191
+ const stringBytes = (value) => {
192
+ let bytes = cachedStrings.get(value);
193
+ if (!bytes) {
194
+ bytes = Buffer.from(value);
195
+ cachedStrings.set(value, bytes);
196
+ }
197
+ return bytes;
198
+ };
199
+ const field = (value) => {
200
+ const bytes = Buffer.isBuffer(value) ? value : stringBytes(String(value));
201
+ if (offset + 8 + bytes.length > slab.length) {
202
+ if (offset) hash.update(slab.subarray(0, offset));
203
+ offset = 0;
204
+ if (8 + bytes.length > slab.length) {
205
+ updateField(hash, bytes);
206
+ return;
207
+ }
208
+ }
209
+ slab.writeUInt32BE(0, offset);
210
+ slab.writeUInt32BE(bytes.length, offset + 4);
211
+ offset += 8;
212
+ bytes.copy(slab, offset);
213
+ offset += bytes.length;
214
+ };
215
+ for (const entry of prepared) {
216
+ field(entry.kind);
217
+ field(entry.rawFile);
218
+ if (entry.type === 'gitlink') {
219
+ field('gitlink');
220
+ field(git(entry.file, ['rev-parse', '--verify', 'HEAD']) ?? 'missing');
221
+ } else if (entry.type === 'symlink') {
222
+ field('symlink');
223
+ field(contentDigest(fs.readlinkSync(entry.file, { encoding: 'buffer' })));
224
+ } else if (entry.type === 'file') {
225
+ field('file');
226
+ field(entry.mode & 0o111);
227
+ field(digests.get(entry));
228
+ } else {
229
+ field('other');
230
+ field(entry.mode);
231
+ }
232
+ }
233
+ if (offset) hash.update(slab.subarray(0, offset));
234
+ }
235
+
236
+ /**
237
+ * Fingerprint current inputs with `.keelson/` excluded, so a ledger append
238
+ * does not invalidate its own evidence. Git supplies the tracked and
239
+ * non-ignored path set; every included entry is freshly content-addressed on
240
+ * every call (regular files through Git's native reader). This deliberately
241
+ * avoids stat or filename caches, including same-mtime edits.
242
+ *
243
+ * Git fingerprints are SHA-256 rather than Git tree IDs. Existing 40-byte
244
+ * Git-tree records therefore become stale and must be re-recorded.
245
+ */
246
+ function gitFingerprintEntries(root) {
247
+ const entries = [];
248
+ try {
249
+ addGitEntries(entries, root, ['ls-files', '-s', '-z'], 'tracked');
250
+ addGitEntries(entries, root, ['ls-files', '--others', '--exclude-standard', '-z'], 'untracked');
251
+ } catch (error) {
252
+ // Do not pay for rev-parse in normal Git projects. The legacy fallback
253
+ // remains for ordinary non-Git directories; an error inside a repository
254
+ // still propagates rather than silently omitting inputs.
255
+ if (error?.status === 128 && isOrdinaryNonGitDirectory(error, root)) return null;
256
+ throw error;
257
+ }
258
+ entries.sort((a, b) => Buffer.compare(a.rawFile, b.rawFile) || a.kind.localeCompare(b.kind));
259
+ return entries;
260
+ }
261
+
262
+ export function worktreeFingerprint(root) {
263
+ const entries = gitFingerprintEntries(root);
264
+ if (entries) {
265
+ const hash = crypto.createHash('sha256').update('keelson-worktree-v2\0');
266
+ hashGitEntries(hash, root, entries);
267
+ return hash.digest('hex');
268
+ }
269
+ const h = crypto.createHash('sha256').update('keelson-worktree-v2\0');
270
+ for (const f of walk(root, { ignore: ['node_modules', '.git', '.keelson'] })) {
271
+ const full = path.join(root, f);
272
+ const stat = fs.lstatSync(full);
273
+ updateField(h, 'non-git');
274
+ updateField(h, f);
275
+ if (stat.isSymbolicLink()) {
276
+ updateField(h, 'symlink');
277
+ updateField(h, contentDigest(fs.readlinkSync(full, { encoding: 'buffer' })));
278
+ } else {
279
+ updateField(h, 'file');
280
+ updateField(h, String(stat.mode & 0o111));
281
+ updateField(h, contentDigest(fs.readFileSync(full)));
282
+ }
283
+ }
284
+ return h.digest('hex');
285
+ }
286
+
287
+ export const lastTag = (root) => git(root, ['describe', '--tags', '--abbrev=0']);
288
+
289
+ /** Names of change directories deleted (folded) since a ref, from git history. */
290
+ export function foldedSince(root, ref) {
291
+ if (!isGitRepo(root) || !ref) return [];
292
+ const log = git(root, ['log', `${ref}..HEAD`, '--diff-filter=D', '--name-only', '--pretty=format:', '--', '.keelson/changes']);
293
+ if (!log) return [];
294
+ const names = new Set();
295
+ for (const line of log.split('\n')) {
296
+ const m = line.match(/^\.keelson\/changes\/([^/]+)\/change\.md$/);
297
+ if (m && m[1] !== 'archive') names.add(m[1]);
298
+ }
299
+ return [...names];
300
+ }
301
+
302
+ /** Ledgers of changes that were folded (deleted) — recovered from git history. */
303
+ export function historicalLedgers(root) {
304
+ if (!isGitRepo(root)) return [];
305
+ const log = git(root, ['log', '--diff-filter=D', '--name-only', '--pretty=format:@@%H', '--', '.keelson/changes']);
306
+ if (!log) return [];
307
+ const out = [];
308
+ let commit = null;
309
+ for (const line of log.split('\n')) {
310
+ if (line.startsWith('@@')) {
311
+ commit = line.slice(2);
312
+ continue;
313
+ }
314
+ if (!commit || !/\.keelson\/changes\/[^/]+\/ledger\.md$/.test(line)) continue;
315
+ const content = git(root, ['show', `${commit}^:${line}`]);
316
+ if (content) out.push({ path: line, commit, content });
317
+ }
318
+ return out;
319
+ }
320
+
321
+ /** Files that import or require any of the given modules, by basename. Navigation, not proof. */
322
+ export function importers(root, files) {
323
+ const names = [...new Set(files.map((f) => path.basename(f).replace(/\.(m?js|cjs|ts|tsx|jsx|py|go|rs|rb|java|kt|swift)$/, '')).filter(Boolean))];
324
+ if (!names.length) return [];
325
+ const pattern = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
326
+ const re = `(import|require|from|include|use)\\b[^\\n]*\\b(${pattern})\\b`;
327
+ // Git uses platform ERE implementations. Keep its expression to POSIX ERE:
328
+ // GNU-only \b is not a word boundary there, and [^\n] excludes the letter n.
329
+ const gitRe = `(import|require|from|include|use)([^A-Za-z0-9_].*[^A-Za-z0-9_]|[^A-Za-z0-9_])(${pattern})([^A-Za-z0-9_]|$)`;
330
+ let matches = null;
331
+ if (isGitRepo(root)) {
332
+ try {
333
+ const out = execFileSync('git', ['grep', '--untracked', '--exclude-standard', '-z', '-l', '-E', '-I', '--', gitRe], { cwd: root, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
334
+ matches = nulRecords(out).map((file) => file.toString('utf8'));
335
+ } catch (error) {
336
+ // `git grep` uses status 1 for a successful search with no matches.
337
+ if (error?.status === 1) matches = [];
338
+ }
339
+ }
340
+ if (matches === null) {
341
+ const hits = [];
342
+ const rx = new RegExp(re);
343
+ for (const f of walk(root, { ignore: ['node_modules', '.git', '.keelson', 'dist', 'build'] })) {
344
+ if (!/\.(m?js|cjs|ts|tsx|jsx|py|go|rs|rb|java|kt|swift|vue|svelte)$/.test(f)) continue;
345
+ try {
346
+ if (rx.test(fs.readFileSync(path.join(root, f), 'utf8'))) hits.push(f);
347
+ } catch {}
348
+ }
349
+ matches = hits;
350
+ }
351
+ const self = new Set(files.map((f) => f.replace(/\\/g, '/')));
352
+ return matches.filter((file) => file && !self.has(file));
353
+ }
@@ -0,0 +1,54 @@
1
+ /** Minimal glob → RegExp: supports **, *, ?, and {a,b}. Posix separators. */
2
+ export function globToRegExp(glob) {
3
+ let re = '';
4
+ let i = 0;
5
+ while (i < glob.length) {
6
+ const c = glob[i];
7
+ if (c === '*') {
8
+ if (glob[i + 1] === '*') {
9
+ // '**/' matches zero or more directories; bare '**' matches anything
10
+ if (glob[i + 2] === '/') {
11
+ re += '(?:.*/)?';
12
+ i += 3;
13
+ } else {
14
+ re += '.*';
15
+ i += 2;
16
+ }
17
+ } else {
18
+ re += '[^/]*';
19
+ i += 1;
20
+ }
21
+ } else if (c === '?') {
22
+ re += '[^/]';
23
+ i += 1;
24
+ } else if (c === '{') {
25
+ const end = glob.indexOf('}', i);
26
+ if (end === -1) {
27
+ re += '\\{';
28
+ i += 1;
29
+ } else {
30
+ const alts = glob.slice(i + 1, end).split(',').map(escape);
31
+ re += `(?:${alts.join('|')})`;
32
+ i = end + 1;
33
+ }
34
+ } else {
35
+ re += escape(c);
36
+ i += 1;
37
+ }
38
+ }
39
+ return new RegExp(`^${re}$`);
40
+ }
41
+
42
+ const escape = (s) => s.replace(/[.+^$()|[\]\\]/g, '\\$&');
43
+
44
+ export function globMatch(glob, filePath) {
45
+ const p = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
46
+ const g = glob.replace(/^\.\//, '');
47
+ if (globToRegExp(g).test(p)) return true;
48
+ // A directory-style glob like "src/api/" or "src/api" matches everything beneath it.
49
+ if (!/[*?{]/.test(g)) {
50
+ const dir = g.replace(/\/$/, '');
51
+ return p === dir || p.startsWith(dir + '/');
52
+ }
53
+ return false;
54
+ }