@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 CHANGED
@@ -1,8 +1,8 @@
1
- # @kitkat-vn/kitcode
1
+ # @kitkatvn/kitcode
2
2
 
3
3
  Local break companion for coding campaigns. This package is the **source of truth** for tracking, the milestones reached, hooks, and the local Mini companion.
4
4
 
5
- Published package: `@kitkat-vn/kitcode`
5
+ Published package: `@kitkatvn/kitcode`. Its tester builds, for the invited testers of KitCode's staging round, are published as `@kitkat-vn/kitcode` (see Releases, below).
6
6
 
7
7
  ---
8
8
 
@@ -29,7 +29,9 @@ Use the permission setting that lets setup commands run without stopping between
29
29
 
30
30
  ### Quick Start
31
31
 
32
- Install KitCode first, from the dist-tag meant for you (see Releases, below): no `latest` release exists yet, and `staging` is for the invited testers of the staging round.
32
+ Install KitCode first: `npm install -g @kitkatvn/kitcode`. The setup prompt on KitCode's dashboard does it for you, at the version it was made for.
33
+
34
+ If `@kitkat-vn/kitcode` is installed, or `@onedigitas/kitcode`, the name KitCode was published under until 0.1.39, remove it first, with `npm uninstall -g @kitkat-vn/kitcode @onedigitas/kitcode`: each package installs the `kitcode` command, and npm does not install one over another. When neither is installed, npm says it is up to date.
33
35
 
34
36
  <table>
35
37
  <tr>
@@ -184,25 +186,41 @@ Both modes count `=` by the same line rules. In Git Mode, KitCode reads only the
184
186
 
185
187
  ## Reward model
186
188
 
187
- The time target is fixed at `3600` active seconds: no flag or variable changes it.
188
-
189
- Each milestone needs both enough active time and enough counted `=`:
189
+ The time targets are fixed, each milestone's own (below): no flag or variable changes them.
190
190
 
191
- - time target for a percent = `ceil(3600 * percent / 100)` seconds: 360 for 10%, 720 for 20%, 1080 for 30%, 1800 for 50%, and 3600 for 100%
192
- - equals target comes from the table below
191
+ Each milestone needs both enough active time and enough counted `=`, each counted from the moment the milestone before it
192
+ was reached: `30%` from install, `50%` from the moment `30%` was reached, `100%` from the moment `50%` was. Nothing earned
193
+ before that moment counts toward it, neither time earned while waiting for `=` nor `=` while waiting for time, so `100%`
194
+ takes 16 hours and 1,800 `=` in all in a release. What was reached stays reached.
193
195
 
194
196
  ### Milestones
195
197
 
196
- | Milestone | Required counted `=` |
197
- | ---: | ---: |
198
- | `10%` | 3 |
199
- | `20%` | 6 |
200
- | `30%` | 9 |
201
- | `50%` | 12 |
202
- | `100%` | 15 |
198
+ A release, `@kitkatvn/kitcode` under `latest`, counts by the production table:
199
+
200
+ | Milestone | Active time, from the milestone before | Counted `=`, from the milestone before |
201
+ | ---: | ---: | ---: |
202
+ | `30%` | 3 hours (10800 s), from install | 300, from install |
203
+ | `50%` | 5 hours (18000 s) | 500 |
204
+ | `100%` | 8 hours (28800 s) | 1,000 |
205
+
206
+ A tester build, `@kitkat-vn/kitcode` under `staging`, counts by the tester table, the short one, so that testers reach each
207
+ milestone quickly; `100%` takes 108 minutes and 36 `=` in all:
208
+
209
+ | Milestone | Active time, from the milestone before | Counted `=`, from the milestone before |
210
+ | ---: | ---: | ---: |
211
+ | `30%` | 18 minutes (1080 s), from install | 9, from install |
212
+ | `50%` | 30 minutes (1800 s) | 12 |
213
+ | `100%` | 60 minutes (3600 s) | 15 |
214
+
215
+ `MILESTONE_TABLE` in `src/integration-spec.mjs` says which this package counts by, and `kitcode summary` names what the next
216
+ milestone still needs.
217
+ Until 2026-10-08 there were 10% and 20% milestones too, and until 2026-10-20 every milestone counted from install. From
218
+ 2026-10-21 to 2026-10-22 the tester table held the production table's numbers; since 2026-10-22 it is the short one again.
203
219
 
204
220
  Once a milestone is reached, the dashboard shows its message: the campaign's own words, which the campaign's server gives each milestone. The prompt hook and the desktop notification say only that a milestone was reached. `summary` and `awards` show which are ready.
205
221
 
222
+ Since 2026-10-22 the milestones count in cycles. Once the campaign's server issues a voucher for a claim, its page links back to the dashboard with a receipt the server signed, and the dashboard hands it to the tracker, which begins the next cycle: `30%`, `50%` and `100%` count again from that moment, each needing its full target, and the active time and the counted `=` keep counting. Nothing else begins one, and a claim that fails or is abandoned changes nothing. A build takes a receipt only if it carries its server's public key: a release under `latest` carries the campaign server's, and a tester build the staging server's. From cycle 2 on, `summary`, the prompt hook and the Mini name the cycle.
223
+
206
224
  Until 2026-10-01 each milestone had a break code, a line of code shown in the dashboard, which recorded it as shown on this machine. The codes are gone, and nothing records one as shown; a code an earlier version recorded as shown stays in `~/.kitcode/state.json`, and its milestone reads as ready.
207
225
 
208
226
  ## Codex and Claude integrations
@@ -234,6 +252,7 @@ KITCODE_HOOKS_OFF=1
234
252
  ~/.kitcode/state.json Projects and their snapshots, onboarding prefs, focus time, equals ledger
235
253
  ~/.kitcode/seen-lines Every line KitCode has seen, as hashes: a line earns once
236
254
  ~/.kitcode/install-id A random id the claim bundle carries, made by the tracker
255
+ ~/.kitcode/tracking-config.json The campaign's targets and end, as the tracker last took them from the server
237
256
  ~/.kitcode/tracker.json Background tracker PID/host/port metadata
238
257
  ~/.kitcode/tracker.log The background tracker's log
239
258
  ~/.kitcode/hook.log Hook errors, when logging succeeds
@@ -253,6 +272,10 @@ By default KitCode does not send:
253
272
 
254
273
  The local API returns totals only: active/idle time, project count, commit count, change batches, counted `=`, and reward progress, with the tracker's health. The claim bundle, which it gives an allowed origin alone, adds when each milestone was reached, the CLI's and the counting rules' versions, and the install id, which names no person, machine or project.
255
274
 
275
+ KitCode connects to kitcode.vn about once an hour to download the campaign's current targets and end date. It sends nothing about you, your computer or your code, and the server does not record the address of this request.
276
+
277
+ Only the background tracker connects, about an hour apart and once when your computer wakes: one request to the dashboard's address plus `/v1/tracking-config`, with no cookie, install id or version. It takes the answer only if KitCode's claim server signed it, and otherwise keeps the targets it had; the hook and the commands never connect. The staging package connects to its staging site instead, and a copy of KitCode run from its repository connects to nothing. If the campaign has ended, by your computer's clock, the hook adds nothing to your prompts and `kitcode summary` says so; your totals are kept.
278
+
256
279
  ## API
257
280
 
258
281
  ```txt
@@ -261,10 +284,11 @@ GET /api/summary
261
284
  GET /api/projects
262
285
  GET /api/events
263
286
  GET /api/claim/bundle
287
+ POST /api/claim/receipt
264
288
  GET /companion
265
289
  ```
266
290
 
267
- The API answers only requests addressed to `127.0.0.1` or `localhost` on its port, and web pages only from an allowed origin (below) or its own. `GET /api/claim/bundle` needs an allowed origin; every `POST` needs an allowed origin and a JSON body, and none is left that changes anything.
291
+ The API answers only requests addressed to `127.0.0.1` or `localhost` on its port, and web pages only from an allowed origin (below) or its own. `GET /api/claim/bundle` needs an allowed origin; every `POST` needs an allowed origin and a JSON body. Since 2026-10-22 one changes something, `POST /api/claim/receipt`: it begins the next cycle on a receipt signed with the key the build carries, for this install, of the cycle it is in or a later one, once, and refuses anything else, changing nothing. A receipt of a later cycle, as after KitCode is installed afresh, begins the cycle after it, numbered as the server counts (since 2026-10-22's phase 3).
268
292
 
269
293
  `/api/events` is SSE and emits `summary` every second.
270
294
 
@@ -303,19 +327,17 @@ With nothing configured, the local API allows the origin of the dashboard `kitco
303
327
 
304
328
  ## Releases
305
329
 
306
- Nobody runs `npm publish` for this package. A release comes from a pushed tag, `cli-v<version>`: the project's CI runs
330
+ Nobody runs `npm publish` for these packages. A release comes from a pushed tag, `cli-v<version>`: the project's CI runs
307
331
  its checks on the tagged commit, packs the tarball from that commit, and stages it on npm with `npm stage publish`. A
308
- staged version becomes public only when a maintainer approves it with two-factor authentication. The package's first
309
- version is the one exception to CI's staging: a maintainer stages CI's tarball by hand, once, since npm lets CI stage
310
- only for a package that exists.
332
+ staged version becomes public only when a maintainer approves it with two-factor authentication. Each package's first
333
+ version is the one exception to CI's staging: a maintainer publishes CI's tarball by hand, once, with two-factor
334
+ authentication, since npm lets CI stage only for a package that exists.
311
335
 
312
- Two dist-tags:
313
-
314
- | Dist-tag | Versions | Install |
315
- | --- | --- | --- |
316
- | `latest` | `<x>.<y>.<z>`, the version in `package.json` | `npm install -g @kitkat-vn/kitcode` |
317
- | `staging` | `<x>.<y>.<z>-staging.<N>`, the tester build of a tagged commit, for the invited testers of the staging round. Its dashboard is KitCode's protected staging site, which only they can open | `npm install -g @kitkat-vn/kitcode@staging` |
336
+ Two packages, each with its own dist-tag:
318
337
 
319
- No `latest` release exists yet.
338
+ | Package | Dist-tag | Versions | Install |
339
+ | --- | --- | --- | --- |
340
+ | `@kitkatvn/kitcode` | `latest` | `<x>.<y>.<z>`, the version in `package.json`. Its dashboard is `https://kitcode.vn/` | `npm install -g @kitkatvn/kitcode` |
341
+ | `@kitkat-vn/kitcode` | `staging` | `<x>.<y+1>.0-staging.<N>`, the tester build of a tagged commit, for the invited testers of the staging round. Its dashboard is KitCode's protected staging site, which only they can open, and it sends KitCode's own diagnostics there | `npm install -g @kitkat-vn/kitcode@staging` |
320
342
 
321
- What each dist-tag points at now: `npm view @kitkat-vn/kitcode dist-tags`.
343
+ What each dist-tag points at now: `npm view @kitkatvn/kitcode dist-tags` and `npm view @kitkat-vn/kitcode dist-tags`.
package/bin/kitcode.mjs CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  updateTrackerRecord,
54
54
  } from '../src/tracker-instance.mjs';
55
55
  import {askTrackerToStop, watchForStopRequest} from '../src/tracker-stop.mjs';
56
+ import {startTrackingConfigLoop, TRACKING_CONFIG_DISPLAY_PATH, trackingConfigUrl} from '../src/tracking-config.mjs';
56
57
  import {normalizeSetupPlatform} from '../src/onboarding-platform.mjs';
57
58
  import {uninstallKitCode} from '../src/uninstall.mjs';
58
59
 
@@ -90,6 +91,8 @@ function trackerAllowedOrigins() {
90
91
  .filter(Boolean),
91
92
  );
92
93
 
94
+ // The dashboard's origin, or, for a DASHBOARD_URL that is not a URL, production's (since 2026-10-22, where it was
95
+ // https://kitcode.vercel.app). A tester build's copy has no fallback (scripts/tester-build.mjs).
93
96
  try {
94
97
  origins.add(new URL(DASHBOARD_URL).origin);
95
98
  } catch {
@@ -283,9 +286,14 @@ function formatDuration(seconds) {
283
286
 
284
287
  // The numbers come from the summary: the same ones the Mini and the dashboard show
285
288
  // (docs/reward-rules.md §10). The next milestone follows the ready ones: until 2026-10-01 a ready milestone hid it, and
286
- // showing a 10–30 % code was what cleared one; with the codes gone nothing does (docs/reward-rules.md §7).
287
- function printRewardSummary() {
288
- const reward = getDiskRewardSummary();
289
+ // showing a 10–30 % code was what cleared one; with the codes gone nothing does (docs/reward-rules.md §7). Since 2026-10-22
290
+ // the milestones are those of the cycle counted in, which it names from cycle 2 on, when it began (docs/reward-rules.md §2,
291
+ // "Cycles"); cycle 1 reads as it always has.
292
+ //
293
+ // Since 2026-10-23 (tracking/2-cli; the plan's D4) a campaign that has ended, by this computer's clock and the end the targets in force
294
+ // give (src/tracking-config.mjs), is said so in the plan's words, and no next milestone is named: the summary has none. Counting, and
295
+ // what was reached, are as before.
296
+ function printRewardSummary(reward = getDiskRewardSummary()) {
289
297
  const next = reward.nextBreak;
290
298
  const ready = reward.milestones.filter((milestone) => milestone.status === 'ready' && !milestone.redeemed);
291
299
 
@@ -294,6 +302,14 @@ function printRewardSummary() {
294
302
  console.log(` active time: ${formatDuration(reward.earnedSeconds)}`);
295
303
  console.log(` campaign progress: ${reward.progressPercent}%`);
296
304
 
305
+ if (reward.campaign?.ended) {
306
+ console.log(` ${campaignEndedWords(reward.campaign)}`);
307
+ }
308
+
309
+ if (Number.isSafeInteger(reward.cycle) && reward.cycle > 1) {
310
+ console.log(` cycle: ${reward.cycle}, its milestones counted from ${reward.cycleStartedAt}`);
311
+ }
312
+
297
313
  if (ready.length > 0) {
298
314
  console.log(` ready: ${ready.map((milestone) => `${milestone.percent}%`).join(', ')}`);
299
315
  }
@@ -315,6 +331,41 @@ function printAwards() {
315
331
  const status = milestone.status === 'redeemed' ? 'ready' : milestone.status;
316
332
  console.log(` ${milestone.percent}% milestone: ${status} (${milestoneProgressPercent(reward, milestone)}%)`);
317
333
  }
334
+
335
+ if (reward.campaign?.ended) {
336
+ console.log(` ${campaignEndedWords(reward.campaign)}`);
337
+ }
338
+ }
339
+
340
+ // An instant in Vietnam time, UTC+7 all year, as "YYYY-MM-DD HH:MM": the campaign's end is set so (docs/backend.md,
341
+ // VOUCHER_CAMPAIGN_END), and the plan's words name its date there.
342
+ function vietnamTime(instant) {
343
+ return new Date(Date.parse(instant) + 7 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ');
344
+ }
345
+
346
+ // The plan's words for a campaign that has ended (D4 of docs/plans/2026-10-22-server-tracking-config.md).
347
+ function campaignEndedWords(campaign) {
348
+ return `KitCode's campaign ended on ${vietnamTime(campaign.endsAt).slice(0, 10)} (Vietnam time). Your totals are kept; no more vouchers can be claimed.`;
349
+ }
350
+
351
+ // Which targets KitCode counts by, and where they came from (src/tracking-config.mjs), so that a fallback never reads as the healthy
352
+ // path; and the campaign's end. From the summary this command builds: the last good copy on disk, as the prompt hook reads it.
353
+ function targetsLine(targets) {
354
+ if (targets.source === 'bundled') {
355
+ return trackingConfigUrl() === null
356
+ ? 'the ones this KitCode bundles; this build never asks a server for them'
357
+ : `the ones this KitCode bundles, since ${TRACKING_CONFIG_DISPLAY_PATH} holds no good copy of the server's yet`;
358
+ }
359
+
360
+ return `the server's, issued ${targets.issuedAt}${targets.source === 'saved' ? `, from the copy in ${TRACKING_CONFIG_DISPLAY_PATH}` : ''}`;
361
+ }
362
+
363
+ function campaignLine(campaign) {
364
+ if (campaign.endsAt === null) {
365
+ return 'no end set';
366
+ }
367
+
368
+ return campaign.ended ? `ended on ${vietnamTime(campaign.endsAt).slice(0, 10)} (Vietnam time)` : `ends ${vietnamTime(campaign.endsAt)} (Vietnam time)`;
318
369
  }
319
370
 
320
371
  async function printStatus(options) {
@@ -335,7 +386,10 @@ async function printStatus(options) {
335
386
  }).join(', ')}; see ${TRACKER_LOG_PATH}`);
336
387
  }
337
388
  console.log(` added projects: ${totals.trackingProjects}`);
338
- printRewardSummary();
389
+ const reward = getDiskRewardSummary();
390
+ console.log(` targets: ${targetsLine(reward.targets)}`);
391
+ console.log(` campaign: ${campaignLine(reward.campaign)}`);
392
+ printRewardSummary(reward);
339
393
  }
340
394
 
341
395
 
@@ -926,6 +980,10 @@ async function runDaemon(options, status = createStatusReporter()) {
926
980
  return {projects: Object.values(runtime.state.projects).filter((project) => project.active).length, tracking: current.tracking, problems: current.problems.map((problem) => problem.code)};
927
981
  },
928
982
  });
983
+ // The campaign's targets and end, from the server, in a build that carries the claim backend's key (src/tracking-config.mjs):
984
+ // a random 5 to 120 s from now, then whenever the last try is over an hour old, and at once after a sleep. The tracker alone
985
+ // fetches; whatever happens to a fetch stays in tracker.log, and the tracker counts by the targets it had.
986
+ const trackingConfig = startTrackingConfigLoop({log});
929
987
  const app = createServer(runtime, VERSION, {diagnostics});
930
988
  let cleanup = async () => {};
931
989
  let server = null;
@@ -965,7 +1023,7 @@ async function runDaemon(options, status = createStatusReporter()) {
965
1023
  report('error', `Stopped: ${error.message}`);
966
1024
  cleanup({persist: false});
967
1025
  recordEvent('tracker-stopped', {reason: 'state-unreadable', uptimeSeconds: uptimeSeconds()}, {source: 'tracker'});
968
- diagnostics.stop().finally(() => exit(1));
1026
+ Promise.allSettled([trackingConfig.stop(), diagnostics.stop()]).finally(() => exit(1));
969
1027
  },
970
1028
  });
971
1029
 
@@ -982,6 +1040,7 @@ async function runDaemon(options, status = createStatusReporter()) {
982
1040
 
983
1041
  log.info(`stopped (${signal})`);
984
1042
  recordEvent('tracker-stopped', {reason: signal === 'stop request' ? 'stop-request' : 'signal', uptimeSeconds: uptimeSeconds()}, {source: 'tracker'});
1043
+ await trackingConfig.stop();
985
1044
  await diagnostics.stop();
986
1045
  exit(0);
987
1046
  };
@@ -997,6 +1056,7 @@ async function runDaemon(options, status = createStatusReporter()) {
997
1056
  await cleanup().catch(() => {});
998
1057
  releaseTracker(process.pid);
999
1058
  recordTrackerNotStarted('tracker', error.code === 'EADDRINUSE' ? 'port-in-use' : 'start-failed', error);
1059
+ await trackingConfig.stop();
1000
1060
  await diagnostics.stop({finalSend: false});
1001
1061
 
1002
1062
  if (error.code === 'EADDRINUSE') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitkat-vn/kitcode",
3
- "version": "0.2.0-staging.9",
3
+ "version": "0.3.0-staging.16",
4
4
  "description": "Local aggregate activity server for KitCode.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,5 +37,5 @@
37
37
  "optionalDependencies": {
38
38
  "electron": "^43.0.0"
39
39
  },
40
- "gitHead": "e2f3a9f874571a418b186a9cb8b5152eb0692b05"
40
+ "gitHead": "1e310fb2aaf244ede82956f5f6046ea9f52a7eb1"
41
41
  }
package/src/api.mjs CHANGED
@@ -3,12 +3,13 @@ import {MAX_EVENT_STREAMS, requireDashboardOrigin, requireDashboardRead, require
3
3
  import {claimBundleFromState} from './claim-bundle.mjs';
4
4
  import {corsMiddleware} from './cors.mjs';
5
5
  import {renderCompanionWindow} from './companion-window.mjs';
6
+ import {cycleReceiptKey, readCycleReceipt} from './cycle-receipt.mjs';
6
7
  import {describeOutcome, diagnosticsStatus, sendQueued} from './diagnostics.mjs';
7
8
  import {INSTALL_ID_DISPLAY_PATH, InstallIdError, readInstallId} from './install-id.mjs';
8
- import {STATE_DISPLAY_PATH} from './integration-spec.mjs';
9
+ import {CYCLE_RECEIPT_PUBLIC_KEY, STATE_DISPLAY_PATH} from './integration-spec.mjs';
9
10
  import {MilestoneRecordError} from './milestones-reached.mjs';
10
- import {buildSummary} from './runtime.mjs';
11
- import {loadState, StateFileError} from './store.mjs';
11
+ import {buildSummary, startNextCycle} from './runtime.mjs';
12
+ import {loadState, StateFileError, StateLockError} from './store.mjs';
12
13
 
13
14
  const projectLevelGone = (_req, res) => {
14
15
  res.status(410).json({error: 'Project-level metadata is no longer exposed'});
@@ -21,6 +22,20 @@ const redeemGone = (_req, res) => {
21
22
  res.status(410).json({error: 'Break codes are no longer shown or recorded: each milestone reached shows its message on the dashboard.'});
22
23
  };
23
24
 
25
+ // What POST /api/claim/receipt says when it changes nothing (docs/security.md, "The cycle receipt"): fixed words, numbers and
26
+ // the times the record holds, never anything of the receipt but its cycle, and "Nothing changed." last, which is true of each.
27
+ const NOTHING_CHANGED = 'Nothing changed.';
28
+ const RECEIPT_WORDS = {
29
+ 'no-key': `This build of KitCode takes no receipts: only a build for a campaign with a claim server does. ${NOTHING_CHANGED}`,
30
+ unreadable: `This is not a receipt KitCode can read. ${NOTHING_CHANGED}`,
31
+ signature: `This receipt was not signed by KitCode's claim server, so KitCode did not take it. ${NOTHING_CHANGED}`,
32
+ 'install-id': `This receipt is for another KitCode install than the one on this computer. ${NOTHING_CHANGED}`,
33
+ used: (cycle, at) => `This receipt began cycle ${cycle} on this computer already, at ${at}. ${NOTHING_CHANGED}`,
34
+ behind: (cycle, current) => `This receipt is for cycle ${cycle}, and KitCode on this computer is in cycle ${current} already. ${NOTHING_CHANGED}`,
35
+ // Until 2026-10-22's phase 3 a receipt of a cycle ahead was refused, `ahead`; since then it begins the cycle after it, as the
36
+ // claim backend counts (milestones-reached.mjs, beginNextCycle).
37
+ };
38
+
24
39
  // `diagnostics` is the tracker's send loop (src/diagnostics.mjs, startDiagnosticsLoop), for Send logs now.
25
40
  export function createServer(runtime, version, {diagnostics = null} = {}) {
26
41
  const app = express();
@@ -107,6 +122,93 @@ export function createServer(runtime, version, {diagnostics = null} = {}) {
107
122
  }
108
123
  });
109
124
 
125
+ // A cycle receipt (src/cycle-receipt.mjs; docs/backend.md, "The cycle receipt"; the plan of 2026-10-21, decision 1): the
126
+ // dashboard posts it once the claim backend's pages hand it back, after a voucher was issued, and the tracker begins the
127
+ // next cycle, whose milestones all count fresh from now (runtime.mjs, startNextCycle). A write, so it passes every write's
128
+ // guards first, a loopback Host, an allow-listed Origin and a JSON body (api-access.mjs); then it must be signed with the
129
+ // claim backend's key, which this build carries, name this install's id, and be the receipt of the cycle this install is in:
130
+ // anything else is refused in plain words and changes nothing. The signature is checked here alone, never by the prompt
131
+ // hook, which reads the record's cycle as it reads the rest (hard rule 7).
132
+ app.post('/api/claim/receipt', (req, res) => {
133
+ res.setHeader('Cache-Control', 'no-store');
134
+ const refuse = (status, reason, error, extra = {}) => res.status(status).json({error, reason, ...extra});
135
+ const text = req.body !== null && typeof req.body === 'object' && !Array.isArray(req.body) && typeof req.body.receipt === 'string' ? req.body.receipt : null;
136
+ const key = cycleReceiptKey(CYCLE_RECEIPT_PUBLIC_KEY);
137
+
138
+ if (key === null) {
139
+ refuse(503, 'no-key', RECEIPT_WORDS['no-key']);
140
+ return;
141
+ }
142
+
143
+ if (text === null) {
144
+ refuse(400, 'unreadable', `Send the receipt as {"receipt": "…"}. ${NOTHING_CHANGED}`);
145
+ return;
146
+ }
147
+
148
+ const read = readCycleReceipt(text, key);
149
+
150
+ if (!read.ok) {
151
+ refuse(422, read.problem, RECEIPT_WORDS[read.problem]);
152
+ return;
153
+ }
154
+
155
+ let installId;
156
+
157
+ try {
158
+ installId = readInstallId();
159
+ } catch (error) {
160
+ if (error instanceof InstallIdError) {
161
+ refuse(500, 'install-id-unreadable', `KitCode cannot read its install id, ${INSTALL_ID_DISPLAY_PATH}, and has left it as it is. ${NOTHING_CHANGED}`);
162
+ return;
163
+ }
164
+
165
+ throw error;
166
+ }
167
+
168
+ if (installId === null) {
169
+ refuse(503, 'no-install-id', `KitCode has no install id yet: the tracker makes one as it starts. Run \`kitcode untrack\`, then \`kitcode track\`. ${NOTHING_CHANGED}`);
170
+ return;
171
+ }
172
+
173
+ if (read.receipt.installId !== installId) {
174
+ refuse(422, 'install-id', RECEIPT_WORDS['install-id']);
175
+ return;
176
+ }
177
+
178
+ let outcome;
179
+
180
+ try {
181
+ outcome = startNextCycle(runtime, read.receipt);
182
+ } catch (error) {
183
+ if (error instanceof StateLockError) {
184
+ res.setHeader('Retry-After', '2');
185
+ refuse(503, 'busy', `KitCode's progress file is busy: try again in a moment. ${NOTHING_CHANGED}`);
186
+ return;
187
+ }
188
+
189
+ if (error instanceof StateFileError) {
190
+ refuse(500, 'state-unreadable', `KitCode cannot read ${STATE_DISPLAY_PATH}. ${NOTHING_CHANGED}`);
191
+ return;
192
+ }
193
+
194
+ throw error;
195
+ }
196
+
197
+ switch (outcome.outcome) {
198
+ case 'started':
199
+ res.json({cycle: outcome.cycle, startedAt: outcome.startedAt});
200
+ return;
201
+ case 'used':
202
+ refuse(409, 'used', RECEIPT_WORDS.used(outcome.cycle, outcome.startedAt), {cycle: outcome.cycle});
203
+ return;
204
+ case 'cycle':
205
+ refuse(409, 'behind', RECEIPT_WORDS.behind(read.receipt.cycle, outcome.current), {cycle: outcome.current});
206
+ return;
207
+ default:
208
+ refuse(500, 'record-damaged', `The record of when each milestone was reached, in ${STATE_DISPLAY_PATH}, is not one KitCode writes, and has been left as it is. ${NOTHING_CHANGED}`);
209
+ }
210
+ });
211
+
110
212
  // A tester build's diagnostics, for the staging dashboard (docs/security.md, "Tester diagnostics"): whether this tracker
111
213
  // sends them, its install id, which the dashboard links its own events by, what waits, and the last send. Only to an
112
214
  // allow-listed Origin, as the claim bundle, which holds the same install id; never the events, or the invited email.
@@ -2,6 +2,11 @@
2
2
  // dashboard all show breakProgressPercent, so they agree. Every number is floored: none of them
3
3
  // reaches a milestone's percent before both of that milestone's targets are reached.
4
4
  //
5
+ // Since 2026-10-20 (rewards/2-no-stack) each milestone counts from the moment the one before it was reached, and a summary
6
+ // says where, `countedFrom` on each milestone (reward.mjs): every number here is then the progress within the current
7
+ // milestone, what was earned since its start, and nothing earned before it. A summary from a tracker before then has no
8
+ // `countedFrom`, and each number is what it was: every milestone counted from install.
9
+ //
5
10
  // Pure, with no imports: the web app imports this file at build time.
6
11
 
7
12
  function earnedOf(reward) {
@@ -11,16 +16,59 @@ function earnedOf(reward) {
11
16
  };
12
17
  }
13
18
 
19
+ function targetsOf(milestone) {
20
+ return {
21
+ seconds: Math.max(0, Number(milestone?.requiredSeconds) || 0),
22
+ equals: Math.max(0, Number(milestone?.requiredEquals) || 0),
23
+ };
24
+ }
25
+
14
26
  function ladderOf(milestones) {
15
27
  return (Array.isArray(milestones) ? milestones : [])
16
28
  .map((milestone) => ({
17
29
  percent: Number(milestone?.percent) || 0,
18
- seconds: Math.max(0, Number(milestone?.requiredSeconds) || 0),
19
- equals: Math.max(0, Number(milestone?.requiredEquals) || 0),
30
+ ...targetsOf(milestone),
31
+ milestone,
20
32
  }))
21
33
  .sort((a, b) => a.percent - b.percent);
22
34
  }
23
35
 
36
+ // Whether a summary's milestones count fresh, each from where it says: a tracker's since 2026-10-20.
37
+ function countsFresh(milestones) {
38
+ return Array.isArray(milestones) && milestones.some((milestone) => milestone !== null && typeof milestone === 'object' && Object.hasOwn(milestone, 'countedFrom'));
39
+ }
40
+
41
+ // A milestone the summary says is reached: ready, or a code an earlier KitCode recorded as shown.
42
+ function isReached(milestone) {
43
+ return milestone?.unlocked === true || milestone?.redeemed === true || milestone?.status === 'ready' || milestone?.status === 'redeemed';
44
+ }
45
+
46
+ // What counts toward `milestone`, {seconds, equals}. Counted fresh: what was earned since its start, `countedFrom`;
47
+ // nothing for one that has not begun; and for one reached, its targets, which it met. From a tracker before 2026-10-20:
48
+ // the totals since install.
49
+ export function earnedToward(reward, milestone) {
50
+ const earned = earnedOf(reward);
51
+
52
+ if (milestone === null || typeof milestone !== 'object' || !Object.hasOwn(milestone, 'countedFrom')) {
53
+ return earned;
54
+ }
55
+
56
+ if (isReached(milestone)) {
57
+ return targetsOf(milestone);
58
+ }
59
+
60
+ const from = milestone.countedFrom;
61
+
62
+ if (from === null || typeof from !== 'object') {
63
+ return {seconds: 0, equals: 0};
64
+ }
65
+
66
+ return {
67
+ seconds: Math.max(0, earned.seconds - Math.floor(Math.max(0, Number(from.seconds) || 0))),
68
+ equals: Math.max(0, earned.equals - Math.max(0, Number(from.equals) || 0)),
69
+ };
70
+ }
71
+
24
72
  // How far `value` has got from one target to the next, from 0 to 1.
25
73
  function segmentFraction(value, from, to) {
26
74
  if (!(to > from)) {
@@ -47,10 +95,33 @@ function climb(ladder, reached, fraction) {
47
95
  return previous.percent;
48
96
  }
49
97
 
98
+ // Counted fresh: past every milestone reached, that one's percent plus `share` of the way to the next, floored, and at
99
+ // most `cap(step)`.
100
+ function climbFresh(reward, milestones, share, cap = (step) => step.percent - 1) {
101
+ let previous = 0;
102
+
103
+ for (const step of ladderOf(milestones)) {
104
+ if (!isReached(step.milestone)) {
105
+ const earned = earnedToward(reward, step.milestone);
106
+ const percent = Math.floor(previous + share(earned, step) * (step.percent - previous));
107
+ return Math.max(previous, Math.min(cap(step), percent));
108
+ }
109
+
110
+ previous = step.percent;
111
+ }
112
+
113
+ return previous;
114
+ }
115
+
116
+ function shareOf(value, target) {
117
+ return target > 0 ? Math.min(1, value / target) : 1;
118
+ }
119
+
50
120
  // Percent of the way to one milestone: floor(100 × min(time ÷ its time, = ÷ its =)), at most 100.
51
- // It is 100 only when both targets are reached, so a locked milestone never shows 100 %.
121
+ // It is 100 only when both targets are reached, so a locked milestone never shows 100 %. Counted fresh, the time and the
122
+ // = are what was earned toward it (earnedToward).
52
123
  export function milestoneProgressPercent(reward, milestone) {
53
- const {seconds, equals} = earnedOf(reward);
124
+ const {seconds, equals} = earnedToward(reward, milestone);
54
125
  const requiredSeconds = Math.max(0, Number(milestone?.requiredSeconds) || 0);
55
126
  const requiredEquals = Math.max(0, Number(milestone?.requiredEquals) || 0);
56
127
  const time = requiredSeconds > 0 ? Math.floor((100 * seconds) / requiredSeconds) : 100;
@@ -59,10 +130,15 @@ export function milestoneProgressPercent(reward, milestone) {
59
130
  return Math.max(0, Math.min(100, time, equalsPercent));
60
131
  }
61
132
 
62
- // The one progress number: how far along the 10, 20, 30, 50, 100 % milestones both targets have
63
- // got together. It is at least the percent of every milestone reached, and below the percent of the
64
- // first one that is not.
133
+ // The one progress number: how far along the milestones, 30, 50 and 100 % since 2026-10-08 (10, 20, 30, 50 and 100 %
134
+ // until then), both targets have got together. It is at least the percent of every milestone reached, and below the percent of the
135
+ // first one that is not. Counted fresh, the share of the way to the next milestone is the share of its own targets earned
136
+ // since its start.
65
137
  export function breakProgressPercent(reward, milestones = reward?.milestones) {
138
+ if (countsFresh(milestones)) {
139
+ return climbFresh(reward, milestones, (earned, step) => Math.min(shareOf(earned.seconds, step.seconds), shareOf(earned.equals, step.equals)));
140
+ }
141
+
66
142
  const {seconds, equals} = earnedOf(reward);
67
143
 
68
144
  return climb(
@@ -72,15 +148,24 @@ export function breakProgressPercent(reward, milestones = reward?.milestones) {
72
148
  );
73
149
  }
74
150
 
75
- // Time alone, placed on the same milestones.
151
+ // Time alone, placed on the same milestones. Counted fresh, it reaches the next milestone's percent once its time is
152
+ // earned, and goes no further: time earned past it while waiting for = counts toward nothing.
76
153
  export function timeProgressPercent(reward, milestones = reward?.milestones) {
154
+ if (countsFresh(milestones)) {
155
+ return climbFresh(reward, milestones, (earned, step) => shareOf(earned.seconds, step.seconds), (step) => step.percent);
156
+ }
157
+
77
158
  const {seconds} = earnedOf(reward);
78
159
 
79
160
  return climb(ladderOf(milestones), (step) => seconds >= step.seconds, (from, to) => segmentFraction(seconds, from.seconds, to.seconds));
80
161
  }
81
162
 
82
- // = alone, placed on the same milestones.
163
+ // = alone, placed on the same milestones, as time alone is.
83
164
  export function equalsProgressPercent(reward, milestones = reward?.milestones) {
165
+ if (countsFresh(milestones)) {
166
+ return climbFresh(reward, milestones, (earned, step) => shareOf(earned.equals, step.equals), (step) => step.percent);
167
+ }
168
+
84
169
  const {equals} = earnedOf(reward);
85
170
 
86
171
  return climb(ladderOf(milestones), (step) => equals >= step.equals, (from, to) => segmentFraction(equals, from.equals, to.equals));
@@ -3,8 +3,11 @@ import {focusTimeFrom} from './focus-clock.mjs';
3
3
  import {milestonesReachedFrom} from './milestones-reached.mjs';
4
4
  import {RULES_VERSION} from './source-snapshot.mjs';
5
5
 
6
- // The claim bundle (docs/backend.md, "The contract"): what a claim for the 50 or 100 % milestone carries,
7
- // in the shape of the contract's schema version 1 (packages/claim-contract). Totals only: the focus time
6
+ // The claim bundle (docs/backend.md, "The contract"): what a claim for the 100 % milestone carries, in the shape of the
7
+ // contract's schema version 4 (packages/claim-contract) since 2026-10-22, the milestones of the cycle counted in, with the
8
+ // cycle and where it began, `cycle` and `cycleStart`; version 3 from 2026-10-20, whose milestones, 30, 50 and 100 %, each
9
+ // carry the totals they counted from and were reached at, `start` and `end`; version 2 from 2026-10-08, the same milestones
10
+ // without them; until then version 1, with 10, 20, 30, 50 and 100 %. Totals only: the focus time
8
11
  // and the = counted, when each milestone was reached, the counting rules, the CLI's version and the
9
12
  // install id, with when the bundle and the milestone record were made.
10
13
  //
@@ -13,13 +16,13 @@ import {RULES_VERSION} from './source-snapshot.mjs';
13
16
  // The CLI never sends it anywhere: the local API gives it to the dashboard, which carries it on in a later
14
17
  // phase.
15
18
 
16
- export const CLAIM_BUNDLE_SCHEMA_VERSION = 1;
19
+ export const CLAIM_BUNDLE_SCHEMA_VERSION = 4;
17
20
 
18
21
  // The bundle for `state`, the user's state.json as read. Pure: it reads nothing else, and writes nothing.
19
22
  // A milestone record that is not what KitCode writes throws (milestones-reached.mjs).
20
23
  export function claimBundleFromState(state, {installId, cliVersion, createdAt}) {
21
24
  const ledger = equalsLedgerFrom(state);
22
- const {recordingSince, milestones} = milestonesReachedFrom(state);
25
+ const {recordingSince, cycle, cycleStart, milestones} = milestonesReachedFrom(state);
23
26
 
24
27
  return {
25
28
  schemaVersion: CLAIM_BUNDLE_SCHEMA_VERSION,
@@ -32,6 +35,16 @@ export function claimBundleFromState(state, {installId, cliVersion, createdAt})
32
35
  equals: ledger.total_equals,
33
36
  },
34
37
  recordingSince,
35
- milestones,
38
+ // The cycle counted in, and where it began: picked by name, as everything here is.
39
+ cycle,
40
+ cycleStart: {at: cycleStart.at, focusSeconds: cycleStart.focusSeconds, equals: cycleStart.equals},
41
+ // Picked by name too: each milestone's start and end are totals, {focusSeconds, equals}, or null.
42
+ milestones: milestones.map(({percent, status, reachedAt, start, end}) => ({
43
+ percent,
44
+ status,
45
+ reachedAt,
46
+ start: start === null ? null : {focusSeconds: start.focusSeconds, equals: start.equals},
47
+ end: end === null ? null : {focusSeconds: end.focusSeconds, equals: end.equals},
48
+ })),
36
49
  };
37
50
  }