@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,30 @@
1
+ import {spawn} from 'node:child_process';
2
+ import {DASHBOARD_URL} from './integration-spec.mjs';
3
+
4
+ export {DASHBOARD_URL};
5
+
6
+ export function resolveDashboardUrl(url = process.env.KITCODE_DASHBOARD_URL || DASHBOARD_URL) {
7
+ return url;
8
+ }
9
+
10
+ export function openDashboard(url = resolveDashboardUrl()) {
11
+ const opener = process.platform === 'darwin'
12
+ ? {command: 'open', args: [url]}
13
+ : process.platform === 'win32'
14
+ ? {command: 'cmd', args: ['/c', 'start', '', url]}
15
+ : {command: 'xdg-open', args: [url]};
16
+
17
+ try {
18
+ const child = spawn(opener.command, opener.args, {
19
+ detached: true,
20
+ stdio: 'ignore',
21
+ windowsHide: true,
22
+ });
23
+
24
+ child.on('error', () => {});
25
+ child.unref();
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
package/src/reward.mjs ADDED
@@ -0,0 +1,311 @@
1
+ import {breakProgressPercent} from './break-progress.mjs';
2
+ import {equalsLedgerFrom, storedEqualsLedger} from './equals-ledger.mjs';
3
+ import {focusTimeFrom} from './focus-clock.mjs';
4
+ import {nextBreakReminder} from './next-break-reminder.mjs';
5
+ import {KITCODE_DISPLAY_MILESTONES, KITCODE_REWARD_TIERS} from './integration-spec.mjs';
6
+ import {loadState, updateState, withStateLock} from './store.mjs';
7
+
8
+ // The focus time the 100 % milestone needs, fixed (operator decision Q5, 2026-09-25; docs/reward-rules.md
9
+ // §3). Until then it was a setting, down to 1 s: --reward-seconds, KITCODE_REWARD_SECONDS, and
10
+ // rewardSettings.requiredSeconds in state.json. A requiredSeconds an older KitCode stored stays in the
11
+ // file, unread, as the removed requiredEquals does.
12
+ export const REWARD_SECONDS = 3600;
13
+ export const REWARD_TIERS = KITCODE_REWARD_TIERS;
14
+ export const CAMPAIGN_MILESTONES = KITCODE_DISPLAY_MILESTONES;
15
+ export const REWARD_MILESTONES = [
16
+ ...REWARD_TIERS.map((tier) => ({...tier, rewardBacked: true})),
17
+ ...CAMPAIGN_MILESTONES.map((milestone) => ({...milestone, rewardBacked: false})),
18
+ ];
19
+ // The = the last milestone needs. The = targets are the table's; no setting changes them (the
20
+ // --reward-equals setting, which gated nothing, was removed on 2026-09-24).
21
+ const FINAL_EQUALS = Math.max(...REWARD_MILESTONES.map((milestone) => milestone.requiredEquals));
22
+
23
+ const VALID_TIER_PERCENTS = new Set(REWARD_TIERS.map((tier) => tier.percent));
24
+
25
+ function isObject(value) {
26
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
27
+ }
28
+
29
+ function normalizeSource(source) {
30
+ return source === 'codex' || source === 'claude' ? source : null;
31
+ }
32
+
33
+ export function normalizeTierPercent(value) {
34
+ const percent = Number(value);
35
+
36
+ if (!VALID_TIER_PERCENTS.has(percent)) {
37
+ return null;
38
+ }
39
+
40
+ return percent;
41
+ }
42
+
43
+ function normalizeTierRecord(entry) {
44
+ const percent = normalizeTierPercent(
45
+ typeof entry === 'object' && entry !== null ? entry.percent : entry,
46
+ );
47
+
48
+ if (!percent) {
49
+ return null;
50
+ }
51
+
52
+ const announcedAt = entry?.announced_at && typeof entry.announced_at === 'object'
53
+ ? Object.fromEntries(
54
+ Object.entries(entry.announced_at)
55
+ .filter(([source, value]) => normalizeSource(source) && typeof value === 'string'),
56
+ )
57
+ : {};
58
+
59
+ return {
60
+ percent,
61
+ redeemed_at: typeof entry?.redeemed_at === 'string' ? entry.redeemed_at : null,
62
+ announced_at: announcedAt,
63
+ };
64
+ }
65
+
66
+ export function normalizeEarnedTiers(entries) {
67
+ const records = new Map();
68
+
69
+ for (const entry of Array.isArray(entries) ? entries : []) {
70
+ const record = normalizeTierRecord(entry);
71
+
72
+ if (!record) {
73
+ continue;
74
+ }
75
+
76
+ const existing = records.get(record.percent);
77
+ records.set(record.percent, {
78
+ percent: record.percent,
79
+ redeemed_at: existing?.redeemed_at ?? record.redeemed_at,
80
+ announced_at: {
81
+ ...(existing?.announced_at ?? {}),
82
+ ...record.announced_at,
83
+ },
84
+ });
85
+ }
86
+
87
+ return [...records.values()].sort((a, b) => a.percent - b.percent);
88
+ }
89
+
90
+ function tierRecordMap(ledger) {
91
+ return new Map(normalizeEarnedTiers(ledger.earned_tiers).map((record) => [record.percent, record]));
92
+ }
93
+
94
+ // Replaces the stored records of one tier with a single record made by `change`, in the place of
95
+ // the first one. Every other entry, including tiers this version does not know, and every field it
96
+ // does not know, is kept as it is.
97
+ function updateTierRecord(ledger, percent, change) {
98
+ const entries = ledger.earned_tiers;
99
+ const isThisTier = (entry) => normalizeTierRecord(entry)?.percent === percent;
100
+ const matching = entries.filter(isThisTier);
101
+ const stored = matching.filter(isObject);
102
+ const merged = normalizeEarnedTiers(matching)[0] ?? {percent, redeemed_at: null, announced_at: {}};
103
+ const record = change({
104
+ ...Object.assign({}, ...stored),
105
+ ...merged,
106
+ announced_at: {
107
+ ...Object.assign({}, ...stored.map((entry) => (isObject(entry.announced_at) ? entry.announced_at : {}))),
108
+ ...merged.announced_at,
109
+ },
110
+ });
111
+ const firstIndex = entries.findIndex(isThisTier);
112
+
113
+ if (firstIndex === -1) {
114
+ const after = entries.findIndex((entry) => Number(isObject(entry) ? entry.percent : entry) > percent);
115
+ entries.splice(after === -1 ? entries.length : after, 0, record);
116
+ return;
117
+ }
118
+
119
+ ledger.earned_tiers = entries.flatMap((entry, index) => {
120
+ if (index === firstIndex) {
121
+ return [record];
122
+ }
123
+
124
+ return isThisTier(entry) ? [] : [entry];
125
+ });
126
+ }
127
+
128
+ function milestoneTimeTarget(requiredSeconds, percent) {
129
+ return Math.ceil(requiredSeconds * (percent / 100));
130
+ }
131
+
132
+ // The reward summary for `earnedSeconds` of focus time and `totalEquals` counted, against the fixed time
133
+ // target and the tier table's = targets. It takes no target: a `settings` a caller still passes, as
134
+ // older code and tests do, is not read.
135
+ export function buildRewardSummary({earnedSeconds, totalEquals, ledger}) {
136
+ const requiredSeconds = REWARD_SECONDS;
137
+ const records = tierRecordMap(ledger);
138
+ const normalizedEarnedSeconds = Math.floor(Math.max(0, Number(earnedSeconds) || 0));
139
+ const normalizedTotalEquals = Math.max(0, Number(totalEquals) || 0);
140
+ const tiers = REWARD_TIERS.map((tier) => {
141
+ const record = records.get(tier.percent);
142
+ const requiredTimeSeconds = milestoneTimeTarget(requiredSeconds, tier.percent);
143
+ const unlocked = (
144
+ normalizedEarnedSeconds >= requiredTimeSeconds &&
145
+ normalizedTotalEquals >= tier.requiredEquals
146
+ );
147
+ const redeemed = Boolean(record?.redeemed_at);
148
+
149
+ return {
150
+ code: tier.code,
151
+ percent: tier.percent,
152
+ requiredEquals: tier.requiredEquals,
153
+ requiredSeconds: requiredTimeSeconds,
154
+ unlocked,
155
+ redeemed,
156
+ redeemedAt: record?.redeemed_at ?? null,
157
+ status: redeemed ? 'redeemed' : unlocked ? 'ready' : 'locked',
158
+ };
159
+ });
160
+ const tierMap = new Map(tiers.map((tier) => [tier.percent, tier]));
161
+
162
+ const summary = {
163
+ requiredSeconds,
164
+ requiredEquals: FINAL_EQUALS,
165
+ earnedSeconds: normalizedEarnedSeconds,
166
+ totalEquals: normalizedTotalEquals,
167
+ timeLeftSeconds: Math.max(0, Math.floor(requiredSeconds - normalizedEarnedSeconds)),
168
+ tiers,
169
+ milestones: REWARD_MILESTONES.map((milestone) => {
170
+ const tier = tierMap.get(milestone.percent);
171
+ const requiredTimeSeconds = tier?.requiredSeconds ?? milestoneTimeTarget(requiredSeconds, milestone.percent);
172
+ const timeReached = normalizedEarnedSeconds >= requiredTimeSeconds;
173
+ const equalsReached = normalizedTotalEquals >= milestone.requiredEquals;
174
+ const unlocked = timeReached && equalsReached;
175
+
176
+ return {
177
+ code: tier?.code ?? milestone.code,
178
+ percent: milestone.percent,
179
+ requiredEquals: milestone.requiredEquals,
180
+ requiredSeconds: requiredTimeSeconds,
181
+ rewardBacked: milestone.rewardBacked,
182
+ displayOnly: !milestone.rewardBacked,
183
+ unlocked,
184
+ redeemed: tier?.redeemed ?? false,
185
+ redeemedAt: tier?.redeemedAt ?? null,
186
+ status: tier?.status ?? (unlocked ? 'ready' : 'locked'),
187
+ };
188
+ }),
189
+ };
190
+
191
+ // The one progress number the CLI, the Mini and the dashboard all show (docs/reward-rules.md
192
+ // §10). `progress` is the same number as a fraction; until 2026-09-24 it was time alone.
193
+ summary.progressPercent = breakProgressPercent(summary);
194
+ summary.progress = summary.progressPercent / 100;
195
+ summary.nextBreak = nextBreakReminder(summary);
196
+ return summary;
197
+ }
198
+
199
+ function rewardSummaryFrom(state) {
200
+ const ledger = equalsLedgerFrom(state);
201
+ // The shared clock (docs/reward-rules.md §4); in a file from before it, the summed total.
202
+ const earnedSeconds = focusTimeFrom(state).activeSeconds;
203
+
204
+ return buildRewardSummary({
205
+ earnedSeconds,
206
+ totalEquals: ledger.total_equals,
207
+ ledger,
208
+ });
209
+ }
210
+
211
+ export function getDiskRewardSummary() {
212
+ return rewardSummaryFrom(loadState());
213
+ }
214
+
215
+ // Records tier changes in the stored ledger, under the state lock.
216
+ function saveTierRecords(changes) {
217
+ updateState((state) => {
218
+ const ledger = storedEqualsLedger(state);
219
+
220
+ for (const [percent, change] of changes) {
221
+ updateTierRecord(ledger, percent, change);
222
+ }
223
+
224
+ ledger.last_updated_at = new Date().toISOString();
225
+ });
226
+ }
227
+
228
+ export function redeemReadyTiers(percent = null) {
229
+ const requestedPercent = percent === null ? null : normalizeTierPercent(percent);
230
+
231
+ if (percent !== null && !requestedPercent) {
232
+ return {redeemed: [], skipped: [], reward: getDiskRewardSummary()};
233
+ }
234
+
235
+ // Readiness is checked and the redeem recorded under one lock, so a tier is redeemed once.
236
+ return withStateLock(() => {
237
+ const reward = getDiskRewardSummary();
238
+ const now = new Date().toISOString();
239
+ const redeemed = [];
240
+ const skipped = [];
241
+
242
+ for (const tier of reward.tiers) {
243
+ if (requestedPercent && tier.percent !== requestedPercent) {
244
+ continue;
245
+ }
246
+
247
+ if (tier.status !== 'ready') {
248
+ skipped.push(tier);
249
+ continue;
250
+ }
251
+
252
+ redeemed.push({...tier, redeemed: true, redeemedAt: now, status: 'redeemed'});
253
+ }
254
+
255
+ if (redeemed.length > 0) {
256
+ saveTierRecords(redeemed.map((tier) => [tier.percent, (record) => ({...record, redeemed_at: now})]));
257
+ }
258
+
259
+ return {
260
+ redeemed,
261
+ skipped,
262
+ reward: getDiskRewardSummary(),
263
+ };
264
+ });
265
+ }
266
+
267
+ export function markTiersAnnounced(source, tiers) {
268
+ const normalizedSource = normalizeSource(source);
269
+
270
+ if (!normalizedSource || tiers.length === 0) {
271
+ return [];
272
+ }
273
+
274
+ const now = new Date().toISOString();
275
+ const announced = [];
276
+ const changes = [];
277
+
278
+ for (const tier of tiers) {
279
+ const percent = normalizeTierPercent(tier.percent);
280
+
281
+ if (!percent) {
282
+ continue;
283
+ }
284
+
285
+ changes.push([percent, (record) => ({...record, announced_at: {...record.announced_at, [normalizedSource]: now}})]);
286
+ announced.push({...tier, announcedAt: now});
287
+ }
288
+
289
+ if (changes.length > 0) {
290
+ saveTierRecords(changes);
291
+ }
292
+
293
+ return announced;
294
+ }
295
+
296
+ export function getReadyUnannouncedTiers(source) {
297
+ const normalizedSource = normalizeSource(source);
298
+
299
+ if (!normalizedSource) {
300
+ return [];
301
+ }
302
+
303
+ const state = loadState();
304
+ const records = tierRecordMap(equalsLedgerFrom(state));
305
+
306
+ return rewardSummaryFrom(state).tiers.filter((tier) => {
307
+ const record = records.get(tier.percent);
308
+
309
+ return tier.status === 'ready' && !record?.announced_at?.[normalizedSource];
310
+ });
311
+ }
@@ -0,0 +1,153 @@
1
+ import fs from 'node:fs';
2
+ import {createRequire} from 'node:module';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import {fileURLToPath} from 'node:url';
7
+ import {writeFileAtomically} from './atomic-file.mjs';
8
+ import {CLI_PACKAGE_NAME, RUNNER_PATH_PARTS, shellQuote} from './integration-spec.mjs';
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ function packageRoot() {
13
+ return path.dirname(path.dirname(fileURLToPath(import.meta.url)));
14
+ }
15
+
16
+ export function runnerPath() {
17
+ return path.join(os.homedir(), ...RUNNER_PATH_PARTS);
18
+ }
19
+
20
+ // The fallback runs this exact version, never whatever is newest on npm.
21
+ function pinnedPackage() {
22
+ return `${CLI_PACKAGE_NAME}@${require(path.join(packageRoot(), 'package.json')).version}`;
23
+ }
24
+
25
+ function runnerScript() {
26
+ const node = shellQuote(process.execPath);
27
+ const bin = shellQuote(path.join(packageRoot(), 'bin', 'kitcode.mjs'));
28
+ const fallback = `npx -y ${shellQuote(pinnedPackage())}`;
29
+
30
+ return [
31
+ '#!/bin/sh',
32
+ `if [ -x ${node} ] && [ -f ${bin} ]; then`,
33
+ ` exec ${node} ${bin} "$@"`,
34
+ 'fi',
35
+ '# The installed CLI is gone. Exit code 2 from the prompt hook blocks the prompt, so the hook',
36
+ '# exits 0 whatever the fallback does.',
37
+ 'if [ "$1" = hook ]; then',
38
+ ` ${fallback} "$@"`,
39
+ ' exit 0',
40
+ 'fi',
41
+ `exec ${fallback} "$@"`,
42
+ '',
43
+ ].join('\n');
44
+ }
45
+
46
+ // The runner for cmd.exe, which does what the runner does. `call`, because npx is itself a .cmd file,
47
+ // and a .cmd file run without it never returns to the one that ran it: before 2026-09-29 nothing ran
48
+ // after the fallback, and the hook's exit code was npx's. Each way out names its exit code: a bare
49
+ // `exit /b` gave cmd /c's caller 0 whatever npx returned (the second Windows run). A % in a path is
50
+ // doubled, as a .cmd file needs.
51
+ function runnerCmdScript() {
52
+ const cmdText = (text) => text.replaceAll('%', '%%');
53
+ const binPath = cmdText(path.join(packageRoot(), 'bin', 'kitcode.mjs'));
54
+ const fallback = `call npx -y ${pinnedPackage()} %*`;
55
+
56
+ return [
57
+ '@echo off',
58
+ `if exist "${binPath}" goto run`,
59
+ 'rem The installed CLI is gone. Exit code 2 from the prompt hook blocks the prompt, so the hook',
60
+ 'rem exits 0 whatever the fallback does.',
61
+ 'if /i "%~1"=="hook" goto hook',
62
+ fallback,
63
+ 'exit /b %errorlevel%',
64
+ ':hook',
65
+ fallback,
66
+ 'exit /b 0',
67
+ ':run',
68
+ `"${cmdText(process.execPath)}" "${binPath}" %*`,
69
+ 'exit /b %errorlevel%',
70
+ '',
71
+ ].join('\r\n');
72
+ }
73
+
74
+ // The runner module, which the hook's command runs on Windows with the `node` on PATH
75
+ // (integration-spec.mjs, hookCommandFor). It does what the runner does: the installed CLI, with the node
76
+ // that installed it, or failing that this one; if the CLI is gone, this exact version from npm; and the
77
+ // prompt hook exits 0 whatever happens, since exit code 2 blocks the user's prompt. On Windows npx is a
78
+ // .cmd file, which only a shell runs, and cmd.exe splits and expands what it is given, so there only
79
+ // arguments it takes as they are reach the fallback. Written on every platform, so that every platform's
80
+ // tests run it.
81
+ function runnerModuleScript() {
82
+ return [
83
+ '// KitCode\'s runner module, written by `kitcode <agent> on` (runner-installer.mjs).',
84
+ 'import {spawnSync} from \'node:child_process\';',
85
+ 'import {existsSync} from \'node:fs\';',
86
+ '',
87
+ `const node = ${JSON.stringify(process.execPath)};`,
88
+ `const bin = ${JSON.stringify(path.join(packageRoot(), 'bin', 'kitcode.mjs'))};`,
89
+ `const pinned = ${JSON.stringify(pinnedPackage())};`,
90
+ 'const args = process.argv.slice(2);',
91
+ 'const windows = process.platform === \'win32\';',
92
+ '',
93
+ '// With a shell, one command line and no arguments beside it, which Node would pass on unquoted.',
94
+ 'function run(command, commandArgs) {',
95
+ ' const options = {stdio: \'inherit\', windowsHide: true};',
96
+ ' const result = commandArgs === undefined ? spawnSync(command, {...options, shell: true}) : spawnSync(command, commandArgs, options);',
97
+ ' return result.status ?? 1;',
98
+ '}',
99
+ '',
100
+ 'let status;',
101
+ '',
102
+ 'if (existsSync(bin)) {',
103
+ ' status = run(existsSync(node) ? node : process.execPath, [bin, ...args]);',
104
+ '} else if (!windows) {',
105
+ ' status = run(\'npx\', [\'-y\', pinned, ...args]);',
106
+ '} else if (args.every((arg) => /^[\\w@./:=,+-]+$/u.test(arg))) {',
107
+ ' status = run([\'npx\', \'-y\', pinned, ...args].join(\' \'));',
108
+ '} else {',
109
+ ' console.error(`KitCode is not installed any more. Install it again with: npm install -g ${pinned}`);',
110
+ ' status = 1;',
111
+ '}',
112
+ '',
113
+ 'process.exit(args[0] === \'hook\' ? 0 : status);',
114
+ '',
115
+ ].join('\n');
116
+ }
117
+
118
+ // Rewrites a runner file only when its content changes, and atomically: the prompt hook may be
119
+ // reading it at that moment, and a half-written script is a shell syntax error, exit 2.
120
+ function writeRunnerFile(filePath, content, mode) {
121
+ try {
122
+ if (fs.readFileSync(filePath, 'utf8') === content && (fs.statSync(filePath).mode & 0o777) === mode) {
123
+ return;
124
+ }
125
+ } catch {
126
+ // Missing or unreadable: write it.
127
+ }
128
+
129
+ writeFileAtomically(filePath, content, {mode});
130
+ }
131
+
132
+ export function installRunner() {
133
+ const filePath = runnerPath();
134
+
135
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
136
+ writeRunnerFile(filePath, runnerScript(), 0o755);
137
+ writeRunnerFile(`${filePath}.cmd`, runnerCmdScript(), 0o644);
138
+ writeRunnerFile(`${filePath}.mjs`, runnerModuleScript(), 0o644);
139
+
140
+ return {
141
+ installed: true,
142
+ path: filePath,
143
+ };
144
+ }
145
+
146
+ export function runnerStatus() {
147
+ const filePath = runnerPath();
148
+
149
+ return {
150
+ installed: fs.existsSync(filePath),
151
+ path: filePath,
152
+ };
153
+ }