@kitkat-vn/kitcode 0.2.0-staging.4 → 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.
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import {spawn} from 'node:child_process';
3
+ import {hookFinished, hookStarted} from './diagnostics.mjs';
3
4
  import {appendLogLine} from './log-file.mjs';
4
5
  import {formatBreakDuration, nextBreakReminder} from './next-break-reminder.mjs';
5
6
  import {getDiskRewardSummary, getReadyUnannouncedTiers, markTiersAnnounced} from './reward.mjs';
@@ -175,11 +176,20 @@ function collectPromptProgress(source) {
175
176
  }
176
177
  }
177
178
 
179
+ // In a tester build alone (src/diagnostics.mjs), each run also leaves a start marker, which it removes as it ends, so that a
180
+ // run its agent stopped at the timeout is found later by whatever sends, since it cannot record its own end; and it records
181
+ // a failure, by the error's code and name, or else a run past 2 s: one event at most, one small file. The hook never touches
182
+ // the network: a tester build's tracker, or a command, sends what it recorded. Neither call ever throws; in any other build
183
+ // each returns at once.
178
184
  export async function runPromptHook({source}) {
179
185
  if (isDisabled()) {
180
186
  return;
181
187
  }
182
188
 
189
+ const started = Date.now();
190
+ const marker = hookStarted(source, started);
191
+ let failure = null;
192
+
183
193
  try {
184
194
  const input = await readStdin();
185
195
  const payload = JSON.parse(input || '{}');
@@ -199,6 +209,9 @@ export async function runPromptHook({source}) {
199
209
  notify('KitCode milestone reached', `Milestone ${label} unlocked. Open kitcode dashboard.`);
200
210
  process.stdout.write(`${JSON.stringify(hookOutputFor({tiers: announced, reward}))}\n`);
201
211
  } catch (error) {
212
+ failure = error;
202
213
  logHookError(error);
214
+ } finally {
215
+ hookFinished(marker, source, Date.now() - started, failure);
203
216
  }
204
217
  }
@@ -1,10 +1,15 @@
1
1
  export const CLI_PACKAGE_NAME = '@kitkat-vn/kitcode';
2
2
  export const CLI_PACKAGE_COMMAND = `npx ${CLI_PACKAGE_NAME}`;
3
3
  export const CLI_GLOBAL_INSTALL_COMMAND = `npm install -g ${CLI_PACKAGE_NAME}`;
4
- // A staging round's tester build, once one is published under npm's `staging` tag (stagingSetupPromptFor, below).
4
+ // A staging round's tester build, published under npm's `staging` tag: since 2026-10-02 the only way a tester installs it
5
+ // (stagingSetupPromptFor, below).
5
6
  export const CLI_STAGING_INSTALL_COMMAND = `${CLI_GLOBAL_INSTALL_COMMAND}@staging`;
6
7
  export const CLI_GLOBAL_COMMAND = 'kitcode';
7
8
  export const DASHBOARD_URL = "https://staging.kitcode.vn/";
9
+ // Where a tester build sends KitCode's own diagnostics (src/diagnostics.mjs; docs/backend.md, "Tester diagnostics"): the
10
+ // staging dashboard's /v1/diagnostics, which scripts/tester-build.mjs alone writes here, as it writes DASHBOARD_URL. null in
11
+ // the repository, and so in every release of it: such a CLI collects, writes and sends no diagnostics at all.
12
+ export const DIAGNOSTICS_URL = "https://staging.kitcode.vn/v1/diagnostics";
8
13
  export const LOCAL_SERVER_URL = 'http://127.0.0.1:4747';
9
14
  export const STATE_DISPLAY_PATH = '~/.kitcode/state.json';
10
15
  export const RUNNER_PATH_PARTS = ['.kitcode', 'bin', 'kitcode'];
@@ -258,16 +263,28 @@ export function assistantSetupPromptFor(source) {
258
263
  ].join('\n');
259
264
  }
260
265
 
266
+ // What the staging setup prompt says of the tester build's diagnostics (src/diagnostics.mjs; the operator's decision 2 of
267
+ // the voucher scheme's phase 4, 2026-10-01): that it sends them, where to, and what they never hold. In the tester's own
268
+ // voice, as the prompt is, and with no URL in it, as the prompt has none.
269
+ export const STAGING_DIAGNOSTICS_NOTICE = 'The tester build sends diagnostic logs to KitCode\'s staging server: KitCode\'s own events and errors, such as the tracker starting or a hook failing, and never my code, my files, my prompts or my project names.';
270
+
261
271
  // The setup prompt a staging dashboard copies for its invited testers (apps/web src/lib/staging-prompts.ts): the one
262
- // above, line for line, with three changes and no other. Step 2 installs the round's tester build, from the file the
263
- // tester downloaded from the staging site, which the dashboard's steps link (install 'file'), or, once a staging
264
- // version is published, from npm's `staging` tag (install 'npm'), and checks its version. Then, before step 3 turns
265
- // KitCode on, it stops a tracker an earlier build left running. And it holds no URL: the public docs the prompt above
266
- // links are not public yet (docs/lessons-learned.md F5). It is made from the prompt above as it stands, so that the two
267
- // cannot drift: a line it changes that is not there once throws, and check-integration-spec.mjs runs both.
268
- export function stagingSetupPromptFor(source, {testerVersion, fileName, install}) {
269
- if (install !== 'file' && install !== 'npm') {
270
- throw new Error('stagingSetupPromptFor installs from a file or from npm: install must be \'file\' or \'npm\'.');
272
+ // above, line for line, with four changes and no other. Step 2 installs the round's tester build from npm's `staging` tag,
273
+ // and checks its version. Then, before step 3 turns KitCode on, it stops a tracker an earlier build left running. It holds
274
+ // no URL: the public docs the prompt above links are not public yet (docs/lessons-learned.md F5). And, since the voucher
275
+ // scheme's phase 4, it says, under the round's line, that the tester build sends diagnostic logs to KitCode's staging
276
+ // server (STAGING_DIAGNOSTICS_NOTICE). It is made from the prompt above as it stands, so that the two cannot drift: a line
277
+ // it changes that is not there once throws, and check-integration-spec.mjs runs both.
278
+ //
279
+ // Until 2026-10-02 it could install a file the tester downloaded from the staging site instead (install 'file', with its
280
+ // fileName). The operator's rule of that day is npm alone, "never let user download file even as tester, must be via npm
281
+ // package only": it takes the tester build's version and nothing else, and refuses an option it does not know, so that a
282
+ // caller still asking for the file is told so rather than given npm in silence.
283
+ export function stagingSetupPromptFor(source, options) {
284
+ const {testerVersion, ...others} = options;
285
+
286
+ if (Object.keys(others).length > 0) {
287
+ throw new Error(`stagingSetupPromptFor takes the tester build's version alone, and installs it from npm's staging tag: it does not know ${Object.keys(others).join(', ')}.`);
271
288
  }
272
289
 
273
290
  const lines = assistantSetupPromptFor(source).split('\n');
@@ -289,15 +306,10 @@ export function stagingSetupPromptFor(source, {testerVersion, fileName, install}
289
306
  throw new Error('stagingSetupPromptFor found no blank line after the public docs: assistantSetupPromptFor changed, and stagingSetupPromptFor must follow it.');
290
307
  }
291
308
 
292
- lines.splice(docs, docsEnd - docs, `KitCode is a local-first project. I am an invited tester of its staging round, which needs KitCode's tester build, ${testerVersion}: step 2 installs it.`);
309
+ lines.splice(docs, docsEnd - docs, `KitCode is a local-first project. I am an invited tester of its staging round, which needs KitCode's tester build, ${testerVersion}: step 2 installs it.`, STAGING_DIAGNOSTICS_NOTICE);
293
310
 
294
311
  const installLine = onlyLine((line) => line.endsWith(`install KitCode globally with: ${CLI_GLOBAL_INSTALL_COMMAND}`), 'the install');
295
- lines[installLine] = lines[installLine].replace(
296
- `with: ${CLI_GLOBAL_INSTALL_COMMAND}`,
297
- install === 'npm'
298
- ? `with: ${CLI_STAGING_INSTALL_COMMAND}`
299
- : `from the tester build I downloaded from KitCode's staging site, ${fileName}. Look for it in my Downloads folder, and if it is not there, ask me where it is. In the folder that holds it, run: npm install -g ./${fileName}`,
300
- );
312
+ lines[installLine] = lines[installLine].replace(`with: ${CLI_GLOBAL_INSTALL_COMMAND}`, `with: ${CLI_STAGING_INSTALL_COMMAND}`);
301
313
 
302
314
  const globalCommand = onlyLine((line) => line === `- After install, use the global ${CLI_GLOBAL_COMMAND} command for all following steps (not npx).`, 'the global command');
303
315
  lines.splice(
@@ -4,6 +4,7 @@ import {app, BrowserWindow, dialog, ipcMain, nativeTheme} from 'electron';
4
4
  import path from 'node:path';
5
5
  import {fileURLToPath} from 'node:url';
6
6
  import {describeProjects, listProjectRecords, registerNewProjects, removeProject, resolveInitialProjectSuggestion} from './runtime.mjs';
7
+ import {diagnosticsEnabled, readInvitedEmail, setInvitedEmail} from './diagnostics.mjs';
7
8
  import {DASHBOARD_URL} from './integration-spec.mjs';
8
9
  import {onboardingPreferences, saveOnboardingPreferences, STORE_DIR} from './store.mjs';
9
10
  import {resolveSetupPlatform} from './onboarding-platform.mjs';
@@ -154,6 +155,8 @@ app.whenReady().then(() => {
154
155
  ...onboardingPreferences(),
155
156
  projects: listProjectRecords(),
156
157
  suggestedProjects: initialSuggestedProjects(),
158
+ // A tester build's: the email the tester gave before, if any (src/diagnostics.mjs).
159
+ ...(diagnosticsEnabled() ? {invitedEmail: readInvitedEmail()} : {}),
157
160
  };
158
161
  });
159
162
  ipcMain.handle('kitcode:onboarding-select-folders', async (event) => {
@@ -164,6 +167,11 @@ app.whenReady().then(() => {
164
167
  });
165
168
  ipcMain.handle('kitcode:onboarding-submit', async (event, input) => {
166
169
  if (event.sender !== window.webContents) return {ok: false, error: 'Invalid setup request.'};
170
+ // A tester build's invited email, kept outside state.json, before anything else is saved; one that is not an address is
171
+ // asked for again, and nothing is saved.
172
+ if (diagnosticsEnabled() && typeof input?.invitedEmail === 'string' && !setInvitedEmail(input.invitedEmail).ok) {
173
+ return {ok: false, error: 'That email address does not look right: check it, or leave it empty.'};
174
+ }
167
175
  const folders = validFolders(input?.folders);
168
176
  if (!folders.length && !listProjectRecords().length) return {ok: false, error: 'Add at least one readable project folder.'};
169
177
  try { registerNewProjects(folders); } catch { return {ok: false, error: 'KitCode could not register one of those folders.'}; }
@@ -1,3 +1,4 @@
1
+ import {diagnosticsEnabled} from './diagnostics.mjs';
1
2
  import {
2
3
  renderPlatformChrome,
3
4
  resolveSetupPlatform,
@@ -5,7 +6,11 @@ import {
5
6
  } from './onboarding-platform.mjs';
6
7
  import {contentSecurityPolicy, fontFaces} from './window-page.mjs';
7
8
 
8
- export function renderOnboardingWindow(platform = resolveSetupPlatform()) {
9
+ // `diagnostics`: whether this is a tester build (src/diagnostics.mjs), unless a test says. Then, and only then, Welcome says
10
+ // that the build sends diagnostic logs to KitCode's staging server, and what they never hold, and asks, optionally, for the
11
+ // email the tester was invited with, so that staff can find their logs by it. Without it the page is as it was, byte for
12
+ // byte.
13
+ export function renderOnboardingWindow(platform = resolveSetupPlatform(), {diagnostics = diagnosticsEnabled()} = {}) {
9
14
  const theme = setupPlatformTheme(platform);
10
15
  // The page's one stylesheet and one script, kept apart so that its policy allows exactly them.
11
16
  const style = `
@@ -460,7 +465,28 @@ export function renderOnboardingWindow(platform = resolveSetupPlatform()) {
460
465
  .status-text[data-state="success"] { color: var(--red-color); }
461
466
 
462
467
  .keyboard-hint.status-shortcuts { color: var(--white-text); }
463
- `;
468
+ ${diagnostics ? `
469
+ /* With a third step the steps outgrow the panel at the window's own 960 x 640: the projects step must not shrink, as it
470
+ does alone to fill the panel, or its list and + ADD PROJECTS spill under the diagnostics step, whose email field then
471
+ takes their clicks (found 2026-10-02). The panel scrolls instead, as its rail expects. */
472
+ .projects-step { flex: 0 0 auto; }
473
+
474
+ .diagnostics-step p { color: var(--white-text); font-size: 10px; line-height: 1.5; margin-bottom: 10px; max-width: 560px; }
475
+
476
+ .diagnostics-step label { display: block; color: var(--grey-text); font-size: 10px; margin-bottom: 6px; }
477
+
478
+ .diagnostics-step input {
479
+ width: 100%;
480
+ max-width: 360px;
481
+ padding: 7px 9px;
482
+ background: transparent;
483
+ color: var(--white-text);
484
+ border: 1px dashed var(--grey-border);
485
+ -webkit-app-region: no-drag;
486
+ }
487
+
488
+ .diagnostics-step input:focus-visible { outline: none; border-color: var(--red-color); }
489
+ ` : ''} `;
464
490
  const script = `
465
491
  const bridge = window.kitcodeOnboarding;
466
492
  const projectList = document.getElementById('projectList');
@@ -472,7 +498,8 @@ export function renderOnboardingWindow(platform = resolveSetupPlatform()) {
472
498
  const setupContent = document.getElementById('setupContent');
473
499
  const railItems = [...document.querySelectorAll('.rail-item')];
474
500
  const stepSections = [...document.querySelectorAll('[data-setup-step]')];
475
- const SAVE_LABEL = '+ SAVE AND OPEN KITCODE ->';
501
+ const SAVE_LABEL = '+ SAVE AND OPEN KITCODE ->';${diagnostics ? `
502
+ const invitedEmail = document.getElementById('invitedEmail');` : ''}
476
503
  const FOLDER_ICON = '<svg class="dropzone-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>';
477
504
  let persistedProjects = [];
478
505
  let pendingProjects = [];
@@ -713,7 +740,8 @@ export function renderOnboardingWindow(platform = resolveSetupPlatform()) {
713
740
  const result = await bridge.submit({
714
741
  folders: pendingProjects.map((project) => project.repoRoot),
715
742
  autoTrack,
716
- companionView,
743
+ companionView,${diagnostics ? `
744
+ invitedEmail: invitedEmail.value,` : ''}
717
745
  });
718
746
 
719
747
  if (Array.isArray(result.projects)) {
@@ -777,7 +805,8 @@ export function renderOnboardingWindow(platform = resolveSetupPlatform()) {
777
805
  suggestedCount += 1;
778
806
  }
779
807
 
780
- document.querySelector('input[name="autoTrack"][value="' + (state.autoTrack === false ? 'no' : 'yes') + '"]').checked = true;
808
+ document.querySelector('input[name="autoTrack"][value="' + (state.autoTrack === false ? 'no' : 'yes') + '"]').checked = true;${diagnostics ? `
809
+ invitedEmail.value = typeof state.invitedEmail === 'string' ? state.invitedEmail : '';` : ''}
781
810
  document.querySelectorAll('.option-group').forEach(syncOptionGroup);
782
811
  renderProjects();
783
812
 
@@ -826,7 +855,12 @@ export function renderOnboardingWindow(platform = resolveSetupPlatform()) {
826
855
  <div class="rail-item" data-step="projects" data-testid="setup-rail-projects">
827
856
  <span class="rail-badge tech-box-red">02</span>
828
857
  <span class="rail-label">PROJECTS</span>
829
- </div>
858
+ </div>${diagnostics ? `
859
+ <div class="step-connector" aria-hidden="true"></div>
860
+ <div class="rail-item" data-step="diagnostics" data-testid="setup-rail-diagnostics">
861
+ <span class="rail-badge tech-box-red">03</span>
862
+ <span class="rail-label">DIAGNOSTICS</span>
863
+ </div>` : ''}
830
864
  </aside>
831
865
  <div class="content">
832
866
  <fieldset class="step" id="trackingStep" data-setup-step="tracking">
@@ -852,7 +886,13 @@ export function renderOnboardingWindow(platform = resolveSetupPlatform()) {
852
886
  <button class="terminal-button tech-box-red" id="pickFolders" type="button" data-testid="add-projects">+ ADD PROJECTS</button>
853
887
  <p class="hint">Existing files become the baseline.<br>Source and diffs stay local.</p>
854
888
  </div>
855
- </section>
889
+ </section>${diagnostics ? `
890
+ <section class="step diagnostics-step" aria-labelledby="diagnosticsTitle" data-setup-step="diagnostics" data-testid="setup-diagnostics">
891
+ <h2 class="section-title" id="diagnosticsTitle">This test build sends diagnostic logs</h2>
892
+ <p>This test build of KitCode sends diagnostic logs to KitCode’s staging server: KitCode’s own events and errors, such as the tracker starting or a hook failing, so that a problem you meet can be traced without asking you for it. They never hold your code, your files, your prompts or your project names.</p>
893
+ <label for="invitedEmail">The email address you were invited with, optional: staff find your logs by it.</label>
894
+ <input id="invitedEmail" name="invitedEmail" type="email" autocomplete="email" maxlength="254" spellcheck="false" data-testid="invited-email">
895
+ </section>` : ''}
856
896
  </div>
857
897
  </div>
858
898
  <div class="dashed-divider section-divider" aria-hidden="true">==================================================================================================================================================================================================</div>
package/src/runtime.mjs CHANGED
@@ -787,8 +787,10 @@ function enqueue(runtime, projectId, run, job) {
787
787
  }
788
788
 
789
789
  // What is wrong with the tracker, by fixed code (the prompt hook turns each code into fixed words,
790
- // so nothing from a file reaches the agent): the projects affected, and since when.
791
- function reportProblem(runtime, code, projectId, message) {
790
+ // so nothing from a file reaches the agent): the projects affected, and since when. `onProblem` hears of each project's
791
+ // problem raised and cleared, with the error that raised it where there is one: a tester build's diagnostics
792
+ // (src/diagnostics.mjs) record it, by the code, an opaque reference to the project and the error's code alone.
793
+ function reportProblem(runtime, code, projectId, message, error = null) {
792
794
  const problem = runtime.problems.get(code) ?? {since: new Date().toISOString(), projects: new Set()};
793
795
 
794
796
  if (problem.projects.has(projectId)) {
@@ -798,6 +800,7 @@ function reportProblem(runtime, code, projectId, message) {
798
800
  problem.projects.add(projectId);
799
801
  runtime.problems.set(code, problem);
800
802
  runtime.log.warn(message);
803
+ runtime.watcherOptions?.onProblem?.({code, projectId, state: 'raised', error});
801
804
  runtime.watcherOptions?.onStatus?.(trackerStatus(runtime));
802
805
  }
803
806
 
@@ -813,6 +816,7 @@ function clearProblem(runtime, code, projectId) {
813
816
  }
814
817
 
815
818
  runtime.log.info(`${code} cleared for ${runtime.state.projects[projectId]?.repoRoot ?? projectId}`);
819
+ runtime.watcherOptions?.onProblem?.({code, projectId, state: 'cleared', error: null});
816
820
  runtime.watcherOptions?.onStatus?.(trackerStatus(runtime));
817
821
  }
818
822
 
@@ -837,7 +841,7 @@ function scanFailed(runtime, projectId, error) {
837
841
  // stays as it is, and the next scan tries again.
838
842
  const root = runtime.state.projects[projectId]?.repoRoot ?? projectId;
839
843
  const missing = error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
840
- reportProblem(runtime, missing ? 'project-missing' : 'scan-failed', projectId, `Scanning ${root} failed (${error?.code ?? 'error'}: ${error?.message ?? error}); its snapshot is kept, and nothing in it is counted until a scan succeeds.`);
844
+ reportProblem(runtime, missing ? 'project-missing' : 'scan-failed', projectId, `Scanning ${root} failed (${error?.code ?? 'error'}: ${error?.message ?? error}); its snapshot is kept, and nothing in it is counted until a scan succeeds.`, error);
841
845
  }
842
846
 
843
847
  // A Git project whose index stayed locked through a scan (index.lock, git.mjs gitArrival 'locked'): the
@@ -1021,12 +1025,12 @@ function watchFailed(runtime, projectId, run, error) {
1021
1025
  const root = runtime.state.projects[projectId]?.repoRoot ?? projectId;
1022
1026
 
1023
1027
  if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
1024
- reportProblem(runtime, 'project-missing', projectId, `Watching ${root} failed (${error.code}: ${error.message}): the folder is gone, so nothing in it is counted.`);
1028
+ reportProblem(runtime, 'project-missing', projectId, `Watching ${root} failed (${error.code}: ${error.message}): the folder is gone, so nothing in it is counted.`, error);
1025
1029
  return;
1026
1030
  }
1027
1031
 
1028
1032
  run.poll = setInterval(() => queueFullScan(runtime, projectId, 'poll'), POLL_INTERVAL_MS);
1029
- reportProblem(runtime, 'watch-failed', projectId, `Watching ${root} failed (${error?.code ?? 'error'}: ${error?.message ?? error}); polling it every ${POLL_INTERVAL_MS / 1000} s instead.`);
1033
+ reportProblem(runtime, 'watch-failed', projectId, `Watching ${root} failed (${error?.code ?? 'error'}: ${error?.message ?? error}); polling it every ${POLL_INTERVAL_MS / 1000} s instead.`, error);
1030
1034
  }
1031
1035
 
1032
1036
  function watchProject(runtime, projectId, run) {
@@ -1271,7 +1275,8 @@ export function buildSummary(runtime) {
1271
1275
  }
1272
1276
 
1273
1277
  // Starts the tracker's work. `options.log` records what goes wrong; `options.onStatus` hears how the
1274
- // tracker is doing whenever that changes, and after every save.
1278
+ // tracker is doing whenever that changes, and after every save; `options.onProblem`, each project's problem as it is raised
1279
+ // and cleared (reportProblem).
1275
1280
  export function startWatchers(runtime, options = {}) {
1276
1281
  runtime.watcherOptions = options;
1277
1282
  runtime.log = options.log ?? SILENT_LOG;