@kitkat-vn/kitcode 0.2.0-staging.3 → 0.2.0-staging.4

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
@@ -267,7 +267,8 @@ function formatDuration(seconds) {
267
267
  }
268
268
 
269
269
  // The numbers come from the summary: the same ones the Mini and the dashboard show
270
- // (docs/reward-rules.md §10).
270
+ // (docs/reward-rules.md §10). The next milestone follows the ready ones: until 2026-10-01 a ready milestone hid it, and
271
+ // showing a 10–30 % code was what cleared one; with the codes gone nothing does (docs/reward-rules.md §7).
271
272
  function printRewardSummary() {
272
273
  const reward = getDiskRewardSummary();
273
274
  const next = reward.nextBreak;
@@ -280,7 +281,6 @@ function printRewardSummary() {
280
281
 
281
282
  if (ready.length > 0) {
282
283
  console.log(` ready: ${ready.map((milestone) => `${milestone.percent}%`).join(', ')}`);
283
- return;
284
284
  }
285
285
 
286
286
  if (next) {
@@ -289,16 +289,16 @@ function printRewardSummary() {
289
289
  }
290
290
  }
291
291
 
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.
292
+ // Each milestone, locked or ready. Since 2026-10-01 none has a code (the operator's decision 6): a tier whose code an
293
+ // earlier version recorded as shown keeps its `redeemed` status in the API, and reads as ready here, a milestone reached;
294
+ // until then it read "code shown", and the 10–30 % tiers "reward".
294
295
  function printAwards() {
295
296
  const reward = getDiskRewardSummary();
296
297
 
297
298
  console.log('KitCode awards');
298
299
  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)}%)`);
300
+ const status = milestone.status === 'redeemed' ? 'ready' : milestone.status;
301
+ console.log(` ${milestone.percent}% milestone: ${status} (${milestoneProgressPercent(reward, milestone)}%)`);
302
302
  }
303
303
  }
304
304
 
@@ -443,7 +443,7 @@ function openElectronEntry(entryName, environment = {}) {
443
443
 
444
444
  function printElectronSetupHelp() {
445
445
  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.");
446
+ console.error("Reinstall the tester build: download kitcode-0.2.0-staging.4.tgz again from the staging site, https://staging.kitcode.vn/, then run npm install -g ./kitcode-0.2.0-staging.4.tgz in the folder you saved it to.");
447
447
  console.error('If install succeeded but Electron is still missing, fix npm cache permissions and reinstall.');
448
448
  }
449
449
 
@@ -458,7 +458,7 @@ function printElectronLaunchFailure(result, label) {
458
458
 
459
459
  if (result?.reason === 'electron-binary-missing') {
460
460
  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.");
461
+ console.error("Reinstall the tester build: download kitcode-0.2.0-staging.4.tgz again from the staging site, https://staging.kitcode.vn/, then run npm install -g ./kitcode-0.2.0-staging.4.tgz in the folder you saved it to.");
462
462
  return;
463
463
  }
464
464
 
@@ -701,7 +701,7 @@ function bindRefusal(options) {
701
701
  }
702
702
 
703
703
  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.`;
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.`;
705
705
  }
706
706
 
707
707
  function sleep(ms) {
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.4",
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": "ed3ca58fd6379cf7cfa15824f451773f092fb6d5"
41
41
  }
package/src/api.mjs CHANGED
@@ -6,14 +6,20 @@ import {renderCompanionWindow} from './companion-window.mjs';
6
6
  import {INSTALL_ID_DISPLAY_PATH, InstallIdError, readInstallId} from './install-id.mjs';
7
7
  import {STATE_DISPLAY_PATH} from './integration-spec.mjs';
8
8
  import {MilestoneRecordError} from './milestones-reached.mjs';
9
- import {buildSummary, persistRuntimeState, refreshRuntimeLedger} from './runtime.mjs';
10
- import {normalizeTierPercent, redeemReadyTiers} from './reward.mjs';
9
+ import {buildSummary} from './runtime.mjs';
11
10
  import {loadState, StateFileError} from './store.mjs';
12
11
 
13
12
  const projectLevelGone = (_req, res) => {
14
13
  res.status(410).json({error: 'Project-level metadata is no longer exposed'});
15
14
  };
16
15
 
16
+ // 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
17
+ // decision 6; docs/reward-rules.md §7): each milestone reached shows its message on the dashboard instead. It records
18
+ // nothing for anyone; a dashboard from before, which still sends it, reads why in fixed words.
19
+ const redeemGone = (_req, res) => {
20
+ res.status(410).json({error: 'Break codes are no longer shown or recorded: each milestone reached shows its message on the dashboard.'});
21
+ };
22
+
17
23
  export function createServer(runtime, version) {
18
24
  const app = express();
19
25
  let openEventStreams = 0;
@@ -54,24 +60,7 @@ export function createServer(runtime, version) {
54
60
  res.json(buildSummary(runtime).global);
55
61
  });
56
62
 
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
- });
63
+ app.post('/api/reward/redeem', redeemGone);
75
64
 
76
65
  // The claim bundle (docs/backend.md, "The contract"): totals only, from state.json as it is on disk,
77
66
  // which this reads and never writes. Only the dashboard's allow-listed origins get it, where
@@ -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
  }
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};
@@ -100,6 +100,9 @@ function notify(title, message) {
100
100
  }
101
101
  }
102
102
 
103
+ // A milestone reached is said in neutral words (the operator's decisions 5 and 6 of 2026-10-01): its message, from the
104
+ // campaign, is on the dashboard, and no milestone is a reward ready, or a code. Until then this said "Reward ready at",
105
+ // and the announcement below "KitCode break code ready".
103
106
  function progressContextFor(reward) {
104
107
  const nextBreak = reward.nextBreak ?? nextBreakReminder(reward);
105
108
  const ready = reward.milestones.filter((entry) => entry.status === 'ready' && !entry.redeemed);
@@ -108,7 +111,7 @@ function progressContextFor(reward) {
108
111
  ];
109
112
 
110
113
  if (ready.length > 0) {
111
- parts.push(`Reward ready at ${ready.map((entry) => `${entry.percent}%`).join(', ')}. Mention \`kitcode dashboard\` briefly after this turn.`);
114
+ parts.push(`Milestone reached at ${ready.map((entry) => `${entry.percent}%`).join(', ')}. Mention \`kitcode dashboard\` briefly after this turn.`);
112
115
  }
113
116
 
114
117
  if (nextBreak) {
@@ -140,7 +143,7 @@ function hookOutputFor({tiers = [], reward = null}) {
140
143
  const command = 'kitcode dashboard';
141
144
  const additionalContext = [
142
145
  progressContext,
143
- `KitCode break code ready (${labels}). Briefly tell the user they can open the dashboard with \`${command}\` after this turn.`,
146
+ `KitCode milestone reached (${labels}). Briefly tell the user they can open the dashboard with \`${command}\` after this turn.`,
144
147
  ].filter(Boolean).join(' ');
145
148
 
146
149
  return {
@@ -193,7 +196,7 @@ export async function runPromptHook({source}) {
193
196
  }
194
197
 
195
198
  const label = announced.map((tier) => `${tier.percent}%`).join(', ');
196
- notify('KitCode break code ready', `Milestone ${label} unlocked. Open kitcode dashboard.`);
199
+ notify('KitCode milestone reached', `Milestone ${label} unlocked. Open kitcode dashboard.`);
197
200
  process.stdout.write(`${JSON.stringify(hookOutputFor({tiers: announced, reward}))}\n`);
198
201
  } catch (error) {
199
202
  logHookError(error);
@@ -10,15 +10,19 @@ export const STATE_DISPLAY_PATH = '~/.kitcode/state.json';
10
10
  export const RUNNER_PATH_PARTS = ['.kitcode', 'bin', 'kitcode'];
11
11
  export const RUNNER_DISPLAY_PATH = `~/${RUNNER_PATH_PARTS.join('/')}`;
12
12
 
13
+ // The milestones and the = each needs (docs/reward-rules.md §2). Until 2026-10-01 each had a break code, one fixed line of
14
+ // code shown to everyone; the operator's decision 6 of that day replaced them with a message the claim backend's
15
+ // settings give each milestone, which the dashboard shows. The 10–30 % tiers still hold a record each in the state file:
16
+ // the hook's announcements, and a code shown before then.
13
17
  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},
18
+ {percent: 10, requiredEquals: 3},
19
+ {percent: 20, requiredEquals: 6},
20
+ {percent: 30, requiredEquals: 9},
17
21
  ];
18
22
 
19
23
  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},
24
+ {percent: 50, requiredEquals: 12, displayOnly: true},
25
+ {percent: 100, requiredEquals: 15, displayOnly: true},
22
26
  ];
23
27
 
24
28
  export const AGENT_SPECS = {
@@ -147,7 +151,7 @@ export function kitCodeSkillMarkdown(source) {
147
151
  '',
148
152
  'Use this skill only as an opt-in break reward. Never block, gate, delay, or nag about work.',
149
153
  '',
150
- 'KitCode is local-first. The CLI owns tracking, reward state, and break codes. The durable local state lives at:',
154
+ 'KitCode is local-first. The CLI owns tracking and reward state. The durable local state lives at:',
151
155
  '',
152
156
  ` ${STATE_DISPLAY_PATH}`,
153
157
  '',
@@ -155,11 +159,11 @@ export function kitCodeSkillMarkdown(source) {
155
159
  '',
156
160
  ` ${LOCAL_SERVER_URL}`,
157
161
  '',
158
- 'When KitCode hook context says a reward is ready, briefly tell the user they can open:',
162
+ 'When KitCode hook context says a milestone was reached, briefly tell the user they can open:',
159
163
  '',
160
164
  ` ${RUNNER_DISPLAY_PATH} dashboard`,
161
165
  '',
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.',
166
+ '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
167
  '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
168
  '',
165
169
  '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 +205,9 @@ export function kitCodeSkillMarkdown(source) {
201
205
  ].join('\n');
202
206
  }
203
207
 
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.`)
208
+ function milestoneLines() {
209
+ return [...KITCODE_REWARD_TIERS, ...KITCODE_DISPLAY_MILESTONES]
210
+ .map((milestone) => `- ${milestone.percent}% milestone: reached after enough tracked time and ${milestone.requiredEquals} counted = characters; the dashboard shows its message.`)
213
211
  .join('\n');
214
212
  }
215
213
 
@@ -341,11 +339,10 @@ export function setupPromptFor(source, options = {}) {
341
339
  `- The web dashboard reads local tracking and reward data from that server.`,
342
340
  `- Tracking and reward state is stored in: ${STATE_DISPLAY_PATH}`,
343
341
  '- 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.',
342
+ '- The CLI/package is the source of truth for tracking, the milestones reached, and hook output.',
345
343
  '',
346
344
  'Reward model:',
347
- rewardTierLines(),
348
- displayMilestoneLines(),
345
+ milestoneLines(),
349
346
  '- Do not reimplement reward calculations manually. Use KitCode CLI output and state.',
350
347
  '',
351
348
  'Required checks:',
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
@@ -855,6 +855,23 @@ function noteGitLock(runtime, projectId, read) {
855
855
  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
856
  }
857
857
 
858
+ // A Git project in which git shows an operation waiting for the user through a scan (a merge, a rebase, a
859
+ // cherry-pick, a revert, am or a squash merge; git.mjs gitArrival 'waiting'): the scan was a baseline, so
860
+ // nothing in the project counts until the operation is finished or aborted. Said as a problem, in fixed
861
+ // words, and cleared, as a lock is (noteGitLock); the log names the files git left. Until 2026-10-01
862
+ // nothing said so, and a REBASE_HEAD that a finished rebase left behind made every scan of a project a
863
+ // baseline for hours (docs/lessons-learned.md F22).
864
+ function noteGitWaiting(runtime, projectId, read) {
865
+ if (read.baseline !== 'waiting') {
866
+ clearProblem(runtime, 'git-unfinished', projectId);
867
+ return;
868
+ }
869
+
870
+ const root = runtime.state.projects[projectId]?.repoRoot ?? projectId;
871
+ const files = Array.isArray(read.gitWaiting) ? read.gitWaiting.join(', ') : 'unknown';
872
+ 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.`);
873
+ }
874
+
858
875
  // Folds a read into state.json and follows it in the tracker's copy. `activityAt` says, from what the
859
876
  // fold changed, when the project was active, or null when it was not: activity is decided by the fold
860
877
  // that counted the change, never before it. When the lock stays busy, the same read is folded a little
@@ -921,6 +938,7 @@ function queueFullScan(runtime, projectId, reason) {
921
938
  const base = snapshotOf(project) ?? emptySnapshot();
922
939
  const read = await runtime.scanner.read({root: project.repoRoot, sourceType: project.sourceType, since: base.git});
923
940
  noteGitLock(runtime, projectId, read);
941
+ noteGitWaiting(runtime, projectId, read);
924
942
  applyRead(runtime, projectId, run, read, base, (result) => (reason !== 'start' && changedByUser(read, result) ? Date.now() : null));
925
943
  });
926
944
  }
@@ -943,6 +961,7 @@ async function scanChangedPaths(runtime, projectId, run, batch) {
943
961
  const read = await runtime.scanner.read({root: project.repoRoot, sourceType: project.sourceType, changedPaths: [...batch.keys()], since: base.git});
944
962
  const firstEvents = new Map([...batch].map(([fileName, at]) => [relativeTo(project.repoRoot, fileName), at]));
945
963
  noteGitLock(runtime, projectId, read);
964
+ noteGitWaiting(runtime, projectId, read);
946
965
 
947
966
  applyRead(runtime, projectId, run, read, base, (result) => {
948
967
  if (!changedByUser(read, result)) {
@@ -1251,13 +1270,6 @@ export function buildSummary(runtime) {
1251
1270
  };
1252
1271
  }
1253
1272
 
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
1273
  // Starts the tracker's work. `options.log` records what goes wrong; `options.onStatus` hears how the
1262
1274
  // tracker is doing whenever that changes, and after every save.
1263
1275
  export function startWatchers(runtime, options = {}) {
@@ -505,7 +505,9 @@ export function readProjectSourcesUnder(root, {sourceType = 'vibe', rules}) {
505
505
  // A read in which git did anything but the user's own commits carries `gitOperation`: what it found
506
506
  // changed is git's, and not activity (operator decision Q4, 2026-09-25). A baseline always does; so does
507
507
  // a read after git acted without moving HEAD, as a stash does, which is read whole too, like a move, so
508
- // that none of what git changed is left for a later scan to take for the user's.
508
+ // that none of what git changed is left for a later scan to take for the user's. A read that is a
509
+ // baseline because an operation waits for the user carries `gitWaiting` too, the files git left for it,
510
+ // for the tracker's log (since 2026-10-01).
509
511
  function checkedAgainstGit(root, sourceType, since, read, readAll) {
510
512
  const first = read();
511
513
 
@@ -513,9 +515,15 @@ function checkedAgainstGit(root, sourceType, since, read, readAll) {
513
515
  return first;
514
516
  }
515
517
 
516
- const {arrival, operation, position} = gitArrival(root, since);
518
+ const {arrival, operation, position, waiting} = gitArrival(root, since);
517
519
  const reads = arrival === 'moved' || arrival === 'unexplained' || (arrival === null && operation) ? readAll() : first;
518
- const mark = (entry) => ({...entry, git: position, ...(arrival ? {baseline: arrival} : {}), ...(operation ? {gitOperation: true} : {})});
520
+ const mark = (entry) => ({
521
+ ...entry,
522
+ git: position,
523
+ ...(arrival ? {baseline: arrival} : {}),
524
+ ...(waiting ? {gitWaiting: waiting} : {}),
525
+ ...(operation ? {gitOperation: true} : {}),
526
+ });
519
527
 
520
528
  return Array.isArray(reads) ? reads.map(mark) : mark(reads);
521
529
  }
@@ -10,6 +10,7 @@ const PROBLEM_SENTENCES = {
10
10
  'project-missing': 'A KitCode project folder is missing, so nothing in it is counted.',
11
11
  'scan-failed': 'KitCode tracker could not read a project, so its = are not being counted.',
12
12
  'git-locked': 'A KitCode project\'s git index has stayed locked (index.lock in its .git folder), so nothing in it is counted until the lock goes; a git command that crashed can leave that file behind.',
13
+ 'git-unfinished': 'A KitCode project has an unfinished git operation (a merge, rebase, cherry-pick, revert, am or squash merge), so nothing in it is counted until that operation is finished or aborted.',
13
14
  };
14
15
  const UNKNOWN_PROBLEM = 'KitCode tracker is working with a problem; its log is ~/.kitcode/tracker.log.';
15
16