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

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
@@ -261,10 +279,11 @@ GET /api/summary
261
279
  GET /api/projects
262
280
  GET /api/events
263
281
  GET /api/claim/bundle
282
+ POST /api/claim/receipt
264
283
  GET /companion
265
284
  ```
266
285
 
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.
286
+ 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
287
 
269
288
  `/api/events` is SSE and emits `summary` every second.
270
289
 
@@ -303,19 +322,17 @@ With nothing configured, the local API allows the origin of the dashboard `kitco
303
322
 
304
323
  ## Releases
305
324
 
306
- Nobody runs `npm publish` for this package. A release comes from a pushed tag, `cli-v<version>`: the project's CI runs
325
+ Nobody runs `npm publish` for these packages. A release comes from a pushed tag, `cli-v<version>`: the project's CI runs
307
326
  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.
327
+ staged version becomes public only when a maintainer approves it with two-factor authentication. Each package's first
328
+ version is the one exception to CI's staging: a maintainer publishes CI's tarball by hand, once, with two-factor
329
+ authentication, since npm lets CI stage only for a package that exists.
311
330
 
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` |
331
+ Two packages, each with its own dist-tag:
318
332
 
319
- No `latest` release exists yet.
333
+ | Package | Dist-tag | Versions | Install |
334
+ | --- | --- | --- | --- |
335
+ | `@kitkatvn/kitcode` | `latest` | `<x>.<y>.<z>`, the version in `package.json`. Its dashboard is `https://kitcode.vn/` | `npm install -g @kitkatvn/kitcode` |
336
+ | `@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
337
 
321
- What each dist-tag points at now: `npm view @kitkat-vn/kitcode dist-tags`.
338
+ 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
@@ -90,6 +90,8 @@ function trackerAllowedOrigins() {
90
90
  .filter(Boolean),
91
91
  );
92
92
 
93
+ // The dashboard's origin, or, for a DASHBOARD_URL that is not a URL, production's (since 2026-10-22, where it was
94
+ // https://kitcode.vercel.app). A tester build's copy has no fallback (scripts/tester-build.mjs).
93
95
  try {
94
96
  origins.add(new URL(DASHBOARD_URL).origin);
95
97
  } catch {
@@ -283,7 +285,9 @@ function formatDuration(seconds) {
283
285
 
284
286
  // The numbers come from the summary: the same ones the Mini and the dashboard show
285
287
  // (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).
288
+ // showing a 10–30 % code was what cleared one; with the codes gone nothing does (docs/reward-rules.md §7). Since 2026-10-22
289
+ // the milestones are those of the cycle counted in, which it names from cycle 2 on, when it began (docs/reward-rules.md §2,
290
+ // "Cycles"); cycle 1 reads as it always has.
287
291
  function printRewardSummary() {
288
292
  const reward = getDiskRewardSummary();
289
293
  const next = reward.nextBreak;
@@ -294,6 +298,10 @@ function printRewardSummary() {
294
298
  console.log(` active time: ${formatDuration(reward.earnedSeconds)}`);
295
299
  console.log(` campaign progress: ${reward.progressPercent}%`);
296
300
 
301
+ if (Number.isSafeInteger(reward.cycle) && reward.cycle > 1) {
302
+ console.log(` cycle: ${reward.cycle}, its milestones counted from ${reward.cycleStartedAt}`);
303
+ }
304
+
297
305
  if (ready.length > 0) {
298
306
  console.log(` ready: ${ready.map((milestone) => `${milestone.percent}%`).join(', ')}`);
299
307
  }
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.15",
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": "e1f3a6b5e2c101cd4d3a24c611b85eb66066762e"
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
  }
@@ -32,10 +32,10 @@ button{cursor:pointer}
32
32
  ${COMPANION_HIDE_CSS}
33
33
  `;
34
34
  const script = `
35
- const API_BASE=${JSON.stringify(apiBase)};const percent=document.getElementById('percent'),countValue=document.getElementById('countValue'),workedValue=document.getElementById('workedValue'),idleValue=document.getElementById('idleValue'),progressFill=document.getElementById('progressFill'),main=document.getElementById('main'),dashboardButton=document.getElementById('dashboardButton');
35
+ const API_BASE=${JSON.stringify(apiBase)};const brandLabel=document.getElementById('brandLabel'),brandSecond=document.getElementById('brandSecond'),percent=document.getElementById('percent'),countValue=document.getElementById('countValue'),workedValue=document.getElementById('workedValue'),idleValue=document.getElementById('idleValue'),progressFill=document.getElementById('progressFill'),main=document.getElementById('main'),dashboardButton=document.getElementById('dashboardButton');
36
36
  function formatDuration(seconds){const totalSeconds=Math.max(0,Math.floor(Number(seconds)||0));if(totalSeconds<60)return totalSeconds+'s';const hours=Math.floor(totalSeconds/3600),minutes=Math.floor((totalSeconds%3600)/60);if(hours>0)return minutes>0?hours+'h '+minutes+'m':hours+'h';return minutes+'m'}
37
37
  function updateProgress(value){progressFill.style.width=Math.max(0,Math.min(100,Number(value)||0))+'%'}
38
- function render(summary){const reward=summary?.reward||{},global=summary?.global||{},value=Math.max(0,Math.min(100,Math.floor(Number(reward.progressPercent)||0)));percent.textContent=value+'%';countValue.textContent=String(Math.max(0,Math.floor(Number(reward.totalEquals)||0)));workedValue.textContent=formatDuration(global.totalActiveSeconds);idleValue.textContent=formatDuration(global.totalIdleSeconds);updateProgress(value);main.classList.remove('offline')}
38
+ function render(summary){const reward=summary?.reward||{},global=summary?.global||{},value=Math.max(0,Math.min(100,Math.floor(Number(reward.progressPercent)||0))),cycle=Number.isSafeInteger(reward.cycle)&&reward.cycle>1?reward.cycle:null;brandSecond.textContent=cycle===null?'COUNTER':'CYCLE '+cycle;brandLabel.setAttribute('aria-label',cycle===null?'KitCode Counter':'KitCode cycle '+cycle);percent.textContent=value+'%';countValue.textContent=String(Math.max(0,Math.floor(Number(reward.totalEquals)||0)));workedValue.textContent=formatDuration(global.totalActiveSeconds);idleValue.textContent=formatDuration(global.totalIdleSeconds);updateProgress(value);main.classList.remove('offline')}
39
39
  function offline(){percent.textContent='--';countValue.textContent='--';workedValue.textContent='--';idleValue.textContent='--';updateProgress(0);main.classList.add('offline')}
40
40
  async function sync(){try{const response=await fetch(API_BASE+'/api/summary');if(!response.ok)throw new Error();render(await response.json())}catch{offline()}}
41
41
  const hideButton=document.getElementById('hideButton');function hideCompanion(){try{const result=window.kitcodeCompanion?.hide?.();if(result&&typeof result.catch==='function')result.catch(()=>window.close());if(!result)window.close()}catch{window.close()}}
@@ -45,6 +45,10 @@ dashboardButton.addEventListener('click',()=>{if(window.kitcodeCompanion?.openDa
45
45
  sync();const events=new EventSource(API_BASE+'/api/events');events.addEventListener('summary',(event)=>{try{render(JSON.parse(event.data))}catch{offline()}});events.onerror=offline;
46
46
  `;
47
47
 
48
+ // Since 2026-10-22 its label names the cycle counted in from cycle 2 on, "KITCODE CYCLE 2" where it says "KITCODE COUNTER",
49
+ // on the same two lines, so that the counters keep their room (docs/reward-rules.md §2, "Cycles"); its percent is that
50
+ // cycle's, as the summary's is.
51
+ //
48
52
  // The button opens the dashboard, where each milestone reached shows its message: "See more". Until 2026-10-01 it was
49
53
  // "Show code", and lit while a 10–30 % code was ready to show there; the break codes are gone (the operator's decision
50
54
  // 6), and a milestone reached stays reached, so nothing lights it now. The Mini cannot show a message itself: the
@@ -55,5 +59,5 @@ sync();const events=new EventSource(API_BASE+'/api/events');events.addEventListe
55
59
  return `<!doctype html>
56
60
  <html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${contentSecurityPolicy({script, style, connect: [apiBase || "'self'"]})}"><meta name="viewport" content="width=device-width, initial-scale=1">
57
61
  <title>KitCode Counter</title><style>${style}</style></head>
58
- <body><main class="widget-window" id="main" data-testid="companion-counter-bar">${renderCompanionTitleBar()}<div class="widget-body"><div class="brand-label" aria-label="KitCode Counter">KITCODE<br>COUNTER</div><strong class="percentage" id="percent" data-testid="mini-percent">--</strong><div class="progress-box" aria-hidden="true"><div class="progress-fill" id="progressFill"><div class="progress-row">========================</div><div class="progress-row">========================</div></div></div><div class="metrics-group" aria-label="KitCode counters"><div class="metric-item"><div class="metric-value" id="countValue" data-testid="mini-count">--</div><div class="metric-label">= COUNT</div></div><div class="metric-item"><div class="metric-value" id="workedValue" data-testid="mini-worked">--</div><div class="metric-label">WORKED</div></div><div class="metric-item"><div class="metric-value" id="idleValue" data-testid="mini-idle">--</div><div class="metric-label">IDLE</div></div></div><button class="claim-btn" id="dashboardButton" data-testid="companion-dashboard" type="button" title="Open the dashboard, where each milestone reached shows its message">See more</button></div></main><script>${script}</script></body></html>`;
62
+ <body><main class="widget-window" id="main" data-testid="companion-counter-bar">${renderCompanionTitleBar()}<div class="widget-body"><div class="brand-label" id="brandLabel" aria-label="KitCode Counter" data-testid="mini-brand">KITCODE<br><span id="brandSecond">COUNTER</span></div><strong class="percentage" id="percent" data-testid="mini-percent">--</strong><div class="progress-box" aria-hidden="true"><div class="progress-fill" id="progressFill"><div class="progress-row">========================</div><div class="progress-row">========================</div></div></div><div class="metrics-group" aria-label="KitCode counters"><div class="metric-item"><div class="metric-value" id="countValue" data-testid="mini-count">--</div><div class="metric-label">= COUNT</div></div><div class="metric-item"><div class="metric-value" id="workedValue" data-testid="mini-worked">--</div><div class="metric-label">WORKED</div></div><div class="metric-item"><div class="metric-value" id="idleValue" data-testid="mini-idle">--</div><div class="metric-label">IDLE</div></div></div><button class="claim-btn" id="dashboardButton" data-testid="companion-dashboard" type="button" title="Open the dashboard, where each milestone reached shows its message">See more</button></div></main><script>${script}</script></body></html>`;
59
63
  }
package/src/cors.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  // The origins a tracker allows with nothing configured: the hosted dashboard's and the local development
2
- // servers'. Pinned by local-api.test.mjs.
2
+ // servers'. Pinned by local-api.test.mjs. Since 2026-10-22 (production/1-cli) the hosted dashboard is production's,
3
+ // https://kitcode.vn, where it was https://kitcode.vercel.app, the previous project's site; a tester build's copy has the
4
+ // staging site's in its place (scripts/tester-build.mjs).
3
5
  export const DEFAULT_ALLOWED_ORIGINS = new Set([
4
6
  // The staging dashboard this tester build is for (scripts/tester-build.mjs in the repository).
5
7
  "https://staging.kitcode.vn",