@kitkat-vn/kitcode 0.2.0-staging.9 → 0.3.0-staging.16

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,25 +3,42 @@ 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';
8
+ import {campaignEnded, trackingConfigInForce} from './tracking-config.mjs';
7
9
 
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
10
  export const REWARD_TIERS = KITCODE_REWARD_TIERS;
14
11
  export const CAMPAIGN_MILESTONES = KITCODE_DISPLAY_MILESTONES;
15
12
  export const REWARD_MILESTONES = [
16
13
  ...REWARD_TIERS.map((tier) => ({...tier, rewardBacked: true})),
17
14
  ...CAMPAIGN_MILESTONES.map((milestone) => ({...milestone, rewardBacked: false})),
18
15
  ];
19
- // The = the last milestone needs. The = targets are the table's; no setting changes them (the
20
- // --reward-equals setting, which gated nothing, was removed on 2026-09-24).
21
- const FINAL_EQUALS = Math.max(...REWARD_MILESTONES.map((milestone) => milestone.requiredEquals));
16
+ export const MILESTONE_PERCENTS = REWARD_MILESTONES.map((milestone) => milestone.percent);
17
+ // The focus time the 100 % milestone needs, fixed (operator decision Q5, 2026-09-25; docs/reward-rules.md
18
+ // §3). Until then it was a setting, down to 1 s: --reward-seconds, KITCODE_REWARD_SECONDS, and
19
+ // rewardSettings.requiredSeconds in state.json. A requiredSeconds an older KitCode stored stays in the
20
+ // file, unread, as the removed requiredEquals does. Since 2026-10-20 each milestone names its own time in the table
21
+ // (integration-spec.mjs), where it was this × its percent: the same numbers in the tester table, which held the operator's
22
+ // from 2026-10-21 to 2026-10-22, 100 %'s 8 h, and since then its own again, 100 %'s 3600 s. The = targets are the table's too; no
23
+ // setting of the user's changes either (the --reward-equals setting, which gated nothing, was removed on 2026-09-24). Since
24
+ // 2026-10-23 these are the bundled table's, the last of the three fallbacks: the summary's requiredSeconds and requiredEquals are
25
+ // the last milestone's in force (milestonesOf, below), which the server's tracking config may set (tracking-config.mjs).
26
+ const LAST_MILESTONE = REWARD_MILESTONES[REWARD_MILESTONES.length - 1];
27
+ export const REWARD_SECONDS = LAST_MILESTONE.requiredSeconds;
22
28
 
23
29
  const VALID_TIER_PERCENTS = new Set(REWARD_TIERS.map((tier) => tier.percent));
24
30
 
31
+ // The milestones, with the targets of `config`, a tracking config (tracking-config.mjs): by default the one in force, the
32
+ // document the tracker took from the server, its last good copy, or the table this KitCode bundles, the REWARD_MILESTONES above.
33
+ // Since 2026-10-23 (tracking/2-cli; docs/plans/2026-10-22-server-tracking-config.md) every milestone is decided against the
34
+ // targets in force at the moment it is decided: a milestone reached stays reached, whatever the targets do after (the record says
35
+ // so); the one in progress keeps what it earned since it began, and is reached once that meets the targets now in force, so a
36
+ // target lowered under what it earned reaches it at the next write, and one raised only lowers its percent. The percents are the
37
+ // contract's, 30, 50 and 100, and 30 % the one tier, as before.
38
+ export function milestonesOf(config = trackingConfigInForce()) {
39
+ return config.milestones.map(({percent, focusSeconds, equals}) => ({percent, requiredSeconds: focusSeconds, requiredEquals: equals, rewardBacked: VALID_TIER_PERCENTS.has(percent)}));
40
+ }
41
+
25
42
  function isObject(value) {
26
43
  return value !== null && typeof value === 'object' && !Array.isArray(value);
27
44
  }
@@ -40,6 +57,27 @@ export function normalizeTierPercent(value) {
40
57
  return percent;
41
58
  }
42
59
 
60
+ function announcementsOf(value) {
61
+ return value && typeof value === 'object' && !Array.isArray(value)
62
+ ? Object.fromEntries(Object.entries(value).filter(([source, at]) => normalizeSource(source) && typeof at === 'string'))
63
+ : {};
64
+ }
65
+
66
+ // Since 2026-10-22 (rewards/3-repeat-vouchers, phase 2) a tier is announced once per agent in each cycle: cycle 1's
67
+ // announcements are `announced_at`, as they always were, and a later cycle's are kept by its number, `announced_in_cycles`,
68
+ // {"2": {"claude": "2026-…"}}, a field written only once a later cycle has one, so that a file with none is byte for byte
69
+ // what it was, and a KitCode from before reads cycle 1's as ever.
70
+ function cycleAnnouncementsOf(value) {
71
+ if (!isObject(value)) {
72
+ return {};
73
+ }
74
+
75
+ return Object.fromEntries(Object.entries(value)
76
+ .filter(([cycle]) => /^[1-9][0-9]*$/u.test(cycle) && Number(cycle) >= 2)
77
+ .map(([cycle, announced]) => [cycle, announcementsOf(announced)])
78
+ .filter(([, announced]) => Object.keys(announced).length > 0));
79
+ }
80
+
43
81
  function normalizeTierRecord(entry) {
44
82
  const percent = normalizeTierPercent(
45
83
  typeof entry === 'object' && entry !== null ? entry.percent : entry,
@@ -49,20 +87,21 @@ function normalizeTierRecord(entry) {
49
87
  return null;
50
88
  }
51
89
 
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
- : {};
90
+ const byCycle = cycleAnnouncementsOf(entry?.announced_in_cycles);
58
91
 
59
92
  return {
60
93
  percent,
61
94
  redeemed_at: typeof entry?.redeemed_at === 'string' ? entry.redeemed_at : null,
62
- announced_at: announcedAt,
95
+ announced_at: announcementsOf(entry?.announced_at),
96
+ ...(Object.keys(byCycle).length > 0 ? {announced_in_cycles: byCycle} : {}),
63
97
  };
64
98
  }
65
99
 
100
+ // Whether `record`, a tier's record, normalized, holds its announcement to `source` in `cycle`.
101
+ function announcedIn(record, source, cycle) {
102
+ return cycle > 1 ? Boolean(record?.announced_in_cycles?.[String(cycle)]?.[source]) : Boolean(record?.announced_at?.[source]);
103
+ }
104
+
66
105
  export function normalizeEarnedTiers(entries) {
67
106
  const records = new Map();
68
107
 
@@ -74,6 +113,12 @@ export function normalizeEarnedTiers(entries) {
74
113
  }
75
114
 
76
115
  const existing = records.get(record.percent);
116
+ const byCycle = {...(existing?.announced_in_cycles ?? {})};
117
+
118
+ for (const [cycle, announced] of Object.entries(record.announced_in_cycles ?? {})) {
119
+ byCycle[cycle] = {...(byCycle[cycle] ?? {}), ...announced};
120
+ }
121
+
77
122
  records.set(record.percent, {
78
123
  percent: record.percent,
79
124
  redeemed_at: existing?.redeemed_at ?? record.redeemed_at,
@@ -81,6 +126,7 @@ export function normalizeEarnedTiers(entries) {
81
126
  ...(existing?.announced_at ?? {}),
82
127
  ...record.announced_at,
83
128
  },
129
+ ...(Object.keys(byCycle).length > 0 ? {announced_in_cycles: byCycle} : {}),
84
130
  });
85
131
  }
86
132
 
@@ -125,72 +171,199 @@ function updateTierRecord(ledger, percent, change) {
125
171
  });
126
172
  }
127
173
 
128
- function milestoneTimeTarget(requiredSeconds, percent) {
129
- return Math.ceil(requiredSeconds * (percent / 100));
174
+ function reachesTargets(earned, milestone) {
175
+ return earned.seconds >= milestone.requiredSeconds && earned.equals >= milestone.requiredEquals;
176
+ }
177
+
178
+ // The milestones as they stacked until 2026-10-20 (docs/reward-rules.md §2): each reached when the totals since install
179
+ // meet its own targets, with every milestone below the highest one reached, or whose code an earlier KitCode recorded as
180
+ // shown (E8). A record that is not what KitCode writes is read so, and nothing is written over it (decideMilestones).
181
+ function stackedReached(totals, redeemed) {
182
+ const highest = Math.max(0, ...REWARD_MILESTONES
183
+ .filter((milestone) => reachesTargets(totals, milestone) || redeemed.has(milestone.percent))
184
+ .map((milestone) => milestone.percent));
185
+
186
+ return new Set(MILESTONE_PERCENTS.filter((percent) => percent <= highest));
187
+ }
188
+
189
+ // Which milestones are reached, and where each counts from (docs/reward-rules.md §2; the plan of 2026-10-20, decisions 1
190
+ // to 3). Each counts its own focus time and = from the moment the milestone before it was reached, the first from install,
191
+ // and is reached when what was earned since meets both its targets: nothing earned before that moment counts toward it,
192
+ // neither the time earned while waiting for = nor the = while waiting for time. So at most one is reached at a time.
193
+ //
194
+ // `totals` are the focus seconds, floored, and the = counted since install; `record` the milestone record as state.json
195
+ // holds it; `redeemed` the tiers an earlier KitCode recorded a code shown for. A milestone the record holds is reached,
196
+ // whatever the totals say now, and so is every one below it: a reached milestone stays reached. One that is not held
197
+ // is reached once the totals since its start meet its targets, its moment the moment this is found: the record's next
198
+ // write records it, with these totals.
199
+ //
200
+ // Decision 2, people who reached milestones before this rule: a milestone reached with no totals known, an earlier
201
+ // KitCode's entry or one reached before the record began, keeps its place, and the one after it counts from the record's
202
+ // freshSince, the totals when this KitCode first wrote such a record; until that write, from the totals now. A file with
203
+ // no record yet is read as the first write will record it: the milestones its totals reached as they stacked, reached
204
+ // before the record began, and the next counting from now.
205
+ //
206
+ // Since 2026-10-22 (rewards/3-repeat-vouchers, phase 2; the plan of 2026-10-21, decision 1) the milestones count in the cycle
207
+ // the record is in (milestone-record.mjs): cycle 1, as above, until the tracker takes a receipt the claim backend signed for
208
+ // a voucher; then the next, whose milestones all count fresh from the totals at that moment, the first from there where cycle
209
+ // 1's counts from install. Nothing of an earlier cycle counts toward a later one, and nothing an earlier KitCode recorded as a
210
+ // code shown (`redeemed`) does either: that is cycle 1's.
211
+ //
212
+ // Returns {stacked, entries, cycle}: `stacked` true for a record that is not what KitCode writes, read as the milestones
213
+ // stacked; otherwise each entry {percent, reached, recorded, countedFrom, end}, `countedFrom` the totals the milestone counts
214
+ // 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
215
+ // known; and `cycle` {number, startedAt, start}, the cycle counted in, when it began (null for cycle 1) and its start totals.
216
+ //
217
+ // Since 2026-10-23 `milestones` gives the targets, milestonesOf's: by default those in force (tracking-config.mjs). What was reached
218
+ // before the record began, and a record not what KitCode writes, are read as the milestones stacked by the table this KitCode bundles,
219
+ // as before, never by the targets in force: they were reached under a KitCode's own table, and the server's raise must not take away
220
+ // what the record's first write would keep (a ruling of tracking/2-cli, docs/reward-rules.md §2).
221
+ export function decideMilestones({totals, record, redeemed = new Set(), milestones = milestonesOf()}) {
222
+ const read = readMilestoneRecord(record, MILESTONE_PERCENTS);
223
+
224
+ if (read.state === 'damaged') {
225
+ return {stacked: true, reached: stackedReached(totals, redeemed)};
226
+ }
227
+
228
+ const current = read.state === 'none' ? null : read.current;
229
+ const later = current !== null && current.cycle > 1;
230
+ const before = read.state === 'none'
231
+ ? new Map([...stackedReached(totals, redeemed)].map((percent) => [percent, {kind: 'before-recording', end: null}]))
232
+ : current.entries;
233
+ const freshSince = read.state === 'none' ? null : current.freshSince;
234
+ const shown = later ? [] : [...redeemed].filter((percent) => MILESTONE_PERCENTS.includes(percent));
235
+ const highest = Math.max(0, ...before.keys(), ...shown);
236
+ const entries = [];
237
+ // Where the next milestone counts from: install for the first of cycle 1, a later cycle's start for its first; null once one
238
+ // has not been reached.
239
+ let start = later ? {...current.start} : {seconds: 0, equals: 0};
240
+ let startKnown = true;
241
+
242
+ for (const milestone of milestones) {
243
+ const entry = before.get(milestone.percent);
244
+
245
+ if (start === null) {
246
+ entries.push({percent: milestone.percent, reached: false, recorded: false, countedFrom: null, end: null});
247
+ continue;
248
+ }
249
+
250
+ if (milestone.percent <= highest) {
251
+ const end = entry?.end ?? null;
252
+ entries.push({percent: milestone.percent, reached: true, recorded: entry !== undefined, countedFrom: startKnown ? start : null, end});
253
+ start = end ?? (freshSince ? {seconds: freshSince.seconds, equals: freshSince.equals} : {...totals});
254
+ startKnown = end !== null || freshSince !== null;
255
+ continue;
256
+ }
257
+
258
+ const earned = {seconds: Math.max(0, totals.seconds - start.seconds), equals: Math.max(0, totals.equals - start.equals)};
259
+
260
+ if (reachesTargets(earned, milestone)) {
261
+ entries.push({percent: milestone.percent, reached: true, recorded: false, countedFrom: start, end: {...totals}});
262
+ start = {...totals};
263
+ startKnown = true;
264
+ continue;
265
+ }
266
+
267
+ entries.push({percent: milestone.percent, reached: false, recorded: false, countedFrom: start, end: null});
268
+ start = null;
269
+ }
270
+
271
+ return {
272
+ stacked: false,
273
+ entries,
274
+ cycle: later ? {number: current.cycle, startedAt: current.startedAt, start: {...current.start}} : {number: 1, startedAt: null, start: {seconds: 0, equals: 0}},
275
+ };
276
+ }
277
+
278
+ // The focus time still needed to reach 100 %, each milestone not reached counted fresh, the current one from its start.
279
+ function timeLeft(totals, milestones) {
280
+ return milestones.reduce((sum, milestone) => {
281
+ if (milestone.unlocked) {
282
+ return sum;
283
+ }
284
+
285
+ const from = milestone.countedFrom?.seconds;
286
+ return sum + Math.max(0, milestone.requiredSeconds - (from === undefined || from === null ? 0 : Math.max(0, totals.seconds - from)));
287
+ }, 0);
130
288
  }
131
289
 
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;
290
+ // The reward summary for `earnedSeconds` of focus time and `totalEquals` counted, against the milestone table
291
+ // (integration-spec.mjs), and the milestone record (`record`, state.json's milestonesReached; decideMilestones). It takes
292
+ // no target: a `settings` a caller still passes, as older code and tests do, is not read.
293
+ //
294
+ // Since 2026-10-20 each milestone of the summary also says where it counts from, `countedFrom`, {seconds, equals}, or null
295
+ // for one that has not begun: what counts toward it is what was earned since (break-progress.mjs). A record that is not
296
+ // what KitCode writes gives the summary as it was before: no `countedFrom`, every milestone counted from install.
297
+ //
298
+ // Since 2026-10-22 the summary says the cycle its milestones count in, `cycle`, 1 until the tracker takes a receipt for a
299
+ // voucher (decideMilestones), and `cycleStartedAt`, when a later one began, null for cycle 1; both null for a record that is
300
+ // not what KitCode writes. Its milestones and tiers are that cycle's: in a later one, none is `redeemed`, a code an earlier
301
+ // KitCode recorded as shown being cycle 1's.
302
+ //
303
+ // Since 2026-10-23 (tracking/2-cli; docs/plans/2026-10-22-server-tracking-config.md) its targets are those of `config`, a tracking
304
+ // config (tracking-config.mjs), by default the one in force, and the summary says which: `targets`, {source, issuedAt}, `source`
305
+ // 'server', 'saved' or 'bundled', `issuedAt` the document's, null for the bundled table; and `campaign`, {endsAt, ended}, the end
306
+ // the config gives, an instant or null, and whether it has passed by `now`, the user's clock. Once it has, `nextBreak` is null:
307
+ // the campaign names no next milestone, though counting goes on, and a milestone reached is reached as before.
308
+ export function buildRewardSummary({earnedSeconds, totalEquals, ledger, record, config = trackingConfigInForce(), now = Date.now()}) {
309
+ const targets = milestonesOf(config);
137
310
  const records = tierRecordMap(ledger);
138
311
  const normalizedEarnedSeconds = Math.floor(Math.max(0, Number(earnedSeconds) || 0));
139
312
  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);
313
+ const totals = {seconds: normalizedEarnedSeconds, equals: normalizedTotalEquals};
314
+ const redeemed = new Set(REWARD_TIERS.filter((tier) => records.get(tier.percent)?.redeemed_at).map((tier) => tier.percent));
315
+ const decided = decideMilestones({totals, record, redeemed, milestones: targets});
316
+ const laterCycle = !decided.stacked && decided.cycle.number > 1;
317
+ const milestones = targets.map((milestone, index) => {
318
+ const record = records.get(milestone.percent);
319
+ const isRedeemed = milestone.rewardBacked && Boolean(record?.redeemed_at) && !laterCycle;
320
+ const unlocked = decided.stacked ? decided.reached.has(milestone.percent) : decided.entries[index].reached;
148
321
 
149
322
  return {
150
- percent: tier.percent,
151
- requiredEquals: tier.requiredEquals,
152
- requiredSeconds: requiredTimeSeconds,
323
+ percent: milestone.percent,
324
+ requiredEquals: milestone.requiredEquals,
325
+ requiredSeconds: milestone.requiredSeconds,
326
+ rewardBacked: milestone.rewardBacked,
327
+ displayOnly: !milestone.rewardBacked,
153
328
  unlocked,
154
- redeemed,
155
- redeemedAt: record?.redeemed_at ?? null,
156
- status: redeemed ? 'redeemed' : unlocked ? 'ready' : 'locked',
329
+ redeemed: isRedeemed,
330
+ redeemedAt: isRedeemed ? record.redeemed_at : null,
331
+ status: isRedeemed ? 'redeemed' : unlocked ? 'ready' : 'locked',
332
+ ...(decided.stacked ? {} : {countedFrom: decided.entries[index].countedFrom}),
157
333
  };
158
334
  });
159
- const tierMap = new Map(tiers.map((tier) => [tier.percent, tier]));
160
-
335
+ const tierPercents = new Set(REWARD_TIERS.map((tier) => tier.percent));
336
+ const tiers = milestones.filter((milestone) => tierPercents.has(milestone.percent)).map((milestone) => ({
337
+ percent: milestone.percent,
338
+ requiredEquals: milestone.requiredEquals,
339
+ requiredSeconds: milestone.requiredSeconds,
340
+ unlocked: milestone.unlocked,
341
+ redeemed: milestone.redeemed,
342
+ redeemedAt: milestone.redeemedAt,
343
+ status: milestone.status,
344
+ }));
345
+
346
+ const last = targets[targets.length - 1];
347
+ const ended = campaignEnded(config, now);
161
348
  const summary = {
162
- requiredSeconds,
163
- requiredEquals: FINAL_EQUALS,
349
+ requiredSeconds: last.requiredSeconds,
350
+ requiredEquals: last.requiredEquals,
164
351
  earnedSeconds: normalizedEarnedSeconds,
165
352
  totalEquals: normalizedTotalEquals,
166
- timeLeftSeconds: Math.max(0, Math.floor(requiredSeconds - normalizedEarnedSeconds)),
353
+ timeLeftSeconds: timeLeft(totals, milestones),
354
+ cycle: decided.stacked ? null : decided.cycle.number,
355
+ cycleStartedAt: decided.stacked ? null : decided.cycle.startedAt,
356
+ targets: {source: config.source ?? 'bundled', issuedAt: config.issuedAt ?? null},
357
+ campaign: {endsAt: config.campaignEndsAt ?? null, ended},
167
358
  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
- }),
359
+ milestones,
187
360
  };
188
361
 
189
362
  // The one progress number the CLI, the Mini and the dashboard all show (docs/reward-rules.md
190
363
  // §10). `progress` is the same number as a fraction; until 2026-09-24 it was time alone.
191
364
  summary.progressPercent = breakProgressPercent(summary);
192
365
  summary.progress = summary.progressPercent / 100;
193
- summary.nextBreak = nextBreakReminder(summary);
366
+ summary.nextBreak = ended ? null : nextBreakReminder(summary);
194
367
  return summary;
195
368
  }
196
369
 
@@ -203,6 +376,7 @@ function rewardSummaryFrom(state) {
203
376
  earnedSeconds,
204
377
  totalEquals: ledger.total_equals,
205
378
  ledger,
379
+ record: state.milestonesReached,
206
380
  });
207
381
  }
208
382
 
@@ -227,6 +401,7 @@ function saveTierRecords(changes) {
227
401
  // code an earlier version recorded as shown keeps its redeemed_at, and so its status, `redeemed`: it was reached, and
228
402
  // the record says so (docs/reward-rules.md §7).
229
403
 
404
+ // Each tier given is announced to `source` in its cycle, `tier.cycle`, 1 when it names none (getReadyUnannouncedTiers).
230
405
  export function markTiersAnnounced(source, tiers) {
231
406
  const normalizedSource = normalizeSource(source);
232
407
 
@@ -245,8 +420,12 @@ export function markTiersAnnounced(source, tiers) {
245
420
  continue;
246
421
  }
247
422
 
248
- changes.push([percent, (record) => ({...record, announced_at: {...record.announced_at, [normalizedSource]: now}})]);
249
- announced.push({...tier, announcedAt: now});
423
+ const cycle = Number.isSafeInteger(tier.cycle) && tier.cycle > 1 ? tier.cycle : 1;
424
+
425
+ changes.push([percent, (record) => (cycle === 1
426
+ ? {...record, announced_at: {...record.announced_at, [normalizedSource]: now}}
427
+ : {...record, announced_in_cycles: {...record.announced_in_cycles, [cycle]: {...record.announced_in_cycles?.[cycle], [normalizedSource]: now}}})]);
428
+ announced.push({...tier, cycle, announcedAt: now});
250
429
  }
251
430
 
252
431
  if (changes.length > 0) {
@@ -265,10 +444,10 @@ export function getReadyUnannouncedTiers(source) {
265
444
 
266
445
  const state = loadState();
267
446
  const records = tierRecordMap(equalsLedgerFrom(state));
447
+ const summary = rewardSummaryFrom(state);
448
+ const cycle = summary.cycle ?? 1;
268
449
 
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
- });
450
+ return summary.tiers
451
+ .filter((tier) => tier.status === 'ready' && !announcedIn(records.get(tier.percent), normalizedSource, cycle))
452
+ .map((tier) => ({...tier, cycle}));
274
453
  }
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 {