@kitkat-vn/kitcode 0.2.0-staging.3 → 0.2.0-staging.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -21
- package/bin/kitcode.mjs +182 -13
- package/package.json +2 -2
- package/src/api.mjs +31 -21
- package/src/companion-window.mjs +10 -8
- package/src/diagnostics.mjs +872 -0
- package/src/git.mjs +48 -8
- package/src/hook-prompt.mjs +19 -3
- package/src/integration-spec.mjs +46 -37
- package/src/onboarding-electron.mjs +8 -0
- package/src/onboarding-window.mjs +47 -7
- package/src/reward.mjs +4 -41
- package/src/runtime.mjs +30 -13
- package/src/source-snapshot.mjs +11 -3
- package/src/tracker-problems.mjs +1 -0
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
|
@@ -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(`
|
|
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
|
|
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
|
|
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
|
}
|
package/src/integration-spec.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
15
|
-
{percent: 20,
|
|
16
|
-
{percent: 30,
|
|
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,
|
|
21
|
-
{percent: 100,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.`)
|
|
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
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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,
|
|
354
|
+
'- The CLI/package is the source of truth for tracking, the milestones reached, and hook output.',
|
|
345
355
|
'',
|
|
346
356
|
'Reward model:',
|
|
347
|
-
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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;
|