@kitkat-vn/kitcode 0.2.0-staging.3 → 0.2.0-staging.5
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 +14 -21
- package/bin/kitcode.mjs +182 -13
- package/package.json +2 -2
- package/src/api.mjs +31 -21
- package/src/companion-window.mjs +10 -8
- package/src/diagnostics.mjs +872 -0
- package/src/git.mjs +48 -8
- package/src/hook-prompt.mjs +19 -3
- package/src/integration-spec.mjs +46 -37
- package/src/onboarding-electron.mjs +8 -0
- package/src/onboarding-window.mjs +47 -7
- package/src/reward.mjs +4 -41
- package/src/runtime.mjs +30 -13
- package/src/source-snapshot.mjs +11 -3
- package/src/tracker-problems.mjs +1 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @kitkat-vn/kitcode
|
|
2
2
|
|
|
3
|
-
Local break companion for coding campaigns. This package is the **source of truth** for tracking,
|
|
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
5
|
Published package: `@kitkat-vn/kitcode`
|
|
6
6
|
|
|
@@ -189,26 +189,21 @@ The time target is fixed at `3600` active seconds: no flag or variable changes i
|
|
|
189
189
|
Each milestone needs both enough active time and enough counted `=`:
|
|
190
190
|
|
|
191
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
|
|
192
|
+
- equals target comes from the table below
|
|
193
193
|
|
|
194
|
-
###
|
|
194
|
+
### Milestones
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
| Milestone | Required counted `=` |
|
|
197
|
+
| ---: | ---: |
|
|
198
|
+
| `10%` | 3 |
|
|
199
|
+
| `20%` | 6 |
|
|
200
|
+
| `30%` | 9 |
|
|
201
|
+
| `50%` | 12 |
|
|
202
|
+
| `100%` | 15 |
|
|
197
203
|
|
|
198
|
-
|
|
199
|
-
| ---: | --- | ---: |
|
|
200
|
-
| `10%` | `if(tired){return 10;}` | 3 |
|
|
201
|
-
| `20%` | `takeBreak(20);` | 6 |
|
|
202
|
-
| `30%` | `while(working)break(30);` | 9 |
|
|
204
|
+
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.
|
|
203
205
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
Shown in summary/awards/dashboard, but not redeemable unless backed by campaign flow.
|
|
207
|
-
|
|
208
|
-
| Milestone | Code | Required counted `=` |
|
|
209
|
-
| ---: | --- | ---: |
|
|
210
|
-
| `50%` | `mediumStake.unlock(50);` | 12 |
|
|
211
|
-
| `100%` | `finalBreak.claim(100);` | 15 |
|
|
206
|
+
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.
|
|
212
207
|
|
|
213
208
|
## Codex and Claude integrations
|
|
214
209
|
|
|
@@ -267,18 +262,16 @@ GET /api/projects
|
|
|
267
262
|
GET /api/events
|
|
268
263
|
GET /api/claim/bundle
|
|
269
264
|
GET /companion
|
|
270
|
-
POST /api/reward/redeem
|
|
271
265
|
```
|
|
272
266
|
|
|
273
|
-
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`
|
|
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.
|
|
274
268
|
|
|
275
269
|
`/api/events` is SSE and emits `summary` every second.
|
|
276
270
|
|
|
277
|
-
`POST /api/reward/redeem` accepts optional `{ "tier": 10 }`. If `tier` is omitted, all ready local tiers are redeemed.
|
|
278
|
-
|
|
279
271
|
Legacy endpoints return `410 Gone`:
|
|
280
272
|
|
|
281
273
|
```txt
|
|
274
|
+
POST /api/reward/redeem
|
|
282
275
|
GET /api/projects/:id/commits
|
|
283
276
|
POST /api/projects/:id/start
|
|
284
277
|
POST /api/projects/:id/stop
|
package/bin/kitcode.mjs
CHANGED
|
@@ -10,6 +10,19 @@ import process from 'node:process';
|
|
|
10
10
|
import {fileURLToPath} from 'node:url';
|
|
11
11
|
import {createServer} from '../src/api.mjs';
|
|
12
12
|
import {isLoopbackAddress} from '../src/api-access.mjs';
|
|
13
|
+
import {
|
|
14
|
+
configProblemCode,
|
|
15
|
+
describeOutcome,
|
|
16
|
+
diagnosticsEnabled,
|
|
17
|
+
diagnosticsStatus,
|
|
18
|
+
errorCodeOf,
|
|
19
|
+
errorNameOf,
|
|
20
|
+
projectRef,
|
|
21
|
+
recordEvent,
|
|
22
|
+
sendQueued,
|
|
23
|
+
startDiagnosticsLoop,
|
|
24
|
+
sweepHookMarkers,
|
|
25
|
+
} from '../src/diagnostics.mjs';
|
|
13
26
|
import {manualHookSnippet} from '../src/hook-installers.mjs';
|
|
14
27
|
import {runPromptHook} from '../src/hook-prompt.mjs';
|
|
15
28
|
import {installIntegration, integrationStatus, uninstallIntegration} from '../src/integration-installers.mjs';
|
|
@@ -236,6 +249,8 @@ Commands:
|
|
|
236
249
|
Internal prompt hook used by Codex and Claude
|
|
237
250
|
codex on|off|status Install, remove, or inspect the Codex hook and skill
|
|
238
251
|
claude on|off|status Install, remove, or inspect the Claude hook and skill
|
|
252
|
+
diagnostics status|send
|
|
253
|
+
A tester build's diagnostics: what waits, and send it now
|
|
239
254
|
|
|
240
255
|
Options:
|
|
241
256
|
--host <host> Host to bind, default ${DEFAULT_HOST}
|
|
@@ -267,7 +282,8 @@ function formatDuration(seconds) {
|
|
|
267
282
|
}
|
|
268
283
|
|
|
269
284
|
// The numbers come from the summary: the same ones the Mini and the dashboard show
|
|
270
|
-
// (docs/reward-rules.md §10).
|
|
285
|
+
// (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).
|
|
271
287
|
function printRewardSummary() {
|
|
272
288
|
const reward = getDiskRewardSummary();
|
|
273
289
|
const next = reward.nextBreak;
|
|
@@ -280,7 +296,6 @@ function printRewardSummary() {
|
|
|
280
296
|
|
|
281
297
|
if (ready.length > 0) {
|
|
282
298
|
console.log(` ready: ${ready.map((milestone) => `${milestone.percent}%`).join(', ')}`);
|
|
283
|
-
return;
|
|
284
299
|
}
|
|
285
300
|
|
|
286
301
|
if (next) {
|
|
@@ -289,16 +304,16 @@ function printRewardSummary() {
|
|
|
289
304
|
}
|
|
290
305
|
}
|
|
291
306
|
|
|
292
|
-
//
|
|
293
|
-
//
|
|
307
|
+
// Each milestone, locked or ready. Since 2026-10-01 none has a code (the operator's decision 6): a tier whose code an
|
|
308
|
+
// earlier version recorded as shown keeps its `redeemed` status in the API, and reads as ready here, a milestone reached;
|
|
309
|
+
// until then it read "code shown", and the 10–30 % tiers "reward".
|
|
294
310
|
function printAwards() {
|
|
295
311
|
const reward = getDiskRewardSummary();
|
|
296
312
|
|
|
297
313
|
console.log('KitCode awards');
|
|
298
314
|
for (const milestone of reward.milestones) {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
console.log(` ${milestone.percent}% ${label}: ${status} (${milestoneProgressPercent(reward, milestone)}%)`);
|
|
315
|
+
const status = milestone.status === 'redeemed' ? 'ready' : milestone.status;
|
|
316
|
+
console.log(` ${milestone.percent}% milestone: ${status} (${milestoneProgressPercent(reward, milestone)}%)`);
|
|
302
317
|
}
|
|
303
318
|
}
|
|
304
319
|
|
|
@@ -443,7 +458,7 @@ function openElectronEntry(entryName, environment = {}) {
|
|
|
443
458
|
|
|
444
459
|
function printElectronSetupHelp() {
|
|
445
460
|
console.error('KitCode setup needs Electron for the desktop folder picker.');
|
|
446
|
-
console.error("Reinstall the tester build:
|
|
461
|
+
console.error("Reinstall the tester build with: npm install -g @kitkat-vn/kitcode@staging");
|
|
447
462
|
console.error('If install succeeded but Electron is still missing, fix npm cache permissions and reinstall.');
|
|
448
463
|
}
|
|
449
464
|
|
|
@@ -458,7 +473,7 @@ function printElectronLaunchFailure(result, label) {
|
|
|
458
473
|
|
|
459
474
|
if (result?.reason === 'electron-binary-missing') {
|
|
460
475
|
console.error(`Electron's binary is missing: Electron points to ${result.detail}, and nothing is there.`);
|
|
461
|
-
console.error("Reinstall the tester build:
|
|
476
|
+
console.error("Reinstall the tester build with: npm install -g @kitkat-vn/kitcode@staging");
|
|
462
477
|
return;
|
|
463
478
|
}
|
|
464
479
|
|
|
@@ -480,6 +495,8 @@ async function openOnboardingWindow(options = {}) {
|
|
|
480
495
|
const platform = normalizeSetupPlatform(options.setupPlatform);
|
|
481
496
|
if (!platform) {
|
|
482
497
|
console.error('Invalid --platform. Use macos, windows, or linux (aliases: darwin, win32).');
|
|
498
|
+
recordWelcomeFailed(options.welcomeCommand ?? 'setup', {reason: 'invalid-platform'});
|
|
499
|
+
await sendOnce();
|
|
483
500
|
return false;
|
|
484
501
|
}
|
|
485
502
|
environment.KITCODE_SETUP_PLATFORM = platform;
|
|
@@ -491,6 +508,8 @@ async function openOnboardingWindow(options = {}) {
|
|
|
491
508
|
}
|
|
492
509
|
|
|
493
510
|
printElectronLaunchFailure(result, 'KitCode Welcome');
|
|
511
|
+
recordWelcomeFailed(options.welcomeCommand ?? 'setup', result);
|
|
512
|
+
await sendOnce();
|
|
494
513
|
return false;
|
|
495
514
|
}
|
|
496
515
|
|
|
@@ -650,29 +669,44 @@ async function handleUninstall(options) {
|
|
|
650
669
|
printUninstallReport(report);
|
|
651
670
|
}
|
|
652
671
|
|
|
672
|
+
// What became of the hook in the agent's settings, for a tester build's diagnostics: by outcome, and a refusal by a fixed code,
|
|
673
|
+
// never the file's path or its words.
|
|
674
|
+
function recordAgentHook(source, action, hook) {
|
|
675
|
+
recordEvent('agent-hook', {agent: source, action, outcome: hook.action, problem: hook.action === 'refused' ? configProblemCode(hook.error) : 'none'}, {source: 'command'});
|
|
676
|
+
}
|
|
677
|
+
|
|
653
678
|
async function handleHookInstaller(source, action) {
|
|
654
679
|
if (action === 'on') {
|
|
655
680
|
const status = installIntegration(source);
|
|
656
681
|
printIntegrationStatus(source, status);
|
|
682
|
+
recordAgentHook(source, 'on', status.hook);
|
|
683
|
+
let failed = false;
|
|
657
684
|
if (status.hook.action === 'refused') {
|
|
658
685
|
printRefusedHook(source, status.hook);
|
|
659
686
|
process.exitCode = 1;
|
|
687
|
+
failed = true;
|
|
660
688
|
}
|
|
661
689
|
if (!onboardingPreferences().completed) {
|
|
662
|
-
if (!(await openOnboardingWindow({host: DEFAULT_HOST, port: DEFAULT_PORT, initialProject: process.cwd()}))) {
|
|
690
|
+
if (!(await openOnboardingWindow({host: DEFAULT_HOST, port: DEFAULT_PORT, initialProject: process.cwd(), welcomeCommand: `${source}-on`}))) {
|
|
663
691
|
console.error('KitCode Welcome could not open. Fix Electron install, then re-run `kitcode setup`.');
|
|
664
692
|
process.exitCode = 1;
|
|
693
|
+
failed = true;
|
|
665
694
|
}
|
|
666
695
|
}
|
|
696
|
+
if (failed) {
|
|
697
|
+
await sendOnce();
|
|
698
|
+
}
|
|
667
699
|
return;
|
|
668
700
|
}
|
|
669
701
|
|
|
670
702
|
if (action === 'off') {
|
|
671
703
|
const status = uninstallIntegration(source);
|
|
672
704
|
printIntegrationStatus(source, status);
|
|
705
|
+
recordAgentHook(source, 'off', status.hook);
|
|
673
706
|
if (status.hook.action === 'refused') {
|
|
674
707
|
printRefusedHook(source, status.hook);
|
|
675
708
|
process.exitCode = 1;
|
|
709
|
+
await sendOnce();
|
|
676
710
|
}
|
|
677
711
|
return;
|
|
678
712
|
}
|
|
@@ -690,6 +724,54 @@ async function isServerRunning(options) {
|
|
|
690
724
|
return Boolean(await trackerHealth(options));
|
|
691
725
|
}
|
|
692
726
|
|
|
727
|
+
// `kitcode diagnostics status` and `kitcode diagnostics send` (docs/way-of-working.md, "A full staging round"): what a tester
|
|
728
|
+
// build's diagnostics hold, and Send logs now from the terminal, for when the dashboard cannot reach the tracker. Any other
|
|
729
|
+
// build says it sends nothing, and sends nothing.
|
|
730
|
+
async function handleDiagnostics(action) {
|
|
731
|
+
if (action === 'status' || action === undefined) {
|
|
732
|
+
const status = diagnosticsStatus();
|
|
733
|
+
|
|
734
|
+
if (!status.enabled) {
|
|
735
|
+
console.log(describeOutcome({outcome: 'disabled'}));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
sweepHookMarkers({source: 'command'});
|
|
740
|
+
console.log('KitCode diagnostics: this tester build sends KitCode\'s own events and errors to its staging server, never your code, files, prompts or project names.');
|
|
741
|
+
console.log(` staging server: ${status.destination}`);
|
|
742
|
+
console.log(` install id: ${status.installId ?? 'none yet'}`);
|
|
743
|
+
console.log(` invited email: ${status.invitedEmail ?? 'none given'}`);
|
|
744
|
+
console.log(` waiting to be sent: ${status.queued} event${status.queued === 1 ? '' : 's'}`);
|
|
745
|
+
|
|
746
|
+
if (status.closedUntil) {
|
|
747
|
+
console.log(` paused: the staging server is not taking diagnostics; KitCode tries again after ${status.closedUntil}`);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
console.log(` last send: ${status.lastSend ? `${status.lastSend.at}, ${describeOutcome(status.lastSend)}` : 'none yet'}`);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (action === 'send') {
|
|
755
|
+
if (!diagnosticsEnabled()) {
|
|
756
|
+
console.log(describeOutcome({outcome: 'disabled'}));
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
sweepHookMarkers({source: 'command'});
|
|
761
|
+
const result = await sendQueued({timeoutMs: 10_000, maxBatches: 5, manual: true, source: 'command'});
|
|
762
|
+
console.log(describeOutcome(result));
|
|
763
|
+
|
|
764
|
+
if (result.outcome !== 'stored' && result.outcome !== 'nothing') {
|
|
765
|
+
process.exitCode = 1;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
printHelp();
|
|
772
|
+
process.exit(1);
|
|
773
|
+
}
|
|
774
|
+
|
|
693
775
|
// The local API has no login. Bound to anything but loopback, it is on every network that can reach
|
|
694
776
|
// that address, so that takes --allow-non-loopback, and a warning.
|
|
695
777
|
function bindRefusal(options) {
|
|
@@ -701,7 +783,7 @@ function bindRefusal(options) {
|
|
|
701
783
|
}
|
|
702
784
|
|
|
703
785
|
function nonLoopbackWarning(options) {
|
|
704
|
-
return `Warning: KitCode's local API will listen on ${options.host}:${options.port}, where other machines may reach it. It has no authentication. It answers only requests addressed to 127.0.0.1 or localhost: that stops web pages, not other programs, and any program that can connect to it can read your progress
|
|
786
|
+
return `Warning: KitCode's local API will listen on ${options.host}:${options.port}, where other machines may reach it. It has no authentication. It answers only requests addressed to 127.0.0.1 or localhost: that stops web pages, not other programs, and any program that can connect to it can read your progress.`;
|
|
705
787
|
}
|
|
706
788
|
|
|
707
789
|
function sleep(ms) {
|
|
@@ -712,6 +794,49 @@ function printTrackerNotRunning() {
|
|
|
712
794
|
console.error('KitCode tracker is not running. Run `kitcode track` first.');
|
|
713
795
|
}
|
|
714
796
|
|
|
797
|
+
// A tester build's diagnostics (src/diagnostics.mjs): a command that saw something fail records it and sends what waits,
|
|
798
|
+
// once, with a short timeout, and quietly, since no tracker may be running to send it later. In any other build each
|
|
799
|
+
// returns at once, and nothing is written or sent.
|
|
800
|
+
const COMMAND_NAMES = new Set(['add', 'remove', 'track', 'untrack', 'list', 'status', 'summary', 'awards', 'dashboard', 'setup', 'uninstall', 'claude', 'codex', 'diagnostics']);
|
|
801
|
+
|
|
802
|
+
function commandName(command) {
|
|
803
|
+
const name = command === 'award' || command === 'rewards' ? 'awards' : command;
|
|
804
|
+
return COMMAND_NAMES.has(name) ? name : 'other';
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Once a process: the first failure sends what waits, then, and what a command records after it waits for the next send.
|
|
808
|
+
let sentOnce = false;
|
|
809
|
+
|
|
810
|
+
async function sendOnce() {
|
|
811
|
+
if (!diagnosticsEnabled() || sentOnce) {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
sentOnce = true;
|
|
816
|
+
|
|
817
|
+
try {
|
|
818
|
+
sweepHookMarkers({source: 'command'});
|
|
819
|
+
await sendQueued({timeoutMs: 3000, maxBatches: 1, source: 'command'});
|
|
820
|
+
} catch {
|
|
821
|
+
// Quietly: whatever sends next sends it.
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function recordCommandFailed(command, error) {
|
|
826
|
+
recordEvent('command-failed', {command: commandName(command), errorCode: errorCodeOf(error), errorName: errorNameOf(error)}, {source: 'command'});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function recordTrackerNotStarted(source, reason, error = null) {
|
|
830
|
+
recordEvent('tracker-not-started', {reason, errorCode: errorCodeOf(error), errorName: errorNameOf(error)}, {source});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const WELCOME_REASONS = new Set(['missing-electron', 'electron-download-failed', 'electron-binary-missing', 'missing-entry', 'spawn-error', 'exited', 'invalid-platform']);
|
|
834
|
+
|
|
835
|
+
function recordWelcomeFailed(command, result) {
|
|
836
|
+
const exitCode = Number.isInteger(result?.code) ? result.code : null;
|
|
837
|
+
recordEvent('welcome-failed', {command, reason: WELCOME_REASONS.has(result?.reason) ? result.reason : 'other', exitCode}, {source: 'command'});
|
|
838
|
+
}
|
|
839
|
+
|
|
715
840
|
async function runDaemon(options, status = createStatusReporter()) {
|
|
716
841
|
status.set('Starting KitCode tracker...');
|
|
717
842
|
const log = createLog(TRACKER_LOG_PATH);
|
|
@@ -729,6 +854,7 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
729
854
|
// keeps the last periodic save.
|
|
730
855
|
const fatal = (kind) => (error) => {
|
|
731
856
|
log.error(`${kind}: ${error?.stack ?? error}`);
|
|
857
|
+
recordEvent('tracker-crashed', {kind: kind === 'uncaughtException' ? 'uncaught-exception' : 'unhandled-rejection', errorCode: errorCodeOf(error), errorName: errorNameOf(error)}, {source: 'tracker'});
|
|
732
858
|
releaseTracker(process.pid);
|
|
733
859
|
process.exit(1);
|
|
734
860
|
};
|
|
@@ -741,6 +867,7 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
741
867
|
if (refusal) {
|
|
742
868
|
status.stop();
|
|
743
869
|
report('error', refusal);
|
|
870
|
+
recordTrackerNotStarted('tracker', 'bind-refused');
|
|
744
871
|
process.exit(1);
|
|
745
872
|
}
|
|
746
873
|
|
|
@@ -758,12 +885,14 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
758
885
|
if (existing && existing.state !== 'unresponsive') {
|
|
759
886
|
status.stop();
|
|
760
887
|
report('warn', `Not started: another KitCode tracker (process ${existing.record.pid}) is ${existing.state} at http://${existing.record.host}:${existing.record.port}.`);
|
|
888
|
+
recordTrackerNotStarted('tracker', 'already-running');
|
|
761
889
|
process.exit(1);
|
|
762
890
|
}
|
|
763
891
|
|
|
764
892
|
if (!claimTracker(record, existing?.record ?? null).claimed) {
|
|
765
893
|
status.stop();
|
|
766
894
|
report('warn', 'Not started: another KitCode tracker started at the same moment.');
|
|
895
|
+
recordTrackerNotStarted('tracker', 'claimed-elsewhere');
|
|
767
896
|
process.exit(1);
|
|
768
897
|
}
|
|
769
898
|
|
|
@@ -774,6 +903,7 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
774
903
|
} catch (error) {
|
|
775
904
|
releaseTracker(process.pid);
|
|
776
905
|
report('error', `Not started: ${error.message}`);
|
|
906
|
+
recordTrackerNotStarted('tracker', error instanceof StateFileError || error instanceof StateLockError ? 'state-unreadable' : 'start-failed', error);
|
|
777
907
|
throw error;
|
|
778
908
|
}
|
|
779
909
|
|
|
@@ -786,7 +916,17 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
786
916
|
report('warn', `KitCode could not make its install id (${error.message}); the claim bundle is not given out until it can.`);
|
|
787
917
|
}
|
|
788
918
|
|
|
789
|
-
|
|
919
|
+
// A tester build's diagnostics while the tracker runs (src/diagnostics.mjs): its events sent every minute, a heartbeat every
|
|
920
|
+
// hour, and Send logs now through the local API. In any other build it does nothing.
|
|
921
|
+
const startedMs = Date.now();
|
|
922
|
+
const uptimeSeconds = () => Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
|
|
923
|
+
const diagnostics = startDiagnosticsLoop({
|
|
924
|
+
snapshot: () => {
|
|
925
|
+
const current = runtime.status?.() ?? {tracking: 'ok', problems: []};
|
|
926
|
+
return {projects: Object.values(runtime.state.projects).filter((project) => project.active).length, tracking: current.tracking, problems: current.problems.map((problem) => problem.code)};
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
const app = createServer(runtime, VERSION, {diagnostics});
|
|
790
930
|
let cleanup = async () => {};
|
|
791
931
|
let server = null;
|
|
792
932
|
|
|
@@ -802,6 +942,8 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
802
942
|
server.close(() => process.exit(code));
|
|
803
943
|
};
|
|
804
944
|
|
|
945
|
+
const PROBLEMS = new Set(['watch-failed', 'project-missing', 'scan-failed', 'git-locked', 'git-unfinished']);
|
|
946
|
+
|
|
805
947
|
cleanup = startWatchers(runtime, {
|
|
806
948
|
log,
|
|
807
949
|
onProgress(message) {
|
|
@@ -811,12 +953,19 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
811
953
|
onStatus(current) {
|
|
812
954
|
updateTrackerRecord(process.pid, {...current, heartbeatAt: new Date().toISOString()});
|
|
813
955
|
},
|
|
956
|
+
// Each project's problem, raised or cleared, by its fixed code and an opaque reference to the project.
|
|
957
|
+
onProblem({code, projectId, state, error}) {
|
|
958
|
+
if (PROBLEMS.has(code)) {
|
|
959
|
+
recordEvent('tracker-problem', {problem: code, state, project: projectRef(projectId), errorCode: errorCodeOf(error)}, {source: 'tracker'});
|
|
960
|
+
}
|
|
961
|
+
},
|
|
814
962
|
// state.json can no longer be read. Stop without writing it: it holds the user's progress.
|
|
815
963
|
onStateError(error) {
|
|
816
964
|
status.stop();
|
|
817
965
|
report('error', `Stopped: ${error.message}`);
|
|
818
966
|
cleanup({persist: false});
|
|
819
|
-
|
|
967
|
+
recordEvent('tracker-stopped', {reason: 'state-unreadable', uptimeSeconds: uptimeSeconds()}, {source: 'tracker'});
|
|
968
|
+
diagnostics.stop().finally(() => exit(1));
|
|
820
969
|
},
|
|
821
970
|
});
|
|
822
971
|
|
|
@@ -832,18 +981,23 @@ async function runDaemon(options, status = createStatusReporter()) {
|
|
|
832
981
|
}
|
|
833
982
|
|
|
834
983
|
log.info(`stopped (${signal})`);
|
|
984
|
+
recordEvent('tracker-stopped', {reason: signal === 'stop request' ? 'stop-request' : 'signal', uptimeSeconds: uptimeSeconds()}, {source: 'tracker'});
|
|
985
|
+
await diagnostics.stop();
|
|
835
986
|
exit(0);
|
|
836
987
|
};
|
|
837
988
|
|
|
838
989
|
server = app.listen(options.port, options.host, () => {
|
|
839
990
|
status.stop();
|
|
840
991
|
log.info(`started: KitCode ${VERSION}, process ${process.pid}, http://${options.host}:${options.port}, node ${process.version}`);
|
|
992
|
+
recordEvent('tracker-started', {projects: Object.values(runtime.state.projects).filter((project) => project.active).length, port: options.port}, {source: 'tracker'});
|
|
841
993
|
});
|
|
842
994
|
|
|
843
995
|
server.on('error', async (error) => {
|
|
844
996
|
status.stop();
|
|
845
997
|
await cleanup().catch(() => {});
|
|
846
998
|
releaseTracker(process.pid);
|
|
999
|
+
recordTrackerNotStarted('tracker', error.code === 'EADDRINUSE' ? 'port-in-use' : 'start-failed', error);
|
|
1000
|
+
await diagnostics.stop({finalSend: false});
|
|
847
1001
|
|
|
848
1002
|
if (error.code === 'EADDRINUSE') {
|
|
849
1003
|
report('error', `Not started: port ${options.port} is already in use. Stop the other server or pass --port <port>.`);
|
|
@@ -874,6 +1028,8 @@ async function startTracker(options) {
|
|
|
874
1028
|
|
|
875
1029
|
if (refusal) {
|
|
876
1030
|
console.error(refusal);
|
|
1031
|
+
recordTrackerNotStarted('command', 'bind-refused');
|
|
1032
|
+
await sendOnce();
|
|
877
1033
|
process.exit(1);
|
|
878
1034
|
}
|
|
879
1035
|
|
|
@@ -952,6 +1108,8 @@ async function startTracker(options) {
|
|
|
952
1108
|
}
|
|
953
1109
|
|
|
954
1110
|
console.error('KitCode tracker did not start. Try running `kitcode track` again.');
|
|
1111
|
+
recordTrackerNotStarted('command', 'did-not-answer');
|
|
1112
|
+
await sendOnce();
|
|
955
1113
|
process.exit(1);
|
|
956
1114
|
}
|
|
957
1115
|
|
|
@@ -1031,6 +1189,8 @@ try {
|
|
|
1031
1189
|
|
|
1032
1190
|
// The folder could not be listed: it is gone, not a folder, or git could not list it.
|
|
1033
1191
|
console.error(`KitCode could not add ${path.resolve(targetPath)}: ${error.message}`);
|
|
1192
|
+
recordCommandFailed('add', error);
|
|
1193
|
+
await sendOnce();
|
|
1034
1194
|
process.exit(1);
|
|
1035
1195
|
}
|
|
1036
1196
|
|
|
@@ -1080,6 +1240,8 @@ try {
|
|
|
1080
1240
|
await handleHookInstaller('codex', options.subcommand);
|
|
1081
1241
|
} else if (options.command === 'claude') {
|
|
1082
1242
|
await handleHookInstaller('claude', options.subcommand);
|
|
1243
|
+
} else if (options.command === 'diagnostics') {
|
|
1244
|
+
await handleDiagnostics(options.subcommand);
|
|
1083
1245
|
} else if (options.command === 'version') {
|
|
1084
1246
|
console.log(VERSION);
|
|
1085
1247
|
} else {
|
|
@@ -1087,6 +1249,13 @@ try {
|
|
|
1087
1249
|
process.exit(options.command === 'help' || !options.command ? 0 : 1);
|
|
1088
1250
|
}
|
|
1089
1251
|
} catch (error) {
|
|
1252
|
+
// A tester build records the failure, and sends it, once: but never from the prompt hook, which touches no network, nor
|
|
1253
|
+
// from the tracker, which recorded its own.
|
|
1254
|
+
if (options.command !== 'hook' && !(options.command === 'track' && process.env.KITCODE_DAEMON === '1')) {
|
|
1255
|
+
recordCommandFailed(options.command, error);
|
|
1256
|
+
await sendOnce();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1090
1259
|
// A state file KitCode cannot read or lock stops the command with the reason. It is never replaced.
|
|
1091
1260
|
if (error instanceof StateFileError || error instanceof StateLockError) {
|
|
1092
1261
|
console.error(error.message);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitkat-vn/kitcode",
|
|
3
|
-
"version": "0.2.0-staging.
|
|
3
|
+
"version": "0.2.0-staging.5",
|
|
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": "507aaa120cd48a65b06380f3eee194d799428db1"
|
|
41
41
|
}
|
package/src/api.mjs
CHANGED
|
@@ -3,18 +3,26 @@ 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 {describeOutcome, diagnosticsStatus, sendQueued} from './diagnostics.mjs';
|
|
6
7
|
import {INSTALL_ID_DISPLAY_PATH, InstallIdError, readInstallId} from './install-id.mjs';
|
|
7
8
|
import {STATE_DISPLAY_PATH} from './integration-spec.mjs';
|
|
8
9
|
import {MilestoneRecordError} from './milestones-reached.mjs';
|
|
9
|
-
import {buildSummary
|
|
10
|
-
import {normalizeTierPercent, redeemReadyTiers} from './reward.mjs';
|
|
10
|
+
import {buildSummary} from './runtime.mjs';
|
|
11
11
|
import {loadState, StateFileError} from './store.mjs';
|
|
12
12
|
|
|
13
13
|
const projectLevelGone = (_req, res) => {
|
|
14
14
|
res.status(410).json({error: 'Project-level metadata is no longer exposed'});
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// The route that recorded a 10–30 % break code as shown, in state.json, gone with the codes on 2026-10-01 (the operator's
|
|
18
|
+
// decision 6; docs/reward-rules.md §7): each milestone reached shows its message on the dashboard instead. It records
|
|
19
|
+
// nothing for anyone; a dashboard from before, which still sends it, reads why in fixed words.
|
|
20
|
+
const redeemGone = (_req, res) => {
|
|
21
|
+
res.status(410).json({error: 'Break codes are no longer shown or recorded: each milestone reached shows its message on the dashboard.'});
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// `diagnostics` is the tracker's send loop (src/diagnostics.mjs, startDiagnosticsLoop), for Send logs now.
|
|
25
|
+
export function createServer(runtime, version, {diagnostics = null} = {}) {
|
|
18
26
|
const app = express();
|
|
19
27
|
let openEventStreams = 0;
|
|
20
28
|
|
|
@@ -54,24 +62,7 @@ export function createServer(runtime, version) {
|
|
|
54
62
|
res.json(buildSummary(runtime).global);
|
|
55
63
|
});
|
|
56
64
|
|
|
57
|
-
app.post('/api/reward/redeem',
|
|
58
|
-
const requestedTier = req.body?.tier;
|
|
59
|
-
|
|
60
|
-
if (requestedTier !== undefined && !normalizeTierPercent(requestedTier)) {
|
|
61
|
-
res.status(400).json({error: 'Invalid reward tier'});
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Readiness is judged from the file, so the tracker's latest time goes there first.
|
|
66
|
-
persistRuntimeState(runtime);
|
|
67
|
-
const result = redeemReadyTiers(requestedTier === undefined ? null : requestedTier);
|
|
68
|
-
refreshRuntimeLedger(runtime);
|
|
69
|
-
|
|
70
|
-
res.json({
|
|
71
|
-
...result,
|
|
72
|
-
summary: buildSummary(runtime),
|
|
73
|
-
});
|
|
74
|
-
});
|
|
65
|
+
app.post('/api/reward/redeem', redeemGone);
|
|
75
66
|
|
|
76
67
|
// The claim bundle (docs/backend.md, "The contract"): totals only, from state.json as it is on disk,
|
|
77
68
|
// which this reads and never writes. Only the dashboard's allow-listed origins get it, where
|
|
@@ -116,6 +107,25 @@ export function createServer(runtime, version) {
|
|
|
116
107
|
}
|
|
117
108
|
});
|
|
118
109
|
|
|
110
|
+
// A tester build's diagnostics, for the staging dashboard (docs/security.md, "Tester diagnostics"): whether this tracker
|
|
111
|
+
// sends them, its install id, which the dashboard links its own events by, what waits, and the last send. Only to an
|
|
112
|
+
// allow-listed Origin, as the claim bundle, which holds the same install id; never the events, or the invited email.
|
|
113
|
+
app.get('/api/diagnostics', requireDashboardOrigin, (_req, res) => {
|
|
114
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
115
|
+
const {invitedEmail, ...status} = diagnosticsStatus();
|
|
116
|
+
res.json(invitedEmail === undefined ? status : {...status, emailGiven: invitedEmail !== null});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Send logs now: the dashboard's button. A write, so it passes every write's guards first: a loopback Host, an allow-listed
|
|
120
|
+
// Origin and a JSON body (api-access.mjs). It sends what waits, past any backoff, and answers with exactly what the
|
|
121
|
+
// server confirmed, or why nothing was sent, in plain words; in a build that sends nothing, that it sends nothing.
|
|
122
|
+
app.post('/api/diagnostics/send', (_req, res, next) => {
|
|
123
|
+
(diagnostics ? diagnostics.sendNow() : sendQueued({timeoutMs: 10_000, maxBatches: 5, manual: true, source: 'tracker'})).then((result) => {
|
|
124
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
125
|
+
res.json({...result, words: describeOutcome(result)});
|
|
126
|
+
}, next);
|
|
127
|
+
});
|
|
128
|
+
|
|
119
129
|
app.get('/api/projects/:id/commits', projectLevelGone);
|
|
120
130
|
app.post('/api/projects/:id/start', projectLevelGone);
|
|
121
131
|
app.post('/api/projects/:id/stop', projectLevelGone);
|
package/src/companion-window.mjs
CHANGED
|
@@ -26,7 +26,6 @@ button{cursor:pointer}
|
|
|
26
26
|
.metric-value{color:var(--white-text);font-family:var(--font-pixel),var(--font-mono);font-size:24px;line-height:1;margin-bottom:2px;letter-spacing:.5px;white-space:nowrap}
|
|
27
27
|
.metric-label{color:var(--red-color);font-family:var(--font-mono);font-size:7px;font-weight:500;letter-spacing:.3px;text-transform:uppercase;white-space:nowrap}
|
|
28
28
|
.claim-btn{-webkit-app-region:no-drag;background:transparent;color:var(--white-text);font-family:var(--font-mono);font-size:10px;font-weight:500;letter-spacing:1px;padding:3px 12px;cursor:pointer;text-transform:uppercase;border-top:2px solid var(--red-color);border-bottom:2px solid var(--red-color);border-left:1px dashed var(--red-color);border-right:1px dashed var(--red-color);white-space:normal;width:min-content;line-height:1.15;transition:background-color .2s ease}
|
|
29
|
-
.claim-btn[data-ready="true"]{background:rgba(240,48,48,.18)}
|
|
30
29
|
.claim-btn:hover,.claim-btn:focus-visible{background:rgba(240,48,48,.15);outline:none}
|
|
31
30
|
.offline .percentage,.offline .metric-value{color:#6f6f6f}
|
|
32
31
|
.offline .progress-row{color:#6f6f6f}
|
|
@@ -36,8 +35,8 @@ ${COMPANION_HIDE_CSS}
|
|
|
36
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');
|
|
37
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'}
|
|
38
37
|
function updateProgress(value){progressFill.style.width=Math.max(0,Math.min(100,Number(value)||0))+'%'}
|
|
39
|
-
function render(summary){const reward=summary?.reward||{},global=summary?.global||{},
|
|
40
|
-
function offline(){percent.textContent='--';countValue.textContent='--';workedValue.textContent='--';idleValue.textContent='--';updateProgress(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')}
|
|
39
|
+
function offline(){percent.textContent='--';countValue.textContent='--';workedValue.textContent='--';idleValue.textContent='--';updateProgress(0);main.classList.add('offline')}
|
|
41
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()}}
|
|
42
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()}}
|
|
43
42
|
hideButton.addEventListener('pointerdown',(event)=>event.stopPropagation());hideButton.addEventListener('click',(event)=>{event.stopPropagation();hideCompanion()});
|
|
@@ -46,12 +45,15 @@ dashboardButton.addEventListener('click',()=>{if(window.kitcodeCompanion?.openDa
|
|
|
46
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;
|
|
47
46
|
`;
|
|
48
47
|
|
|
49
|
-
// The button opens the dashboard, where
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
48
|
+
// The button opens the dashboard, where each milestone reached shows its message: "See more". Until 2026-10-01 it was
|
|
49
|
+
// "Show code", and lit while a 10–30 % code was ready to show there; the break codes are gone (the operator's decision
|
|
50
|
+
// 6), and a milestone reached stays reached, so nothing lights it now. The Mini cannot show a message itself: the
|
|
51
|
+
// messages are the claim backend's, and the tracker, which serves this page, never talks to it. Two short words, which
|
|
52
|
+
// wrap onto two lines, as KITCODE COUNTER does on the left: "Dashboard" or "Messages" took 28 to 35 px more, and the
|
|
53
|
+
// counters then ran into each other at long-campaign values (measured in the 560 x 76 window with 2500 = and
|
|
54
|
+
// 11h 48m, 2026-09-24 and 2026-10-01).
|
|
53
55
|
return `<!doctype html>
|
|
54
56
|
<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">
|
|
55
57
|
<title>KitCode Counter</title><style>${style}</style></head>
|
|
56
|
-
<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"
|
|
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>`;
|
|
57
59
|
}
|