@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/src/git.mjs CHANGED
@@ -182,11 +182,24 @@ const OWN_COMMIT = /^commit(?: \((?:amend|initial)\))?:(?: |$)/;
182
182
  // names are 40 hex characters, or 64 in a SHA-256 repository.
183
183
  const REFLOG_LINE = /^([0-9a-f]{40}|[0-9a-f]{64}) ([0-9a-f]{40}|[0-9a-f]{64}) [^\t]*(?:\t(.*))?$/;
184
184
 
185
- // The files git leaves while an operation waits for the user: a merge (a pull's, too) stopped on a
186
- // conflict or run with --no-commit, a cherry-pick, a revert, a rebase, `git am`, and a squash merge not
187
- // yet committed. What they brought is in the working tree before any reflog line says so.
188
- const WAITING_OPERATIONS = ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'REBASE_HEAD', 'rebase-merge', 'rebase-apply', 'SQUASH_MSG'];
189
- const GIT_PATHS = ['logs/HEAD', 'index', 'index.lock', ...WAITING_OPERATIONS];
185
+ // The files git leaves while an operation waits for the user, the ones git itself goes by to say that one
186
+ // is in progress (wt-status.c): a merge (a pull's, too) stopped on a conflict or run with --no-commit,
187
+ // MERGE_HEAD; a cherry-pick, CHERRY_PICK_HEAD; a revert, REVERT_HEAD; a rebase, rebase-merge/, or
188
+ // rebase-apply/ with the apply backend; `git am`, rebase-apply/. What they brought is in the working tree
189
+ // before any reflog line says so. Each goes when the operation is finished, aborted or quit, and for as
190
+ // long as it stays git says the operation is in progress. Until 2026-10-01 REBASE_HEAD was among them: git
191
+ // writes it when a rebase stops, and leaves it behind when the rebase ends after its last pick stopped
192
+ // (--continue, --skip) or is left with --quit. Of the everyday commands only the next rebase removes it, a
193
+ // commit, a checkout or a reset does not, while git says no rebase is in progress, and every scan of the
194
+ // project was a baseline (docs/lessons-learned.md F22).
195
+ const WAITING_OPERATIONS = ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply'];
196
+ // A squash merge leaves no MERGE_HEAD and moves no HEAD: what it brought waits in the index, and SQUASH_MSG
197
+ // is the message git offers the commit that records it. git leaves SQUASH_MSG behind when that is restored
198
+ // away (git restore, git checkout -- <path>), and says nothing is in progress; the next commit, a reset or a
199
+ // switch of branch removes it. So SQUASH_MSG says a squash merge waits only while the index differs from
200
+ // HEAD.
201
+ const SQUASH_MESSAGE = 'SQUASH_MSG';
202
+ const GIT_PATHS = ['logs/HEAD', 'index', 'index.lock', ...WAITING_OPERATIONS, SQUASH_MESSAGE];
190
203
 
191
204
  // A command writing the index (a checkout, a merge, add, commit) holds index.lock while it writes the
192
205
  // working tree, and moves HEAD and writes its reflog line just after it lets go. A lock is waited for
@@ -370,6 +383,29 @@ function readReflog(logPath, from) {
370
383
  }
371
384
  }
372
385
 
386
+ // Whether the index differs from HEAD: anything staged, a conflict too. When git cannot say (no commit
387
+ // yet, say), it is taken to: when in doubt, a baseline. diff-index reads the index and HEAD's tree, and
388
+ // writes nothing.
389
+ function indexDiffersFromHead(repoRoot) {
390
+ try {
391
+ runGit(repoRoot, ['diff-index', '--cached', '--quiet', 'HEAD', '--']);
392
+ return false;
393
+ } catch {
394
+ return true;
395
+ }
396
+ }
397
+
398
+ // The operations waiting for the user, by the files git leaves for them: none, or their names.
399
+ function waitingOperations(repoRoot, paths) {
400
+ const waiting = WAITING_OPERATIONS.filter((name) => fs.existsSync(paths[name]));
401
+
402
+ if (fs.existsSync(paths[SQUASH_MESSAGE]) && indexDiffersFromHead(repoRoot)) {
403
+ waiting.push(SQUASH_MESSAGE);
404
+ }
405
+
406
+ return waiting;
407
+ }
408
+
373
409
  // Whether the reflog explains how HEAD got from `since` to `now`, and how. `arrival`: null when HEAD did
374
410
  // not move, or moved only by the user's own commits; 'moved' when it moved some other way; 'unexplained'
375
411
  // when the reflog was rewritten, is gone, or does not lead from one to the other (HEAD moved with no
@@ -412,7 +448,9 @@ function arrivalFromReflog(since, now, log) {
412
448
  // before, and the position now, for the next scan to start from. `arrival`:
413
449
  // null nothing: HEAD is where it was, or moved only by the user's own commits;
414
450
  // 'moved' HEAD moved some other way: a pull, a merge, a checkout, a rebase, a reset …;
415
- // 'waiting' a merge, a cherry-pick, a revert, a rebase, am or a squash merge waits for the user;
451
+ // 'waiting' a merge, a cherry-pick, a revert, a rebase, am or a squash merge waits for the user,
452
+ // and `waiting` names the files git left for it (the tracker reports it, runtime.mjs
453
+ // noteGitWaiting, since 2026-10-01);
416
454
  // 'locked' index.lock stayed for longer than LOCK_WAIT_MS: a long command, or a git that died
417
455
  // holding it, which git itself refuses to run past until the file is removed (until
418
456
  // 2026-09-25 this was 'waiting'; the tracker now reports it, runtime.mjs noteGitLock);
@@ -440,8 +478,10 @@ export function gitArrival(repoRoot, since) {
440
478
  return {arrival: 'locked', operation: true, position};
441
479
  }
442
480
 
443
- if (WAITING_OPERATIONS.some((name) => fs.existsSync(paths[name]))) {
444
- return {arrival: 'waiting', operation: true, position};
481
+ const waiting = waitingOperations(repoRoot, paths);
482
+
483
+ if (waiting.length > 0) {
484
+ return {arrival: 'waiting', operation: true, position, waiting};
445
485
  }
446
486
 
447
487
  return {...reflog, position};
@@ -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';
@@ -100,6 +101,9 @@ function notify(title, message) {
100
101
  }
101
102
  }
102
103
 
104
+ // A milestone reached is said in neutral words (the operator's decisions 5 and 6 of 2026-10-01): its message, from the
105
+ // campaign, is on the dashboard, and no milestone is a reward ready, or a code. Until then this said "Reward ready at",
106
+ // and the announcement below "KitCode break code ready".
103
107
  function progressContextFor(reward) {
104
108
  const nextBreak = reward.nextBreak ?? nextBreakReminder(reward);
105
109
  const ready = reward.milestones.filter((entry) => entry.status === 'ready' && !entry.redeemed);
@@ -108,7 +112,7 @@ function progressContextFor(reward) {
108
112
  ];
109
113
 
110
114
  if (ready.length > 0) {
111
- parts.push(`Reward ready at ${ready.map((entry) => `${entry.percent}%`).join(', ')}. Mention \`kitcode dashboard\` briefly after this turn.`);
115
+ parts.push(`Milestone reached at ${ready.map((entry) => `${entry.percent}%`).join(', ')}. Mention \`kitcode dashboard\` briefly after this turn.`);
112
116
  }
113
117
 
114
118
  if (nextBreak) {
@@ -140,7 +144,7 @@ function hookOutputFor({tiers = [], reward = null}) {
140
144
  const command = 'kitcode dashboard';
141
145
  const additionalContext = [
142
146
  progressContext,
143
- `KitCode break code ready (${labels}). Briefly tell the user they can open the dashboard with \`${command}\` after this turn.`,
147
+ `KitCode milestone reached (${labels}). Briefly tell the user they can open the dashboard with \`${command}\` after this turn.`,
144
148
  ].filter(Boolean).join(' ');
145
149
 
146
150
  return {
@@ -172,11 +176,20 @@ function collectPromptProgress(source) {
172
176
  }
173
177
  }
174
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.
175
184
  export async function runPromptHook({source}) {
176
185
  if (isDisabled()) {
177
186
  return;
178
187
  }
179
188
 
189
+ const started = Date.now();
190
+ const marker = hookStarted(source, started);
191
+ let failure = null;
192
+
180
193
  try {
181
194
  const input = await readStdin();
182
195
  const payload = JSON.parse(input || '{}');
@@ -193,9 +206,12 @@ export async function runPromptHook({source}) {
193
206
  }
194
207
 
195
208
  const label = announced.map((tier) => `${tier.percent}%`).join(', ');
196
- notify('KitCode break code ready', `Milestone ${label} unlocked. Open kitcode dashboard.`);
209
+ notify('KitCode milestone reached', `Milestone ${label} unlocked. Open kitcode dashboard.`);
197
210
  process.stdout.write(`${JSON.stringify(hookOutputFor({tiers: announced, reward}))}\n`);
198
211
  } catch (error) {
212
+ failure = error;
199
213
  logHookError(error);
214
+ } finally {
215
+ hookFinished(marker, source, Date.now() - started, failure);
200
216
  }
201
217
  }
@@ -1,24 +1,33 @@
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'];
11
16
  export const RUNNER_DISPLAY_PATH = `~/${RUNNER_PATH_PARTS.join('/')}`;
12
17
 
18
+ // The milestones and the = each needs (docs/reward-rules.md §2). Until 2026-10-01 each had a break code, one fixed line of
19
+ // code shown to everyone; the operator's decision 6 of that day replaced them with a message the claim backend's
20
+ // settings give each milestone, which the dashboard shows. The 10–30 % tiers still hold a record each in the state file:
21
+ // the hook's announcements, and a code shown before then.
13
22
  export const KITCODE_REWARD_TIERS = [
14
- {percent: 10, code: 'if(tired){return 10;}', requiredEquals: 3},
15
- {percent: 20, code: 'takeBreak(20);', requiredEquals: 6},
16
- {percent: 30, code: 'while(working)break(30);', requiredEquals: 9},
23
+ {percent: 10, requiredEquals: 3},
24
+ {percent: 20, requiredEquals: 6},
25
+ {percent: 30, requiredEquals: 9},
17
26
  ];
18
27
 
19
28
  export const KITCODE_DISPLAY_MILESTONES = [
20
- {percent: 50, code: 'mediumStake.unlock(50);', requiredEquals: 12, displayOnly: true},
21
- {percent: 100, code: 'finalBreak.claim(100);', requiredEquals: 15, displayOnly: true},
29
+ {percent: 50, requiredEquals: 12, displayOnly: true},
30
+ {percent: 100, requiredEquals: 15, displayOnly: true},
22
31
  ];
23
32
 
24
33
  export const AGENT_SPECS = {
@@ -147,7 +156,7 @@ export function kitCodeSkillMarkdown(source) {
147
156
  '',
148
157
  'Use this skill only as an opt-in break reward. Never block, gate, delay, or nag about work.',
149
158
  '',
150
- 'KitCode is local-first. The CLI owns tracking, reward state, and break codes. The durable local state lives at:',
159
+ 'KitCode is local-first. The CLI owns tracking and reward state. The durable local state lives at:',
151
160
  '',
152
161
  ` ${STATE_DISPLAY_PATH}`,
153
162
  '',
@@ -155,11 +164,11 @@ export function kitCodeSkillMarkdown(source) {
155
164
  '',
156
165
  ` ${LOCAL_SERVER_URL}`,
157
166
  '',
158
- 'When KitCode hook context says a reward is ready, briefly tell the user they can open:',
167
+ 'When KitCode hook context says a milestone was reached, briefly tell the user they can open:',
159
168
  '',
160
169
  ` ${RUNNER_DISPLAY_PATH} dashboard`,
161
170
  '',
162
- 'The prompt hook runs after each prompt submit and may add compact progress context such as total `=` counted, active time, next break remaining (`=` left and time left), or reward readiness. Treat that hook context as authoritative.',
171
+ 'The prompt hook runs after each prompt submit and may add compact progress context such as total `=` counted, active time, next break remaining (`=` left and time left), or milestones reached. Treat that hook context as authoritative.',
163
172
  'When hook context includes remaining equals and time to the next break milestone, briefly mention both in one short line after your normal answer on every turn (for example: `Break next: 4 = to break · 18m to break (20%).`). Do not calculate these values yourself.',
164
173
  '',
165
174
  'Do not run git counting commands, do not edit the ledger, and do not reimplement reward logic in chat. Use the KitCode CLI/server/hook output as the authority.',
@@ -201,15 +210,9 @@ export function kitCodeSkillMarkdown(source) {
201
210
  ].join('\n');
202
211
  }
203
212
 
204
- function rewardTierLines() {
205
- return KITCODE_REWARD_TIERS
206
- .map((tier) => `- ${tier.percent}% reward tier: ${tier.code} after enough tracked time and ${tier.requiredEquals} counted = characters.`)
207
- .join('\n');
208
- }
209
-
210
- function displayMilestoneLines() {
211
- return KITCODE_DISPLAY_MILESTONES
212
- .map((milestone) => `- ${milestone.percent}% dashboard milestone: ${milestone.code} is display-only unless the CLI later exposes it as a reward tier.`)
213
+ function milestoneLines() {
214
+ return [...KITCODE_REWARD_TIERS, ...KITCODE_DISPLAY_MILESTONES]
215
+ .map((milestone) => `- ${milestone.percent}% milestone: reached after enough tracked time and ${milestone.requiredEquals} counted = characters; the dashboard shows its message.`)
213
216
  .join('\n');
214
217
  }
215
218
 
@@ -260,16 +263,28 @@ export function assistantSetupPromptFor(source) {
260
263
  ].join('\n');
261
264
  }
262
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
+
263
271
  // The setup prompt a staging dashboard copies for its invited testers (apps/web src/lib/staging-prompts.ts): the one
264
- // above, line for line, with three changes and no other. Step 2 installs the round's tester build, from the file the
265
- // tester downloaded from the staging site, which the dashboard's steps link (install 'file'), or, once a staging
266
- // version is published, from npm's `staging` tag (install 'npm'), and checks its version. Then, before step 3 turns
267
- // KitCode on, it stops a tracker an earlier build left running. And it holds no URL: the public docs the prompt above
268
- // links are not public yet (docs/lessons-learned.md F5). It is made from the prompt above as it stands, so that the two
269
- // cannot drift: a line it changes that is not there once throws, and check-integration-spec.mjs runs both.
270
- export function stagingSetupPromptFor(source, {testerVersion, fileName, install}) {
271
- if (install !== 'file' && install !== 'npm') {
272
- 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(', ')}.`);
273
288
  }
274
289
 
275
290
  const lines = assistantSetupPromptFor(source).split('\n');
@@ -291,15 +306,10 @@ export function stagingSetupPromptFor(source, {testerVersion, fileName, install}
291
306
  throw new Error('stagingSetupPromptFor found no blank line after the public docs: assistantSetupPromptFor changed, and stagingSetupPromptFor must follow it.');
292
307
  }
293
308
 
294
- 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);
295
310
 
296
311
  const installLine = onlyLine((line) => line.endsWith(`install KitCode globally with: ${CLI_GLOBAL_INSTALL_COMMAND}`), 'the install');
297
- lines[installLine] = lines[installLine].replace(
298
- `with: ${CLI_GLOBAL_INSTALL_COMMAND}`,
299
- install === 'npm'
300
- ? `with: ${CLI_STAGING_INSTALL_COMMAND}`
301
- : `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}`,
302
- );
312
+ lines[installLine] = lines[installLine].replace(`with: ${CLI_GLOBAL_INSTALL_COMMAND}`, `with: ${CLI_STAGING_INSTALL_COMMAND}`);
303
313
 
304
314
  const globalCommand = onlyLine((line) => line === `- After install, use the global ${CLI_GLOBAL_COMMAND} command for all following steps (not npx).`, 'the global command');
305
315
  lines.splice(
@@ -341,11 +351,10 @@ export function setupPromptFor(source, options = {}) {
341
351
  `- The web dashboard reads local tracking and reward data from that server.`,
342
352
  `- Tracking and reward state is stored in: ${STATE_DISPLAY_PATH}`,
343
353
  '- Git Mode tracks commit totals and source-change batches; Vibe Mode tracks the same source-change batches without git metadata.',
344
- '- The CLI/package is the source of truth for tracking, reward eligibility, which break codes were shown, and hook output.',
354
+ '- The CLI/package is the source of truth for tracking, the milestones reached, and hook output.',
345
355
  '',
346
356
  'Reward model:',
347
- rewardTierLines(),
348
- displayMilestoneLines(),
357
+ milestoneLines(),
349
358
  '- Do not reimplement reward calculations manually. Use KitCode CLI output and state.',
350
359
  '',
351
360
  'Required checks:',
@@ -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/reward.mjs CHANGED
@@ -3,7 +3,7 @@ import {equalsLedgerFrom, storedEqualsLedger} from './equals-ledger.mjs';
3
3
  import {focusTimeFrom} from './focus-clock.mjs';
4
4
  import {nextBreakReminder} from './next-break-reminder.mjs';
5
5
  import {KITCODE_DISPLAY_MILESTONES, KITCODE_REWARD_TIERS} from './integration-spec.mjs';
6
- import {loadState, updateState, withStateLock} from './store.mjs';
6
+ import {loadState, updateState} from './store.mjs';
7
7
 
8
8
  // The focus time the 100 % milestone needs, fixed (operator decision Q5, 2026-09-25; docs/reward-rules.md
9
9
  // §3). Until then it was a setting, down to 1 s: --reward-seconds, KITCODE_REWARD_SECONDS, and
@@ -147,7 +147,6 @@ export function buildRewardSummary({earnedSeconds, totalEquals, ledger}) {
147
147
  const redeemed = Boolean(record?.redeemed_at);
148
148
 
149
149
  return {
150
- code: tier.code,
151
150
  percent: tier.percent,
152
151
  requiredEquals: tier.requiredEquals,
153
152
  requiredSeconds: requiredTimeSeconds,
@@ -174,7 +173,6 @@ export function buildRewardSummary({earnedSeconds, totalEquals, ledger}) {
174
173
  const unlocked = timeReached && equalsReached;
175
174
 
176
175
  return {
177
- code: tier?.code ?? milestone.code,
178
176
  percent: milestone.percent,
179
177
  requiredEquals: milestone.requiredEquals,
180
178
  requiredSeconds: requiredTimeSeconds,
@@ -225,44 +223,9 @@ function saveTierRecords(changes) {
225
223
  });
226
224
  }
227
225
 
228
- export function redeemReadyTiers(percent = null) {
229
- const requestedPercent = percent === null ? null : normalizeTierPercent(percent);
230
-
231
- if (percent !== null && !requestedPercent) {
232
- return {redeemed: [], skipped: [], reward: getDiskRewardSummary()};
233
- }
234
-
235
- // Readiness is checked and the redeem recorded under one lock, so a tier is redeemed once.
236
- return withStateLock(() => {
237
- const reward = getDiskRewardSummary();
238
- const now = new Date().toISOString();
239
- const redeemed = [];
240
- const skipped = [];
241
-
242
- for (const tier of reward.tiers) {
243
- if (requestedPercent && tier.percent !== requestedPercent) {
244
- continue;
245
- }
246
-
247
- if (tier.status !== 'ready') {
248
- skipped.push(tier);
249
- continue;
250
- }
251
-
252
- redeemed.push({...tier, redeemed: true, redeemedAt: now, status: 'redeemed'});
253
- }
254
-
255
- if (redeemed.length > 0) {
256
- saveTierRecords(redeemed.map((tier) => [tier.percent, (record) => ({...record, redeemed_at: now})]));
257
- }
258
-
259
- return {
260
- redeemed,
261
- skipped,
262
- reward: getDiskRewardSummary(),
263
- };
264
- });
265
- }
226
+ // Since 2026-10-01 nothing records a code as shown (the operator's decision 6: the break codes are gone). A tier whose
227
+ // code an earlier version recorded as shown keeps its redeemed_at, and so its status, `redeemed`: it was reached, and
228
+ // the record says so (docs/reward-rules.md §7).
266
229
 
267
230
  export function markTiersAnnounced(source, tiers) {
268
231
  const normalizedSource = normalizeSource(source);
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
@@ -855,6 +859,23 @@ function noteGitLock(runtime, projectId, read) {
855
859
  reportProblem(runtime, 'git-locked', projectId, `The git index of ${root} has stayed locked (index.lock) through a scan: its scans are baselines, so nothing in it is counted until the lock goes. A git command that crashed can leave that file behind.`);
856
860
  }
857
861
 
862
+ // A Git project in which git shows an operation waiting for the user through a scan (a merge, a rebase, a
863
+ // cherry-pick, a revert, am or a squash merge; git.mjs gitArrival 'waiting'): the scan was a baseline, so
864
+ // nothing in the project counts until the operation is finished or aborted. Said as a problem, in fixed
865
+ // words, and cleared, as a lock is (noteGitLock); the log names the files git left. Until 2026-10-01
866
+ // nothing said so, and a REBASE_HEAD that a finished rebase left behind made every scan of a project a
867
+ // baseline for hours (docs/lessons-learned.md F22).
868
+ function noteGitWaiting(runtime, projectId, read) {
869
+ if (read.baseline !== 'waiting') {
870
+ clearProblem(runtime, 'git-unfinished', projectId);
871
+ return;
872
+ }
873
+
874
+ const root = runtime.state.projects[projectId]?.repoRoot ?? projectId;
875
+ const files = Array.isArray(read.gitWaiting) ? read.gitWaiting.join(', ') : 'unknown';
876
+ reportProblem(runtime, 'git-unfinished', projectId, `Git shows an unfinished operation in ${root} (${files}): its scans are baselines, so nothing in it is counted until the operation is finished or aborted.`);
877
+ }
878
+
858
879
  // Folds a read into state.json and follows it in the tracker's copy. `activityAt` says, from what the
859
880
  // fold changed, when the project was active, or null when it was not: activity is decided by the fold
860
881
  // that counted the change, never before it. When the lock stays busy, the same read is folded a little
@@ -921,6 +942,7 @@ function queueFullScan(runtime, projectId, reason) {
921
942
  const base = snapshotOf(project) ?? emptySnapshot();
922
943
  const read = await runtime.scanner.read({root: project.repoRoot, sourceType: project.sourceType, since: base.git});
923
944
  noteGitLock(runtime, projectId, read);
945
+ noteGitWaiting(runtime, projectId, read);
924
946
  applyRead(runtime, projectId, run, read, base, (result) => (reason !== 'start' && changedByUser(read, result) ? Date.now() : null));
925
947
  });
926
948
  }
@@ -943,6 +965,7 @@ async function scanChangedPaths(runtime, projectId, run, batch) {
943
965
  const read = await runtime.scanner.read({root: project.repoRoot, sourceType: project.sourceType, changedPaths: [...batch.keys()], since: base.git});
944
966
  const firstEvents = new Map([...batch].map(([fileName, at]) => [relativeTo(project.repoRoot, fileName), at]));
945
967
  noteGitLock(runtime, projectId, read);
968
+ noteGitWaiting(runtime, projectId, read);
946
969
 
947
970
  applyRead(runtime, projectId, run, read, base, (result) => {
948
971
  if (!changedByUser(read, result)) {
@@ -1002,12 +1025,12 @@ function watchFailed(runtime, projectId, run, error) {
1002
1025
  const root = runtime.state.projects[projectId]?.repoRoot ?? projectId;
1003
1026
 
1004
1027
  if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
1005
- 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);
1006
1029
  return;
1007
1030
  }
1008
1031
 
1009
1032
  run.poll = setInterval(() => queueFullScan(runtime, projectId, 'poll'), POLL_INTERVAL_MS);
1010
- 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);
1011
1034
  }
1012
1035
 
1013
1036
  function watchProject(runtime, projectId, run) {
@@ -1251,15 +1274,9 @@ export function buildSummary(runtime) {
1251
1274
  };
1252
1275
  }
1253
1276
 
1254
- // The ledger changed on disk (a redeem, say): read it again for the next summary.
1255
- export function refreshRuntimeLedger(runtime) {
1256
- if (runtime.ledger) {
1257
- runtime.ledger = loadEqualsLedger();
1258
- }
1259
- }
1260
-
1261
1277
  // Starts the tracker's work. `options.log` records what goes wrong; `options.onStatus` hears how the
1262
- // 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).
1263
1280
  export function startWatchers(runtime, options = {}) {
1264
1281
  runtime.watcherOptions = options;
1265
1282
  runtime.log = options.log ?? SILENT_LOG;