@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 +45 -28
- package/bin/kitcode.mjs +9 -1
- 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 +7 -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 +15 -3
- package/src/integration-spec.mjs +175 -48
- package/src/milestone-record.mjs +214 -0
- package/src/milestones-reached.mjs +163 -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 +214 -63
- package/src/runtime.mjs +48 -4
- package/src/welcome-words.mjs +115 -0
- package/src/window-page.mjs +16 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# @
|
|
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
|
|
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
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
|
199
|
-
|
|
|
200
|
-
| `30%` |
|
|
201
|
-
| `50%` |
|
|
202
|
-
| `100%` |
|
|
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,
|
|
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
|
|
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.
|
|
309
|
-
version is the one exception to CI's staging: a maintainer
|
|
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-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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.
|
package/src/break-progress.mjs
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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} =
|
|
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
|
|
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));
|
package/src/claim-bundle.mjs
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/src/companion-window.mjs
CHANGED
|
@@ -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",
|