@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/README.md +50 -28
- package/bin/kitcode.mjs +65 -5
- package/package.json +2 -2
- package/src/api.mjs +105 -3
- package/src/break-progress.mjs +94 -9
- package/src/claim-bundle.mjs +18 -5
- package/src/companion-window.mjs +9 -3
- package/src/cors.mjs +3 -1
- package/src/cycle-receipt.mjs +97 -0
- package/src/fonts/jetbrains-mono/OFL.txt +93 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-400-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-500-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-700-normal.woff2 +0 -0
- package/src/hook-prompt.mjs +24 -3
- package/src/integration-spec.mjs +175 -48
- package/src/milestone-record.mjs +214 -0
- package/src/milestones-reached.mjs +168 -64
- package/src/next-break-reminder.mjs +4 -3
- package/src/onboarding-electron.mjs +12 -11
- package/src/onboarding-platform.mjs +5 -2
- package/src/onboarding-window.mjs +94 -63
- package/src/reward.mjs +247 -68
- package/src/runtime.mjs +48 -4
- package/src/tracking-config.mjs +501 -0
- package/src/welcome-words.mjs +115 -0
- package/src/window-page.mjs +16 -2
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
|
-
|
|
20
|
-
//
|
|
21
|
-
|
|
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
|
|
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:
|
|
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
|
|
129
|
-
return
|
|
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
|
|
133
|
-
//
|
|
134
|
-
// older code and tests do, is not read.
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
);
|
|
147
|
-
const
|
|
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:
|
|
151
|
-
requiredEquals:
|
|
152
|
-
requiredSeconds:
|
|
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
|
|
156
|
-
status:
|
|
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
|
|
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:
|
|
349
|
+
requiredSeconds: last.requiredSeconds,
|
|
350
|
+
requiredEquals: last.requiredEquals,
|
|
164
351
|
earnedSeconds: normalizedEarnedSeconds,
|
|
165
352
|
totalEquals: normalizedTotalEquals,
|
|
166
|
-
timeLeftSeconds:
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
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
|
|
270
|
-
|
|
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
|
-
|
|
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 {
|