@kitkat-vn/kitcode 0.2.0-staging.9 → 0.3.0-staging.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -28
- package/bin/kitcode.mjs +65 -5
- package/package.json +2 -2
- package/src/api.mjs +105 -3
- package/src/break-progress.mjs +94 -9
- package/src/claim-bundle.mjs +18 -5
- package/src/companion-window.mjs +9 -3
- package/src/cors.mjs +3 -1
- package/src/cycle-receipt.mjs +97 -0
- package/src/fonts/jetbrains-mono/OFL.txt +93 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-400-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-500-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-700-normal.woff2 +0 -0
- package/src/hook-prompt.mjs +24 -3
- package/src/integration-spec.mjs +175 -48
- package/src/milestone-record.mjs +214 -0
- package/src/milestones-reached.mjs +168 -64
- package/src/next-break-reminder.mjs +4 -3
- package/src/onboarding-electron.mjs +12 -11
- package/src/onboarding-platform.mjs +5 -2
- package/src/onboarding-window.mjs +94 -63
- package/src/reward.mjs +247 -68
- package/src/runtime.mjs +48 -4
- package/src/tracking-config.mjs +501 -0
- package/src/welcome-words.mjs +115 -0
- package/src/window-page.mjs +16 -2
package/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
|
|
@@ -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,
|
|
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
|
|
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.
|
|
309
|
-
version is the one exception to CI's staging: a maintainer
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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.
|
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
|
}
|