@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,28 @@
1
+ import {hookStatus, installHook, uninstallHook} from './hook-installers.mjs';
2
+ import {installRunner, runnerStatus} from './runner-installer.mjs';
3
+ import {installSkill, skillStatus, uninstallSkill} from './skill-installer.mjs';
4
+
5
+ export function installIntegration(source) {
6
+ const runner = installRunner();
7
+
8
+ return {
9
+ runner,
10
+ hook: installHook(source),
11
+ skill: installSkill(source),
12
+ };
13
+ }
14
+
15
+ export function uninstallIntegration(source) {
16
+ return {
17
+ hook: uninstallHook(source),
18
+ skill: uninstallSkill(source),
19
+ };
20
+ }
21
+
22
+ export function integrationStatus(source) {
23
+ return {
24
+ runner: runnerStatus(),
25
+ hook: hookStatus(source),
26
+ skill: skillStatus(source),
27
+ };
28
+ }
@@ -0,0 +1,398 @@
1
+ export const CLI_PACKAGE_NAME = '@kitkat-vn/kitcode';
2
+ export const CLI_PACKAGE_COMMAND = `npx ${CLI_PACKAGE_NAME}`;
3
+ export const CLI_GLOBAL_INSTALL_COMMAND = `npm install -g ${CLI_PACKAGE_NAME}`;
4
+ // A staging round's tester build, once one is published under npm's `staging` tag (stagingSetupPromptFor, below).
5
+ export const CLI_STAGING_INSTALL_COMMAND = `${CLI_GLOBAL_INSTALL_COMMAND}@staging`;
6
+ export const CLI_GLOBAL_COMMAND = 'kitcode';
7
+ export const DASHBOARD_URL = "https://staging.kitcode.vn/";
8
+ export const LOCAL_SERVER_URL = 'http://127.0.0.1:4747';
9
+ export const STATE_DISPLAY_PATH = '~/.kitcode/state.json';
10
+ export const RUNNER_PATH_PARTS = ['.kitcode', 'bin', 'kitcode'];
11
+ export const RUNNER_DISPLAY_PATH = `~/${RUNNER_PATH_PARTS.join('/')}`;
12
+
13
+ export const KITCODE_REWARD_TIERS = [
14
+ {percent: 10, code: 'if(tired){return 10;}', requiredEquals: 3},
15
+ {percent: 20, code: 'takeBreak(20);', requiredEquals: 6},
16
+ {percent: 30, code: 'while(working)break(30);', requiredEquals: 9},
17
+ ];
18
+
19
+ export const KITCODE_DISPLAY_MILESTONES = [
20
+ {percent: 50, code: 'mediumStake.unlock(50);', requiredEquals: 12, displayOnly: true},
21
+ {percent: 100, code: 'finalBreak.claim(100);', requiredEquals: 15, displayOnly: true},
22
+ ];
23
+
24
+ export const AGENT_SPECS = {
25
+ codex: {
26
+ source: 'codex',
27
+ agentName: 'Codex',
28
+ setupCommand: `${CLI_GLOBAL_COMMAND} codex on`,
29
+ statusCommand: `${CLI_GLOBAL_COMMAND} codex status`,
30
+ offCommand: `${CLI_GLOBAL_COMMAND} codex off`,
31
+ skillPathParts: ['.codex', 'skills', 'kitcode', 'SKILL.md'],
32
+ hookConfigPathParts: ['.codex', 'hooks.json'],
33
+ hookEventName: 'UserPromptSubmit',
34
+ },
35
+ claude: {
36
+ source: 'claude',
37
+ agentName: 'Claude',
38
+ setupCommand: `${CLI_GLOBAL_COMMAND} claude on`,
39
+ statusCommand: `${CLI_GLOBAL_COMMAND} claude status`,
40
+ offCommand: `${CLI_GLOBAL_COMMAND} claude off`,
41
+ skillPathParts: ['.claude', 'skills', 'kitcode', 'SKILL.md'],
42
+ hookConfigPathParts: ['.claude', 'settings.json'],
43
+ hookEventName: 'UserPromptSubmit',
44
+ },
45
+ };
46
+
47
+ export function kitCodeCommand(command = '') {
48
+ return command ? `${CLI_PACKAGE_COMMAND} ${command}` : CLI_PACKAGE_COMMAND;
49
+ }
50
+
51
+ export function globalKitCodeCommand(command = '') {
52
+ return command ? `${CLI_GLOBAL_COMMAND} ${command}` : CLI_GLOBAL_COMMAND;
53
+ }
54
+
55
+ export function sourceConfig(source) {
56
+ const config = AGENT_SPECS[source];
57
+
58
+ if (!config) {
59
+ throw new Error(`Unsupported KitCode integration source: ${source}`);
60
+ }
61
+
62
+ return config;
63
+ }
64
+
65
+ export function displayHomePath(pathParts) {
66
+ return `~/${pathParts.join('/')}`;
67
+ }
68
+
69
+ export function shellQuote(value) {
70
+ const text = String(value);
71
+
72
+ if (/^[A-Za-z0-9_./:@%+=,~-]+$/.test(text)) {
73
+ return text;
74
+ }
75
+
76
+ return `'${text.replaceAll("'", "'\"'\"'")}'`;
77
+ }
78
+
79
+ // On Windows, Claude Code runs a hook's command in Git Bash, or in PowerShell when Git Bash is not
80
+ // installed, and Codex runs it in PowerShell (docs/security.md, "The prompt hook on Windows"). A command
81
+ // a shell cannot parse fails there, and one bash cannot parse exits 2, which blocks the user's prompt and
82
+ // erases it. So on Windows the command is one both shells read the same way: `node`, which both find on
83
+ // PATH, then the runner module beside the runner (runner-installer.mjs), as one quoted argument with
84
+ // forward slashes, which both take in a path and neither reads as an escape. It is in double quotes,
85
+ // unless the path holds $ or a backtick, which both expand there, or a quote PowerShell ends them with;
86
+ // then in single quotes, in which neither expands anything, unless the path also holds a quote either
87
+ // shell ends those with. Such a path keeps the command macOS and Linux have, which bash parses whatever
88
+ // the path, and so never exits 2 for.
89
+ const DOUBLE_QUOTED_UNSAFE = /[$`"“”„]/u;
90
+ const SINGLE_QUOTED_UNSAFE = /['‘’‚‛]/u;
91
+
92
+ function windowsRunnerArgument(runnerPath) {
93
+ const runnerModule = `${runnerPath.replaceAll('\\', '/')}.mjs`;
94
+
95
+ if (!DOUBLE_QUOTED_UNSAFE.test(runnerModule)) {
96
+ return `"${runnerModule}"`;
97
+ }
98
+
99
+ return SINGLE_QUOTED_UNSAFE.test(runnerModule) ? null : `'${runnerModule}'`;
100
+ }
101
+
102
+ export function hookCommandFor(source, options = {}) {
103
+ sourceConfig(source);
104
+
105
+ const runnerPath = options.runnerPath ?? RUNNER_DISPLAY_PATH;
106
+ // globalThis: the dashboard bundles this file for its setup prompts, and a browser has no process.
107
+ const platform = options.platform ?? globalThis.process?.platform;
108
+ const windowsArgument = platform === 'win32' && options.runnerPath !== undefined ? windowsRunnerArgument(runnerPath) : null;
109
+
110
+ // Everywhere else, and in the display form the setup prompts show, the command KitCode has always
111
+ // written: Codex makes its users approve a hook again whenever its command changes (docs/security.md).
112
+ return windowsArgument === null
113
+ ? `${shellQuote(runnerPath)} hook prompt --source ${source}`
114
+ : `node ${windowsArgument} hook prompt --source ${source}`;
115
+ }
116
+
117
+ export function legacyHookCommandFor(source) {
118
+ sourceConfig(source);
119
+
120
+ return `kitcode hook prompt --source ${source}`;
121
+ }
122
+
123
+ export function hookConfigFor(source, options = {}) {
124
+ return {
125
+ type: 'command',
126
+ command: hookCommandFor(source, options),
127
+ timeout: 5,
128
+ };
129
+ }
130
+
131
+ const REMOTE_SESSION_REDIRECT = {
132
+ codex: 'Run commands on my local machine shell, not a remote sandbox. If you only have a remote session, tell me to paste this prompt into Codex Task or a Codex project chat instead.',
133
+ claude: 'Run commands on my local machine shell, not a remote sandbox. If you only have a remote session, tell me to paste this prompt into Claude Code instead.',
134
+ };
135
+
136
+ export function kitCodeSkillMarkdown(source) {
137
+ const config = sourceConfig(source);
138
+ const skillPath = displayHomePath(config.skillPathParts);
139
+
140
+ return [
141
+ '---',
142
+ 'name: kitcode',
143
+ `description: "KitKat Have-a-break -- local-first reward companion for ${config.agentName}. Use for KitCode setup, tracker status, rewards, and dashboard requests. Use KitCode CLI and hook context as the source of truth; do not calculate or mutate rewards yourself."`,
144
+ '---',
145
+ '',
146
+ '# KitCode',
147
+ '',
148
+ 'Use this skill only as an opt-in break reward. Never block, gate, delay, or nag about work.',
149
+ '',
150
+ 'KitCode is local-first. The CLI owns tracking, reward state, and break codes. The durable local state lives at:',
151
+ '',
152
+ ` ${STATE_DISPLAY_PATH}`,
153
+ '',
154
+ 'The local dashboard reads from the KitCode server at:',
155
+ '',
156
+ ` ${LOCAL_SERVER_URL}`,
157
+ '',
158
+ 'When KitCode hook context says a reward is ready, briefly tell the user they can open:',
159
+ '',
160
+ ` ${RUNNER_DISPLAY_PATH} dashboard`,
161
+ '',
162
+ 'The prompt hook runs after each prompt submit and may add compact progress context such as total `=` counted, active time, next break remaining (`=` left and time left), or reward readiness. Treat that hook context as authoritative.',
163
+ 'When hook context includes remaining equals and time to the next break milestone, briefly mention both in one short line after your normal answer on every turn (for example: `Break next: 4 = to break · 18m to break (20%).`). Do not calculate these values yourself.',
164
+ '',
165
+ 'Do not run git counting commands, do not edit the ledger, and do not reimplement reward logic in chat. Use the KitCode CLI/server/hook output as the authority.',
166
+ '',
167
+ '## Chat actions',
168
+ '',
169
+ 'When the user directly asks to open a KitCode surface, run the matching command without asking for confirmation. These actions are opt-in and local:',
170
+ '',
171
+ `- Open dashboard — for “mở dashboard”, “xem tiến độ”, “show my KitCode progress”, or equivalent: \`${RUNNER_DISPLAY_PATH} dashboard\`.`,
172
+ '',
173
+ 'When the user starts a message with `/kitcode`, treat it as a KitCode management shortcut and run the matching local CLI command:',
174
+ '',
175
+ `- \`/kitcode track\` or \`/kitcode start\` — run \`${RUNNER_DISPLAY_PATH} track\`.`,
176
+ `- \`/kitcode summary\`, \`/kitcode progress\`, or \`/kitcode status\` — run \`${RUNNER_DISPLAY_PATH} summary\` or \`${RUNNER_DISPLAY_PATH} status\`.`,
177
+ `- \`/kitcode award\`, \`/kitcode awards\`, or \`/kitcode rewards\` — run \`${RUNNER_DISPLAY_PATH} awards\`.`,
178
+ `- \`/kitcode dashboard\` — run \`${RUNNER_DISPLAY_PATH} dashboard\`.`,
179
+ `- \`/kitcode setup\` — run \`${RUNNER_DISPLAY_PATH} setup\`.`,
180
+ '',
181
+ 'If the tracker is not running, run the requested command first and report its CLI error; only start it with the user’s explicit request to start tracking.',
182
+ '',
183
+ 'Useful checks:',
184
+ '',
185
+ ` ${RUNNER_DISPLAY_PATH} ${source} status`,
186
+ ` ${RUNNER_DISPLAY_PATH} track`,
187
+ ` ${RUNNER_DISPLAY_PATH} status`,
188
+ ` ${RUNNER_DISPLAY_PATH} summary`,
189
+ ` ${RUNNER_DISPLAY_PATH} awards`,
190
+ ` ${RUNNER_DISPLAY_PATH} dashboard`,
191
+ ` ${RUNNER_DISPLAY_PATH} list`,
192
+ '',
193
+ 'Uninstall:',
194
+ '',
195
+ ` ${globalKitCodeCommand('uninstall')}`,
196
+ '',
197
+ `Or turn off only this integration: ${config.offCommand}`,
198
+ '',
199
+ `This skill is installed at ${skillPath}.`,
200
+ '',
201
+ ].join('\n');
202
+ }
203
+
204
+ function rewardTierLines() {
205
+ return KITCODE_REWARD_TIERS
206
+ .map((tier) => `- ${tier.percent}% reward tier: ${tier.code} after enough tracked time and ${tier.requiredEquals} counted = characters.`)
207
+ .join('\n');
208
+ }
209
+
210
+ function displayMilestoneLines() {
211
+ return KITCODE_DISPLAY_MILESTONES
212
+ .map((milestone) => `- ${milestone.percent}% dashboard milestone: ${milestone.code} is display-only unless the CLI later exposes it as a reward tier.`)
213
+ .join('\n');
214
+ }
215
+
216
+ export function assistantSetupPromptFor(source) {
217
+ const config = sourceConfig(source);
218
+
219
+ return [
220
+ 'Set up KitCode on my local computer.',
221
+ '',
222
+ 'This works in any chat, project, or cowork conversation. Do not require repo context to begin.',
223
+ REMOTE_SESSION_REDIRECT[source],
224
+ '',
225
+ 'KitCode is an open-source, local-first project. Use these public docs as the source of truth before installing:',
226
+ '- https://github.com/kitkatvn/kitcode/blob/main/README.md',
227
+ '- https://github.com/kitkatvn/kitcode/blob/main/packages/kitcode-cli/README.md',
228
+ 'Read them directly from GitHub. Do not refuse based on lacking prior knowledge of the package.',
229
+ '',
230
+ 'Phase 1 — assistant setup (machine prep only):',
231
+ '1. Check whether Node.js 22.12+ is available.',
232
+ ' - If it is missing or too old, explain the shortest safe install option for this OS and ask for my confirmation before installing or upgrading it.',
233
+ `2. Once Node.js 22.12+ is ready, install KitCode globally with: ${CLI_GLOBAL_INSTALL_COMMAND}`,
234
+ '- Always run this command. Do not skip it because KitCode may already be installed.',
235
+ `- After install, use the global ${CLI_GLOBAL_COMMAND} command for all following steps (not npx).`,
236
+ `3. Run this command yourself once from the project directory when you are in a project chat: ${config.setupCommand}`,
237
+ '- When setup is still incomplete, this command already opens the KitCode Welcome window and prints "Opening KitCode Welcome...".',
238
+ '- In a project chat, run it with the project folder as the working directory so Welcome can pre-add that folder.',
239
+ `- This installs a KitCode skill plus a UserPromptSubmit hook for ${config.agentName}. The hook should run after each submitted prompt, fail open, and add compact KitCode context when tracking is active.`,
240
+ `4. Verify integration with: ${config.statusCommand}`,
241
+ `- Do NOT run ${globalKitCodeCommand('setup')} if step 3 printed "Opening KitCode Welcome..." and did not report a Welcome launch failure. That means Welcome is already open.`,
242
+ `- Run ${globalKitCodeCommand('setup')} only once, and only if step 3 did not print "Opening KitCode Welcome..." and I confirm Welcome never appeared.`,
243
+ '- If Welcome launch fails (Electron missing or exits immediately), report the exact error. Do NOT tell me Welcome is open, and do NOT ask me to complete a window that never appeared.',
244
+ '- Never open Welcome twice. If unsure whether Welcome opened, ask me before running setup.',
245
+ '- Welcome should open as a single stable desktop window without flashing a terminal or other KitCode surface first.',
246
+ '',
247
+ 'Do NOT during Phase 1:',
248
+ `- Do NOT run ${globalKitCodeCommand('add')} or ${globalKitCodeCommand('track')}.`,
249
+ '- Do NOT manually edit KitCode state or calculate rewards.',
250
+ '',
251
+ 'Phase 2 — human setup (required for success):',
252
+ '- Only after Welcome actually opened: I must complete the KitCode Welcome window: confirm project folder(s) and background tracking YES/NO.',
253
+ '- In a project chat, Welcome should already list the current project folder. Outside a project chat, I add folder(s) manually.',
254
+ '- Setup is not complete until I save Welcome with at least one project folder.',
255
+ '',
256
+ 'Actually execute the commands. Do not stop after printing instructions for me to run in another terminal.',
257
+ '- If a command fails, report the exact failure after attempting it and continue with any safe local checks that can unblock setup.',
258
+ '',
259
+ 'After Phase 1, tell me to finish Welcome only if Welcome actually opened. Only then is KitCode ready to track.',
260
+ ].join('\n');
261
+ }
262
+
263
+ // The setup prompt a staging dashboard copies for its invited testers (apps/web src/lib/staging-prompts.ts): the one
264
+ // above, line for line, with three changes and no other. Step 2 installs the round's tester build, from the file the
265
+ // tester downloaded from the staging site, which the dashboard's steps link (install 'file'), or, once a staging
266
+ // version is published, from npm's `staging` tag (install 'npm'), and checks its version. Then, before step 3 turns
267
+ // KitCode on, it stops a tracker an earlier build left running. And it holds no URL: the public docs the prompt above
268
+ // links are not public yet (docs/lessons-learned.md F5). It is made from the prompt above as it stands, so that the two
269
+ // cannot drift: a line it changes that is not there once throws, and check-integration-spec.mjs runs both.
270
+ export function stagingSetupPromptFor(source, {testerVersion, fileName, install}) {
271
+ if (install !== 'file' && install !== 'npm') {
272
+ throw new Error('stagingSetupPromptFor installs from a file or from npm: install must be \'file\' or \'npm\'.');
273
+ }
274
+
275
+ const lines = assistantSetupPromptFor(source).split('\n');
276
+ const onlyLine = (matches, what) => {
277
+ const found = lines.flatMap((line, index) => (matches(line) ? [index] : []));
278
+
279
+ if (found.length !== 1) {
280
+ throw new Error(`stagingSetupPromptFor found ${what} ${found.length} times in the setup prompt, not once: assistantSetupPromptFor changed, and stagingSetupPromptFor must follow it.`);
281
+ }
282
+
283
+ return found[0];
284
+ };
285
+
286
+ // The paragraph of public docs, to the blank line after it, gives way to one line about the round.
287
+ const docs = onlyLine((line) => line.startsWith('KitCode is an open-source, local-first project.'), 'the public docs');
288
+ const docsEnd = lines.indexOf('', docs);
289
+
290
+ if (docsEnd === -1) {
291
+ throw new Error('stagingSetupPromptFor found no blank line after the public docs: assistantSetupPromptFor changed, and stagingSetupPromptFor must follow it.');
292
+ }
293
+
294
+ lines.splice(docs, docsEnd - docs, `KitCode is a local-first project. I am an invited tester of its staging round, which needs KitCode's tester build, ${testerVersion}: step 2 installs it.`);
295
+
296
+ const installLine = onlyLine((line) => line.endsWith(`install KitCode globally with: ${CLI_GLOBAL_INSTALL_COMMAND}`), 'the install');
297
+ lines[installLine] = lines[installLine].replace(
298
+ `with: ${CLI_GLOBAL_INSTALL_COMMAND}`,
299
+ install === 'npm'
300
+ ? `with: ${CLI_STAGING_INSTALL_COMMAND}`
301
+ : `from the tester build I downloaded from KitCode's staging site, ${fileName}. Look for it in my Downloads folder, and if it is not there, ask me where it is. In the folder that holds it, run: npm install -g ./${fileName}`,
302
+ );
303
+
304
+ const globalCommand = onlyLine((line) => line === `- After install, use the global ${CLI_GLOBAL_COMMAND} command for all following steps (not npx).`, 'the global command');
305
+ lines.splice(
306
+ globalCommand + 1,
307
+ 0,
308
+ `- Check that ${globalKitCodeCommand('--version')} prints ${testerVersion}. If it prints anything else, report exactly what it printed, and do not go on to step 3.`,
309
+ `- Then, before step 3, run: ${globalKitCodeCommand('untrack')}. It stops a KitCode tracker that an earlier build left running, so that the tracker Welcome starts is this build's; when none is running, it only says so.`,
310
+ );
311
+
312
+ return lines.join('\n');
313
+ }
314
+
315
+ export function setupPromptFor(source, options = {}) {
316
+ const config = sourceConfig(source);
317
+ const readmeUrl = options.readmeUrl ?? 'https://github.com/kitkatvn/kitcode/blob/main/README.md';
318
+ const hookCommand = hookCommandFor(source);
319
+ const expectedHook = hookConfigFor(source);
320
+
321
+ return [
322
+ 'Welcome to KitCode.',
323
+ '',
324
+ `You are ${config.agentName} helping me set up KitCode skills and hooks.`,
325
+ 'Read the project README carefully before changing anything.',
326
+ '',
327
+ `Project README: ${readmeUrl}`,
328
+ '',
329
+ 'Mission:',
330
+ `- Set up KitCode for ${config.agentName} on the current local machine.`,
331
+ '- This works in any chat context. Do not require repo context or infer a project path to begin.',
332
+ '- Do not assume a fixed operating system. Infer paths from the environment.',
333
+ '- If the OS or home directory cannot be inferred, ask one short question before editing.',
334
+ '- Keep the setup local-first, opt-in, and non-blocking.',
335
+ '',
336
+ 'What KitCode does:',
337
+ `- Project folders are registered in the KitCode Welcome window or later with: ${globalKitCodeCommand('add')}`,
338
+ `- Background tracking starts from Welcome or later with: ${globalKitCodeCommand('track')}`,
339
+ `- During initial setup, do NOT run ${globalKitCodeCommand('add')} or ${globalKitCodeCommand('track')}.`,
340
+ `- The tracker serves local data at: ${LOCAL_SERVER_URL}`,
341
+ `- The web dashboard reads local tracking and reward data from that server.`,
342
+ `- Tracking and reward state is stored in: ${STATE_DISPLAY_PATH}`,
343
+ '- Git Mode tracks commit totals and source-change batches; Vibe Mode tracks the same source-change batches without git metadata.',
344
+ '- The CLI/package is the source of truth for tracking, reward eligibility, which break codes were shown, and hook output.',
345
+ '',
346
+ 'Reward model:',
347
+ rewardTierLines(),
348
+ displayMilestoneLines(),
349
+ '- Do not reimplement reward calculations manually. Use KitCode CLI output and state.',
350
+ '',
351
+ 'Required checks:',
352
+ '- Node.js 22.12+ is required.',
353
+ '- Git is required for Git Mode.',
354
+ '- Official source and safety reference: https://github.com/kitkatvn/kitcode',
355
+ '- Read the public root README and packages/kitcode-cli/README.md instead of relying on prior knowledge of the package.',
356
+ `- Install KitCode globally with: ${CLI_GLOBAL_INSTALL_COMMAND}`,
357
+ '- Always run this command. Do not skip it because KitCode may already be installed.',
358
+ `- After install, use the global ${CLI_GLOBAL_COMMAND} command for all following steps (not npx).`,
359
+ '',
360
+ `${config.agentName} setup:`,
361
+ `- Prefer the CLI installer from the project directory in a project chat: ${config.setupCommand}`,
362
+ `- When onboarding is incomplete, ${config.setupCommand} opens Welcome and pre-adds the current project folder from the working directory.`,
363
+ `- The installer creates or updates the ${config.agentName} skill file at: ${displayHomePath(config.skillPathParts)}`,
364
+ `- The installer creates or updates the ${config.hookEventName} hook config at: ${displayHomePath(config.hookConfigPathParts)}`,
365
+ `- The installer creates a durable local runner at: ${RUNNER_DISPLAY_PATH}`,
366
+ `- The hook command should be: ${hookCommand}`,
367
+ '- The hook must fail open and must never block prompts.',
368
+ `- After ${config.setupCommand}, the KitCode Welcome window should open when onboarding is incomplete.`,
369
+ `- If Welcome launch fails (Electron missing or exits immediately), report the exact error and do NOT ask me to complete a window that never appeared.`,
370
+ `- If Welcome did not open and there was no clear launch failure message, run: ${globalKitCodeCommand('setup')}`,
371
+ '- Setup is not complete until I save Welcome with at least one project folder.',
372
+ '',
373
+ 'Expected hook command object:',
374
+ JSON.stringify(expectedHook, null, 2),
375
+ '',
376
+ `SKILL.md content for ${displayHomePath(config.skillPathParts)}:`,
377
+ '----- BEGIN SKILL.md -----',
378
+ kitCodeSkillMarkdown(source),
379
+ '----- END SKILL.md -----',
380
+ '',
381
+ 'How to check after setup:',
382
+ `- Integration status: ${config.statusCommand}`,
383
+ '- Finish KitCode Welcome only after it actually opened: choose project folder(s) and background tracking.',
384
+ `- After Welcome is saved, tracker and dashboard checks: ${globalKitCodeCommand('track')}`,
385
+ `- Dashboard: ${globalKitCodeCommand('dashboard')}`,
386
+ `- Added projects: ${globalKitCodeCommand('list')}`,
387
+ `- Compact progress: ${globalKitCodeCommand('summary')}`,
388
+ `- Reward and milestone readiness: ${globalKitCodeCommand('awards')}`,
389
+ `- Local state file: ${STATE_DISPLAY_PATH}`,
390
+ `- Dashboard: ${DASHBOARD_URL}`,
391
+ '',
392
+ 'How to uninstall:',
393
+ `- Remove everything locally: ${globalKitCodeCommand('uninstall')}`,
394
+ `- Or turn off only this integration: ${config.offCommand}`,
395
+ '',
396
+ 'Finish with a concise summary of files changed, commands run, and anything I should run next.',
397
+ ].join('\n');
398
+ }
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // KitCode's own logs, ~/.kitcode/hook.log and tracker.log, are bounded. When a log reaches
5
+ // LOG_MAX_BYTES, its last LOG_MAX_BYTES become <file>.1, replacing the one before, and the log starts
6
+ // again: the two hold about twice that at most, even after a version that had no limit. They name
7
+ // local paths, so they are private to their user.
8
+ export const LOG_MAX_BYTES = 256 * 1024;
9
+ const LOG_FILE_MODE = 0o600;
10
+
11
+ function rotate(filePath, maxBytes) {
12
+ let size;
13
+
14
+ try {
15
+ size = fs.statSync(filePath).size;
16
+ } catch {
17
+ return;
18
+ }
19
+
20
+ if (size < maxBytes) {
21
+ return;
22
+ }
23
+
24
+ const tail = Buffer.alloc(maxBytes);
25
+ const fd = fs.openSync(filePath, 'r');
26
+ let length;
27
+
28
+ try {
29
+ length = fs.readSync(fd, tail, 0, maxBytes, size - maxBytes);
30
+ } finally {
31
+ fs.closeSync(fd);
32
+ }
33
+
34
+ // From the first whole line on.
35
+ const kept = tail.subarray(0, length);
36
+ const firstLine = size > maxBytes ? kept.indexOf(0x0a) + 1 : 0;
37
+ fs.writeFileSync(`${filePath}.1`, kept.subarray(firstLine), {mode: LOG_FILE_MODE});
38
+ fs.chmodSync(`${filePath}.1`, LOG_FILE_MODE);
39
+ fs.rmSync(filePath, {force: true});
40
+ }
41
+
42
+ // Appends one line, stamped with the time. It never throws: a log that cannot be written must not
43
+ // stop the prompt hook or the tracker.
44
+ export function appendLogLine(filePath, text, {maxBytes = LOG_MAX_BYTES} = {}) {
45
+ try {
46
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
47
+ rotate(filePath, maxBytes);
48
+ fs.appendFileSync(filePath, `${new Date().toISOString()} ${text}\n`, {mode: LOG_FILE_MODE});
49
+ } catch {
50
+ // Nothing else to do.
51
+ }
52
+ }
53
+
54
+ export function createLog(filePath, options) {
55
+ return {
56
+ info: (text) => appendLogLine(filePath, `info ${text}`, options),
57
+ warn: (text) => appendLogLine(filePath, `warn ${text}`, options),
58
+ error: (text) => appendLogLine(filePath, `error ${text}`, options),
59
+ };
60
+ }
@@ -0,0 +1,130 @@
1
+ import {equalsLedgerFrom} from './equals-ledger.mjs';
2
+ import {focusTimeFrom} from './focus-clock.mjs';
3
+ import {buildRewardSummary, REWARD_MILESTONES} from './reward.mjs';
4
+
5
+ // When each milestone first became ready (docs/reward-rules.md §6.1), for the claim bundle. Kept in
6
+ // state.json, where the tracker writes the progress it belongs to, as `milestonesReached`:
7
+ //
8
+ // {"recordingSince": "2026-09-25T09:15:00.004Z",
9
+ // "milestones": {"10": {"reachedBeforeRecording": true}, "50": {"reachedAt": "2026-09-25T11:03:27.918Z"}}}
10
+ //
11
+ // It is written where progress goes up, and nowhere else: the tracker's save, which moves the focus
12
+ // clock, and the updates that credit = (runtime.mjs persistRuntimeState, foldScan, switchProjectRules).
13
+ // Never by the prompt hook, which runs on every message a user sends; never by a redeem, a removal or a
14
+ // load. An entry once written is never changed or removed: a rules switch, a re-baseline, a restart or
15
+ // progress going down again leaves it where it is.
16
+ //
17
+ // 0.1.39 drops it when it rewrites the file (install-id.mjs says how). The next write of this version then
18
+ // begins a new record, and a milestone reached by then is recorded as reached before recording began:
19
+ // the time it was reached is lost, and none is made up.
20
+
21
+ export const MILESTONE_PERCENTS = REWARD_MILESTONES.map((milestone) => milestone.percent);
22
+
23
+ // A time as toISOString writes it, and a real one: 2026-02-30 is not.
24
+ const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
25
+
26
+ function isObject(value) {
27
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
28
+ }
29
+
30
+ function isInstant(value) {
31
+ if (typeof value !== 'string' || !INSTANT.test(value)) {
32
+ return false;
33
+ }
34
+
35
+ const time = Date.parse(value);
36
+ return Number.isFinite(time) && new Date(time).toISOString() === value;
37
+ }
38
+
39
+ // A record as KitCode writes it: when it began, and its entries.
40
+ function isRecord(record) {
41
+ return isObject(record) && isInstant(record.recordingSince) && isObject(record.milestones);
42
+ }
43
+
44
+ // The milestones reached by `state` now: every milestone up to the highest one that is ready or
45
+ // redeemed. A milestone's targets are never below a lower one's, so a higher milestone ready means every
46
+ // lower one was ready then too, even one that reads locked now: a redeemed tier keeps its status when
47
+ // its = go down (docs/reward-rules.md §6, E8).
48
+ function reachedNow(state) {
49
+ const ledger = equalsLedgerFrom(state);
50
+ const reward = buildRewardSummary({earnedSeconds: focusTimeFrom(state).activeSeconds, totalEquals: ledger.total_equals, ledger});
51
+ const highest = Math.max(0, ...reward.milestones.filter((milestone) => milestone.status !== 'locked').map((milestone) => milestone.percent));
52
+
53
+ return MILESTONE_PERCENTS.filter((percent) => percent <= highest);
54
+ }
55
+
56
+ // Records, in `stored`, each milestone first found reached: `stored` is the file's own object, inside
57
+ // updateState, after the update has written the progress it is making. The first write of a KitCode that
58
+ // keeps the record begins it, at `now`, and a milestone already reached then is recorded as reached
59
+ // before recording began, with no time, since none is known. From then on, a milestone newly reached is
60
+ // recorded at `now`. A record, or an entry, that is not what KitCode writes is left exactly as it is:
61
+ // never repaired, never written over. `now` is the tracker's clock, which a test can move.
62
+ export function noteMilestonesReached(stored, now = new Date(Date.now())) {
63
+ const record = stored.milestonesReached;
64
+
65
+ if (record !== undefined && !isRecord(record)) {
66
+ return;
67
+ }
68
+
69
+ const reached = reachedNow(stored);
70
+ const at = now.toISOString();
71
+
72
+ if (record === undefined) {
73
+ stored.milestonesReached = {
74
+ recordingSince: at,
75
+ milestones: Object.fromEntries(reached.map((percent) => [String(percent), {reachedBeforeRecording: true}])),
76
+ };
77
+ return;
78
+ }
79
+
80
+ for (const percent of reached) {
81
+ if (!Object.hasOwn(record.milestones, String(percent))) {
82
+ record.milestones[String(percent)] = {reachedAt: at};
83
+ }
84
+ }
85
+ }
86
+
87
+ // A milestone record that is not what KitCode writes. The claim bundle does not guess at it.
88
+ export class MilestoneRecordError extends Error {
89
+ constructor(problem) {
90
+ super(`The record of when each milestone was reached, in state.json, ${problem}. KitCode has left it as it is.`);
91
+ this.name = 'MilestoneRecordError';
92
+ }
93
+ }
94
+
95
+ function entryFor(percent, record) {
96
+ const key = String(percent);
97
+
98
+ if (!Object.hasOwn(record.milestones, key)) {
99
+ return {percent, status: 'not-reached', reachedAt: null};
100
+ }
101
+
102
+ const entry = record.milestones[key];
103
+
104
+ if (isObject(entry) && entry.reachedBeforeRecording === true && entry.reachedAt === undefined) {
105
+ return {percent, status: 'reached-before-recording', reachedAt: null};
106
+ }
107
+
108
+ if (isObject(entry) && isInstant(entry.reachedAt) && entry.reachedBeforeRecording === undefined) {
109
+ return {percent, status: 'reached', reachedAt: entry.reachedAt};
110
+ }
111
+
112
+ throw new MilestoneRecordError(`holds an entry for ${percent} % that KitCode does not write`);
113
+ }
114
+
115
+ // The record as the claim bundle reports it: when it began, or null when it has not, and each
116
+ // milestone's status and time, from 10 to 100 %. Reads only; a damaged record throws a
117
+ // MilestoneRecordError.
118
+ export function milestonesReachedFrom(state) {
119
+ const record = state.milestonesReached;
120
+
121
+ if (record === undefined) {
122
+ return {recordingSince: null, milestones: MILESTONE_PERCENTS.map((percent) => ({percent, status: 'not-reached', reachedAt: null}))};
123
+ }
124
+
125
+ if (!isRecord(record)) {
126
+ throw new MilestoneRecordError('is not the record KitCode writes');
127
+ }
128
+
129
+ return {recordingSince: record.recordingSince, milestones: MILESTONE_PERCENTS.map((percent) => entryFor(percent, record))};
130
+ }