@kitkat-vn/kitcode 0.2.0-staging.10

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +321 -0
  3. package/bin/kitcode.mjs +1266 -0
  4. package/package.json +41 -0
  5. package/src/api-access.mjs +119 -0
  6. package/src/api.mjs +175 -0
  7. package/src/app-identity.mjs +29 -0
  8. package/src/assets/kitcode-icon.png +0 -0
  9. package/src/atomic-file.mjs +85 -0
  10. package/src/break-progress.mjs +87 -0
  11. package/src/claim-bundle.mjs +38 -0
  12. package/src/companion-controls.mjs +40 -0
  13. package/src/companion-electron.mjs +85 -0
  14. package/src/companion-preload.cjs +10 -0
  15. package/src/companion-window-options.mjs +16 -0
  16. package/src/companion-window.mjs +59 -0
  17. package/src/cors.mjs +51 -0
  18. package/src/diagnostics.mjs +872 -0
  19. package/src/equals-ledger.mjs +166 -0
  20. package/src/focus-clock.mjs +86 -0
  21. package/src/fonts/fira-code/OFL.txt +93 -0
  22. package/src/fonts/fira-code/fira-code-latin-400-normal.woff2 +0 -0
  23. package/src/fonts/fira-code/fira-code-latin-500-normal.woff2 +0 -0
  24. package/src/fonts/fira-code/fira-code-latin-700-normal.woff2 +0 -0
  25. package/src/fonts/vt323/OFL.txt +93 -0
  26. package/src/fonts/vt323/vt323-latin-400-normal.woff2 +0 -0
  27. package/src/git.mjs +488 -0
  28. package/src/hook-installers.mjs +397 -0
  29. package/src/hook-prompt.mjs +217 -0
  30. package/src/install-id.mjs +94 -0
  31. package/src/integration-installers.mjs +28 -0
  32. package/src/integration-spec.mjs +410 -0
  33. package/src/log-file.mjs +60 -0
  34. package/src/milestones-reached.mjs +133 -0
  35. package/src/next-break-reminder.mjs +78 -0
  36. package/src/onboarding-electron.mjs +252 -0
  37. package/src/onboarding-platform.mjs +50 -0
  38. package/src/onboarding-preload.cjs +19 -0
  39. package/src/onboarding-window.mjs +914 -0
  40. package/src/open-dashboard.mjs +30 -0
  41. package/src/reward.mjs +274 -0
  42. package/src/runner-installer.mjs +153 -0
  43. package/src/runtime.mjs +1340 -0
  44. package/src/scan-worker.mjs +23 -0
  45. package/src/scanner.mjs +84 -0
  46. package/src/seen-lines.mjs +148 -0
  47. package/src/skill-installer.mjs +76 -0
  48. package/src/source-snapshot.mjs +733 -0
  49. package/src/store.mjs +595 -0
  50. package/src/tracker-instance.mjs +166 -0
  51. package/src/tracker-problems.mjs +31 -0
  52. package/src/tracker-stop.mjs +73 -0
  53. package/src/uninstall.mjs +80 -0
  54. package/src/vibe.mjs +4 -0
  55. package/src/window-guards.mjs +7 -0
  56. package/src/window-page.mjs +49 -0
@@ -0,0 +1,1340 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import crypto from 'node:crypto';
6
+ import {addSourceEqualsOnce, equalsLedgerFrom, loadEqualsLedger, removeProjectEquals} from './equals-ledger.mjs';
7
+ import {countCommits, createFolderProjectId, createProjectId, tryDetectRepoRoot} from './git.mjs';
8
+ import {
9
+ isLineSeen,
10
+ loadSeenLines,
11
+ loadState,
12
+ recordSeenLines,
13
+ seenLinesExist,
14
+ STORE_PATH,
15
+ StateFileError,
16
+ StateLockError,
17
+ updateState,
18
+ withStateLock,
19
+ } from './store.mjs';
20
+ import {
21
+ applySourceScan,
22
+ convertSnapshot,
23
+ createSourceSnapshot,
24
+ emptySnapshot,
25
+ listedFileKeys,
26
+ readProjectScan,
27
+ readProjectScanUnder,
28
+ readSourceEntry,
29
+ RULES,
30
+ RULES_VERSION,
31
+ sameSnapshotFiles,
32
+ shouldIgnoreRelativePath,
33
+ SNAPSHOT_FORMAT,
34
+ SnapshotError,
35
+ snapshotLineKeys,
36
+ snapshotRules,
37
+ } from './source-snapshot.mjs';
38
+ import {
39
+ advanceWindow,
40
+ createActivityWindow,
41
+ focusTimeFrom,
42
+ recordActivity,
43
+ startedFocusTime,
44
+ storedFocusTime,
45
+ } from './focus-clock.mjs';
46
+ import {noteMilestonesReached} from './milestones-reached.mjs';
47
+ import {buildRewardSummary} from './reward.mjs';
48
+ import {createScanner} from './scanner.mjs';
49
+
50
+ export const DEFAULT_HOST = '127.0.0.1';
51
+ export const DEFAULT_PORT = 4747;
52
+
53
+ const SYNC_INTERVAL_MS = 2000;
54
+ const SAVE_INTERVAL_MS = 10000;
55
+ const GIT_INTERVAL_MS = 15000;
56
+ const SOURCE_FALLBACK_INTERVAL_MS = 60000;
57
+ const SOURCE_DEBOUNCE_MS = 500;
58
+ // The tracker's thread stops while it waits for the state lock, and with it the local API. So its
59
+ // periodic save waits briefly and tries again on the next one, and a scan's result is folded in
60
+ // with a short wait and tried again a little later.
61
+ const PERIODIC_SAVE_LOCK_TIMEOUT_MS = 2000;
62
+ const SCAN_LOCK_TIMEOUT_MS = 1000;
63
+ const SCAN_RETRY_MS = 2000;
64
+ // A project whose folder cannot be watched is scanned this often instead.
65
+ export const POLL_INTERVAL_MS = 5000;
66
+ const SILENT_LOG = {info() {}, warn() {}, error() {}};
67
+
68
+ function isObject(value) {
69
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
70
+ }
71
+
72
+ function normalizeProject(project) {
73
+ const sourceType = project.sourceType === 'vibe' ? 'vibe' : 'git';
74
+
75
+ return {
76
+ id: project.id,
77
+ repoRoot: project.repoRoot,
78
+ sourceType,
79
+ active: true,
80
+ activeSeconds: Number(project.activeSeconds) || 0,
81
+ idleSeconds: Number(project.idleSeconds) || 0,
82
+ commitCount: Number(project.commitCount) || 0,
83
+ changeBatchCount: Number(project.changeBatchCount) || 0,
84
+ lastActiveAt: project.lastActiveAt ?? new Date().toISOString(),
85
+ sourceSnapshot: project.sourceSnapshot?.files && typeof project.sourceSnapshot === 'object'
86
+ ? project.sourceSnapshot
87
+ : project.vibeSnapshot?.files && typeof project.vibeSnapshot === 'object'
88
+ ? project.vibeSnapshot
89
+ : null,
90
+ };
91
+ }
92
+
93
+ function normalizeState(state) {
94
+ const projects = {};
95
+
96
+ for (const [id, project] of Object.entries(state.projects ?? {})) {
97
+ projects[id] = normalizeProject({id, ...project});
98
+ }
99
+
100
+ return {
101
+ version: 4,
102
+ projects,
103
+ ...(state.onboarding ? {onboarding: state.onboarding} : {}),
104
+ };
105
+ }
106
+
107
+ function detectProject(targetPath = '.') {
108
+ const absolutePath = path.resolve(process.cwd(), targetPath);
109
+ const gitRoot = tryDetectRepoRoot(absolutePath);
110
+
111
+ if (gitRoot) {
112
+ return {
113
+ id: createProjectId(gitRoot),
114
+ root: gitRoot,
115
+ sourceType: 'git',
116
+ };
117
+ }
118
+
119
+ return {
120
+ id: createFolderProjectId(absolutePath),
121
+ root: absolutePath,
122
+ sourceType: 'vibe',
123
+ };
124
+ }
125
+
126
+ export function describeProject(targetPath = '.') {
127
+ const project = detectProject(targetPath);
128
+
129
+ return {
130
+ id: project.id,
131
+ repoRoot: project.root,
132
+ sourceType: project.sourceType,
133
+ };
134
+ }
135
+
136
+ export function describeProjects(targetPaths = []) {
137
+ const projects = new Map();
138
+
139
+ for (const targetPath of targetPaths) {
140
+ const project = describeProject(targetPath);
141
+ projects.set(project.id, project);
142
+ }
143
+
144
+ return [...projects.values()];
145
+ }
146
+
147
+ export function resolveInitialProjectSuggestion(targetPath) {
148
+ if (!targetPath || typeof targetPath !== 'string') {
149
+ return null;
150
+ }
151
+
152
+ let absolutePath;
153
+ try {
154
+ absolutePath = path.resolve(targetPath);
155
+ } catch {
156
+ return null;
157
+ }
158
+
159
+ try {
160
+ if (!fs.statSync(absolutePath).isDirectory()) {
161
+ return null;
162
+ }
163
+ } catch {
164
+ return null;
165
+ }
166
+
167
+ const home = path.resolve(os.homedir());
168
+ if (absolutePath === home || absolutePath === path.parse(absolutePath).root) {
169
+ return null;
170
+ }
171
+
172
+ const project = describeProject(absolutePath);
173
+ const registeredIds = new Set(listProjectRecords().map((entry) => entry.id));
174
+ if (registeredIds.has(project.id)) {
175
+ return null;
176
+ }
177
+
178
+ return project;
179
+ }
180
+
181
+ function createProjectRecord(detectedProject, existing = {}) {
182
+ const now = new Date().toISOString();
183
+
184
+ return normalizeProject({
185
+ id: detectedProject.id,
186
+ repoRoot: detectedProject.root,
187
+ sourceType: detectedProject.sourceType,
188
+ active: true,
189
+ activeSeconds: existing.activeSeconds ?? 0,
190
+ idleSeconds: existing.idleSeconds ?? 0,
191
+ commitCount: existing.commitCount ?? 0,
192
+ changeBatchCount: existing.changeBatchCount ?? 0,
193
+ lastActiveAt: existing.lastActiveAt ?? now,
194
+ sourceSnapshot: existing.sourceSnapshot ?? existing.vibeSnapshot ?? createSourceSnapshot(detectedProject.root, {sourceType: detectedProject.sourceType}),
195
+ });
196
+ }
197
+
198
+ // Puts `project` into the stored state, over the record already there, so that fields this version
199
+ // does not know are kept. A legacy vibeSnapshot goes: normalizeProject has made it sourceSnapshot.
200
+ function storeProject(stored, project) {
201
+ if (!isObject(stored.projects)) {
202
+ stored.projects = {};
203
+ }
204
+
205
+ const {vibeSnapshot, ...existing} = isObject(stored.projects[project.id]) ? stored.projects[project.id] : {};
206
+ stored.projects[project.id] = {...existing, ...project};
207
+ stored.version = 4;
208
+ }
209
+
210
+ function projectTotals(state) {
211
+ const projects = Object.values(normalizeState(state).projects);
212
+
213
+ return {
214
+ totalProjects: projects.length,
215
+ trackingProjects: projects.filter((project) => project.active).length,
216
+ };
217
+ }
218
+
219
+ function pathContains(parent, child) {
220
+ const relative = path.relative(parent, child);
221
+
222
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
223
+ }
224
+
225
+ function findProjectIdForPath(state, targetPath = '.') {
226
+ if (state.projects[targetPath]) {
227
+ return targetPath;
228
+ }
229
+
230
+ const detectedProject = detectProject(targetPath);
231
+
232
+ if (state.projects[detectedProject.id]) {
233
+ return detectedProject.id;
234
+ }
235
+
236
+ const absolutePath = path.resolve(process.cwd(), targetPath);
237
+ const candidates = Object.values(state.projects)
238
+ .filter((project) => project.sourceType === 'vibe' && pathContains(project.repoRoot, absolutePath))
239
+ .sort((a, b) => b.repoRoot.length - a.repoRoot.length);
240
+
241
+ return candidates[0]?.id ?? detectedProject.id;
242
+ }
243
+
244
+
245
+ // The snapshot a project record holds (older files call it vibeSnapshot), or null.
246
+ function snapshotOf(record) {
247
+ for (const snapshot of [record?.sourceSnapshot, record?.vibeSnapshot]) {
248
+ if (isObject(snapshot) && isObject(snapshot.files)) {
249
+ return snapshot;
250
+ }
251
+ }
252
+
253
+ return null;
254
+ }
255
+
256
+ // The snapshot a project record holds as it is stored: sourceSnapshot, or vibeSnapshot in older
257
+ // files, or null when it has none yet. Unlike snapshotOf, it returns a damaged one, to be reported.
258
+ function storedSnapshot(record) {
259
+ return record?.sourceSnapshot ?? record?.vibeSnapshot ?? null;
260
+ }
261
+
262
+ // A snapshot that cannot be counted against stops every command that scans, naming the file and the
263
+ // project (docs/security.md, "The state file").
264
+ function snapshotProblem(projectId, record, error) {
265
+ const project = typeof record?.repoRoot === 'string' ? `the project at ${record.repoRoot} (${projectId})` : `project ${projectId}`;
266
+
267
+ return error.newer
268
+ ? new StateFileError(`the snapshot of ${project} was written by a newer KitCode: ${error.message}`, error, {
269
+ advice: 'KitCode has left the file as it is. Going back to an older KitCode is not supported: use the KitCode that wrote it, or a later one.',
270
+ })
271
+ : new StateFileError(`the snapshot of ${project} is damaged: ${error.message}`, error);
272
+ }
273
+
274
+ function checkedSnapshot(projectId, record, check) {
275
+ try {
276
+ return check(storedSnapshot(record));
277
+ } catch (error) {
278
+ throw error instanceof SnapshotError ? snapshotProblem(projectId, record, error) : error;
279
+ }
280
+ }
281
+
282
+ // The counting rules a project's snapshot was taken under, or null when it has none yet.
283
+ function rulesOfRecord(projectId, record, {newest = RULES_VERSION} = {}) {
284
+ return storedSnapshot(record) === null ? null : checkedSnapshot(projectId, record, (snapshot) => snapshotRules(snapshot, {newest}));
285
+ }
286
+
287
+ // Brings state.json to this version's snapshot format (docs/reward-rules.md §5.2), in one locked
288
+ // update: each snapshot an older KitCode wrote is converted by cutting its line keys to 16
289
+ // characters, which counts nothing and loses nothing, and seen-lines is made from the snapshots when
290
+ // there is none yet. Every command that scans runs it first, in its own update; once the file is
291
+ // converted it does nothing and writes nothing. A snapshot that cannot be converted, or that a newer
292
+ // KitCode wrote, stops it with a StateFileError naming the project, before anything is written.
293
+ //
294
+ // A converted snapshot can hold files the scanner no longer reads, which the next full scan drops, as
295
+ // it always has: a .env file, a key, a lockfile, a folder .gitignore names. Their lines are not seen,
296
+ // so no hash of a line of a secret file outlives that scan in seen-lines (ruling, 2026-09-25).
297
+ export function migrateState({newest = RULES_VERSION} = {}) {
298
+ return withStateLock(() => {
299
+ const projects = Object.entries(loadState().projects ?? {}).filter(([, record]) => isObject(record) && storedSnapshot(record) !== null);
300
+ const seed = !seenLinesExist();
301
+ const converted = [];
302
+ const seen = [];
303
+ let collisions = 0;
304
+
305
+ for (const [projectId, record] of projects) {
306
+ const format = checkedSnapshot(projectId, record, (snapshot) => {
307
+ snapshotRules(snapshot, {newest});
308
+ return snapshot.version ?? 1;
309
+ });
310
+
311
+ if (format === SNAPSHOT_FORMAT && !seed) {
312
+ continue;
313
+ }
314
+
315
+ const result = checkedSnapshot(projectId, record, (snapshot) => convertSnapshot(snapshot, {newest}));
316
+ const listed = result.converted ? listedFileKeys(record.repoRoot, {sourceType: record.sourceType === 'vibe' ? 'vibe' : 'git'}) : null;
317
+ collisions += result.collisions;
318
+ seen.push(...snapshotLineKeys(result.snapshot, listed));
319
+
320
+ if (result.converted || record.sourceSnapshot !== result.snapshot) {
321
+ converted.push([projectId, result.snapshot]);
322
+ }
323
+ }
324
+
325
+ if (converted.length > 0) {
326
+ updateState((stored) => {
327
+ for (const [projectId, snapshot] of converted) {
328
+ const {vibeSnapshot, ...current} = stored.projects[projectId];
329
+ stored.projects[projectId] = {...current, sourceSnapshot: snapshot};
330
+ }
331
+ });
332
+ }
333
+
334
+ recordSeenLines(seen, {seeding: seed});
335
+ return {converted: converted.length, collisions};
336
+ });
337
+ }
338
+
339
+ const SWITCH_ATTEMPTS = 3;
340
+
341
+ // The rules switch (docs/reward-rules.md §5.6), for one project. When its snapshot was taken under
342
+ // older counting rules than `to`, whatever is new since the snapshot is first counted under the old
343
+ // rules: code written while the tracker was stopped, say. Then the snapshot is taken again under the
344
+ // new rules, which counts nothing, and records them. Both come from one read of the project, so no
345
+ // edit falls between them, and they are written in one locked update. A snapshot already under `to`,
346
+ // or a project with none, is left as it is. `rules` is the rules by version.
347
+ //
348
+ // The read of a Git project is checked against git like any scan's (§5.8): when code can have arrived
349
+ // through git since the snapshot, nothing is counted under the old rules either. A snapshot written
350
+ // before hardening phase 2 recorded no git position, so a Git project's switch from it counts nothing.
351
+ export function switchProjectRules(projectId, {rules = RULES, to = RULES_VERSION} = {}) {
352
+ for (let attempt = 0; attempt < SWITCH_ATTEMPTS; attempt += 1) {
353
+ const record = loadState().projects?.[projectId];
354
+ const from = isObject(record) ? rulesOfRecord(projectId, record, {newest: to}) : null;
355
+
356
+ if (from === null || from >= to) {
357
+ return {switched: false, from, to, equalsAdded: 0};
358
+ }
359
+
360
+ if (!rules[from] || !rules[to]) {
361
+ throw new Error(`KitCode has no counting rules ${rules[from] ? to : from} to switch ${projectId} with.`);
362
+ }
363
+
364
+ const project = {id: projectId, repoRoot: record.repoRoot, sourceType: record.sourceType === 'vibe' ? 'vibe' : 'git'};
365
+ const base = checkedSnapshot(projectId, record, (snapshot) => convertSnapshot(snapshot, {newest: to})).snapshot;
366
+ let reads;
367
+
368
+ try {
369
+ reads = readProjectScanUnder(project.repoRoot, {sourceType: project.sourceType, rules: [rules[from], rules[to]], since: base.git});
370
+ } catch (error) {
371
+ throw Object.assign(new Error(`KitCode could not read ${project.repoRoot} to switch its counting rules: ${error.message}`, {cause: error}), {code: 'KITCODE_PROJECT_UNREADABLE'});
372
+ }
373
+
374
+ const [oldRead, newRead] = reads;
375
+ const result = withStateLock(() => {
376
+ migrateState({newest: to});
377
+ const snapshot = snapshotOf(loadState().projects?.[projectId]);
378
+
379
+ // Another process counted in it since the read began: read it again.
380
+ if (!snapshot || snapshot.rulesVersion !== from || !sameSnapshotFiles(snapshot.files, base.files)) {
381
+ return null;
382
+ }
383
+
384
+ const credited = applySourceScan(snapshot, oldRead);
385
+ const baseline = applySourceScan(emptySnapshot(to), newRead);
386
+ const files = Object.fromEntries(Object.entries(baseline.snapshot.files).map(([fileKey, entry]) => (
387
+ [fileKey, {...credited.snapshot.files[fileKey], lineCounts: entry.lineCounts}]
388
+ )));
389
+
390
+ updateState((stored) => {
391
+ const {vibeSnapshot, ...current} = stored.projects[projectId];
392
+ stored.projects[projectId] = {...current, sourceSnapshot: {...credited.snapshot, rulesVersion: to, files}};
393
+
394
+ if (credited.equalsAdded > 0) {
395
+ stored.projects[projectId].changeBatchCount = (Number(current.changeBatchCount) || 0) + 1;
396
+ addSourceEqualsOnce(project, crypto.randomUUID(), credited.equalsAdded);
397
+ noteMilestonesReached(stored);
398
+ }
399
+ });
400
+ recordSeenLines([...credited.addedKeys, ...baseline.addedKeys]);
401
+
402
+ return {switched: true, from, to, equalsAdded: credited.equalsAdded};
403
+ });
404
+
405
+ if (result) {
406
+ return result;
407
+ }
408
+ }
409
+
410
+ throw new Error(`KitCode could not switch the counting rules of ${projectId}: its snapshot changed each time it was read.`);
411
+ }
412
+
413
+ // Makes state.json ready for the tracker. `kitcode track` runs it before it starts the tracker, where
414
+ // a failure can be shown, and the tracker again as it starts: the file converted (migrateState),
415
+ // seen-lines checked, and each project whose snapshot was taken under older rules switched
416
+ // (switchProjectRules). A project whose folder cannot be read now keeps its rules until it can be
417
+ // read; the tracker's scans of it fail meanwhile, as they would anyway. Returns the projects switched,
418
+ // and those that could not be read.
419
+ export function prepareState({rules = RULES, to = RULES_VERSION} = {}) {
420
+ migrateState({newest: to});
421
+ loadSeenLines();
422
+ const switched = [];
423
+ const unreadable = [];
424
+
425
+ for (const [projectId, record] of Object.entries(loadState().projects ?? {})) {
426
+ if (!isObject(record) || (rulesOfRecord(projectId, record, {newest: to}) ?? to) >= to) {
427
+ continue;
428
+ }
429
+
430
+ try {
431
+ if (switchProjectRules(projectId, {rules, to}).switched) {
432
+ switched.push(projectId);
433
+ }
434
+ } catch (error) {
435
+ if (error.code !== 'KITCODE_PROJECT_UNREADABLE') {
436
+ throw error;
437
+ }
438
+
439
+ unreadable.push(projectId);
440
+ }
441
+ }
442
+
443
+ return {switched, unreadable};
444
+ }
445
+
446
+ // Folds a read of `project`'s files into state.json, in one locked update against the snapshot on
447
+ // disk, which is the record of what has been counted (docs/reward-rules.md §5.5). `base` is the
448
+ // snapshot the read started from: a file another process has counted since is read again under the
449
+ // lock rather than trusted. The = on added lines are written together with the snapshot that holds
450
+ // them, so a line counts once, whether kitcode add or the tracker reads it first, and the lines are
451
+ // recorded as seen. Under rules that credit a line once per user, a line seen before, in any project,
452
+ // earns nothing (§5.2, §5.7); a read that is a baseline, because code can have arrived through git,
453
+ // earns nothing at all (§5.8). Returns null when the project is not in state.json, or when a partial
454
+ // read cannot make its first snapshot.
455
+ function foldScan(project, read, base, {lockTimeoutMs} = {}) {
456
+ return withStateLock(() => {
457
+ // An older file is converted first, in this same update: after an upgrade, the first scan does it.
458
+ migrateState();
459
+ const record = loadState().projects?.[project.id];
460
+
461
+ if (!isObject(record)) {
462
+ return null;
463
+ }
464
+
465
+ const snapshot = snapshotOf(record);
466
+
467
+ if (!snapshot && !read.complete) {
468
+ return null;
469
+ }
470
+
471
+ // Other rules need the rules switch, which reads under both; the tracker makes it as it starts.
472
+ if (snapshot && snapshot.rulesVersion !== read.rulesVersion) {
473
+ throw new Error(`The snapshot of ${project.repoRoot} was taken under counting rules ${snapshot.rulesVersion}, and this scan is under rules ${read.rulesVersion}: the rules switch runs when the tracker starts.`);
474
+ }
475
+
476
+ // A project with no snapshot yet gets this read as its baseline: nothing in it counts, and nothing
477
+ // in it is activity.
478
+ const result = snapshot
479
+ ? applySourceScan(snapshot, read, {
480
+ base,
481
+ reread: (relativePath) => readSourceEntry(project.repoRoot, relativePath, {checkPath: true}),
482
+ seen: isLineSeen,
483
+ })
484
+ : {...applySourceScan(emptySnapshot(read.rulesVersion), read), equalsAdded: 0, linesChanged: []};
485
+
486
+ if (!snapshot || record.vibeSnapshot || result.changedFiles > 0 || result.equalsAdded > 0 || result.gitMoved) {
487
+ updateState((stored) => {
488
+ const {vibeSnapshot, ...current} = stored.projects[project.id];
489
+ stored.projects[project.id] = {...current, sourceSnapshot: result.snapshot};
490
+
491
+ if (result.equalsAdded > 0) {
492
+ stored.projects[project.id].changeBatchCount = (Number(current.changeBatchCount) || 0) + 1;
493
+ addSourceEqualsOnce(project, crypto.randomUUID(), result.equalsAdded);
494
+ // = credited can make a milestone ready: recorded in this same update (docs/reward-rules.md §6.1).
495
+ noteMilestonesReached(stored);
496
+ }
497
+ });
498
+ }
499
+
500
+ recordSeenLines(result.addedKeys);
501
+ const after = loadState();
502
+
503
+ return {
504
+ ...result,
505
+ changeBatchCount: Number(after.projects[project.id].changeBatchCount) || 0,
506
+ ledger: equalsLedgerFrom(after),
507
+ };
508
+ }, {lockTimeoutMs});
509
+ }
510
+
511
+ // The tracker's shared clock, as it starts: from the file's clock, or, the first time a file is
512
+ // loaded under the shared-clock rule, from its summed total (operator decision Q3, 2026-09-24).
513
+ function focusClockFrom(stored, now = Date.now()) {
514
+ const focus = storedFocusTime(stored)
515
+ ? {...focusTimeFrom(stored), carriedOver: stored.focusTime.carriedOver}
516
+ : startedFocusTime(stored, new Date(now));
517
+
518
+ return {...focus, window: createActivityWindow(), lastTick: now};
519
+ }
520
+
521
+ // What state.json is on disk now, cheaply: it changes whenever the file is written.
522
+ function stateFileIdentity() {
523
+ try {
524
+ const stat = fs.statSync(STORE_PATH);
525
+ return `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
526
+ } catch {
527
+ return 'missing';
528
+ }
529
+ }
530
+
531
+ // The tracker's state as it starts. Until 2026-09-25 it also stored the time target it was started
532
+ // with (--reward-seconds, KITCODE_REWARD_SECONDS); the target is fixed now (reward.mjs REWARD_SECONDS).
533
+ export function createRuntime(options) {
534
+ // Before anything is counted, or written: the file converted, and older rules switched.
535
+ prepareState();
536
+ // Taken before the read: a write after it shows as a change at the next reconcile.
537
+ const stateIdentity = stateFileIdentity();
538
+ const stored = loadState();
539
+
540
+ return {
541
+ options,
542
+ state: normalizeState(stored),
543
+ ledger: equalsLedgerFrom(stored),
544
+ focus: focusClockFrom(stored),
545
+ stateIdentity,
546
+ projectRuns: new Map(),
547
+ };
548
+ }
549
+
550
+ export function registerProject(targetPath = '.') {
551
+ const detected = detectProject(targetPath);
552
+ const project = {id: detected.id, repoRoot: detected.root, sourceType: detected.sourceType};
553
+ let registered = loadState().projects?.[detected.id];
554
+
555
+ // A snapshot taken under older counting rules is switched first (docs/reward-rules.md §5.6).
556
+ if (isObject(registered) && (rulesOfRecord(detected.id, registered) ?? RULES_VERSION) < RULES_VERSION) {
557
+ switchProjectRules(detected.id);
558
+ registered = loadState().projects?.[detected.id];
559
+ }
560
+
561
+ // The files are read outside the lock: on a large project that takes a while, and the tracker and
562
+ // the prompt hook must not wait for it. The fold makes up for anything counted meanwhile. A Git
563
+ // project's read is checked against git since the snapshot (§5.8); a new project's is its baseline.
564
+ const base = snapshotOf(registered) ?? emptySnapshot();
565
+ const read = readProjectScan(detected.root, {sourceType: detected.sourceType, since: base.git});
566
+ const commitCount = detected.sourceType === 'git' ? countCommits(detected.root) : 0;
567
+
568
+ return withStateLock(() => {
569
+ // An older file is converted in this same update, so a command that fails converts nothing.
570
+ migrateState();
571
+ const record = loadState().projects?.[detected.id];
572
+
573
+ if (!isObject(record)) {
574
+ // A new project: what is there now is its baseline, and never counts; its lines are seen.
575
+ const baseline = applySourceScan(emptySnapshot(read.rulesVersion), read);
576
+ const created = createProjectRecord(detected, {commitCount, sourceSnapshot: baseline.snapshot});
577
+ updateState((stored) => storeProject(stored, created));
578
+ recordSeenLines(baseline.addedKeys);
579
+ } else {
580
+ // Added again: whatever is new since its snapshot counts, once.
581
+ foldScan(project, read, base);
582
+
583
+ if (record.active !== true || (detected.sourceType === 'git' && Number(record.commitCount) !== commitCount)) {
584
+ updateState((stored) => {
585
+ stored.projects[detected.id].active = true;
586
+
587
+ if (detected.sourceType === 'git') {
588
+ stored.projects[detected.id].commitCount = commitCount;
589
+ }
590
+ });
591
+ }
592
+ }
593
+
594
+ return projectTotals(loadState());
595
+ });
596
+ }
597
+
598
+ export function listProjects() {
599
+ return projectTotals(loadState());
600
+ }
601
+
602
+ export function listProjectRecords() {
603
+ const state = normalizeState(loadState());
604
+
605
+ return Object.values(state.projects).map((project) => ({
606
+ id: project.id,
607
+ repoRoot: project.repoRoot,
608
+ sourceType: project.sourceType,
609
+ active: project.active,
610
+ }));
611
+ }
612
+
613
+ export function registerNewProjects(targetPaths = []) {
614
+ const existingIds = new Set(listProjectRecords().map((project) => project.id));
615
+ const addedProjects = [];
616
+
617
+ for (const project of describeProjects(targetPaths)) {
618
+ if (existingIds.has(project.id)) {
619
+ continue;
620
+ }
621
+
622
+ registerProject(project.repoRoot);
623
+ existingIds.add(project.id);
624
+ addedProjects.push(project);
625
+ }
626
+
627
+ return {
628
+ addedProjects,
629
+ projects: listProjectRecords(),
630
+ };
631
+ }
632
+
633
+ export function removeProject(targetPathOrProjectId = '.') {
634
+ return withStateLock(() => {
635
+ const state = normalizeState(loadState());
636
+ const projectId = findProjectIdForPath(state, targetPathOrProjectId);
637
+
638
+ const project = state.projects[projectId];
639
+
640
+ if (!project) {
641
+ return null;
642
+ }
643
+
644
+ return updateState((stored) => {
645
+ // Removing a project never lowers focus time (docs/reward-rules.md §4): the shared clock
646
+ // cannot say which project a second belonged to. A file from before the shared clock starts
647
+ // it now, from the summed total, when that total holds this project's time.
648
+ if (!storedFocusTime(stored) && (Number(stored.projects[projectId]?.activeSeconds) || 0) > 0) {
649
+ stored.focusTime = startedFocusTime(stored);
650
+ }
651
+
652
+ delete stored.projects[projectId];
653
+ stored.version = 4;
654
+ removeProjectEquals(projectId);
655
+
656
+ return projectTotals(stored);
657
+ });
658
+ });
659
+ }
660
+
661
+ export function setAllProjectsActive(active) {
662
+ return updateState((stored) => {
663
+ for (const project of Object.values(isObject(stored.projects) ? stored.projects : {})) {
664
+ if (isObject(project)) {
665
+ project.active = active;
666
+ }
667
+ }
668
+
669
+ return projectTotals(stored);
670
+ });
671
+ }
672
+
673
+ export function setProjectActiveByPath(targetPath = '.', active = true) {
674
+ return withStateLock(() => {
675
+ const state = normalizeState(loadState());
676
+ const projectId = findProjectIdForPath(state, targetPath);
677
+
678
+ if (!state.projects[projectId]) {
679
+ return null;
680
+ }
681
+
682
+ return updateState((stored) => {
683
+ stored.projects[projectId].active = active;
684
+ return projectTotals(stored);
685
+ });
686
+ });
687
+ }
688
+
689
+ // Scans `project` now and records any = it finds. A registered project is folded against its
690
+ // snapshot on disk; one that is not (check-project-ledger.mjs scans such a project) against its own.
691
+ export function refreshProject(project) {
692
+ if (!project.sourceSnapshot || !project.sourceSnapshot.files) {
693
+ project.sourceSnapshot = createSourceSnapshot(project.repoRoot, {sourceType: project.sourceType});
694
+ return false;
695
+ }
696
+
697
+ const base = project.sourceSnapshot;
698
+ const read = readProjectScan(project.repoRoot, {sourceType: project.sourceType, since: base.git});
699
+ let result = foldScan(project, read, base);
700
+
701
+ if (result) {
702
+ project.changeBatchCount = result.changeBatchCount;
703
+ } else {
704
+ // Its = are counted against the lines seen, and recorded, and so are its lines, as for a
705
+ // registered project.
706
+ result = withStateLock(() => {
707
+ const unregistered = applySourceScan(base, read, {seen: isLineSeen});
708
+
709
+ if (unregistered.equalsAdded > 0) {
710
+ addSourceEqualsOnce(project, crypto.randomUUID(), unregistered.equalsAdded);
711
+ project.changeBatchCount = (Number(project.changeBatchCount) || 0) + 1;
712
+ }
713
+
714
+ recordSeenLines(unregistered.addedKeys);
715
+ return unregistered;
716
+ });
717
+ }
718
+
719
+ project.sourceSnapshot = result.snapshot;
720
+
721
+ if (project.sourceType === 'git') {
722
+ project.commitCount = countCommits(project.repoRoot);
723
+ }
724
+
725
+ return result.changedFiles > 0;
726
+ }
727
+
728
+ // Real activity in a project, at `at`: it makes that project, and the shared clock, active for the
729
+ // idle window after it.
730
+ function touchProject(runtime, project, run, at = Date.now()) {
731
+ recordActivity(run.window, at);
732
+ recordActivity(runtime.focus.window, at);
733
+ project.lastActiveAt = new Date(at).toISOString();
734
+ }
735
+
736
+ // Whether a folded scan found real activity (operator decision Q4, 2026-09-25; docs/reward-rules.md §4):
737
+ // the normalised lines of a file the scanner reads changed, lines added or removed, and git did not
738
+ // change them. A touch, a save of the same text, or of the same lines re-spaced, re-quoted or with a
739
+ // trailing ; or , added or taken away, changes no line; a git operation on its own, and code that
740
+ // arrives through git, are git's (source-snapshot.mjs checkedAgainstGit).
741
+ function changedByUser(read, result) {
742
+ return !read.gitOperation && result.linesChanged.length > 0;
743
+ }
744
+
745
+ function tickProject(project, run, now) {
746
+ const {activeMs, idleMs} = advanceWindow(run.window, run.lastTick, now);
747
+ project.activeSeconds += activeMs / 1000;
748
+ project.idleSeconds += idleMs / 1000;
749
+ run.lastTick = now;
750
+ }
751
+
752
+ // Moves every clock on to now. Each project keeps its own active and idle seconds; the shared clock
753
+ // counts each second once, active when any project was, and runs only while a project is watched.
754
+ function tickActiveProjects(runtime) {
755
+ const now = Date.now();
756
+
757
+ for (const [projectId, run] of runtime.projectRuns.entries()) {
758
+ const project = runtime.state.projects[projectId];
759
+
760
+ if (project) {
761
+ tickProject(project, run, now);
762
+ }
763
+ }
764
+
765
+ const focus = runtime.focus;
766
+
767
+ if (runtime.projectRuns.size > 0) {
768
+ const {activeMs, idleMs} = advanceWindow(focus.window, focus.lastTick, now);
769
+ focus.activeSeconds += activeMs / 1000;
770
+ focus.idleSeconds += idleMs / 1000;
771
+ }
772
+
773
+ focus.lastTick = now;
774
+ }
775
+
776
+ // Runs `job` after the project's earlier scans: one scan of a project at a time, in order.
777
+ function enqueue(runtime, projectId, run, job) {
778
+ run.queue = run.queue.then(async () => {
779
+ try {
780
+ await job();
781
+ } catch (error) {
782
+ scanFailed(runtime, projectId, error);
783
+ }
784
+ });
785
+
786
+ return run.queue;
787
+ }
788
+
789
+ // What is wrong with the tracker, by fixed code (the prompt hook turns each code into fixed words,
790
+ // so nothing from a file reaches the agent): the projects affected, and since when. `onProblem` hears of each project's
791
+ // problem raised and cleared, with the error that raised it where there is one: a tester build's diagnostics
792
+ // (src/diagnostics.mjs) record it, by the code, an opaque reference to the project and the error's code alone.
793
+ function reportProblem(runtime, code, projectId, message, error = null) {
794
+ const problem = runtime.problems.get(code) ?? {since: new Date().toISOString(), projects: new Set()};
795
+
796
+ if (problem.projects.has(projectId)) {
797
+ return;
798
+ }
799
+
800
+ problem.projects.add(projectId);
801
+ runtime.problems.set(code, problem);
802
+ runtime.log.warn(message);
803
+ runtime.watcherOptions?.onProblem?.({code, projectId, state: 'raised', error});
804
+ runtime.watcherOptions?.onStatus?.(trackerStatus(runtime));
805
+ }
806
+
807
+ function clearProblem(runtime, code, projectId) {
808
+ const problem = runtime.problems.get(code);
809
+
810
+ if (!problem?.projects.delete(projectId)) {
811
+ return;
812
+ }
813
+
814
+ if (problem.projects.size === 0) {
815
+ runtime.problems.delete(code);
816
+ }
817
+
818
+ runtime.log.info(`${code} cleared for ${runtime.state.projects[projectId]?.repoRoot ?? projectId}`);
819
+ runtime.watcherOptions?.onProblem?.({code, projectId, state: 'cleared', error: null});
820
+ runtime.watcherOptions?.onStatus?.(trackerStatus(runtime));
821
+ }
822
+
823
+ // How the tracker is doing: `ok`, or `degraded` with the problems it knows of.
824
+ export function trackerStatus(runtime) {
825
+ const problems = [...(runtime.problems ?? new Map()).entries()].map(([code, problem]) => ({
826
+ code,
827
+ projects: problem.projects.size,
828
+ since: problem.since,
829
+ }));
830
+
831
+ return {tracking: problems.length > 0 ? 'degraded' : 'ok', problems};
832
+ }
833
+
834
+ function scanFailed(runtime, projectId, error) {
835
+ if (error instanceof StateFileError) {
836
+ runtime.watcherOptions?.onStateError?.(error);
837
+ return;
838
+ }
839
+
840
+ // The project could not be read now: its folder is gone, or git could not list it. Its snapshot
841
+ // stays as it is, and the next scan tries again.
842
+ const root = runtime.state.projects[projectId]?.repoRoot ?? projectId;
843
+ const missing = error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
844
+ reportProblem(runtime, missing ? 'project-missing' : 'scan-failed', projectId, `Scanning ${root} failed (${error?.code ?? 'error'}: ${error?.message ?? error}); its snapshot is kept, and nothing in it is counted until a scan succeeds.`, error);
845
+ }
846
+
847
+ // A Git project whose index stayed locked through a scan (index.lock, git.mjs gitArrival 'locked'): the
848
+ // scan was a baseline, so nothing in the project counts while the lock stays, and git refuses to run
849
+ // there too. Said as a problem, in fixed words, to the prompt hook, the dashboard and kitcode status, and
850
+ // cleared by the project's next scan that does not find it; a full scan runs every minute. Each read
851
+ // is judged once, as it comes back: a retried fold of an older read does not raise it again.
852
+ function noteGitLock(runtime, projectId, read) {
853
+ if (read.baseline !== 'locked') {
854
+ clearProblem(runtime, 'git-locked', projectId);
855
+ return;
856
+ }
857
+
858
+ const root = runtime.state.projects[projectId]?.repoRoot ?? projectId;
859
+ reportProblem(runtime, 'git-locked', projectId, `The git index of ${root} has stayed locked (index.lock) through a scan: its scans are baselines, so nothing in it is counted until the lock goes. A git command that crashed can leave that file behind.`);
860
+ }
861
+
862
+ // A Git project in which git shows an operation waiting for the user through a scan (a merge, a rebase, a
863
+ // cherry-pick, a revert, am or a squash merge; git.mjs gitArrival 'waiting'): the scan was a baseline, so
864
+ // nothing in the project counts until the operation is finished or aborted. Said as a problem, in fixed
865
+ // words, and cleared, as a lock is (noteGitLock); the log names the files git left. Until 2026-10-01
866
+ // nothing said so, and a REBASE_HEAD that a finished rebase left behind made every scan of a project a
867
+ // baseline for hours (docs/lessons-learned.md F22).
868
+ function noteGitWaiting(runtime, projectId, read) {
869
+ if (read.baseline !== 'waiting') {
870
+ clearProblem(runtime, 'git-unfinished', projectId);
871
+ return;
872
+ }
873
+
874
+ const root = runtime.state.projects[projectId]?.repoRoot ?? projectId;
875
+ const files = Array.isArray(read.gitWaiting) ? read.gitWaiting.join(', ') : 'unknown';
876
+ reportProblem(runtime, 'git-unfinished', projectId, `Git shows an unfinished operation in ${root} (${files}): its scans are baselines, so nothing in it is counted until the operation is finished or aborted.`);
877
+ }
878
+
879
+ // Folds a read into state.json and follows it in the tracker's copy. `activityAt` says, from what the
880
+ // fold changed, when the project was active, or null when it was not: activity is decided by the fold
881
+ // that counted the change, never before it. When the lock stays busy, the same read is folded a little
882
+ // later; the fold allows for anything counted in between.
883
+ function applyRead(runtime, projectId, run, read, base, activityAt) {
884
+ const project = runtime.state.projects[projectId];
885
+
886
+ if (!project) {
887
+ return null;
888
+ }
889
+
890
+ let result;
891
+
892
+ try {
893
+ result = foldScan(project, read, base, {lockTimeoutMs: SCAN_LOCK_TIMEOUT_MS});
894
+ } catch (error) {
895
+ if (!(error instanceof StateLockError)) {
896
+ throw error;
897
+ }
898
+
899
+ setTimeout(() => {
900
+ if (!run.stopped) {
901
+ enqueue(runtime, projectId, run, () => applyRead(runtime, projectId, run, read, base, activityAt));
902
+ }
903
+ }, SCAN_RETRY_MS).unref();
904
+ return null;
905
+ }
906
+
907
+ if (result) {
908
+ project.sourceSnapshot = result.snapshot;
909
+ project.changeBatchCount = result.changeBatchCount;
910
+ runtime.ledger = result.ledger;
911
+ clearProblem(runtime, 'scan-failed', projectId);
912
+ clearProblem(runtime, 'project-missing', projectId);
913
+ const at = activityAt(result);
914
+
915
+ if (at !== null) {
916
+ touchProject(runtime, project, run, at);
917
+ }
918
+ }
919
+
920
+ return result;
921
+ }
922
+
923
+ // A scan of every file of the project. What it finds changed is activity as any scan's is
924
+ // (changedByUser), from when it found it; but what the first scan after a start finds happened while
925
+ // the tracker was not watching, and is counted but is not activity.
926
+ function queueFullScan(runtime, projectId, reason) {
927
+ const run = runtime.projectRuns.get(projectId);
928
+
929
+ if (!run || run.fullScanQueued) {
930
+ return;
931
+ }
932
+
933
+ run.fullScanQueued = true;
934
+ enqueue(runtime, projectId, run, async () => {
935
+ run.fullScanQueued = false;
936
+ const project = runtime.state.projects[projectId];
937
+
938
+ if (!project || run.stopped) {
939
+ return;
940
+ }
941
+
942
+ const base = snapshotOf(project) ?? emptySnapshot();
943
+ const read = await runtime.scanner.read({root: project.repoRoot, sourceType: project.sourceType, since: base.git});
944
+ noteGitLock(runtime, projectId, read);
945
+ noteGitWaiting(runtime, projectId, read);
946
+ applyRead(runtime, projectId, run, read, base, (result) => (reason !== 'start' && changedByUser(read, result) ? Date.now() : null));
947
+ });
948
+ }
949
+
950
+ function relativeTo(root, fileName) {
951
+ return path.relative(root, path.resolve(root, String(fileName))).split(path.sep).join('/');
952
+ }
953
+
954
+ // The files changed since the last batch. The scan is activity when the fold finds the user changed
955
+ // the normalised lines of a file the scanner reads (changedByUser): an event alone, on any file, is
956
+ // not. It counts from the first event of a file whose lines changed.
957
+ async function scanChangedPaths(runtime, projectId, run, batch) {
958
+ const project = runtime.state.projects[projectId];
959
+
960
+ if (!project || run.stopped) {
961
+ return;
962
+ }
963
+
964
+ const base = snapshotOf(project) ?? emptySnapshot();
965
+ const read = await runtime.scanner.read({root: project.repoRoot, sourceType: project.sourceType, changedPaths: [...batch.keys()], since: base.git});
966
+ const firstEvents = new Map([...batch].map(([fileName, at]) => [relativeTo(project.repoRoot, fileName), at]));
967
+ noteGitLock(runtime, projectId, read);
968
+ noteGitWaiting(runtime, projectId, read);
969
+
970
+ applyRead(runtime, projectId, run, read, base, (result) => {
971
+ if (!changedByUser(read, result)) {
972
+ return null;
973
+ }
974
+
975
+ const changed = new Set(result.linesChanged);
976
+ const times = read.files.filter((entry) => changed.has(entry.fileKey)).map((entry) => firstEvents.get(entry.relativePath)).filter(Number.isFinite);
977
+ return times.length > 0 ? Math.min(...times) : Date.now();
978
+ });
979
+ }
980
+
981
+ function flushChanges(runtime, projectId, run) {
982
+ clearTimeout(run.sourceTimer);
983
+ run.sourceTimer = null;
984
+
985
+ if (run.rescanRequested) {
986
+ run.rescanRequested = false;
987
+ queueFullScan(runtime, projectId, 'change');
988
+ }
989
+
990
+ if (run.changedPaths.size > 0) {
991
+ const batch = run.changedPaths;
992
+ run.changedPaths = new Map();
993
+ enqueue(runtime, projectId, run, () => scanChangedPaths(runtime, projectId, run, batch));
994
+ }
995
+ }
996
+
997
+ function onWatchEvent(runtime, projectId, run, fileName) {
998
+ if (run.stopped) {
999
+ return;
1000
+ }
1001
+
1002
+ if (fileName === null || fileName === undefined || fileName === '') {
1003
+ // The platform did not say which file changed: look at them all.
1004
+ run.rescanRequested = true;
1005
+ } else if (shouldIgnoreRelativePath(String(fileName))) {
1006
+ return;
1007
+ } else if (!run.changedPaths.has(String(fileName))) {
1008
+ run.changedPaths.set(String(fileName), Date.now());
1009
+ }
1010
+
1011
+ run.sourceTimer ??= setTimeout(() => flushChanges(runtime, projectId, run), SOURCE_DEBOUNCE_MS);
1012
+ }
1013
+
1014
+ // Without a watcher, the project is scanned in full every few seconds: changes are still counted,
1015
+ // and still make it active, a little later. The tracker says so in its log, /api/health and
1016
+ // tracker.json, and the prompt hook tells the agent.
1017
+ function watchFailed(runtime, projectId, run, error) {
1018
+ run.watcher?.close();
1019
+ run.watcher = null;
1020
+
1021
+ if (run.stopped || run.poll) {
1022
+ return;
1023
+ }
1024
+
1025
+ const root = runtime.state.projects[projectId]?.repoRoot ?? projectId;
1026
+
1027
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
1028
+ reportProblem(runtime, 'project-missing', projectId, `Watching ${root} failed (${error.code}: ${error.message}): the folder is gone, so nothing in it is counted.`, error);
1029
+ return;
1030
+ }
1031
+
1032
+ run.poll = setInterval(() => queueFullScan(runtime, projectId, 'poll'), POLL_INTERVAL_MS);
1033
+ reportProblem(runtime, 'watch-failed', projectId, `Watching ${root} failed (${error?.code ?? 'error'}: ${error?.message ?? error}); polling it every ${POLL_INTERVAL_MS / 1000} s instead.`, error);
1034
+ }
1035
+
1036
+ function watchProject(runtime, projectId, run) {
1037
+ const project = runtime.state.projects[projectId];
1038
+
1039
+ try {
1040
+ run.watcher = fs.watch(project.repoRoot, {recursive: true}, (_eventType, fileName) => onWatchEvent(runtime, projectId, run, fileName));
1041
+ // Without this listener, a watcher's error (too many open files, say) ends the tracker.
1042
+ run.watcher.on('error', (error) => watchFailed(runtime, projectId, run, error));
1043
+ } catch (error) {
1044
+ watchFailed(runtime, projectId, run, error);
1045
+ }
1046
+ }
1047
+
1048
+ // HEAD, the working tree's status and the commit count, asked of git off the tracker's thread. Until
1049
+ // 2026-09-25 a change between two polls was activity: an empty commit, a stash or a checkout earned
1050
+ // time. It is not (operator decision Q4): a change asks for a full scan instead, which records where
1051
+ // git is for the next scan's check (docs/reward-rules.md §5.8), and is activity only for lines the user
1052
+ // changed. The status leaves out the files the scanner does not read (scan-worker.mjs), so an install
1053
+ // that rewrites a lockfile asks for nothing.
1054
+ function pollGit(runtime, projectId, run) {
1055
+ const project = runtime.state.projects[projectId];
1056
+
1057
+ if (!project || project.sourceType !== 'git' || run.gitPending || run.stopped) {
1058
+ return;
1059
+ }
1060
+
1061
+ run.gitPending = true;
1062
+ runtime.scanner.git({root: project.repoRoot}).then(({signature, commitCount}) => {
1063
+ const current = runtime.state.projects[projectId];
1064
+
1065
+ if (!current || run.stopped || signature === null) {
1066
+ return;
1067
+ }
1068
+
1069
+ if (run.lastGitSignature !== undefined && signature !== run.lastGitSignature) {
1070
+ queueFullScan(runtime, projectId, 'git');
1071
+ }
1072
+
1073
+ run.lastGitSignature = signature;
1074
+ current.commitCount = commitCount;
1075
+ }).catch(() => {
1076
+ // Git could not be asked now; the next poll tries again.
1077
+ }).finally(() => {
1078
+ run.gitPending = false;
1079
+ });
1080
+ }
1081
+
1082
+ function startProject(runtime, project) {
1083
+ if (runtime.projectRuns.has(project.id)) {
1084
+ return;
1085
+ }
1086
+
1087
+ // No activity yet: a project becomes active only on real activity, never because the tracker
1088
+ // started watching it (until 2026-09-24 it was active for its first 5 minutes).
1089
+ const run = {
1090
+ lastTick: Date.now(),
1091
+ window: createActivityWindow(),
1092
+ lastGitSignature: undefined,
1093
+ gitPending: false,
1094
+ changedPaths: new Map(),
1095
+ rescanRequested: false,
1096
+ sourceTimer: null,
1097
+ fullScanQueued: false,
1098
+ queue: Promise.resolve(),
1099
+ watcher: null,
1100
+ poll: null,
1101
+ stopped: false,
1102
+ };
1103
+
1104
+ runtime.projectRuns.set(project.id, run);
1105
+ watchProject(runtime, project.id, run);
1106
+ queueFullScan(runtime, project.id, 'start');
1107
+ pollGit(runtime, project.id, run);
1108
+ }
1109
+
1110
+ function closeRun(run) {
1111
+ run.stopped = true;
1112
+ clearTimeout(run.sourceTimer);
1113
+ run.sourceTimer = null;
1114
+ clearInterval(run.poll);
1115
+ run.poll = null;
1116
+ run.watcher?.close();
1117
+ run.watcher = null;
1118
+ }
1119
+
1120
+ function stopProject(runtime, projectId) {
1121
+ const run = runtime.projectRuns.get(projectId);
1122
+
1123
+ if (!run) {
1124
+ return;
1125
+ }
1126
+
1127
+ const project = runtime.state.projects[projectId];
1128
+
1129
+ if (project) {
1130
+ tickProject(project, run, Date.now());
1131
+ }
1132
+
1133
+ closeRun(run);
1134
+ runtime.projectRuns.delete(projectId);
1135
+
1136
+ for (const code of [...runtime.problems.keys()]) {
1137
+ clearProblem(runtime, code, projectId);
1138
+ }
1139
+ }
1140
+
1141
+ // Re-reads state.json when it changed, and follows it: projects added are watched, projects removed
1142
+ // are dropped, and the ledger is refreshed. The tracker's own counters stay its own; each project's
1143
+ // snapshot comes from the file, where every scan writes it.
1144
+ function reconcileProjects(runtime) {
1145
+ tickActiveProjects(runtime);
1146
+ const identity = stateFileIdentity();
1147
+
1148
+ if (identity !== runtime.stateIdentity) {
1149
+ const stored = loadState();
1150
+ const diskState = normalizeState(stored);
1151
+ runtime.stateIdentity = identity;
1152
+ runtime.ledger = equalsLedgerFrom(stored);
1153
+
1154
+ for (const [projectId, project] of Object.entries(runtime.state.projects)) {
1155
+ if (!diskState.projects[projectId]) {
1156
+ stopProject(runtime, projectId);
1157
+ continue;
1158
+ }
1159
+
1160
+ diskState.projects[projectId] = {
1161
+ ...diskState.projects[projectId],
1162
+ activeSeconds: project.activeSeconds,
1163
+ idleSeconds: project.idleSeconds,
1164
+ commitCount: project.commitCount,
1165
+ lastActiveAt: project.lastActiveAt,
1166
+ };
1167
+ }
1168
+
1169
+ runtime.state = diskState;
1170
+ }
1171
+
1172
+ for (const project of Object.values(runtime.state.projects)) {
1173
+ if (project.active) {
1174
+ startProject(runtime, project);
1175
+ } else {
1176
+ stopProject(runtime, project.id);
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ // Writes the tracker's counters into the projects on disk: time, and the commit count git reports.
1182
+ // Snapshots and change counts are not among them; each scan writes those as it counts. Projects
1183
+ // added or removed on disk since the last reconcile stay added or removed, and everything else in
1184
+ // the file is left as it is. Since 2026-09-25 it also records a milestone its time has made ready
1185
+ // (docs/reward-rules.md §6.1), within one save, 10 s, of it becoming ready.
1186
+ export function persistRuntimeState(runtime, options) {
1187
+ updateState((stored) => {
1188
+ if (!isObject(stored.projects)) {
1189
+ return;
1190
+ }
1191
+
1192
+ for (const [projectId, project] of Object.entries(runtime.state.projects)) {
1193
+ if (!isObject(stored.projects[projectId])) {
1194
+ continue;
1195
+ }
1196
+
1197
+ stored.projects[projectId] = {
1198
+ ...stored.projects[projectId],
1199
+ activeSeconds: project.activeSeconds,
1200
+ idleSeconds: project.idleSeconds,
1201
+ commitCount: project.commitCount,
1202
+ lastActiveAt: project.lastActiveAt,
1203
+ };
1204
+ }
1205
+
1206
+ // The shared clock is the tracker's own counter too. Fields it does not know are kept.
1207
+ if (runtime.focus) {
1208
+ stored.focusTime = {
1209
+ ...(isObject(stored.focusTime) ? stored.focusTime : {}),
1210
+ activeSeconds: runtime.focus.activeSeconds,
1211
+ idleSeconds: runtime.focus.idleSeconds,
1212
+ ...(runtime.focus.carriedOver ? {carriedOver: runtime.focus.carriedOver} : {}),
1213
+ };
1214
+ }
1215
+
1216
+ stored.version = 4;
1217
+ noteMilestonesReached(stored);
1218
+ }, options);
1219
+ }
1220
+
1221
+ // Runs one of the tracker's periodic jobs. A busy lock skips it until the next tick. A state file
1222
+ // that cannot be read stops the tracker through `onStateError`, before anything writes to it.
1223
+ function trackerJob(options, job) {
1224
+ return () => {
1225
+ try {
1226
+ job();
1227
+ } catch (error) {
1228
+ if (error instanceof StateLockError) {
1229
+ return;
1230
+ }
1231
+
1232
+ if (error instanceof StateFileError && options.onStateError) {
1233
+ options.onStateError(error);
1234
+ return;
1235
+ }
1236
+
1237
+ throw error;
1238
+ }
1239
+ };
1240
+ }
1241
+
1242
+ // What /api/summary and /api/events return. The tracker answers from memory: its clock, and the
1243
+ // ledger as of its last read of state.json. A runtime without them reads the file.
1244
+ export function buildSummary(runtime) {
1245
+ const projects = Object.values(runtime.state.projects);
1246
+ const trackingProjects = projects.filter((project) => project.active);
1247
+ const focus = runtime.focus ?? focusTimeFrom(runtime.state);
1248
+ const totalCommits = projects.reduce((sum, project) => sum + project.commitCount, 0);
1249
+ const totalChangeBatches = projects.reduce((sum, project) => sum + project.changeBatchCount, 0);
1250
+ const ledger = runtime.ledger ?? loadEqualsLedger();
1251
+ const totalEquals = ledger.total_equals;
1252
+ const reward = buildRewardSummary({
1253
+ earnedSeconds: focus.activeSeconds,
1254
+ totalEquals,
1255
+ ledger,
1256
+ });
1257
+
1258
+ return {
1259
+ connected: true,
1260
+ global: {
1261
+ totalActiveSeconds: Math.floor(focus.activeSeconds),
1262
+ totalIdleSeconds: Math.floor(focus.idleSeconds),
1263
+ totalCommits,
1264
+ totalProjects: projects.length,
1265
+ trackingProjects: trackingProjects.length,
1266
+ totalEquals,
1267
+ totalChangeBatches,
1268
+ sourceModes: {
1269
+ git: trackingProjects.some((project) => project.sourceType === 'git'),
1270
+ vibe: trackingProjects.some((project) => project.sourceType === 'vibe'),
1271
+ },
1272
+ },
1273
+ reward,
1274
+ };
1275
+ }
1276
+
1277
+ // Starts the tracker's work. `options.log` records what goes wrong; `options.onStatus` hears how the
1278
+ // tracker is doing whenever that changes, and after every save; `options.onProblem`, each project's problem as it is raised
1279
+ // and cleared (reportProblem).
1280
+ export function startWatchers(runtime, options = {}) {
1281
+ runtime.watcherOptions = options;
1282
+ runtime.log = options.log ?? SILENT_LOG;
1283
+ runtime.problems = new Map();
1284
+ runtime.status = () => trackerStatus(runtime);
1285
+ runtime.scanner = createScanner({
1286
+ onError: (error) => runtime.log.error(`The scan worker failed (${error?.message ?? error}); a new one starts with the next scan.`),
1287
+ });
1288
+ options.onProgress?.('Loading added projects...');
1289
+ reconcileProjects(runtime);
1290
+ options.onProgress?.('Watching folders...');
1291
+
1292
+ const syncTimer = setInterval(trackerJob(options, () => reconcileProjects(runtime)), SYNC_INTERVAL_MS);
1293
+ const saveTimer = setInterval(trackerJob(options, () => {
1294
+ tickActiveProjects(runtime);
1295
+ persistRuntimeState(runtime, {lockTimeoutMs: PERIODIC_SAVE_LOCK_TIMEOUT_MS});
1296
+ options.onStatus?.(trackerStatus(runtime));
1297
+ }), SAVE_INTERVAL_MS);
1298
+ const gitTimer = setInterval(() => {
1299
+ for (const [projectId, run] of runtime.projectRuns.entries()) {
1300
+ pollGit(runtime, projectId, run);
1301
+ }
1302
+ }, GIT_INTERVAL_MS);
1303
+ const rescanTimer = setInterval(() => {
1304
+ for (const projectId of runtime.projectRuns.keys()) {
1305
+ queueFullScan(runtime, projectId, 'rescan');
1306
+ }
1307
+ }, SOURCE_FALLBACK_INTERVAL_MS);
1308
+
1309
+ // Stops every timer and watcher, then saves: changes still waiting are scanned first. With
1310
+ // {persist: false} it saves and scans nothing: for a state file that can no longer be read, which
1311
+ // must not be written either.
1312
+ return async ({persist = true} = {}) => {
1313
+ clearInterval(syncTimer);
1314
+ clearInterval(saveTimer);
1315
+ clearInterval(gitTimer);
1316
+ clearInterval(rescanTimer);
1317
+ const runs = [...runtime.projectRuns.entries()];
1318
+
1319
+ if (persist) {
1320
+ for (const [projectId, run] of runs) {
1321
+ flushChanges(runtime, projectId, run);
1322
+ }
1323
+
1324
+ await Promise.allSettled(runs.map(([, run]) => run.queue));
1325
+ }
1326
+
1327
+ for (const [, run] of runs) {
1328
+ closeRun(run);
1329
+ }
1330
+
1331
+ await runtime.scanner.close();
1332
+
1333
+ if (persist) {
1334
+ tickActiveProjects(runtime);
1335
+ persistRuntimeState(runtime);
1336
+ }
1337
+
1338
+ runtime.projectRuns.clear();
1339
+ };
1340
+ }