@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
@@ -0,0 +1,397 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {isDeepStrictEqual} from 'node:util';
5
+ import {writeFileAtomically} from './atomic-file.mjs';
6
+ import {
7
+ hookConfigFor,
8
+ hookCommandFor as specHookCommandFor,
9
+ legacyHookCommandFor,
10
+ sourceConfig as integrationSourceConfig,
11
+ } from './integration-spec.mjs';
12
+ import {runnerPath} from './runner-installer.mjs';
13
+
14
+ // ~/.claude/settings.json and ~/.codex/hooks.json belong to the user (hard rule 3). KitCode refuses
15
+ // to write a file it cannot parse or that the user made read-only, keeps every key it did not add,
16
+ // and backs up the exact previous bytes before every write.
17
+ const NEW_CONFIG_MODE = 0o600;
18
+
19
+ export class ConfigFileError extends Error {
20
+ constructor(filePath, problem, cause) {
21
+ super(`${filePath} ${problem}`, {cause});
22
+ this.name = 'ConfigFileError';
23
+ this.filePath = filePath;
24
+ this.problem = problem;
25
+ }
26
+ }
27
+
28
+ function configPath(source) {
29
+ return path.join(os.homedir(), ...integrationSourceConfig(source).hookConfigPathParts);
30
+ }
31
+
32
+ function isObject(value) {
33
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
34
+ }
35
+
36
+ function isSymbolicLink(filePath) {
37
+ try {
38
+ return fs.lstatSync(filePath).isSymbolicLink();
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ // The file's indentation and final newline, so that a rewrite changes only KitCode's entry.
45
+ function formatOf(text) {
46
+ const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? (text.includes('\n') ? 2 : 0);
47
+
48
+ return {indent, finalNewline: text.endsWith('\n')};
49
+ }
50
+
51
+ function serializeConfig(config, format) {
52
+ return `${JSON.stringify(config, null, format.indent)}${format.finalNewline ? '\n' : ''}`;
53
+ }
54
+
55
+ // A missing file reads as {exists: false}. Anything else KitCode cannot read, parse, or merge into
56
+ // throws ConfigFileError: it is never taken for an empty config.
57
+ function readConfig(filePath) {
58
+ let stat;
59
+ let realPath;
60
+
61
+ try {
62
+ stat = fs.statSync(filePath);
63
+ realPath = fs.realpathSync(filePath);
64
+ } catch (error) {
65
+ if (error.code === 'ENOENT' && !isSymbolicLink(filePath)) {
66
+ return {exists: false, filePath, realPath: filePath};
67
+ }
68
+
69
+ throw new ConfigFileError(filePath, error.code === 'ENOENT'
70
+ ? 'is a symbolic link to a file that does not exist'
71
+ : `could not be read (${error.code})`, error);
72
+ }
73
+
74
+ if (!stat.isFile()) {
75
+ throw new ConfigFileError(filePath, 'is not a regular file');
76
+ }
77
+
78
+ let bytes;
79
+
80
+ try {
81
+ bytes = fs.readFileSync(realPath);
82
+ } catch (error) {
83
+ throw new ConfigFileError(filePath, `could not be read (${error.code})`, error);
84
+ }
85
+
86
+ const text = bytes.toString('utf8');
87
+ let config;
88
+
89
+ try {
90
+ config = JSON.parse(text);
91
+ } catch (error) {
92
+ throw new ConfigFileError(filePath, `is not valid JSON (${error.message})`, error);
93
+ }
94
+
95
+ if (!isObject(config)) {
96
+ throw new ConfigFileError(filePath, 'does not hold a JSON object');
97
+ }
98
+
99
+ if (config.hooks !== undefined && !isObject(config.hooks)) {
100
+ throw new ConfigFileError(filePath, 'has a "hooks" value that is not an object');
101
+ }
102
+
103
+ if (config.hooks?.UserPromptSubmit !== undefined && !Array.isArray(config.hooks.UserPromptSubmit)) {
104
+ throw new ConfigFileError(filePath, 'has a "hooks.UserPromptSubmit" value that is not a list');
105
+ }
106
+
107
+ return {exists: true, filePath, realPath, bytes, config, mode: stat.mode & 0o777, format: formatOf(text)};
108
+ }
109
+
110
+ // A file the user made read-only is theirs to change, not KitCode's. A rename replaces a file whatever
111
+ // its mode, as long as its folder is writable, so the file itself is asked: for a symbolic link, the
112
+ // file it points to. The mode, a lock (chflags uchg), an ACL and a read-only disk all count. Returns
113
+ // why KitCode may not write the file, or null.
114
+ function readOnlyRefusal(current) {
115
+ try {
116
+ fs.accessSync(current.realPath, fs.constants.W_OK);
117
+ return null;
118
+ } catch (error) {
119
+ if (!['EACCES', 'EPERM', 'EROFS'].includes(error.code)) {
120
+ throw error;
121
+ }
122
+
123
+ const problem = isSymbolicLink(current.filePath) ? `links to ${current.realPath}, which is read-only` : 'is read-only';
124
+ return Object.assign(new ConfigFileError(current.filePath, problem, error), {readOnly: true});
125
+ }
126
+ }
127
+
128
+ // A copy of the exact bytes KitCode read, with the file's own mode, next to the path the agent
129
+ // reads (not inside the folder a symbolic link points into). Never overwrites an earlier backup.
130
+ function backUp(current) {
131
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
132
+
133
+ for (let attempt = 0; ; attempt += 1) {
134
+ const backupPath = `${current.filePath}.kitcode-${stamp}${attempt > 0 ? `-${attempt}` : ''}.bak`;
135
+ let fd;
136
+
137
+ try {
138
+ fd = fs.openSync(backupPath, 'wx', 0o600);
139
+ } catch (error) {
140
+ if (error.code === 'EEXIST') {
141
+ continue;
142
+ }
143
+
144
+ throw error;
145
+ }
146
+
147
+ try {
148
+ fs.fchmodSync(fd, current.mode);
149
+ fs.writeFileSync(fd, current.bytes);
150
+ fs.fsyncSync(fd);
151
+ } finally {
152
+ fs.closeSync(fd);
153
+ }
154
+
155
+ return backupPath;
156
+ }
157
+ }
158
+
159
+ // Replaces the file with `config`: a backup of the previous bytes first, then an atomic write that
160
+ // keeps the file's mode. A new file is created 0600, with the folder it needs. A read-only file is
161
+ // refused before anything is written, its backup included.
162
+ function writeConfig(current, config) {
163
+ const format = current.exists ? current.format : {indent: 2, finalNewline: true};
164
+ let backupPath = null;
165
+
166
+ if (current.exists) {
167
+ const readOnly = readOnlyRefusal(current);
168
+
169
+ if (readOnly) {
170
+ throw readOnly;
171
+ }
172
+
173
+ // A user, or the agent itself, may have saved the file since KitCode read it.
174
+ if (!fs.readFileSync(current.realPath).equals(current.bytes)) {
175
+ throw new ConfigFileError(current.filePath, 'changed while KitCode was updating it; run the command again');
176
+ }
177
+
178
+ backupPath = backUp(current);
179
+ } else {
180
+ fs.mkdirSync(path.dirname(current.realPath), {recursive: true});
181
+ }
182
+
183
+ writeFileAtomically(current.realPath, serializeConfig(config, format), {mode: current.exists ? current.mode : NEW_CONFIG_MODE});
184
+ return backupPath;
185
+ }
186
+
187
+ function asConfigFileError(filePath, error) {
188
+ return error instanceof ConfigFileError
189
+ ? error
190
+ : new ConfigFileError(filePath, `could not be written (${error.code ?? error.message})`, error);
191
+ }
192
+
193
+ function hookCommandFor(source) {
194
+ return specHookCommandFor(source, {runnerPath: runnerPath()});
195
+ }
196
+
197
+ function createKitCodeHook(source) {
198
+ return hookConfigFor(source, {runnerPath: runnerPath()});
199
+ }
200
+
201
+ function isKitCodeHook(source, hook) {
202
+ return hook?.type === 'command' && (
203
+ hook.command === hookCommandFor(source) ||
204
+ hook.command === specHookCommandFor(source) ||
205
+ hook.command === legacyHookCommandFor(source) ||
206
+ // What KitCode wrote on Windows before 2026-09-29: the runner's path quoted for sh, which PowerShell
207
+ // cannot parse (docs/security.md, "The prompt hook on Windows"). `on` replaces it and `off` removes
208
+ // it. Elsewhere it is the command itself.
209
+ hook.command === specHookCommandFor(source, {runnerPath: runnerPath(), platform: 'linux'})
210
+ );
211
+ }
212
+
213
+ function hasKitCodeHook(source, group) {
214
+ return isObject(group) && Array.isArray(group.hooks) && group.hooks.some((hook) => isKitCodeHook(source, hook));
215
+ }
216
+
217
+ // The KitCode entries in hooks.UserPromptSubmit, as they are in the file.
218
+ function kitCodeEntries(source, config) {
219
+ const groups = Array.isArray(config.hooks?.UserPromptSubmit) ? config.hooks.UserPromptSubmit : [];
220
+
221
+ return groups
222
+ .filter((group) => hasKitCodeHook(source, group))
223
+ .flatMap((group) => group.hooks.filter((hook) => isKitCodeHook(source, hook)));
224
+ }
225
+
226
+ // What to add by hand when KitCode may not write the file itself.
227
+ export function manualHookSnippet(source) {
228
+ return {
229
+ hooks: {
230
+ UserPromptSubmit: [
231
+ {
232
+ matcher: '',
233
+ hooks: [createKitCodeHook(source)],
234
+ },
235
+ ],
236
+ },
237
+ };
238
+ }
239
+
240
+ // Leaves exactly one KitCode entry, `entry`, in hooks.UserPromptSubmit. An existing KitCode entry
241
+ // is replaced where it stands; any others go, with groups that only held them. Returns whether the
242
+ // config changed.
243
+ function placeKitCodeHook(source, config, entry) {
244
+ const groups = Array.isArray(config.hooks?.UserPromptSubmit) ? config.hooks.UserPromptSubmit : [];
245
+ const nextGroups = [];
246
+ let placed = false;
247
+ let changed = false;
248
+
249
+ for (const group of groups) {
250
+ if (!hasKitCodeHook(source, group)) {
251
+ nextGroups.push(group);
252
+ continue;
253
+ }
254
+
255
+ const hooks = [];
256
+
257
+ for (const hook of group.hooks) {
258
+ if (!isKitCodeHook(source, hook)) {
259
+ hooks.push(hook);
260
+ } else if (!placed) {
261
+ placed = true;
262
+ changed ||= !isDeepStrictEqual(hook, entry);
263
+ hooks.push(entry);
264
+ } else {
265
+ changed = true;
266
+ }
267
+ }
268
+
269
+ if (hooks.length > 0) {
270
+ nextGroups.push({...group, hooks});
271
+ } else {
272
+ changed = true;
273
+ }
274
+ }
275
+
276
+ if (!placed) {
277
+ nextGroups.push({
278
+ matcher: '',
279
+ hooks: [entry],
280
+ });
281
+ changed = true;
282
+ }
283
+
284
+ if (changed) {
285
+ config.hooks = {...(isObject(config.hooks) ? config.hooks : {}), UserPromptSubmit: nextGroups};
286
+ }
287
+
288
+ return changed;
289
+ }
290
+
291
+ // Removes every KitCode entry from hooks.UserPromptSubmit, and the groups and keys that removing
292
+ // them leaves empty. Returns whether there was anything to remove.
293
+ function removeKitCodeHooks(source, config) {
294
+ const groups = config.hooks?.UserPromptSubmit;
295
+
296
+ if (!Array.isArray(groups) || !groups.some((group) => hasKitCodeHook(source, group))) {
297
+ return false;
298
+ }
299
+
300
+ const nextGroups = [];
301
+
302
+ for (const group of groups) {
303
+ if (!hasKitCodeHook(source, group)) {
304
+ nextGroups.push(group);
305
+ continue;
306
+ }
307
+
308
+ const hooks = group.hooks.filter((hook) => !isKitCodeHook(source, hook));
309
+
310
+ if (hooks.length > 0) {
311
+ nextGroups.push({...group, hooks});
312
+ }
313
+ }
314
+
315
+ if (nextGroups.length > 0) {
316
+ config.hooks.UserPromptSubmit = nextGroups;
317
+ } else {
318
+ delete config.hooks.UserPromptSubmit;
319
+ }
320
+
321
+ if (Object.keys(config.hooks).length === 0) {
322
+ delete config.hooks;
323
+ }
324
+
325
+ return true;
326
+ }
327
+
328
+ export function isHookInstalled(source) {
329
+ return hookStatus(source).installed === true;
330
+ }
331
+
332
+ // `action` is what happened: 'installed', 'unchanged' (already there), or 'refused' (the file was
333
+ // left as it is; `error` says why). Refused because the file is read-only, it also has `entries`: the
334
+ // KitCode entries already in the file, which the one added by hand replaces.
335
+ export function installHook(source) {
336
+ const filePath = configPath(source);
337
+ const command = hookCommandFor(source);
338
+ let entries = [];
339
+
340
+ try {
341
+ const current = readConfig(filePath);
342
+ const config = current.exists ? current.config : {};
343
+ entries = kitCodeEntries(source, config);
344
+
345
+ if (!placeKitCodeHook(source, config, createKitCodeHook(source))) {
346
+ return {installed: true, action: 'unchanged', path: filePath, command};
347
+ }
348
+
349
+ const backupPath = writeConfig(current, config);
350
+ return {installed: true, action: 'installed', path: filePath, command, backupPath};
351
+ } catch (error) {
352
+ const refusal = asConfigFileError(filePath, error);
353
+ return {installed: false, action: 'refused', path: filePath, command, error: refusal, ...(refusal.readOnly ? {entries} : {})};
354
+ }
355
+ }
356
+
357
+ // `action`: 'removed', 'not-found' (no file, or no KitCode entry in it; nothing was written), or
358
+ // 'refused' (the file was left as it is; `error` says why). Refused because the file is read-only, it
359
+ // also has `entries`: the KitCode entries to delete by hand.
360
+ export function uninstallHook(source) {
361
+ const filePath = configPath(source);
362
+ const command = hookCommandFor(source);
363
+ let entries = [];
364
+
365
+ try {
366
+ const current = readConfig(filePath);
367
+ entries = current.exists ? kitCodeEntries(source, current.config) : [];
368
+
369
+ if (!current.exists || !removeKitCodeHooks(source, current.config)) {
370
+ return {installed: false, action: 'not-found', path: filePath, command};
371
+ }
372
+
373
+ const backupPath = writeConfig(current, current.config);
374
+ return {installed: false, action: 'removed', path: filePath, command, backupPath};
375
+ } catch (error) {
376
+ const refusal = asConfigFileError(filePath, error);
377
+ return {installed: null, action: 'refused', path: filePath, command, error: refusal, ...(refusal.readOnly ? {entries} : {})};
378
+ }
379
+ }
380
+
381
+ // `installed` is null when the file cannot be read: that says nothing about the hook. A file KitCode
382
+ // can read but may not write also has `error`, which says why, and `entries`, the KitCode entries in
383
+ // it.
384
+ export function hookStatus(source) {
385
+ const filePath = configPath(source);
386
+ const command = hookCommandFor(source);
387
+
388
+ try {
389
+ const current = readConfig(filePath);
390
+ const entries = current.exists ? kitCodeEntries(source, current.config) : [];
391
+ const readOnly = current.exists ? readOnlyRefusal(current) : null;
392
+
393
+ return {installed: entries.length > 0, path: filePath, command, ...(readOnly ? {error: readOnly, entries} : {})};
394
+ } catch (error) {
395
+ return {installed: null, path: filePath, command, error: asConfigFileError(filePath, error)};
396
+ }
397
+ }
@@ -0,0 +1,201 @@
1
+ import path from 'node:path';
2
+ import {spawn} from 'node:child_process';
3
+ import {appendLogLine} from './log-file.mjs';
4
+ import {formatBreakDuration, nextBreakReminder} from './next-break-reminder.mjs';
5
+ import {getDiskRewardSummary, getReadyUnannouncedTiers, markTiersAnnounced} from './reward.mjs';
6
+ import {STORE_DIR, StateLockError, withStateLock} from './store.mjs';
7
+ import {isProcessAlive, readTrackerRecord} from './tracker-instance.mjs';
8
+ import {problemSentences, TRACKER_STOPPED} from './tracker-problems.mjs';
9
+
10
+ export const HOOK_LOG_PATH = path.join(STORE_DIR, 'hook.log');
11
+
12
+ // The hook has 5 s in all (integration-spec.mjs). Another KitCode process holds the state lock for
13
+ // milliseconds at a time; waiting longer than this means it is stuck, or busy with a large write.
14
+ const HOOK_LOCK_TIMEOUT_MS = 1000;
15
+
16
+ function isDisabled() {
17
+ return ['1', 'true', 'yes'].includes(String(process.env.KITCODE_HOOKS_OFF ?? '').toLowerCase());
18
+ }
19
+
20
+ function isKitCodeManagementPrompt(prompt) {
21
+ const trimmed = String(prompt ?? '').trim().toLowerCase();
22
+
23
+ return trimmed === 'kitcode' ||
24
+ trimmed.startsWith('kitcode ') ||
25
+ trimmed === '/kitcode' ||
26
+ trimmed.startsWith('/kitcode ');
27
+ }
28
+
29
+ // Bounded (log-file.mjs), and it never throws: the hook fails open even when logging fails.
30
+ function logHookError(error) {
31
+ appendLogLine(HOOK_LOG_PATH, error?.stack ?? error?.message ?? String(error));
32
+ }
33
+
34
+ // What the hook says about the tracker, in fixed words only (tracker-problems.mjs): tracker.json is
35
+ // the user's file, and only a pid, two times and problem codes are read from it.
36
+ // The tracker writes tracker.json at least every 10 s; a minute without means it is stuck.
37
+ const HEARTBEAT_STALE_MS = 60_000;
38
+
39
+ function trackerContext(now = Date.now()) {
40
+ const record = readTrackerRecord();
41
+
42
+ if (!record || !isProcessAlive(record.pid)) {
43
+ return TRACKER_STOPPED;
44
+ }
45
+
46
+ // A tracker from before 2026-09-24 writes no heartbeat, and is taken at its word.
47
+ const heartbeat = Date.parse(record.heartbeatAt);
48
+
49
+ if (Number.isFinite(heartbeat) && now - heartbeat > HEARTBEAT_STALE_MS) {
50
+ return 'KitCode tracker is not responding, so focus time may not be counted.';
51
+ }
52
+
53
+ if (record.tracking !== 'degraded') {
54
+ return '';
55
+ }
56
+
57
+ return problemSentences(record.problems).join(' ');
58
+ }
59
+
60
+ function readStdin() {
61
+ return new Promise((resolve, reject) => {
62
+ let input = '';
63
+
64
+ process.stdin.setEncoding('utf8');
65
+ process.stdin.on('data', (chunk) => {
66
+ input += chunk;
67
+ });
68
+ process.stdin.on('error', reject);
69
+ process.stdin.on('end', () => resolve(input));
70
+ });
71
+ }
72
+
73
+ function notify(title, message) {
74
+ const command = process.platform === 'darwin'
75
+ ? {
76
+ bin: 'osascript',
77
+ args: ['-e', `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)}`],
78
+ }
79
+ : process.platform === 'win32'
80
+ ? {
81
+ bin: 'powershell.exe',
82
+ args: ['-NoProfile', '-Command', `[void][System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show(${JSON.stringify(message)}, ${JSON.stringify(title)})`],
83
+ }
84
+ : {
85
+ bin: 'notify-send',
86
+ args: [title, message],
87
+ };
88
+
89
+ try {
90
+ const child = spawn(command.bin, command.args, {
91
+ detached: true,
92
+ stdio: 'ignore',
93
+ windowsHide: true,
94
+ });
95
+
96
+ child.on('error', () => {});
97
+ child.unref();
98
+ } catch {
99
+ // Desktop notifications are best-effort.
100
+ }
101
+ }
102
+
103
+ function progressContextFor(reward) {
104
+ const nextBreak = reward.nextBreak ?? nextBreakReminder(reward);
105
+ const ready = reward.milestones.filter((entry) => entry.status === 'ready' && !entry.redeemed);
106
+ const parts = [
107
+ `KitCode progress: ${reward.totalEquals} = counted, ${formatBreakDuration(reward.earnedSeconds)} active.`,
108
+ ];
109
+
110
+ if (ready.length > 0) {
111
+ parts.push(`Reward ready at ${ready.map((entry) => `${entry.percent}%`).join(', ')}. Mention \`kitcode dashboard\` briefly after this turn.`);
112
+ }
113
+
114
+ if (nextBreak) {
115
+ const almost = nextBreak.almost ? ' Almost there.' : '';
116
+ const equalsCopy = nextBreak.equalsCopy ?? `${nextBreak.equalsLeft} = to break`;
117
+ const timeCopy = nextBreak.timeCopy ?? `${nextBreak.durationLeft} to break`;
118
+ parts.push(
119
+ `Next break milestone: ${nextBreak.percent}% (${nextBreak.progressPercent}% there; ${equalsCopy}; ${timeCopy}).${almost}`,
120
+ );
121
+ parts.push(`After this turn, briefly mention remaining progress in one short line like: ${nextBreak.mentionLine}`);
122
+ }
123
+
124
+ return parts.join(' ');
125
+ }
126
+
127
+ function hookOutputFor({tiers = [], reward = null}) {
128
+ const progressContext = [reward ? progressContextFor(reward) : '', trackerContext()].filter(Boolean).join(' ');
129
+
130
+ if (tiers.length === 0) {
131
+ return {
132
+ hookSpecificOutput: {
133
+ hookEventName: 'UserPromptSubmit',
134
+ additionalContext: progressContext,
135
+ },
136
+ };
137
+ }
138
+
139
+ const labels = tiers.map((tier) => `${tier.percent}%`).join(', ');
140
+ const command = 'kitcode dashboard';
141
+ const additionalContext = [
142
+ progressContext,
143
+ `KitCode break code ready (${labels}). Briefly tell the user they can open the dashboard with \`${command}\` after this turn.`,
144
+ ].filter(Boolean).join(' ');
145
+
146
+ return {
147
+ hookSpecificOutput: {
148
+ hookEventName: 'UserPromptSubmit',
149
+ additionalContext,
150
+ },
151
+ };
152
+ }
153
+
154
+ // What the hook reports, and the tiers it announces, from one read of state.json. The read and the
155
+ // announcement happen under the state lock, so a tier is announced once per agent, and the file is
156
+ // written only when something is announced. If the lock stays busy, progress is still reported from
157
+ // a plain read, and the announcement waits for a later prompt.
158
+ function collectPromptProgress(source) {
159
+ try {
160
+ return withStateLock(() => {
161
+ const reward = getDiskRewardSummary();
162
+ const readyTiers = getReadyUnannouncedTiers(source);
163
+
164
+ return {reward, announced: markTiersAnnounced(source, readyTiers)};
165
+ }, {lockTimeoutMs: HOOK_LOCK_TIMEOUT_MS});
166
+ } catch (error) {
167
+ if (!(error instanceof StateLockError)) {
168
+ throw error;
169
+ }
170
+
171
+ return {reward: getDiskRewardSummary(), announced: []};
172
+ }
173
+ }
174
+
175
+ export async function runPromptHook({source}) {
176
+ if (isDisabled()) {
177
+ return;
178
+ }
179
+
180
+ try {
181
+ const input = await readStdin();
182
+ const payload = JSON.parse(input || '{}');
183
+
184
+ if (isKitCodeManagementPrompt(payload.prompt)) {
185
+ return;
186
+ }
187
+
188
+ const {reward, announced} = collectPromptProgress(source);
189
+
190
+ if (announced.length === 0) {
191
+ process.stdout.write(`${JSON.stringify(hookOutputFor({reward}))}\n`);
192
+ return;
193
+ }
194
+
195
+ const label = announced.map((tier) => `${tier.percent}%`).join(', ');
196
+ notify('KitCode break code ready', `Milestone ${label} unlocked. Open kitcode dashboard.`);
197
+ process.stdout.write(`${JSON.stringify(hookOutputFor({tiers: announced, reward}))}\n`);
198
+ } catch (error) {
199
+ logHookError(error);
200
+ }
201
+ }
@@ -0,0 +1,94 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {syncDirectory} from './atomic-file.mjs';
5
+ import {STORE_DIR} from './store.mjs';
6
+
7
+ // The install id the claim bundle carries (docs/backend.md, "The contract"): a random version 4 UUID,
8
+ // made once per HOME, in ~/.kitcode/install-id. It says which claims came from one install; it names no
9
+ // person, machine or project.
10
+ //
11
+ // It has a file of its own, and is not in state.json, because 0.1.39 rewrites state.json from a copy
12
+ // holding only the fields it knows (its runtime.mjs normalizeState keeps version, projects and
13
+ // onboarding), so a field added there is gone the first time 0.1.39 runs `add`, `list` or `status`, or
14
+ // its tracker saves. 0.1.39 never opens this file. `kitcode uninstall` deletes it with the rest of
15
+ // ~/.kitcode.
16
+ export const INSTALL_ID_PATH = path.join(STORE_DIR, 'install-id');
17
+ export const INSTALL_ID_DISPLAY_PATH = '~/.kitcode/install-id';
18
+
19
+ const INSTALL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
20
+ const INSTALL_ID_MODE = 0o600;
21
+
22
+ // An install-id file that cannot be read, or does not hold an install id.
23
+ export class InstallIdError extends Error {
24
+ constructor(problem, cause) {
25
+ super(`KitCode cannot read ${INSTALL_ID_PATH}: ${problem}. KitCode has left it as it is: a new id would make this install look like another.`, {cause});
26
+ this.name = 'InstallIdError';
27
+ }
28
+ }
29
+
30
+ // The install id, or null when there is none yet. A file that cannot be read, or does not hold an
31
+ // install id, throws an InstallIdError, and is never replaced.
32
+ export function readInstallId() {
33
+ let text;
34
+
35
+ try {
36
+ text = fs.readFileSync(INSTALL_ID_PATH, 'utf8');
37
+ } catch (error) {
38
+ if (error.code === 'ENOENT') {
39
+ return null;
40
+ }
41
+
42
+ throw new InstallIdError(error.code ? `${error.code}` : String(error), error);
43
+ }
44
+
45
+ const id = text.endsWith('\n') ? text.slice(0, -1) : text;
46
+
47
+ if (!INSTALL_ID.test(id)) {
48
+ throw new InstallIdError('it does not hold an install id');
49
+ }
50
+
51
+ return id;
52
+ }
53
+
54
+ // Makes the install id, once per HOME, and returns it; one already made is returned as it is. Of two
55
+ // processes making it at once, one id is kept: each writes its id whole to a temporary file, then links
56
+ // that into place, which the filesystem refuses once the name is taken, and the one refused reads the
57
+ // other's. The file is never written over, so no reader ever sees half an id or a second one.
58
+ export function ensureInstallId() {
59
+ const existing = readInstallId();
60
+
61
+ if (existing !== null) {
62
+ return existing;
63
+ }
64
+
65
+ fs.mkdirSync(STORE_DIR, {recursive: true});
66
+ const id = crypto.randomUUID();
67
+ const tempPath = path.join(STORE_DIR, `.install-id.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
68
+ const fd = fs.openSync(tempPath, 'wx', INSTALL_ID_MODE);
69
+
70
+ try {
71
+ try {
72
+ fs.fchmodSync(fd, INSTALL_ID_MODE);
73
+ fs.writeFileSync(fd, `${id}\n`);
74
+ fs.fsyncSync(fd);
75
+ } finally {
76
+ fs.closeSync(fd);
77
+ }
78
+
79
+ try {
80
+ fs.linkSync(tempPath, INSTALL_ID_PATH);
81
+ } catch (error) {
82
+ if (error.code !== 'EEXIST') {
83
+ throw error;
84
+ }
85
+
86
+ return readInstallId();
87
+ }
88
+
89
+ syncDirectory(STORE_DIR);
90
+ return id;
91
+ } finally {
92
+ fs.rmSync(tempPath, {force: true});
93
+ }
94
+ }