@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 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, reward eligibility, redeem state, hooks, and the local Mini companion.
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 tier table below
192
+ - equals target comes from the table below
193
193
 
194
- ### Local reward tiers
194
+ ### Milestones
195
195
 
196
- Their break codes are shown in the dashboard, which records a code as shown through `POST /api/reward/redeem`, on this machine only.
196
+ | Milestone | Required counted `=` |
197
+ | ---: | ---: |
198
+ | `10%` | 3 |
199
+ | `20%` | 6 |
200
+ | `30%` | 9 |
201
+ | `50%` | 12 |
202
+ | `100%` | 15 |
197
203
 
198
- | Tier | Code | Required counted `=` |
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
- ### Display milestones
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` and `POST /api/reward/redeem` need an allowed origin, and the POST a JSON body.
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
- // A redeemed tier's code was shown, on this machine only: nothing is claimed with the campaign
293
- // (operator decision, 2026-09-24). The API keeps its `redeemed` status, which the deployed dashboard reads.
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 label = milestone.rewardBacked ? 'reward' : 'milestone';
300
- const status = milestone.status === 'redeemed' ? 'code shown' : milestone.status;
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: download kitcode-0.2.0-staging.3.tgz again from the staging site, https://staging.kitcode.vn/, then run npm install -g ./kitcode-0.2.0-staging.3.tgz in the folder you saved it to.");
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: download kitcode-0.2.0-staging.3.tgz again from the staging site, https://staging.kitcode.vn/, then run npm install -g ./kitcode-0.2.0-staging.3.tgz in the folder you saved it to.");
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 and mark your break codes as shown.`;
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
- const app = createServer(runtime, VERSION);
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
- exit(1);
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",
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": "ac0e5e53e90fc09132dabc63474e01e962f50a6d"
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, persistRuntimeState, refreshRuntimeLedger} from './runtime.mjs';
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
- export function createServer(runtime, version) {
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', (req, res) => {
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);
@@ -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||{},tiers=reward.tiers||[],ready=tiers.some((tier)=>tier.status==='ready'),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);dashboardButton.dataset.ready=ready?'true':'false';main.classList.remove('offline')}
40
- function offline(){percent.textContent='--';countValue.textContent='--';workedValue.textContent='--';idleValue.textContent='--';updateProgress(0);dashboardButton.dataset.ready='false';main.classList.add('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)));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 a ready code is shown: "Show code", not a claim (operator
50
- // decision, 2026-09-24). It wraps onto two lines, as KITCODE COUNTER does on the left: on one line
51
- // it took 28 px from the counters, which then ran into each other at long-campaign values
52
- // (measured in the 560 x 76 window with 2500 = and 11h 48m).
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" data-ready="false">Show code</button></div></main><script>${script}</script></body></html>`;
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
  }