@kitkat-vn/kitcode 0.2.0-staging.10 → 0.2.0-staging.14

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.
package/src/reward.mjs CHANGED
@@ -3,22 +3,27 @@ import {equalsLedgerFrom, storedEqualsLedger} from './equals-ledger.mjs';
3
3
  import {focusTimeFrom} from './focus-clock.mjs';
4
4
  import {nextBreakReminder} from './next-break-reminder.mjs';
5
5
  import {KITCODE_DISPLAY_MILESTONES, KITCODE_REWARD_TIERS} from './integration-spec.mjs';
6
+ import {readMilestoneRecord} from './milestone-record.mjs';
6
7
  import {loadState, updateState} from './store.mjs';
7
8
 
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
9
  export const REWARD_TIERS = KITCODE_REWARD_TIERS;
14
10
  export const CAMPAIGN_MILESTONES = KITCODE_DISPLAY_MILESTONES;
15
11
  export const REWARD_MILESTONES = [
16
12
  ...REWARD_TIERS.map((tier) => ({...tier, rewardBacked: true})),
17
13
  ...CAMPAIGN_MILESTONES.map((milestone) => ({...milestone, rewardBacked: false})),
18
14
  ];
15
+ export const MILESTONE_PERCENTS = REWARD_MILESTONES.map((milestone) => milestone.percent);
16
+ // The focus time the 100 % milestone needs, fixed (operator decision Q5, 2026-09-25; docs/reward-rules.md
17
+ // §3). Until then it was a setting, down to 1 s: --reward-seconds, KITCODE_REWARD_SECONDS, and
18
+ // rewardSettings.requiredSeconds in state.json. A requiredSeconds an older KitCode stored stays in the
19
+ // file, unread, as the removed requiredEquals does. Since 2026-10-20 each milestone names its own time in the table
20
+ // (integration-spec.mjs), where it was this × its percent: the same numbers in the tester table, which held the operator's
21
+ // from 2026-10-21 to 2026-10-22, 100 %'s 8 h, and since then its own again, 100 %'s 3600 s.
22
+ const LAST_MILESTONE = REWARD_MILESTONES[REWARD_MILESTONES.length - 1];
23
+ export const REWARD_SECONDS = LAST_MILESTONE.requiredSeconds;
19
24
  // The = the last milestone needs. The = targets are the table's; no setting changes them (the
20
25
  // --reward-equals setting, which gated nothing, was removed on 2026-09-24).
21
- const FINAL_EQUALS = Math.max(...REWARD_MILESTONES.map((milestone) => milestone.requiredEquals));
26
+ const FINAL_EQUALS = LAST_MILESTONE.requiredEquals;
22
27
 
23
28
  const VALID_TIER_PERCENTS = new Set(REWARD_TIERS.map((tier) => tier.percent));
24
29
 
@@ -40,6 +45,27 @@ export function normalizeTierPercent(value) {
40
45
  return percent;
41
46
  }
42
47
 
48
+ function announcementsOf(value) {
49
+ return value && typeof value === 'object' && !Array.isArray(value)
50
+ ? Object.fromEntries(Object.entries(value).filter(([source, at]) => normalizeSource(source) && typeof at === 'string'))
51
+ : {};
52
+ }
53
+
54
+ // Since 2026-10-22 (rewards/3-repeat-vouchers, phase 2) a tier is announced once per agent in each cycle: cycle 1's
55
+ // announcements are `announced_at`, as they always were, and a later cycle's are kept by its number, `announced_in_cycles`,
56
+ // {"2": {"claude": "2026-…"}}, a field written only once a later cycle has one, so that a file with none is byte for byte
57
+ // what it was, and a KitCode from before reads cycle 1's as ever.
58
+ function cycleAnnouncementsOf(value) {
59
+ if (!isObject(value)) {
60
+ return {};
61
+ }
62
+
63
+ return Object.fromEntries(Object.entries(value)
64
+ .filter(([cycle]) => /^[1-9][0-9]*$/u.test(cycle) && Number(cycle) >= 2)
65
+ .map(([cycle, announced]) => [cycle, announcementsOf(announced)])
66
+ .filter(([, announced]) => Object.keys(announced).length > 0));
67
+ }
68
+
43
69
  function normalizeTierRecord(entry) {
44
70
  const percent = normalizeTierPercent(
45
71
  typeof entry === 'object' && entry !== null ? entry.percent : entry,
@@ -49,20 +75,21 @@ function normalizeTierRecord(entry) {
49
75
  return null;
50
76
  }
51
77
 
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
- : {};
78
+ const byCycle = cycleAnnouncementsOf(entry?.announced_in_cycles);
58
79
 
59
80
  return {
60
81
  percent,
61
82
  redeemed_at: typeof entry?.redeemed_at === 'string' ? entry.redeemed_at : null,
62
- announced_at: announcedAt,
83
+ announced_at: announcementsOf(entry?.announced_at),
84
+ ...(Object.keys(byCycle).length > 0 ? {announced_in_cycles: byCycle} : {}),
63
85
  };
64
86
  }
65
87
 
88
+ // Whether `record`, a tier's record, normalized, holds its announcement to `source` in `cycle`.
89
+ function announcedIn(record, source, cycle) {
90
+ return cycle > 1 ? Boolean(record?.announced_in_cycles?.[String(cycle)]?.[source]) : Boolean(record?.announced_at?.[source]);
91
+ }
92
+
66
93
  export function normalizeEarnedTiers(entries) {
67
94
  const records = new Map();
68
95
 
@@ -74,6 +101,12 @@ export function normalizeEarnedTiers(entries) {
74
101
  }
75
102
 
76
103
  const existing = records.get(record.percent);
104
+ const byCycle = {...(existing?.announced_in_cycles ?? {})};
105
+
106
+ for (const [cycle, announced] of Object.entries(record.announced_in_cycles ?? {})) {
107
+ byCycle[cycle] = {...(byCycle[cycle] ?? {}), ...announced};
108
+ }
109
+
77
110
  records.set(record.percent, {
78
111
  percent: record.percent,
79
112
  redeemed_at: existing?.redeemed_at ?? record.redeemed_at,
@@ -81,6 +114,7 @@ export function normalizeEarnedTiers(entries) {
81
114
  ...(existing?.announced_at ?? {}),
82
115
  ...record.announced_at,
83
116
  },
117
+ ...(Object.keys(byCycle).length > 0 ? {announced_in_cycles: byCycle} : {}),
84
118
  });
85
119
  }
86
120
 
@@ -125,65 +159,176 @@ function updateTierRecord(ledger, percent, change) {
125
159
  });
126
160
  }
127
161
 
128
- function milestoneTimeTarget(requiredSeconds, percent) {
129
- return Math.ceil(requiredSeconds * (percent / 100));
162
+ function reachesTargets(earned, milestone) {
163
+ return earned.seconds >= milestone.requiredSeconds && earned.equals >= milestone.requiredEquals;
164
+ }
165
+
166
+ // The milestones as they stacked until 2026-10-20 (docs/reward-rules.md §2): each reached when the totals since install
167
+ // meet its own targets, with every milestone below the highest one reached, or whose code an earlier KitCode recorded as
168
+ // shown (E8). A record that is not what KitCode writes is read so, and nothing is written over it (decideMilestones).
169
+ function stackedReached(totals, redeemed) {
170
+ const highest = Math.max(0, ...REWARD_MILESTONES
171
+ .filter((milestone) => reachesTargets(totals, milestone) || redeemed.has(milestone.percent))
172
+ .map((milestone) => milestone.percent));
173
+
174
+ return new Set(MILESTONE_PERCENTS.filter((percent) => percent <= highest));
130
175
  }
131
176
 
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;
177
+ // Which milestones are reached, and where each counts from (docs/reward-rules.md §2; the plan of 2026-10-20, decisions 1
178
+ // to 3). Each counts its own focus time and = from the moment the milestone before it was reached, the first from install,
179
+ // and is reached when what was earned since meets both its targets: nothing earned before that moment counts toward it,
180
+ // neither the time earned while waiting for = nor the = while waiting for time. So at most one is reached at a time.
181
+ //
182
+ // `totals` are the focus seconds, floored, and the = counted since install; `record` the milestone record as state.json
183
+ // holds it; `redeemed` the tiers an earlier KitCode recorded a code shown for. A milestone the record holds is reached,
184
+ // whatever the totals say now, and so is every one below it: a reached milestone stays reached. One that is not held
185
+ // is reached once the totals since its start meet its targets, its moment the moment this is found: the record's next
186
+ // write records it, with these totals.
187
+ //
188
+ // Decision 2, people who reached milestones before this rule: a milestone reached with no totals known, an earlier
189
+ // KitCode's entry or one reached before the record began, keeps its place, and the one after it counts from the record's
190
+ // freshSince, the totals when this KitCode first wrote such a record; until that write, from the totals now. A file with
191
+ // no record yet is read as the first write will record it: the milestones its totals reached as they stacked, reached
192
+ // before the record began, and the next counting from now.
193
+ //
194
+ // Since 2026-10-22 (rewards/3-repeat-vouchers, phase 2; the plan of 2026-10-21, decision 1) the milestones count in the cycle
195
+ // the record is in (milestone-record.mjs): cycle 1, as above, until the tracker takes a receipt the claim backend signed for
196
+ // a voucher; then the next, whose milestones all count fresh from the totals at that moment, the first from there where cycle
197
+ // 1's counts from install. Nothing of an earlier cycle counts toward a later one, and nothing an earlier KitCode recorded as a
198
+ // code shown (`redeemed`) does either: that is cycle 1's.
199
+ //
200
+ // Returns {stacked, entries, cycle}: `stacked` true for a record that is not what KitCode writes, read as the milestones
201
+ // stacked; otherwise each entry {percent, reached, recorded, countedFrom, end}, `countedFrom` the totals the milestone counts
202
+ // from, or null for one that has not begun, or whose start is not known; `end` the totals it was reached at, null when not
203
+ // known; and `cycle` {number, startedAt, start}, the cycle counted in, when it began (null for cycle 1) and its start totals.
204
+ export function decideMilestones({totals, record, redeemed = new Set()}) {
205
+ const read = readMilestoneRecord(record, MILESTONE_PERCENTS);
206
+
207
+ if (read.state === 'damaged') {
208
+ return {stacked: true, reached: stackedReached(totals, redeemed)};
209
+ }
210
+
211
+ const current = read.state === 'none' ? null : read.current;
212
+ const later = current !== null && current.cycle > 1;
213
+ const before = read.state === 'none'
214
+ ? new Map([...stackedReached(totals, redeemed)].map((percent) => [percent, {kind: 'before-recording', end: null}]))
215
+ : current.entries;
216
+ const freshSince = read.state === 'none' ? null : current.freshSince;
217
+ const shown = later ? [] : [...redeemed].filter((percent) => MILESTONE_PERCENTS.includes(percent));
218
+ const highest = Math.max(0, ...before.keys(), ...shown);
219
+ const entries = [];
220
+ // Where the next milestone counts from: install for the first of cycle 1, a later cycle's start for its first; null once one
221
+ // has not been reached.
222
+ let start = later ? {...current.start} : {seconds: 0, equals: 0};
223
+ let startKnown = true;
224
+
225
+ for (const milestone of REWARD_MILESTONES) {
226
+ const entry = before.get(milestone.percent);
227
+
228
+ if (start === null) {
229
+ entries.push({percent: milestone.percent, reached: false, recorded: false, countedFrom: null, end: null});
230
+ continue;
231
+ }
232
+
233
+ if (milestone.percent <= highest) {
234
+ const end = entry?.end ?? null;
235
+ entries.push({percent: milestone.percent, reached: true, recorded: entry !== undefined, countedFrom: startKnown ? start : null, end});
236
+ start = end ?? (freshSince ? {seconds: freshSince.seconds, equals: freshSince.equals} : {...totals});
237
+ startKnown = end !== null || freshSince !== null;
238
+ continue;
239
+ }
240
+
241
+ const earned = {seconds: Math.max(0, totals.seconds - start.seconds), equals: Math.max(0, totals.equals - start.equals)};
242
+
243
+ if (reachesTargets(earned, milestone)) {
244
+ entries.push({percent: milestone.percent, reached: true, recorded: false, countedFrom: start, end: {...totals}});
245
+ start = {...totals};
246
+ startKnown = true;
247
+ continue;
248
+ }
249
+
250
+ entries.push({percent: milestone.percent, reached: false, recorded: false, countedFrom: start, end: null});
251
+ start = null;
252
+ }
253
+
254
+ return {
255
+ stacked: false,
256
+ entries,
257
+ cycle: later ? {number: current.cycle, startedAt: current.startedAt, start: {...current.start}} : {number: 1, startedAt: null, start: {seconds: 0, equals: 0}},
258
+ };
259
+ }
260
+
261
+ // The focus time still needed to reach 100 %, each milestone not reached counted fresh, the current one from its start.
262
+ function timeLeft(totals, milestones) {
263
+ return milestones.reduce((sum, milestone) => {
264
+ if (milestone.unlocked) {
265
+ return sum;
266
+ }
267
+
268
+ const from = milestone.countedFrom?.seconds;
269
+ return sum + Math.max(0, milestone.requiredSeconds - (from === undefined || from === null ? 0 : Math.max(0, totals.seconds - from)));
270
+ }, 0);
271
+ }
272
+
273
+ // The reward summary for `earnedSeconds` of focus time and `totalEquals` counted, against the milestone table
274
+ // (integration-spec.mjs), and the milestone record (`record`, state.json's milestonesReached; decideMilestones). It takes
275
+ // no target: a `settings` a caller still passes, as older code and tests do, is not read.
276
+ //
277
+ // Since 2026-10-20 each milestone of the summary also says where it counts from, `countedFrom`, {seconds, equals}, or null
278
+ // for one that has not begun: what counts toward it is what was earned since (break-progress.mjs). A record that is not
279
+ // what KitCode writes gives the summary as it was before: no `countedFrom`, every milestone counted from install.
280
+ //
281
+ // Since 2026-10-22 the summary says the cycle its milestones count in, `cycle`, 1 until the tracker takes a receipt for a
282
+ // voucher (decideMilestones), and `cycleStartedAt`, when a later one began, null for cycle 1; both null for a record that is
283
+ // not what KitCode writes. Its milestones and tiers are that cycle's: in a later one, none is `redeemed`, a code an earlier
284
+ // KitCode recorded as shown being cycle 1's.
285
+ export function buildRewardSummary({earnedSeconds, totalEquals, ledger, record}) {
137
286
  const records = tierRecordMap(ledger);
138
287
  const normalizedEarnedSeconds = Math.floor(Math.max(0, Number(earnedSeconds) || 0));
139
288
  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);
289
+ const totals = {seconds: normalizedEarnedSeconds, equals: normalizedTotalEquals};
290
+ const redeemed = new Set(REWARD_TIERS.filter((tier) => records.get(tier.percent)?.redeemed_at).map((tier) => tier.percent));
291
+ const decided = decideMilestones({totals, record, redeemed});
292
+ const laterCycle = !decided.stacked && decided.cycle.number > 1;
293
+ const milestones = REWARD_MILESTONES.map((milestone, index) => {
294
+ const record = records.get(milestone.percent);
295
+ const isRedeemed = milestone.rewardBacked && Boolean(record?.redeemed_at) && !laterCycle;
296
+ const unlocked = decided.stacked ? decided.reached.has(milestone.percent) : decided.entries[index].reached;
148
297
 
149
298
  return {
150
- percent: tier.percent,
151
- requiredEquals: tier.requiredEquals,
152
- requiredSeconds: requiredTimeSeconds,
299
+ percent: milestone.percent,
300
+ requiredEquals: milestone.requiredEquals,
301
+ requiredSeconds: milestone.requiredSeconds,
302
+ rewardBacked: milestone.rewardBacked,
303
+ displayOnly: !milestone.rewardBacked,
153
304
  unlocked,
154
- redeemed,
155
- redeemedAt: record?.redeemed_at ?? null,
156
- status: redeemed ? 'redeemed' : unlocked ? 'ready' : 'locked',
305
+ redeemed: isRedeemed,
306
+ redeemedAt: isRedeemed ? record.redeemed_at : null,
307
+ status: isRedeemed ? 'redeemed' : unlocked ? 'ready' : 'locked',
308
+ ...(decided.stacked ? {} : {countedFrom: decided.entries[index].countedFrom}),
157
309
  };
158
310
  });
159
- const tierMap = new Map(tiers.map((tier) => [tier.percent, tier]));
311
+ const tierPercents = new Set(REWARD_TIERS.map((tier) => tier.percent));
312
+ const tiers = milestones.filter((milestone) => tierPercents.has(milestone.percent)).map((milestone) => ({
313
+ percent: milestone.percent,
314
+ requiredEquals: milestone.requiredEquals,
315
+ requiredSeconds: milestone.requiredSeconds,
316
+ unlocked: milestone.unlocked,
317
+ redeemed: milestone.redeemed,
318
+ redeemedAt: milestone.redeemedAt,
319
+ status: milestone.status,
320
+ }));
160
321
 
161
322
  const summary = {
162
- requiredSeconds,
323
+ requiredSeconds: REWARD_SECONDS,
163
324
  requiredEquals: FINAL_EQUALS,
164
325
  earnedSeconds: normalizedEarnedSeconds,
165
326
  totalEquals: normalizedTotalEquals,
166
- timeLeftSeconds: Math.max(0, Math.floor(requiredSeconds - normalizedEarnedSeconds)),
327
+ timeLeftSeconds: timeLeft(totals, milestones),
328
+ cycle: decided.stacked ? null : decided.cycle.number,
329
+ cycleStartedAt: decided.stacked ? null : decided.cycle.startedAt,
167
330
  tiers,
168
- milestones: REWARD_MILESTONES.map((milestone) => {
169
- const tier = tierMap.get(milestone.percent);
170
- const requiredTimeSeconds = tier?.requiredSeconds ?? milestoneTimeTarget(requiredSeconds, milestone.percent);
171
- const timeReached = normalizedEarnedSeconds >= requiredTimeSeconds;
172
- const equalsReached = normalizedTotalEquals >= milestone.requiredEquals;
173
- const unlocked = timeReached && equalsReached;
174
-
175
- return {
176
- percent: milestone.percent,
177
- requiredEquals: milestone.requiredEquals,
178
- requiredSeconds: requiredTimeSeconds,
179
- rewardBacked: milestone.rewardBacked,
180
- displayOnly: !milestone.rewardBacked,
181
- unlocked,
182
- redeemed: tier?.redeemed ?? false,
183
- redeemedAt: tier?.redeemedAt ?? null,
184
- status: tier?.status ?? (unlocked ? 'ready' : 'locked'),
185
- };
186
- }),
331
+ milestones,
187
332
  };
188
333
 
189
334
  // The one progress number the CLI, the Mini and the dashboard all show (docs/reward-rules.md
@@ -203,6 +348,7 @@ function rewardSummaryFrom(state) {
203
348
  earnedSeconds,
204
349
  totalEquals: ledger.total_equals,
205
350
  ledger,
351
+ record: state.milestonesReached,
206
352
  });
207
353
  }
208
354
 
@@ -227,6 +373,7 @@ function saveTierRecords(changes) {
227
373
  // code an earlier version recorded as shown keeps its redeemed_at, and so its status, `redeemed`: it was reached, and
228
374
  // the record says so (docs/reward-rules.md §7).
229
375
 
376
+ // Each tier given is announced to `source` in its cycle, `tier.cycle`, 1 when it names none (getReadyUnannouncedTiers).
230
377
  export function markTiersAnnounced(source, tiers) {
231
378
  const normalizedSource = normalizeSource(source);
232
379
 
@@ -245,8 +392,12 @@ export function markTiersAnnounced(source, tiers) {
245
392
  continue;
246
393
  }
247
394
 
248
- changes.push([percent, (record) => ({...record, announced_at: {...record.announced_at, [normalizedSource]: now}})]);
249
- announced.push({...tier, announcedAt: now});
395
+ const cycle = Number.isSafeInteger(tier.cycle) && tier.cycle > 1 ? tier.cycle : 1;
396
+
397
+ changes.push([percent, (record) => (cycle === 1
398
+ ? {...record, announced_at: {...record.announced_at, [normalizedSource]: now}}
399
+ : {...record, announced_in_cycles: {...record.announced_in_cycles, [cycle]: {...record.announced_in_cycles?.[cycle], [normalizedSource]: now}}})]);
400
+ announced.push({...tier, cycle, announcedAt: now});
250
401
  }
251
402
 
252
403
  if (changes.length > 0) {
@@ -265,10 +416,10 @@ export function getReadyUnannouncedTiers(source) {
265
416
 
266
417
  const state = loadState();
267
418
  const records = tierRecordMap(equalsLedgerFrom(state));
419
+ const summary = rewardSummaryFrom(state);
420
+ const cycle = summary.cycle ?? 1;
268
421
 
269
- return rewardSummaryFrom(state).tiers.filter((tier) => {
270
- const record = records.get(tier.percent);
271
-
272
- return tier.status === 'ready' && !record?.announced_at?.[normalizedSource];
273
- });
422
+ return summary.tiers
423
+ .filter((tier) => tier.status === 'ready' && !announcedIn(records.get(tier.percent), normalizedSource, cycle))
424
+ .map((tier) => ({...tier, cycle}));
274
425
  }
package/src/runtime.mjs CHANGED
@@ -43,7 +43,7 @@ import {
43
43
  startedFocusTime,
44
44
  storedFocusTime,
45
45
  } from './focus-clock.mjs';
46
- import {noteMilestonesReached} from './milestones-reached.mjs';
46
+ import {beginNextCycle, noteMilestonesReached} from './milestones-reached.mjs';
47
47
  import {buildRewardSummary} from './reward.mjs';
48
48
  import {createScanner} from './scanner.mjs';
49
49
 
@@ -452,7 +452,10 @@ export function prepareState({rules = RULES, to = RULES_VERSION} = {}) {
452
452
  // earns nothing (§5.2, §5.7); a read that is a baseline, because code can have arrived through git,
453
453
  // earns nothing at all (§5.8). Returns null when the project is not in state.json, or when a partial
454
454
  // read cannot make its first snapshot.
455
- function foldScan(project, read, base, {lockTimeoutMs} = {}) {
455
+ //
456
+ // `focusSeconds` is the tracker's own clock, when it folds: ahead of the file's by up to one save, and the moment a milestone
457
+ // these = reach is recorded at with it (milestones-reached.mjs).
458
+ function foldScan(project, read, base, {lockTimeoutMs, focusSeconds} = {}) {
456
459
  return withStateLock(() => {
457
460
  // An older file is converted first, in this same update: after an upgrade, the first scan does it.
458
461
  migrateState();
@@ -492,7 +495,7 @@ function foldScan(project, read, base, {lockTimeoutMs} = {}) {
492
495
  stored.projects[project.id].changeBatchCount = (Number(current.changeBatchCount) || 0) + 1;
493
496
  addSourceEqualsOnce(project, crypto.randomUUID(), result.equalsAdded);
494
497
  // = credited can make a milestone ready: recorded in this same update (docs/reward-rules.md §6.1).
495
- noteMilestonesReached(stored);
498
+ noteMilestonesReached(stored, undefined, {focusSeconds});
496
499
  }
497
500
  });
498
501
  }
@@ -504,6 +507,7 @@ function foldScan(project, read, base, {lockTimeoutMs} = {}) {
504
507
  ...result,
505
508
  changeBatchCount: Number(after.projects[project.id].changeBatchCount) || 0,
506
509
  ledger: equalsLedgerFrom(after),
510
+ record: after.milestonesReached,
507
511
  };
508
512
  }, {lockTimeoutMs});
509
513
  }
@@ -541,6 +545,8 @@ export function createRuntime(options) {
541
545
  options,
542
546
  state: normalizeState(stored),
543
547
  ledger: equalsLedgerFrom(stored),
548
+ // The milestone record, which decides the milestones since 2026-10-20 (reward.mjs decideMilestones).
549
+ record: stored.milestonesReached,
544
550
  focus: focusClockFrom(stored),
545
551
  stateIdentity,
546
552
  projectRuns: new Map(),
@@ -890,7 +896,7 @@ function applyRead(runtime, projectId, run, read, base, activityAt) {
890
896
  let result;
891
897
 
892
898
  try {
893
- result = foldScan(project, read, base, {lockTimeoutMs: SCAN_LOCK_TIMEOUT_MS});
899
+ result = foldScan(project, read, base, {lockTimeoutMs: SCAN_LOCK_TIMEOUT_MS, focusSeconds: runtime.focus?.activeSeconds});
894
900
  } catch (error) {
895
901
  if (!(error instanceof StateLockError)) {
896
902
  throw error;
@@ -908,6 +914,7 @@ function applyRead(runtime, projectId, run, read, base, activityAt) {
908
914
  project.sourceSnapshot = result.snapshot;
909
915
  project.changeBatchCount = result.changeBatchCount;
910
916
  runtime.ledger = result.ledger;
917
+ runtime.record = result.record;
911
918
  clearProblem(runtime, 'scan-failed', projectId);
912
919
  clearProblem(runtime, 'project-missing', projectId);
913
920
  const at = activityAt(result);
@@ -1150,6 +1157,7 @@ function reconcileProjects(runtime) {
1150
1157
  const diskState = normalizeState(stored);
1151
1158
  runtime.stateIdentity = identity;
1152
1159
  runtime.ledger = equalsLedgerFrom(stored);
1160
+ runtime.record = stored.milestonesReached;
1153
1161
 
1154
1162
  for (const [projectId, project] of Object.entries(runtime.state.projects)) {
1155
1163
  if (!diskState.projects[projectId]) {
@@ -1184,6 +1192,8 @@ function reconcileProjects(runtime) {
1184
1192
  // the file is left as it is. Since 2026-09-25 it also records a milestone its time has made ready
1185
1193
  // (docs/reward-rules.md §6.1), within one save, 10 s, of it becoming ready.
1186
1194
  export function persistRuntimeState(runtime, options) {
1195
+ let saved = null;
1196
+
1187
1197
  updateState((stored) => {
1188
1198
  if (!isObject(stored.projects)) {
1189
1199
  return;
@@ -1215,7 +1225,40 @@ export function persistRuntimeState(runtime, options) {
1215
1225
 
1216
1226
  stored.version = 4;
1217
1227
  noteMilestonesReached(stored);
1228
+ saved = {record: stored.milestonesReached === undefined ? undefined : structuredClone(stored.milestonesReached)};
1218
1229
  }, options);
1230
+
1231
+ // What the save recorded, for the tracker's own summary until its next read of the file. A copy: the file's object is
1232
+ // the store's.
1233
+ if (saved) {
1234
+ runtime.record = saved.record;
1235
+ }
1236
+ }
1237
+
1238
+ // Begins the next cycle on `receipt`, one the tracker has read and checked (api.mjs, POST /api/claim/receipt; the plan of
1239
+ // 2026-10-21, decision 1), in one locked write with the tracker's own save: its counters first, as every save writes them,
1240
+ // so that the cycle starts at the totals the tracker shows, the focus time on its own clock among them; then the record
1241
+ // (milestones-reached.mjs, beginNextCycle), which says whether this receipt may begin one, and adds the cycle if it may.
1242
+ // Nothing else in the file changes, and the totals are never reduced. Returns beginNextCycle's outcome; throws the store's
1243
+ // StateLockError when the file stays locked, and its StateFileError when it cannot be read, as a save does, having written
1244
+ // nothing.
1245
+ export function startNextCycle(runtime, receipt, {lockTimeoutMs = PERIODIC_SAVE_LOCK_TIMEOUT_MS} = {}) {
1246
+ let outcome = null;
1247
+ let saved;
1248
+
1249
+ withStateLock(() => {
1250
+ tickActiveProjects(runtime);
1251
+ persistRuntimeState(runtime);
1252
+ outcome = updateState((stored) => {
1253
+ const result = beginNextCycle(stored, receipt, new Date(Date.now()));
1254
+ saved = stored.milestonesReached === undefined ? undefined : structuredClone(stored.milestonesReached);
1255
+ return result;
1256
+ });
1257
+ }, {lockTimeoutMs});
1258
+
1259
+ // What the write recorded, for the tracker's own summary at once: the new cycle's milestones count from now.
1260
+ runtime.record = saved;
1261
+ return outcome;
1219
1262
  }
1220
1263
 
1221
1264
  // Runs one of the tracker's periodic jobs. A busy lock skips it until the next tick. A state file
@@ -1253,6 +1296,7 @@ export function buildSummary(runtime) {
1253
1296
  earnedSeconds: focus.activeSeconds,
1254
1297
  totalEquals,
1255
1298
  ledger,
1299
+ record: 'record' in runtime ? runtime.record : loadState().milestonesReached,
1256
1300
  });
1257
1301
 
1258
1302
  return {
@@ -0,0 +1,115 @@
1
+ // Every word Welcome shows, in one place: the client's Vietnamese, word for word as the deck has it, and the operator's
2
+ // approved drafts where the deck does not reach (docs/plans/2026-10-22-vietnamese-setup-and-welcome-strings.md; each line
3
+ // names its row there, a number for the deck's, an id for a draft's). "just make sure the translation stay truthful to the
4
+ // deck": the deck's own spelling stays, "Kitcode" in row 56 included. A "\n" is the deck's line break; "{N}" is a count and
5
+ // "{name}" a folder's name, which the page fills in. Everything here is NFC, and every character is in the fonts Welcome
6
+ // embeds (test/welcome-words.test.mjs). Nothing here may say a reward was claimed, saved or confirmed (hard rule 8): "Đã lưu
7
+ // project" is about projects.
8
+ //
9
+ // The page shows some of these in capitals, as the English was: the rail, the + THÊM PROJECT button (typed so by
10
+ // onboarding-window.mjs), and the section titles and the hint (by its stylesheet).
11
+
12
+ export const WELCOME_WORDS = {
13
+ // The page's own name, which the OS lists the window by (X8a), and the native window's, before the page loads (X8b).
14
+ title: 'Thiết lập KitCode',
15
+ windowTitle: 'Chào mừng bạn đến với KitCode',
16
+
17
+ // The title bar (src/onboarding-platform.mjs): its tooltip (X7e), and the close dot's tooltip and name (X7g, X7f).
18
+ chrome: {
19
+ drag: 'Kéo để di chuyển cửa sổ',
20
+ close: 'Đóng',
21
+ closeName: 'Đóng cửa sổ thiết lập',
22
+ },
23
+
24
+ // The rail's steps 01 and 02 (rows 54, 55).
25
+ rail: {
26
+ tracking: 'Thiết lập',
27
+ projects: 'Chọn project',
28
+ },
29
+
30
+ // Step 01: the question (row 56), its radio group's name (X7c, row 56 again), and the answers (rows 57, 58), between "[ ]".
31
+ tracking: {
32
+ question: 'Bắt đầu tracking sau khi thiết lập Kitcode',
33
+ groupName: 'Bắt đầu tracking sau khi thiết lập Kitcode',
34
+ yes: 'CÓ',
35
+ no: 'KHÔNG',
36
+ },
37
+
38
+ // Step 02: the heading (row 59), the list's name (X7d), the button after "+ " (row 60), the hint beside it (row 61), the
39
+ // empty list (X4), and each project's remove button, its tooltip (X7a) and its name (X7b).
40
+ projects: {
41
+ heading: 'Chọn một hoặc nhiều folder local',
42
+ listName: 'Danh sách project của KitCode',
43
+ add: 'Thêm project',
44
+ hint: 'Các tệp hiện có sẽ được lấy làm mốc để theo dõi thay đổi.\nMã nguồn và mọi thay đổi đều chỉ được lưu trên máy bạn.',
45
+ empty: 'Chưa thêm project nào',
46
+ removeTitle: 'Gỡ project',
47
+ removeName: 'Gỡ {name}',
48
+ },
49
+
50
+ // The main button: between "+ " and " ->" (row 62); while saving (N18); saved (row 72); after a failed save (X5a).
51
+ buttons: {
52
+ save: 'ĐÃ XONG, MỞ KITCODE ĐỂ TÍNH CÔNG',
53
+ saving: 'Đang lưu…',
54
+ saved: 'Thiết lập hoàn tất.',
55
+ retry: 'Thử lưu lại',
56
+ },
57
+
58
+ // The message line, after "/* ".
59
+ messages: {
60
+ ready: 'Chọn một hay nhiều project để tiếp tục', // row 77
61
+ suggested: 'Đã thêm {N} folder project. Kiểm tra và lưu để tiếp tục.', // row 64
62
+ loaded: 'Đã chọn {N} project', // row 70
63
+ stateFailed: 'Không thể tải trạng thái thiết lập trên máy. Hãy khởi động lại quá trình thiết lập và thử lại nhé!', // row 76
64
+ noBridge: 'Không thể kết nối với KitCode setup. Vui lòng đóng cửa sổ này và thử lại.', // row 73
65
+ picking: 'Đang mở trình chọn folder…', // N14
66
+ pickCanceled: 'Đã hủy chọn project', // row 65
67
+ readyToAdd: '{N} project đã sẵn sàng', // row 69
68
+ alreadyAdded: 'Project đã được thêm', // row 63
69
+ pickerFailed: 'Không thể mở trình chọn folder.\nHãy khởi động lại phần thiết lập và thử lại nhé!', // row 66
70
+ removed: 'Đã gỡ project', // row 67
71
+ removedFromSetup: 'Đã gỡ project khỏi phần thiết lập này.', // N15
72
+ removeFailed: 'Không thể gỡ project.', // N16a
73
+ removeFailedRetry: 'Không thể gỡ project. Hãy thử lại nhé!', // N16b
74
+ noProjects: 'Hãy chọn ít nhất một project', // row 68
75
+ saving: 'Đang lưu project và tùy chỉnh…', // row 75
76
+ saveFailed: 'Không thể lưu thiết lập.', // N17
77
+ requestFailed: 'Không thể gửi yêu cầu thiết lập. Yên tâm, lựa chọn của bạn vẫn còn đây.', // X5b
78
+ saved: 'Vậy là xong. KitCode sắp lên sóng!', // row 71
79
+ },
80
+ };
81
+
82
+ // A tester build's alone (src/diagnostics.mjs): the rail's step 03 (N19), the step's heading (row 78), what the build sends
83
+ // and never holds (row 79, two paragraphs), and the invited email's label (row 80). Any other build's page holds none of it.
84
+ export const WELCOME_TESTER_WORDS = {
85
+ rail: 'Tìm sự cố',
86
+ heading: 'TÌM SỰ CỐ ĐỂ KHÔNG SAI SỐ',
87
+ text: 'KitCode gửi diagnostic log đến máy chủ, ghi lại các hoạt động và lỗi của KitCode (như lỗi khởi động tracker hoặc lỗi hook). Nhờ vậy, đội ngũ phát triển có thể truy vết sự cố mà không cần hỏi thêm bạn.\n\nDiagnostic log không chứa mã nguồn, tệp, prompt hay tên project của bạn.',
88
+ emailLabel: 'Email nhận lời mời (không bắt buộc): Giúp đội ngũ hỗ trợ tìm đúng diagnostic log của bạn nhanh hơn.',
89
+ };
90
+
91
+ // What the main process answers (src/onboarding-electron.mjs), which the page shows on its message line as it is.
92
+ export const WELCOME_ERRORS = {
93
+ invalidRequest: 'Yêu cầu thiết lập không hợp lệ.', // X6a, answered only to a sender other than Welcome's page
94
+ noProjects: 'Hãy chọn ít nhất một project', // X6c, row 68's
95
+ registerFailed: 'KitCode không thể thêm một trong các folder bạn vừa chọn.', // X6d
96
+ trackerFailed: 'Đã lưu project, nhưng tracker không khởi động được', // row 74
97
+ projectNotFound: 'Không tìm thấy project.', // X6e
98
+ removeFailed: 'KitCode không thể gỡ project này.', // X6f
99
+ };
100
+
101
+ // A tester build's alone: an invited email that is not an address (X6b).
102
+ export const WELCOME_TESTER_ERRORS = {
103
+ invalidEmail: 'Email này có vẻ chưa đúng. Kiểm tra lại hoặc để trống nhé.',
104
+ };
105
+
106
+ // What the deck keeps English ("ko cần dịch", rows 81-83), and what is never shown (N13): marked lang="en" in the page.
107
+ // The status bar's codes (setup:ready, projects:removed…) are codes, and stay where the script sets them (K7).
108
+ export const WELCOME_ENGLISH = {
109
+ topTag: 'local-first setup', // row 81
110
+ mode: 'NORMAL', // row 82
111
+ shortcuts: '↑↓ move # space select # enter continue', // row 82
112
+ pending: 'PENDING', // row 83
113
+ tracked: 'TRACKED', // row 83
114
+ added: '{N} ADDED', // N13, display: none
115
+ };
@@ -7,12 +7,26 @@ import fs from 'node:fs';
7
7
  // 5.3.0, under the SIL Open Font License 1.1 (fonts/<family>/OFL.txt, from github.com/google/fonts).
8
8
  // Embedded as data: URLs, so a page asks no server for them, whether it is a file, a data: URL or
9
9
  // served by the tracker. A character outside the subset falls back to the next font in the stack.
10
+ // Since 2026-10-22 also JetBrains Mono, Welcome's alone, which speaks Vietnamese: its latin and vietnamese subsets, from
11
+ // @fontsource/jetbrains-mono 5.3.0, each with the unicode-range Google Fonts gives it, so that the two read as one font.
12
+ // Fira Code has no Vietnamese subset (docs/plans/2026-10-22-vietnamese-setup-and-welcome.md, "Fonts"); the Mini keeps it.
13
+ const JETBRAINS_MONO_RANGES = {
14
+ latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
15
+ vietnamese: 'U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB',
16
+ };
17
+
10
18
  const FONTS = {
11
19
  'vt323': {family: 'VT323', faces: [{weight: 400, file: 'vt323/vt323-latin-400-normal.woff2'}]},
12
20
  'fira-code': {
13
21
  family: 'Fira Code',
14
22
  faces: [400, 500, 700].map((weight) => ({weight, file: `fira-code/fira-code-latin-${weight}-normal.woff2`})),
15
23
  },
24
+ 'jetbrains-mono': {
25
+ family: 'JetBrains Mono',
26
+ faces: [400, 500, 700].flatMap((weight) => ['vietnamese', 'latin'].map((subset) => (
27
+ {weight, file: `jetbrains-mono/jetbrains-mono-${subset}-${weight}-normal.woff2`, range: JETBRAINS_MONO_RANGES[subset]}
28
+ ))),
29
+ },
16
30
  };
17
31
 
18
32
  const fontFaceCache = new Map();
@@ -21,9 +35,9 @@ export function fontFaces(names) {
21
35
  const key = names.join(',');
22
36
 
23
37
  if (!fontFaceCache.has(key)) {
24
- fontFaceCache.set(key, names.flatMap((name) => FONTS[name].faces.map(({weight, file}) => {
38
+ fontFaceCache.set(key, names.flatMap((name) => FONTS[name].faces.map(({weight, file, range}) => {
25
39
  const data = fs.readFileSync(new URL(`./fonts/${file}`, import.meta.url)).toString('base64');
26
- return `@font-face{font-family:'${FONTS[name].family}';font-style:normal;font-weight:${weight};font-display:swap;src:url(data:font/woff2;base64,${data}) format('woff2')}`;
40
+ return `@font-face{font-family:'${FONTS[name].family}';font-style:normal;font-weight:${weight};font-display:swap;src:url(data:font/woff2;base64,${data}) format('woff2')${range ? `;unicode-range:${range}` : ''}}`;
27
41
  })).join('\n'));
28
42
  }
29
43