@kitkat-vn/kitcode 0.2.0-staging.3

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -0
  3. package/bin/kitcode.mjs +1097 -0
  4. package/package.json +41 -0
  5. package/src/api-access.mjs +119 -0
  6. package/src/api.mjs +165 -0
  7. package/src/atomic-file.mjs +85 -0
  8. package/src/break-progress.mjs +87 -0
  9. package/src/claim-bundle.mjs +37 -0
  10. package/src/companion-controls.mjs +40 -0
  11. package/src/companion-electron.mjs +87 -0
  12. package/src/companion-preload.cjs +10 -0
  13. package/src/companion-window.mjs +57 -0
  14. package/src/cors.mjs +51 -0
  15. package/src/equals-ledger.mjs +166 -0
  16. package/src/focus-clock.mjs +86 -0
  17. package/src/fonts/fira-code/OFL.txt +93 -0
  18. package/src/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
  19. package/src/fonts/fira-code/fira-code-latin-500-normal.woff2 +0 -0
  20. package/src/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
  21. package/src/fonts/vt323/OFL.txt +93 -0
  22. package/src/fonts/vt323/vt323-latin-400-normal.woff2 +0 -0
  23. package/src/git.mjs +448 -0
  24. package/src/hook-installers.mjs +397 -0
  25. package/src/hook-prompt.mjs +201 -0
  26. package/src/install-id.mjs +94 -0
  27. package/src/integration-installers.mjs +28 -0
  28. package/src/integration-spec.mjs +398 -0
  29. package/src/log-file.mjs +60 -0
  30. package/src/milestones-reached.mjs +130 -0
  31. package/src/next-break-reminder.mjs +78 -0
  32. package/src/onboarding-electron.mjs +240 -0
  33. package/src/onboarding-platform.mjs +50 -0
  34. package/src/onboarding-preload.cjs +19 -0
  35. package/src/onboarding-window.mjs +874 -0
  36. package/src/open-dashboard.mjs +30 -0
  37. package/src/reward.mjs +311 -0
  38. package/src/runner-installer.mjs +153 -0
  39. package/src/runtime.mjs +1323 -0
  40. package/src/scan-worker.mjs +23 -0
  41. package/src/scanner.mjs +84 -0
  42. package/src/seen-lines.mjs +148 -0
  43. package/src/skill-installer.mjs +76 -0
  44. package/src/source-snapshot.mjs +725 -0
  45. package/src/store.mjs +595 -0
  46. package/src/tracker-instance.mjs +166 -0
  47. package/src/tracker-problems.mjs +30 -0
  48. package/src/tracker-stop.mjs +73 -0
  49. package/src/uninstall.mjs +80 -0
  50. package/src/vibe.mjs +4 -0
  51. package/src/window-guards.mjs +7 -0
  52. package/src/window-page.mjs +49 -0
package/src/git.mjs ADDED
@@ -0,0 +1,448 @@
1
+ import {execFileSync} from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ // A listing of a large repository is long; git's own limits apply, not Node's 1 MB default.
7
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
8
+
9
+ // `input` is written to git's stdin. Exit codes in `okStatus` besides 0 return stdout instead of
10
+ // throwing. GIT_OPTIONAL_LOCKS=0 keeps `git status` from rewriting the user's index, and from holding
11
+ // index.lock while the user runs git: KitCode only ever reads a repository.
12
+ export function runGit(repoRoot, args, {input, okStatus = [], trim = true} = {}) {
13
+ let output;
14
+
15
+ try {
16
+ output = execFileSync('git', ['-C', repoRoot, ...args], {
17
+ encoding: 'utf8',
18
+ input,
19
+ maxBuffer: GIT_MAX_BUFFER,
20
+ env: {...process.env, GIT_OPTIONAL_LOCKS: '0'},
21
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'],
22
+ windowsHide: true,
23
+ });
24
+ } catch (error) {
25
+ if (!okStatus.includes(error.status)) {
26
+ throw error;
27
+ }
28
+
29
+ output = String(error.stdout ?? '');
30
+ }
31
+
32
+ return trim ? output.trim() : output;
33
+ }
34
+
35
+ export function detectRepoRoot(cwd) {
36
+ try {
37
+ return execFileSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
38
+ encoding: 'utf8',
39
+ stdio: ['ignore', 'pipe', 'ignore'],
40
+ windowsHide: true,
41
+ }).trim();
42
+ } catch {
43
+ throw new Error('Not inside a git repository. Run `kitcode add` from a project repo.');
44
+ }
45
+ }
46
+
47
+ export function tryDetectRepoRoot(cwd) {
48
+ try {
49
+ return detectRepoRoot(cwd);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export function readRemote(repoRoot) {
56
+ try {
57
+ return runGit(repoRoot, ['config', '--get', 'remote.origin.url']);
58
+ } catch {
59
+ return '';
60
+ }
61
+ }
62
+
63
+ export function createProjectId(repoRoot) {
64
+ return crypto.createHash('sha256').update(`${repoRoot}\n${readRemote(repoRoot)}`).digest('hex').slice(0, 16);
65
+ }
66
+
67
+ export function createFolderProjectId(folderRoot) {
68
+ return crypto.createHash('sha256').update(`vibe\n${folderRoot}`).digest('hex').slice(0, 16);
69
+ }
70
+
71
+ export function countCommits(repoRoot) {
72
+ try {
73
+ return Number(runGit(repoRoot, ['rev-list', '--count', 'HEAD'])) || 0;
74
+ } catch {
75
+ return 0;
76
+ }
77
+ }
78
+
79
+ // `git status --porcelain=v1 -z`: each entry is two status letters, a space and a path, and a rename
80
+ // or a copy is followed by the path it came from. Paths come as they are, never quoted.
81
+ function statusEntries(output) {
82
+ const fields = output.split('\0');
83
+ const entries = [];
84
+
85
+ for (let index = 0; index < fields.length; index += 1) {
86
+ if (fields[index].length < 4) {
87
+ continue;
88
+ }
89
+
90
+ const entry = {text: fields[index], paths: [fields[index].slice(3)]};
91
+
92
+ if (/[RC]/.test(fields[index].slice(0, 2)) && index + 1 < fields.length) {
93
+ index += 1;
94
+ entry.text += `\0${fields[index]}`;
95
+ entry.paths.push(fields[index]);
96
+ }
97
+
98
+ entries.push(entry);
99
+ }
100
+
101
+ return entries;
102
+ }
103
+
104
+ // HEAD and the working tree's status: it changes when the user commits, checks out or stages. null
105
+ // when git cannot describe the repository, which is never activity. (Until 2026-09-24 a failure
106
+ // returned the time, so a repository with no commits yet looked changed at every poll.)
107
+ //
108
+ // An entry whose every path `isIgnored` matches is left out, so a change to a file the scanner does
109
+ // not read, such as a lockfile an install rewrote, is not activity either (since 2026-09-24). An
110
+ // untracked folder git shows as one entry is judged by the folder's own name.
111
+ export function getGitSignature(repoRoot, isIgnored = () => false) {
112
+ let status;
113
+
114
+ try {
115
+ status = runGit(repoRoot, ['status', '--porcelain=v1', '-z'], {trim: false});
116
+ } catch {
117
+ return null;
118
+ }
119
+
120
+ let head = '';
121
+
122
+ try {
123
+ head = runGit(repoRoot, ['rev-parse', '--verify', '--quiet', 'HEAD']);
124
+ } catch {
125
+ // No commits yet: there is no HEAD to name.
126
+ }
127
+
128
+ const seen = statusEntries(status).filter((entry) => !entry.paths.every((entryPath) => isIgnored(entryPath)));
129
+
130
+ return `${head}\n${seen.map((entry) => entry.text).join('\0')}`;
131
+ }
132
+
133
+ function nulSeparated(output) {
134
+ return [...new Set(output.split('\0').filter(Boolean))];
135
+ }
136
+
137
+ // The files git would show the user: tracked, and untracked but not ignored by .gitignore,
138
+ // .git/info/exclude or the user's global excludes. Paths are relative to the root, with `/`. Folders
139
+ // named in `excludeFolders` are not even walked. Throws when git cannot list the repository.
140
+ export function listRepositoryFiles(repoRoot, excludeFolders = []) {
141
+ return nulSeparated(runGit(repoRoot, [
142
+ 'ls-files',
143
+ '-z',
144
+ '--cached',
145
+ '--others',
146
+ '--exclude-standard',
147
+ ...excludeFolders.map((folder) => `--exclude=${folder}/`),
148
+ ], {trim: false}));
149
+ }
150
+
151
+ // The paths among `relativePaths` that the repository's ignore rules match. A tracked file is never
152
+ // ignored, as in git itself. Throws when git cannot check.
153
+ export function ignoredPaths(repoRoot, relativePaths) {
154
+ if (relativePaths.length === 0) {
155
+ return [];
156
+ }
157
+
158
+ return nulSeparated(runGit(repoRoot, ['check-ignore', '-z', '--stdin'], {
159
+ input: `${relativePaths.join('\0')}\0`,
160
+ okStatus: [1],
161
+ trim: false,
162
+ }));
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------------------------------
166
+ // Code that arrives through git (docs/reward-rules.md §5.8; operator decision Q2, 2026-09-25). A scan of
167
+ // a Git project asks gitArrival what can have arrived through git since the last scan, from HEAD's
168
+ // reflog, and is a baseline when anything can have. The position it returns is recorded on the
169
+ // snapshot, so the scan that follows starts where this one ended: the tracker's 15 s git poll plays no
170
+ // part in it.
171
+
172
+ // The user's own commits, by the HEAD reflog's message: a commit, `commit: <subject>`; an amended one,
173
+ // `commit (amend): <subject>`; a repository's first, `commit (initial): <subject>`. With no subject git
174
+ // writes `commit:`. Every other message is another way HEAD moved: a pull, a merge, a checkout or a
175
+ // switch, a rebase, a reset, a cherry-pick, a revert, am, a commit that ends a merge or a cherry-pick
176
+ // (`commit (merge)`, `commit (cherry-pick)`), a clone, a tool. test/git-arrivals.test.mjs locks these
177
+ // formats against git itself.
178
+ const OWN_COMMIT = /^commit(?: \((?:amend|initial)\))?:(?: |$)/;
179
+
180
+ // A line of a reflog as git's files backend writes it: `<old> <new> <name> <<email>> <time> <zone>`, a
181
+ // tab, and the message on one line; with no message (update-ref without -m), no tab either. Object
182
+ // names are 40 hex characters, or 64 in a SHA-256 repository.
183
+ const REFLOG_LINE = /^([0-9a-f]{40}|[0-9a-f]{64}) ([0-9a-f]{40}|[0-9a-f]{64}) [^\t]*(?:\t(.*))?$/;
184
+
185
+ // The files git leaves while an operation waits for the user: a merge (a pull's, too) stopped on a
186
+ // conflict or run with --no-commit, a cherry-pick, a revert, a rebase, `git am`, and a squash merge not
187
+ // yet committed. What they brought is in the working tree before any reflog line says so.
188
+ const WAITING_OPERATIONS = ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'REBASE_HEAD', 'rebase-merge', 'rebase-apply', 'SQUASH_MSG'];
189
+ const GIT_PATHS = ['logs/HEAD', 'index', 'index.lock', ...WAITING_OPERATIONS];
190
+
191
+ // A command writing the index (a checkout, a merge, add, commit) holds index.lock while it writes the
192
+ // working tree, and moves HEAD and writes its reflog line just after it lets go. A lock is waited for
193
+ // this long; the index having just been written, this long again for the reflog line.
194
+ const LOCK_WAIT_MS = 1000;
195
+ const LOCK_POLL_MS = 25;
196
+ const SETTLE_MS = 200;
197
+ const FRESH_INDEX_MS = 1000;
198
+ // How much of the reflog's end is read to find its last line.
199
+ const REFLOG_TAIL_BYTES = 64 * 1024;
200
+
201
+ // One line of a HEAD reflog: the commit HEAD pointed at before, and after, and the message. null for a
202
+ // line git does not write.
203
+ export function parseReflogLine(line) {
204
+ const match = REFLOG_LINE.exec(line);
205
+ return match ? {old: match[1], new: match[2], message: match[3] ?? ''} : null;
206
+ }
207
+
208
+ // How one reflog entry moved HEAD: 'same' when HEAD points at the commit it pointed at before
209
+ // (checkout -b, a stash, reset --hard HEAD), 'own' for the user's own commit, 'other' for any other move.
210
+ export function headMove(entry) {
211
+ if (entry.old === entry.new) {
212
+ return 'same';
213
+ }
214
+
215
+ return OWN_COMMIT.test(entry.message) ? 'own' : 'other';
216
+ }
217
+
218
+ function isObject(value) {
219
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
220
+ }
221
+
222
+ function sleepSync(ms) {
223
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
224
+ }
225
+
226
+ function lineHash(bytes) {
227
+ return crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 16);
228
+ }
229
+
230
+ // A position gitArrival returned: HEAD's commit, or null when there was none yet, and where HEAD's
231
+ // reflog ended, or null when there was no reflog.
232
+ function isPosition(value) {
233
+ return isObject(value)
234
+ && (value.head === null || typeof value.head === 'string')
235
+ && (value.reflog === null || (isObject(value.reflog)
236
+ && Number.isSafeInteger(value.reflog.bytes) && value.reflog.bytes >= 0
237
+ && (value.reflog.last === null || typeof value.reflog.last === 'string')));
238
+ }
239
+
240
+ // HEAD's commit (null when there is none yet), and where git keeps the files gitArrival reads, asked
241
+ // with --git-path so that a linked worktree's own are found.
242
+ function headAndPaths(repoRoot) {
243
+ const lines = runGit(repoRoot, ['rev-parse', ...GIT_PATHS.flatMap((name) => ['--git-path', name]), '--verify', '--quiet', 'HEAD'], {okStatus: [1], trim: false}).split('\n');
244
+
245
+ if (lines.length < GIT_PATHS.length + 1) {
246
+ throw new Error('git rev-parse did not name every path KitCode asked for');
247
+ }
248
+
249
+ return {
250
+ head: /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(lines[GIT_PATHS.length]) ? lines[GIT_PATHS.length] : null,
251
+ paths: Object.fromEntries(GIT_PATHS.map((name, index) => [name, path.resolve(repoRoot, lines[index])])),
252
+ };
253
+ }
254
+
255
+ function modifiedAt(file) {
256
+ try {
257
+ return fs.statSync(file).mtimeMs;
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ // Waits, up to LOCK_WAIT_MS, for a command writing the index to finish, and, when one did or the index
264
+ // has just been written, SETTLE_MS more for its reflog line. `writing` when the lock is still held.
265
+ function waitForIndex(paths) {
266
+ const deadline = Date.now() + LOCK_WAIT_MS;
267
+ let waited = false;
268
+
269
+ while (fs.existsSync(paths['index.lock'])) {
270
+ if (Date.now() >= deadline) {
271
+ return {writing: true, settled: false};
272
+ }
273
+
274
+ waited = true;
275
+ sleepSync(LOCK_POLL_MS);
276
+ }
277
+
278
+ const written = modifiedAt(paths.index);
279
+
280
+ if (waited || (written !== null && Date.now() - written < FRESH_INDEX_MS)) {
281
+ sleepSync(SETTLE_MS);
282
+ return {writing: false, settled: true};
283
+ }
284
+
285
+ return {writing: false, settled: false};
286
+ }
287
+
288
+ // `bytes` of the file at `fd`, from `start` to `end`.
289
+ function readRange(fd, start, end) {
290
+ const buffer = Buffer.alloc(end - start);
291
+ let done = 0;
292
+
293
+ while (done < buffer.length) {
294
+ const read = fs.readSync(fd, buffer, done, buffer.length - done, start + done);
295
+
296
+ if (read === 0) {
297
+ break;
298
+ }
299
+
300
+ done += read;
301
+ }
302
+
303
+ return buffer.subarray(0, done);
304
+ }
305
+
306
+ // The start, in `buffer` (which begins at `offset` in the file), of the line that ends just before
307
+ // `end`, or -1 when that line starts before the buffer does.
308
+ function lineStart(buffer, offset, end) {
309
+ if (end - offset < 1) {
310
+ return offset === 0 ? 0 : -1;
311
+ }
312
+
313
+ const newline = end - offset >= 2 ? buffer.lastIndexOf(0x0a, end - offset - 2) : -1;
314
+ return newline >= 0 ? offset + newline + 1 : (offset === 0 ? 0 : -1);
315
+ }
316
+
317
+ // HEAD's reflog: where it ends now (`bytes`, up to the end of its last complete line, and `last`, a hash
318
+ // of that line), and, when `from` is given (a position's reflog, or null for "from its start"), the
319
+ // complete lines after it. `rewritten` when it no longer holds, at `from`, the line it held then: git
320
+ // expired entries, or it is not the same repository. null when there is no reflog.
321
+ function readReflog(logPath, from) {
322
+ let fd;
323
+
324
+ try {
325
+ fd = fs.openSync(logPath, 'r');
326
+ } catch (error) {
327
+ if (error.code === 'ENOENT') {
328
+ return null;
329
+ }
330
+
331
+ throw error;
332
+ }
333
+
334
+ try {
335
+ const size = fs.fstatSync(fd).size;
336
+ const since = from === undefined ? null : (from?.bytes ?? 0);
337
+ let offset = Math.max(0, Math.min(since ?? size, size) - REFLOG_TAIL_BYTES);
338
+
339
+ for (;;) {
340
+ const buffer = readRange(fd, offset, size);
341
+ const end = offset + buffer.lastIndexOf(0x0a) + 1;
342
+ const lastStart = end > offset ? lineStart(buffer, offset, end) : (offset === 0 ? 0 : -1);
343
+ const sinceStart = since !== null && since > 0 && since <= size ? lineStart(buffer, offset, since) : 0;
344
+
345
+ // A line longer than the part read: read it all.
346
+ if ((end === offset && offset > 0) || lastStart < 0 || sinceStart < 0) {
347
+ offset = 0;
348
+ continue;
349
+ }
350
+
351
+ const position = {bytes: end, last: end > lastStart ? lineHash(buffer.subarray(lastStart - offset, end - offset - 1)) : null};
352
+
353
+ if (since === null) {
354
+ return {...position, lines: null, rewritten: false};
355
+ }
356
+
357
+ const aligned = since === 0
358
+ ? from?.last == null
359
+ : since <= end && buffer[since - offset - 1] === 0x0a && lineHash(buffer.subarray(sinceStart - offset, since - offset - 1)) === from.last;
360
+
361
+ if (!aligned) {
362
+ return {...position, lines: null, rewritten: true};
363
+ }
364
+
365
+ const lines = buffer.subarray(since - offset, end - offset).toString('utf8').split('\n').filter(Boolean);
366
+ return {...position, lines, rewritten: false};
367
+ }
368
+ } finally {
369
+ fs.closeSync(fd);
370
+ }
371
+ }
372
+
373
+ // Whether the reflog explains how HEAD got from `since` to `now`, and how. `arrival`: null when HEAD did
374
+ // not move, or moved only by the user's own commits; 'moved' when it moved some other way; 'unexplained'
375
+ // when the reflog was rewritten, is gone, or does not lead from one to the other (HEAD moved with no
376
+ // entry). `operation`: whether git did anything to HEAD but the user's own commit, even without moving
377
+ // it (a stash, `reset --hard HEAD`, `checkout -b`).
378
+ function arrivalFromReflog(since, now, log) {
379
+ if (!log) {
380
+ return since.reflog === null && since.head === now.head ? {arrival: null, operation: false} : {arrival: 'unexplained', operation: true};
381
+ }
382
+
383
+ if (log.rewritten || log.lines === null) {
384
+ return {arrival: 'unexplained', operation: true};
385
+ }
386
+
387
+ let head = since.head;
388
+ let moved = false;
389
+ let operation = false;
390
+
391
+ for (const line of log.lines) {
392
+ const entry = parseReflogLine(line);
393
+
394
+ if (!entry || !(entry.old === head || (head === null && /^0+$/.test(entry.old)))) {
395
+ return {arrival: 'unexplained', operation: true};
396
+ }
397
+
398
+ const move = headMove(entry);
399
+ moved ||= move === 'other';
400
+ operation ||= move !== 'own';
401
+ head = entry.new;
402
+ }
403
+
404
+ if (head !== now.head) {
405
+ return {arrival: 'unexplained', operation: true};
406
+ }
407
+
408
+ return {arrival: moved ? 'moved' : null, operation};
409
+ }
410
+
411
+ // What can have arrived through git in `repoRoot` since `since`, a position this function returned
412
+ // before, and the position now, for the next scan to start from. `arrival`:
413
+ // null nothing: HEAD is where it was, or moved only by the user's own commits;
414
+ // 'moved' HEAD moved some other way: a pull, a merge, a checkout, a rebase, a reset …;
415
+ // 'waiting' a merge, a cherry-pick, a revert, a rebase, am or a squash merge waits for the user;
416
+ // 'locked' index.lock stayed for longer than LOCK_WAIT_MS: a long command, or a git that died
417
+ // holding it, which git itself refuses to run past until the file is removed (until
418
+ // 2026-09-25 this was 'waiting'; the tracker now reports it, runtime.mjs noteGitLock);
419
+ // 'unexplained' the reflog cannot say how HEAD got here: rewritten, gone, or no entry for a move;
420
+ // 'unrecorded' there is no position to compare with.
421
+ // Anything but null makes a scan a baseline. `operation` says whether git did anything since `since` but
422
+ // the user's own commits: true whenever `arrival` is not null, and also when HEAD's reflog shows git
423
+ // acting without moving HEAD, as a stash does (it takes the user's changes away, and writes `reset:
424
+ // moving to HEAD`). What changed in the working tree then is git's, and is not activity (operator
425
+ // decision Q4, 2026-09-25). Throws when git cannot describe the repository.
426
+ export function gitArrival(repoRoot, since) {
427
+ let {head, paths} = headAndPaths(repoRoot);
428
+ const index = waitForIndex(paths);
429
+
430
+ if (index.settled) {
431
+ ({head} = headAndPaths(repoRoot));
432
+ }
433
+
434
+ const recorded = isPosition(since);
435
+ const log = readReflog(paths['logs/HEAD'], recorded ? since.reflog : undefined);
436
+ const position = {head, reflog: log ? {bytes: log.bytes, last: log.last} : null};
437
+ const reflog = recorded ? arrivalFromReflog(since, position, log) : {arrival: 'unrecorded', operation: true};
438
+
439
+ if (index.writing) {
440
+ return {arrival: 'locked', operation: true, position};
441
+ }
442
+
443
+ if (WAITING_OPERATIONS.some((name) => fs.existsSync(paths[name]))) {
444
+ return {arrival: 'waiting', operation: true, position};
445
+ }
446
+
447
+ return {...reflog, position};
448
+ }