@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 +14 -21
- package/bin/kitcode.mjs +10 -10
- package/package.json +2 -2
- package/src/api.mjs +9 -20
- package/src/companion-window.mjs +10 -8
- package/src/git.mjs +48 -8
- package/src/hook-prompt.mjs +6 -3
- package/src/integration-spec.mjs +17 -20
- package/src/reward.mjs +4 -41
- package/src/runtime.mjs +19 -7
- package/src/source-snapshot.mjs +11 -3
- package/src/tracker-problems.mjs +1 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @kitkat-vn/kitcode
|
|
2
2
|
|
|
3
|
-
Local break companion for coding campaigns. This package is the **source of truth** for tracking,
|
|
3
|
+
Local break companion for coding campaigns. This package is the **source of truth** for tracking, the milestones reached, hooks, and the local Mini companion.
|
|
4
4
|
|
|
5
5
|
Published package: `@kitkat-vn/kitcode`
|
|
6
6
|
|
|
@@ -189,26 +189,21 @@ The time target is fixed at `3600` active seconds: no flag or variable changes i
|
|
|
189
189
|
Each milestone needs both enough active time and enough counted `=`:
|
|
190
190
|
|
|
191
191
|
- time target for a percent = `ceil(3600 * percent / 100)` seconds: 360 for 10%, 720 for 20%, 1080 for 30%, 1800 for 50%, and 3600 for 100%
|
|
192
|
-
- equals target comes from the
|
|
192
|
+
- equals target comes from the table below
|
|
193
193
|
|
|
194
|
-
###
|
|
194
|
+
### Milestones
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
| Milestone | Required counted `=` |
|
|
197
|
+
| ---: | ---: |
|
|
198
|
+
| `10%` | 3 |
|
|
199
|
+
| `20%` | 6 |
|
|
200
|
+
| `30%` | 9 |
|
|
201
|
+
| `50%` | 12 |
|
|
202
|
+
| `100%` | 15 |
|
|
197
203
|
|
|
198
|
-
|
|
199
|
-
| ---: | --- | ---: |
|
|
200
|
-
| `10%` | `if(tired){return 10;}` | 3 |
|
|
201
|
-
| `20%` | `takeBreak(20);` | 6 |
|
|
202
|
-
| `30%` | `while(working)break(30);` | 9 |
|
|
204
|
+
Once a milestone is reached, the dashboard shows its message: the campaign's own words, which the campaign's server gives each milestone. The prompt hook and the desktop notification say only that a milestone was reached. `summary` and `awards` show which are ready.
|
|
203
205
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
Shown in summary/awards/dashboard, but not redeemable unless backed by campaign flow.
|
|
207
|
-
|
|
208
|
-
| Milestone | Code | Required counted `=` |
|
|
209
|
-
| ---: | --- | ---: |
|
|
210
|
-
| `50%` | `mediumStake.unlock(50);` | 12 |
|
|
211
|
-
| `100%` | `finalBreak.claim(100);` | 15 |
|
|
206
|
+
Until 2026-10-01 each milestone had a break code, a line of code shown in the dashboard, which recorded it as shown on this machine. The codes are gone, and nothing records one as shown; a code an earlier version recorded as shown stays in `~/.kitcode/state.json`, and its milestone reads as ready.
|
|
212
207
|
|
|
213
208
|
## Codex and Claude integrations
|
|
214
209
|
|
|
@@ -267,18 +262,16 @@ GET /api/projects
|
|
|
267
262
|
GET /api/events
|
|
268
263
|
GET /api/claim/bundle
|
|
269
264
|
GET /companion
|
|
270
|
-
POST /api/reward/redeem
|
|
271
265
|
```
|
|
272
266
|
|
|
273
|
-
The API answers only requests addressed to `127.0.0.1` or `localhost` on its port, and web pages only from an allowed origin (below) or its own. `GET /api/claim/bundle`
|
|
267
|
+
The API answers only requests addressed to `127.0.0.1` or `localhost` on its port, and web pages only from an allowed origin (below) or its own. `GET /api/claim/bundle` needs an allowed origin; every `POST` needs an allowed origin and a JSON body, and none is left that changes anything.
|
|
274
268
|
|
|
275
269
|
`/api/events` is SSE and emits `summary` every second.
|
|
276
270
|
|
|
277
|
-
`POST /api/reward/redeem` accepts optional `{ "tier": 10 }`. If `tier` is omitted, all ready local tiers are redeemed.
|
|
278
|
-
|
|
279
271
|
Legacy endpoints return `410 Gone`:
|
|
280
272
|
|
|
281
273
|
```txt
|
|
274
|
+
POST /api/reward/redeem
|
|
282
275
|
GET /api/projects/:id/commits
|
|
283
276
|
POST /api/projects/:id/start
|
|
284
277
|
POST /api/projects/:id/stop
|
package/bin/kitcode.mjs
CHANGED
|
@@ -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
|
-
//
|
|
293
|
-
//
|
|
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
|
|
300
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
+
"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": "
|
|
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
|
|
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',
|
|
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
|
package/src/companion-window.mjs
CHANGED
|
@@ -26,7 +26,6 @@ button{cursor:pointer}
|
|
|
26
26
|
.metric-value{color:var(--white-text);font-family:var(--font-pixel),var(--font-mono);font-size:24px;line-height:1;margin-bottom:2px;letter-spacing:.5px;white-space:nowrap}
|
|
27
27
|
.metric-label{color:var(--red-color);font-family:var(--font-mono);font-size:7px;font-weight:500;letter-spacing:.3px;text-transform:uppercase;white-space:nowrap}
|
|
28
28
|
.claim-btn{-webkit-app-region:no-drag;background:transparent;color:var(--white-text);font-family:var(--font-mono);font-size:10px;font-weight:500;letter-spacing:1px;padding:3px 12px;cursor:pointer;text-transform:uppercase;border-top:2px solid var(--red-color);border-bottom:2px solid var(--red-color);border-left:1px dashed var(--red-color);border-right:1px dashed var(--red-color);white-space:normal;width:min-content;line-height:1.15;transition:background-color .2s ease}
|
|
29
|
-
.claim-btn[data-ready="true"]{background:rgba(240,48,48,.18)}
|
|
30
29
|
.claim-btn:hover,.claim-btn:focus-visible{background:rgba(240,48,48,.15);outline:none}
|
|
31
30
|
.offline .percentage,.offline .metric-value{color:#6f6f6f}
|
|
32
31
|
.offline .progress-row{color:#6f6f6f}
|
|
@@ -36,8 +35,8 @@ ${COMPANION_HIDE_CSS}
|
|
|
36
35
|
const API_BASE=${JSON.stringify(apiBase)};const percent=document.getElementById('percent'),countValue=document.getElementById('countValue'),workedValue=document.getElementById('workedValue'),idleValue=document.getElementById('idleValue'),progressFill=document.getElementById('progressFill'),main=document.getElementById('main'),dashboardButton=document.getElementById('dashboardButton');
|
|
37
36
|
function formatDuration(seconds){const totalSeconds=Math.max(0,Math.floor(Number(seconds)||0));if(totalSeconds<60)return totalSeconds+'s';const hours=Math.floor(totalSeconds/3600),minutes=Math.floor((totalSeconds%3600)/60);if(hours>0)return minutes>0?hours+'h '+minutes+'m':hours+'h';return minutes+'m'}
|
|
38
37
|
function updateProgress(value){progressFill.style.width=Math.max(0,Math.min(100,Number(value)||0))+'%'}
|
|
39
|
-
function render(summary){const reward=summary?.reward||{},global=summary?.global||{},
|
|
40
|
-
function offline(){percent.textContent='--';countValue.textContent='--';workedValue.textContent='--';idleValue.textContent='--';updateProgress(0);
|
|
38
|
+
function render(summary){const reward=summary?.reward||{},global=summary?.global||{},value=Math.max(0,Math.min(100,Math.floor(Number(reward.progressPercent)||0)));percent.textContent=value+'%';countValue.textContent=String(Math.max(0,Math.floor(Number(reward.totalEquals)||0)));workedValue.textContent=formatDuration(global.totalActiveSeconds);idleValue.textContent=formatDuration(global.totalIdleSeconds);updateProgress(value);main.classList.remove('offline')}
|
|
39
|
+
function offline(){percent.textContent='--';countValue.textContent='--';workedValue.textContent='--';idleValue.textContent='--';updateProgress(0);main.classList.add('offline')}
|
|
41
40
|
async function sync(){try{const response=await fetch(API_BASE+'/api/summary');if(!response.ok)throw new Error();render(await response.json())}catch{offline()}}
|
|
42
41
|
const hideButton=document.getElementById('hideButton');function hideCompanion(){try{const result=window.kitcodeCompanion?.hide?.();if(result&&typeof result.catch==='function')result.catch(()=>window.close());if(!result)window.close()}catch{window.close()}}
|
|
43
42
|
hideButton.addEventListener('pointerdown',(event)=>event.stopPropagation());hideButton.addEventListener('click',(event)=>{event.stopPropagation();hideCompanion()});
|
|
@@ -46,12 +45,15 @@ dashboardButton.addEventListener('click',()=>{if(window.kitcodeCompanion?.openDa
|
|
|
46
45
|
sync();const events=new EventSource(API_BASE+'/api/events');events.addEventListener('summary',(event)=>{try{render(JSON.parse(event.data))}catch{offline()}});events.onerror=offline;
|
|
47
46
|
`;
|
|
48
47
|
|
|
49
|
-
// The button opens the dashboard, where
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
48
|
+
// The button opens the dashboard, where each milestone reached shows its message: "See more". Until 2026-10-01 it was
|
|
49
|
+
// "Show code", and lit while a 10–30 % code was ready to show there; the break codes are gone (the operator's decision
|
|
50
|
+
// 6), and a milestone reached stays reached, so nothing lights it now. The Mini cannot show a message itself: the
|
|
51
|
+
// messages are the claim backend's, and the tracker, which serves this page, never talks to it. Two short words, which
|
|
52
|
+
// wrap onto two lines, as KITCODE COUNTER does on the left: "Dashboard" or "Messages" took 28 to 35 px more, and the
|
|
53
|
+
// counters then ran into each other at long-campaign values (measured in the 560 x 76 window with 2500 = and
|
|
54
|
+
// 11h 48m, 2026-09-24 and 2026-10-01).
|
|
53
55
|
return `<!doctype html>
|
|
54
56
|
<html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${contentSecurityPolicy({script, style, connect: [apiBase || "'self'"]})}"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
55
57
|
<title>KitCode Counter</title><style>${style}</style></head>
|
|
56
|
-
<body><main class="widget-window" id="main" data-testid="companion-counter-bar">${renderCompanionTitleBar()}<div class="widget-body"><div class="brand-label" aria-label="KitCode Counter">KITCODE<br>COUNTER</div><strong class="percentage" id="percent" data-testid="mini-percent">--</strong><div class="progress-box" aria-hidden="true"><div class="progress-fill" id="progressFill"><div class="progress-row">========================</div><div class="progress-row">========================</div></div></div><div class="metrics-group" aria-label="KitCode counters"><div class="metric-item"><div class="metric-value" id="countValue" data-testid="mini-count">--</div><div class="metric-label">= COUNT</div></div><div class="metric-item"><div class="metric-value" id="workedValue" data-testid="mini-worked">--</div><div class="metric-label">WORKED</div></div><div class="metric-item"><div class="metric-value" id="idleValue" data-testid="mini-idle">--</div><div class="metric-label">IDLE</div></div></div><button class="claim-btn" id="dashboardButton" data-testid="companion-dashboard" type="button"
|
|
58
|
+
<body><main class="widget-window" id="main" data-testid="companion-counter-bar">${renderCompanionTitleBar()}<div class="widget-body"><div class="brand-label" aria-label="KitCode Counter">KITCODE<br>COUNTER</div><strong class="percentage" id="percent" data-testid="mini-percent">--</strong><div class="progress-box" aria-hidden="true"><div class="progress-fill" id="progressFill"><div class="progress-row">========================</div><div class="progress-row">========================</div></div></div><div class="metrics-group" aria-label="KitCode counters"><div class="metric-item"><div class="metric-value" id="countValue" data-testid="mini-count">--</div><div class="metric-label">= COUNT</div></div><div class="metric-item"><div class="metric-value" id="workedValue" data-testid="mini-worked">--</div><div class="metric-label">WORKED</div></div><div class="metric-item"><div class="metric-value" id="idleValue" data-testid="mini-idle">--</div><div class="metric-label">IDLE</div></div></div><button class="claim-btn" id="dashboardButton" data-testid="companion-dashboard" type="button" title="Open the dashboard, where each milestone reached shows its message">See more</button></div></main><script>${script}</script></body></html>`;
|
|
57
59
|
}
|
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
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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};
|
package/src/hook-prompt.mjs
CHANGED
|
@@ -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(`
|
|
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
|
|
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
|
|
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);
|
package/src/integration-spec.mjs
CHANGED
|
@@ -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,
|
|
15
|
-
{percent: 20,
|
|
16
|
-
{percent: 30,
|
|
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,
|
|
21
|
-
{percent: 100,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
205
|
-
return KITCODE_REWARD_TIERS
|
|
206
|
-
.map((
|
|
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,
|
|
342
|
+
'- The CLI/package is the source of truth for tracking, the milestones reached, and hook output.',
|
|
345
343
|
'',
|
|
346
344
|
'Reward model:',
|
|
347
|
-
|
|
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
|
|
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
|
-
|
|
229
|
-
|
|
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 = {}) {
|
package/src/source-snapshot.mjs
CHANGED
|
@@ -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) => ({
|
|
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
|
}
|
package/src/tracker-problems.mjs
CHANGED
|
@@ -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
|
|