kimaki 0.21.0 → 0.22.0
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/dist/cli-commands/project.js +82 -15
- package/dist/cli-commands/send.js +43 -3
- package/dist/cli-runner.js +57 -1
- package/dist/commands/btw.js +4 -3
- package/dist/commands/new-worktree.js +14 -2
- package/dist/commands/session.js +66 -13
- package/dist/commands/tasks.js +10 -3
- package/dist/commands/user-command.js +3 -2
- package/dist/discord-bot.js +38 -8
- package/dist/message-preprocessing.js +9 -2
- package/dist/system-message.js +20 -8
- package/dist/system-message.test.js +17 -8
- package/dist/worktree-lifecycle.e2e.test.js +70 -1
- package/dist/worktrees.js +63 -0
- package/package.json +5 -5
- package/skills/holocron/SKILL.md +23 -0
- package/src/cli-commands/project.ts +100 -17
- package/src/cli-commands/send.ts +56 -5
- package/src/cli-runner.ts +81 -0
- package/src/commands/btw.ts +6 -2
- package/src/commands/new-worktree.ts +14 -1
- package/src/commands/session.ts +79 -17
- package/src/commands/tasks.ts +11 -3
- package/src/commands/user-command.ts +3 -2
- package/src/discord-bot.ts +42 -7
- package/src/message-preprocessing.ts +11 -2
- package/src/system-message.test.ts +17 -8
- package/src/system-message.ts +20 -8
- package/src/worktree-lifecycle.e2e.test.ts +88 -1
- package/src/worktrees.ts +74 -0
package/dist/discord-bot.js
CHANGED
|
@@ -16,7 +16,7 @@ import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
|
16
16
|
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
|
|
17
17
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
18
18
|
import YAML from 'yaml';
|
|
19
|
-
import { getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
19
|
+
import { getFileAttachments, getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
20
20
|
import { extractBtwSuffix } from './btw-prefix-detection.js';
|
|
21
21
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
22
22
|
import { forkSessionToBtwThread } from './commands/btw.js';
|
|
@@ -523,9 +523,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
523
523
|
? extractBtwSuffix(message.content || '')
|
|
524
524
|
: null;
|
|
525
525
|
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
526
|
+
const btwSdkDir = worktreeInfo?.status === 'ready' &&
|
|
527
|
+
worktreeInfo.workspace_directory
|
|
528
|
+
? worktreeInfo.workspace_directory
|
|
529
|
+
: projectDirectory;
|
|
526
530
|
const result = await forkSessionToBtwThread({
|
|
527
531
|
sourceThread: thread,
|
|
528
532
|
projectDirectory,
|
|
533
|
+
sdkDirectory: btwSdkDir,
|
|
529
534
|
prompt: btwResult.prompt,
|
|
530
535
|
userId: message.author.id,
|
|
531
536
|
username: message.member?.displayName || message.author.displayName,
|
|
@@ -820,6 +825,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
820
825
|
return;
|
|
821
826
|
if (!message.content)
|
|
822
827
|
return;
|
|
828
|
+
// Discord fires MESSAGE_UPDATE for embed-only updates (link preview
|
|
829
|
+
// unfurling) without the user actually editing the message content.
|
|
830
|
+
// editedTimestamp is null for these; skip them to avoid false queue removals.
|
|
831
|
+
if (!message.editedTimestamp)
|
|
832
|
+
return;
|
|
823
833
|
const channel = message.channel;
|
|
824
834
|
const isThread = [
|
|
825
835
|
ChannelType.PublicThread,
|
|
@@ -917,7 +927,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
917
927
|
return; // Not an auto-start thread
|
|
918
928
|
}
|
|
919
929
|
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
920
|
-
const textAttachmentsContent = await
|
|
930
|
+
const [textAttachmentsContent, fileAttachments] = await Promise.all([
|
|
931
|
+
getTextAttachments(starterMessage),
|
|
932
|
+
getFileAttachments(starterMessage),
|
|
933
|
+
]);
|
|
921
934
|
const messageText = resolveMentions(starterMessage).trim();
|
|
922
935
|
const prompt = textAttachmentsContent
|
|
923
936
|
? `${messageText}\n\n${textAttachmentsContent}`
|
|
@@ -941,22 +954,31 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
941
954
|
});
|
|
942
955
|
return;
|
|
943
956
|
}
|
|
944
|
-
// Start worktree creation concurrently if requested
|
|
957
|
+
// Start worktree creation concurrently if requested via marker OR
|
|
958
|
+
// if the channel/global toggle enables auto-worktrees.
|
|
945
959
|
// The runtime is created immediately so follow-up messages queue
|
|
946
960
|
// naturally; the worktree promise is awaited inside enqueueIncoming.
|
|
961
|
+
const autoWorktreeEnabled = !marker.worktree &&
|
|
962
|
+
!marker.cwd &&
|
|
963
|
+
(store.getState().useWorktrees ||
|
|
964
|
+
(await getChannelWorktreesEnabled(parent.id)));
|
|
965
|
+
const effectiveWorktreeName = marker.worktree ||
|
|
966
|
+
(autoWorktreeEnabled
|
|
967
|
+
? formatAutoWorktreeName(thread.name.slice(0, 50))
|
|
968
|
+
: undefined);
|
|
947
969
|
let worktreePromise;
|
|
948
|
-
if (
|
|
949
|
-
discordLogger.log(`[BOT_SESSION] Creating worktree: ${
|
|
970
|
+
if (effectiveWorktreeName && (await isGitRepositoryRoot(projectDirectory))) {
|
|
971
|
+
discordLogger.log(`[BOT_SESSION] Creating worktree: ${effectiveWorktreeName}`);
|
|
950
972
|
const worktreeStatusMessage = await thread
|
|
951
973
|
.send({
|
|
952
|
-
content: worktreeCreatingMessage(
|
|
974
|
+
content: worktreeCreatingMessage(effectiveWorktreeName),
|
|
953
975
|
flags: SILENT_MESSAGE_FLAGS,
|
|
954
976
|
})
|
|
955
977
|
.catch(() => undefined);
|
|
956
978
|
worktreePromise = createWorktreeInBackground({
|
|
957
979
|
thread,
|
|
958
980
|
starterMessage: worktreeStatusMessage,
|
|
959
|
-
worktreeName:
|
|
981
|
+
worktreeName: effectiveWorktreeName,
|
|
960
982
|
projectDirectory,
|
|
961
983
|
rest: discordClient.rest,
|
|
962
984
|
});
|
|
@@ -964,6 +986,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
964
986
|
else if (marker.worktree) {
|
|
965
987
|
discordLogger.warn(`[BOT_SESSION] Skipping requested worktree for non-git project directory: ${projectDirectory}`);
|
|
966
988
|
}
|
|
989
|
+
else if (autoWorktreeEnabled) {
|
|
990
|
+
discordLogger.warn(`[BOT_SESSION] Skipping auto-worktree for non-git project directory: ${projectDirectory}`);
|
|
991
|
+
}
|
|
967
992
|
// --cwd: reuse an existing project subfolder or worktree directory. Revalidate at bot-time
|
|
968
993
|
// (CLI validated at send-time but the path could become stale).
|
|
969
994
|
// Only worktree directories are stored in thread_workspaces. Project
|
|
@@ -1054,7 +1079,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1054
1079
|
const permissionRules = await getChannelReferencePermissionRules({
|
|
1055
1080
|
message: starterMessage,
|
|
1056
1081
|
});
|
|
1057
|
-
return {
|
|
1082
|
+
return {
|
|
1083
|
+
prompt,
|
|
1084
|
+
permissionRules,
|
|
1085
|
+
mode: 'opencode',
|
|
1086
|
+
...(fileAttachments.length > 0 && { images: fileAttachments }),
|
|
1087
|
+
};
|
|
1058
1088
|
},
|
|
1059
1089
|
});
|
|
1060
1090
|
}
|
|
@@ -276,15 +276,22 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
const qs = extractQueueSuffix(prompt);
|
|
279
|
+
const fileAttachments = await getFileAttachments(message);
|
|
280
|
+
const textAttachmentsContent = await getTextAttachments(message);
|
|
281
|
+
const finalPrompt = textAttachmentsContent
|
|
282
|
+
? `${qs.prompt}\n\n${textAttachmentsContent}`
|
|
283
|
+
: qs.prompt;
|
|
279
284
|
if (shouldSkipEmptyPrompt({
|
|
280
285
|
message,
|
|
281
|
-
prompt:
|
|
286
|
+
prompt: finalPrompt,
|
|
287
|
+
images: fileAttachments,
|
|
282
288
|
hasVoiceAttachment,
|
|
283
289
|
})) {
|
|
284
290
|
return { prompt: '', mode: 'opencode', skip: true };
|
|
285
291
|
}
|
|
286
292
|
return {
|
|
287
|
-
prompt:
|
|
293
|
+
prompt: finalPrompt,
|
|
294
|
+
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
288
295
|
repliedMessage,
|
|
289
296
|
permissionRules,
|
|
290
297
|
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
package/dist/system-message.js
CHANGED
|
@@ -226,6 +226,9 @@ ${escapePromptText(repliedMessage.text)}
|
|
|
226
226
|
export function getOpencodeSystemMessage({ sessionId, channelId, guildId, threadId, channelTopic, agents, userId, parentSessionId, }) {
|
|
227
227
|
const userArg = ` --user '${userId || '<discord-user-id>'}'`;
|
|
228
228
|
const parentSessionArg = ` --parent-session ${sessionId}`;
|
|
229
|
+
// Prefer thread ID for cross-machine compatibility; fall back to session ID.
|
|
230
|
+
const archiveTarget = threadId ? `${threadId} (or --session ${sessionId})` : `--session ${sessionId}`;
|
|
231
|
+
const sendToSelfTarget = threadId ? `--thread ${threadId} (or --session ${sessionId})` : `--session ${sessionId}`;
|
|
229
232
|
const topicContext = channelTopic?.trim()
|
|
230
233
|
? `\n\n<channel-topic>\n${channelTopic.trim()}\n</channel-topic>`
|
|
231
234
|
: '';
|
|
@@ -320,7 +323,7 @@ To ask the user to upload files from their device, use the \`kimaki_file_upload\
|
|
|
320
323
|
|
|
321
324
|
To archive the current Discord thread (hide it from sidebar) and stop the session, run:
|
|
322
325
|
|
|
323
|
-
kimaki session archive
|
|
326
|
+
kimaki session archive ${archiveTarget}
|
|
324
327
|
|
|
325
328
|
Only do this when the user explicitly asks to close or archive the thread, and only after your final message.
|
|
326
329
|
|
|
@@ -366,13 +369,13 @@ To send a prompt to an existing thread instead of creating a new one:
|
|
|
366
369
|
|
|
367
370
|
kimaki send --thread <thread_id> --prompt 'follow-up prompt' --agent <current_agent>
|
|
368
371
|
|
|
369
|
-
Use this when you already have the Discord thread ID.
|
|
372
|
+
Use this when you already have the Discord thread ID. Prefer \`--thread\` over \`--session\` because thread IDs work across machines while session IDs only resolve on the machine that created the session.
|
|
370
373
|
|
|
371
|
-
To send to the thread associated with a known session:
|
|
374
|
+
To send to the thread associated with a known session (same machine only):
|
|
372
375
|
|
|
373
376
|
kimaki send --session <session_id> --prompt 'follow-up prompt' --agent <current_agent>
|
|
374
377
|
|
|
375
|
-
Use this when you have the OpenCode session ID.
|
|
378
|
+
Use this when you only have the OpenCode session ID and the session was created on this machine.
|
|
376
379
|
|
|
377
380
|
Use --notify-only to create a notification thread without starting an AI session:
|
|
378
381
|
|
|
@@ -398,6 +401,11 @@ Important:
|
|
|
398
401
|
- Do NOT tell that prompt to "create a new worktree" again, or it can create recursive worktree threads.
|
|
399
402
|
- Ask the new session to operate on its current checkout only (e.g. "validate current worktree", "run checks in this repo").
|
|
400
403
|
|
|
404
|
+
Use --file to attach local files (images, text files, PDFs) to the message:
|
|
405
|
+
|
|
406
|
+
kimaki send --channel ${channelId} --prompt 'Review this screenshot' --file /path/to/screenshot.png --agent <current_agent>${parentSessionArg}${userArg}
|
|
407
|
+
kimaki send --thread <thread_id> --prompt 'Here is the error log' --file ./error.log --file ./stack-trace.txt --agent <current_agent>
|
|
408
|
+
|
|
401
409
|
Use --agent to specify which agent to use for the session:
|
|
402
410
|
|
|
403
411
|
kimaki send --channel ${channelId} --prompt 'Plan the refactor of the auth module' --agent plan${parentSessionArg}${userArg}
|
|
@@ -450,7 +458,7 @@ Notification strategy for scheduled tasks:
|
|
|
450
458
|
- Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
|
|
451
459
|
- With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
|
|
452
460
|
- If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
|
|
453
|
-
- Example no-op cleanup command: \`kimaki session archive
|
|
461
|
+
- Example no-op cleanup command: \`kimaki session archive ${archiveTarget}\`
|
|
454
462
|
|
|
455
463
|
Manage scheduled tasks with:
|
|
456
464
|
|
|
@@ -466,10 +474,10 @@ Use case patterns:
|
|
|
466
474
|
- Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention the user via Discord ID only when failures require review".
|
|
467
475
|
- Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
|
|
468
476
|
- Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
|
|
469
|
-
- Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive
|
|
477
|
+
- Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive ${archiveTarget}\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
|
|
470
478
|
- Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
|
|
471
479
|
|
|
472
|
-
kimaki send
|
|
480
|
+
kimaki send ${sendToSelfTarget} --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
|
|
473
481
|
|
|
474
482
|
Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp. The \`--notify-only\` flag creates just a notification message without starting a new AI session. The \`<@userId>\` mention ensures the user gets a Discord notification.
|
|
475
483
|
|
|
@@ -573,7 +581,11 @@ If you don't know which project the thread belongs to, try each project from \`k
|
|
|
573
581
|
\`\`\`bash
|
|
574
582
|
# List all registered projects with their channel IDs and guild names
|
|
575
583
|
kimaki project list
|
|
576
|
-
kimaki project list --json # machine-readable output with guild_id, guild_name
|
|
584
|
+
kimaki project list --json # machine-readable output with guild_id, guild_name, is_local
|
|
585
|
+
|
|
586
|
+
# Include projects from other machines (scans Kimaki category in Discord)
|
|
587
|
+
kimaki project list --all
|
|
588
|
+
kimaki project list --all --json # remote projects have is_local: false and directory: null
|
|
577
589
|
|
|
578
590
|
# Resolve by channel name (prefer adding guild_name filter if duplicates exist)
|
|
579
591
|
kimaki project list --json | jq -r '.[] | select(.channel_name == "project-name") | .channel_id + " " + .guild_name + " " + .directory'
|
|
@@ -137,7 +137,7 @@ describe('system-message', () => {
|
|
|
137
137
|
|
|
138
138
|
To archive the current Discord thread (hide it from sidebar) and stop the session, run:
|
|
139
139
|
|
|
140
|
-
kimaki session archive --session ses_123
|
|
140
|
+
kimaki session archive thread_123 (or --session ses_123)
|
|
141
141
|
|
|
142
142
|
Only do this when the user explicitly asks to close or archive the thread, and only after your final message.
|
|
143
143
|
|
|
@@ -182,13 +182,13 @@ describe('system-message', () => {
|
|
|
182
182
|
|
|
183
183
|
kimaki send --thread <thread_id> --prompt 'follow-up prompt' --agent <current_agent>
|
|
184
184
|
|
|
185
|
-
Use this when you already have the Discord thread ID.
|
|
185
|
+
Use this when you already have the Discord thread ID. Prefer \`--thread\` over \`--session\` because thread IDs work across machines while session IDs only resolve on the machine that created the session.
|
|
186
186
|
|
|
187
|
-
To send to the thread associated with a known session:
|
|
187
|
+
To send to the thread associated with a known session (same machine only):
|
|
188
188
|
|
|
189
189
|
kimaki send --session <session_id> --prompt 'follow-up prompt' --agent <current_agent>
|
|
190
190
|
|
|
191
|
-
Use this when you have the OpenCode session ID.
|
|
191
|
+
Use this when you only have the OpenCode session ID and the session was created on this machine.
|
|
192
192
|
|
|
193
193
|
Use --notify-only to create a notification thread without starting an AI session:
|
|
194
194
|
|
|
@@ -214,6 +214,11 @@ describe('system-message', () => {
|
|
|
214
214
|
- Do NOT tell that prompt to "create a new worktree" again, or it can create recursive worktree threads.
|
|
215
215
|
- Ask the new session to operate on its current checkout only (e.g. "validate current worktree", "run checks in this repo").
|
|
216
216
|
|
|
217
|
+
Use --file to attach local files (images, text files, PDFs) to the message:
|
|
218
|
+
|
|
219
|
+
kimaki send --channel chan_123 --prompt 'Review this screenshot' --file /path/to/screenshot.png --agent <current_agent> --parent-session ses_123 --user '<discord-user-id>'
|
|
220
|
+
kimaki send --thread <thread_id> --prompt 'Here is the error log' --file ./error.log --file ./stack-trace.txt --agent <current_agent>
|
|
221
|
+
|
|
217
222
|
Use --agent to specify which agent to use for the session:
|
|
218
223
|
|
|
219
224
|
kimaki send --channel chan_123 --prompt 'Plan the refactor of the auth module' --agent plan --parent-session ses_123 --user '<discord-user-id>'
|
|
@@ -270,7 +275,7 @@ describe('system-message', () => {
|
|
|
270
275
|
- Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
|
|
271
276
|
- With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
|
|
272
277
|
- If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
|
|
273
|
-
- Example no-op cleanup command: \`kimaki session archive --session ses_123\`
|
|
278
|
+
- Example no-op cleanup command: \`kimaki session archive thread_123 (or --session ses_123)\`
|
|
274
279
|
|
|
275
280
|
Manage scheduled tasks with:
|
|
276
281
|
|
|
@@ -286,10 +291,10 @@ describe('system-message', () => {
|
|
|
286
291
|
- Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention the user via Discord ID only when failures require review".
|
|
287
292
|
- Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
|
|
288
293
|
- Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
|
|
289
|
-
- Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive --session ses_123\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
|
|
294
|
+
- Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive thread_123 (or --session ses_123)\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
|
|
290
295
|
- Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
|
|
291
296
|
|
|
292
|
-
kimaki send --session ses_123 --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
|
|
297
|
+
kimaki send --thread thread_123 (or --session ses_123) --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
|
|
293
298
|
|
|
294
299
|
Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp. The \`--notify-only\` flag creates just a notification message without starting a new AI session. The \`<@userId>\` mention ensures the user gets a Discord notification.
|
|
295
300
|
|
|
@@ -393,7 +398,11 @@ describe('system-message', () => {
|
|
|
393
398
|
\`\`\`bash
|
|
394
399
|
# List all registered projects with their channel IDs and guild names
|
|
395
400
|
kimaki project list
|
|
396
|
-
kimaki project list --json # machine-readable output with guild_id, guild_name
|
|
401
|
+
kimaki project list --json # machine-readable output with guild_id, guild_name, is_local
|
|
402
|
+
|
|
403
|
+
# Include projects from other machines (scans Kimaki category in Discord)
|
|
404
|
+
kimaki project list --all
|
|
405
|
+
kimaki project list --all --json # remote projects have is_local: false and directory: null
|
|
397
406
|
|
|
398
407
|
# Resolve by channel name (prefer adding guild_name filter if duplicates exist)
|
|
399
408
|
kimaki project list --json | jq -r '.[] | select(.channel_name == "project-name") | .channel_id + " " + .guild_name + " " + .directory'
|
|
@@ -9,7 +9,8 @@ import fs from 'node:fs';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import url from 'node:url';
|
|
11
11
|
import { describe, beforeAll, afterAll, test, expect } from 'vitest';
|
|
12
|
-
import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
|
|
12
|
+
import { ChannelType, Client, GatewayIntentBits, Partials, Routes } from 'discord.js';
|
|
13
|
+
import YAML from 'yaml';
|
|
13
14
|
import { DigitalDiscord } from 'discord-digital-twin/src';
|
|
14
15
|
import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
|
|
15
16
|
import { setDataDir } from './config.js';
|
|
@@ -35,6 +36,7 @@ function normalizeWorktreeLifecycleText(text) {
|
|
|
35
36
|
.replaceAll(CHANNEL_WORKTREE_NAME, 'CHANNEL_WORKTREE_NAME')
|
|
36
37
|
.replaceAll(AUTO_WORKTREE_SUFFIX, 'AUTO_WORKTREE_NAME')
|
|
37
38
|
.replaceAll(WORKTREE_NAME, 'WORKTREE_NAME')
|
|
39
|
+
.replaceAll(WORKTREE_SUFFIX, 'SUFFIX')
|
|
38
40
|
.replace(/ses_[a-zA-Z0-9]+/g, 'ses_TEST')
|
|
39
41
|
.replace(/<#\d+>/g, '<#THREAD_ID>')
|
|
40
42
|
.replace(/`[^`\n]*\/worktrees\/[^`\n]*`/g, '`/tmp/worktrees/WORKTREE_NAME`');
|
|
@@ -630,4 +632,71 @@ describe('worktree lifecycle', () => {
|
|
|
630
632
|
const okCount = (text.match(/⬥ ok/g) || []).length;
|
|
631
633
|
expect(okCount).toBe(2);
|
|
632
634
|
}, 20_000);
|
|
635
|
+
test('kimaki send --channel auto-creates worktree when channel toggle is enabled', async () => {
|
|
636
|
+
// Simulate `kimaki send --channel AUTO_WORKTREE_CHANNEL_ID --prompt '...'`
|
|
637
|
+
// WITHOUT --worktree flag. The bot-side ThreadCreate handler should detect
|
|
638
|
+
// the channel toggle and auto-create a worktree.
|
|
639
|
+
const prompt = `Reply with exactly: send-auto-wt-${WORKTREE_SUFFIX}`;
|
|
640
|
+
const embedMarker = {
|
|
641
|
+
start: true,
|
|
642
|
+
username: 'worktree-tester',
|
|
643
|
+
userId: TEST_USER_ID,
|
|
644
|
+
};
|
|
645
|
+
// Post starter message (what `kimaki send` does)
|
|
646
|
+
const starterMessage = await discord
|
|
647
|
+
.channel(AUTO_WORKTREE_CHANNEL_ID)
|
|
648
|
+
.bot()
|
|
649
|
+
.sendMessage({
|
|
650
|
+
content: `» **kimaki-cli:**\n${prompt}`,
|
|
651
|
+
embeds: [
|
|
652
|
+
{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } },
|
|
653
|
+
],
|
|
654
|
+
});
|
|
655
|
+
// Create thread on that message (what `kimaki send` does via REST)
|
|
656
|
+
const threadData = (await botClient.rest.post(Routes.threads(AUTO_WORKTREE_CHANNEL_ID, starterMessage.id), {
|
|
657
|
+
body: {
|
|
658
|
+
name: prompt.slice(0, 80),
|
|
659
|
+
auto_archive_duration: 1440,
|
|
660
|
+
},
|
|
661
|
+
}));
|
|
662
|
+
// Wait for worktree creation status message (Branch:)
|
|
663
|
+
await waitForBotMessageContaining({
|
|
664
|
+
discord,
|
|
665
|
+
threadId: threadData.id,
|
|
666
|
+
userId: TEST_USER_ID,
|
|
667
|
+
text: 'Branch:',
|
|
668
|
+
timeout: 25_000,
|
|
669
|
+
});
|
|
670
|
+
// Wait for the bot reply to the prompt. The starter message is from
|
|
671
|
+
// the bot (kimaki-cli), not a user, so just wait for the text to appear.
|
|
672
|
+
await waitForBotMessageContaining({
|
|
673
|
+
discord,
|
|
674
|
+
threadId: threadData.id,
|
|
675
|
+
userId: discord.botUserId,
|
|
676
|
+
text: '⬥ ok',
|
|
677
|
+
timeout: 10_000,
|
|
678
|
+
});
|
|
679
|
+
// Snapshot the thread content
|
|
680
|
+
const th = discord.thread(threadData.id);
|
|
681
|
+
expect(normalizeWorktreeLifecycleText(await th.text())).toMatchInlineSnapshot(`
|
|
682
|
+
"--- from: assistant (TestBot)
|
|
683
|
+
» **kimaki-cli:**
|
|
684
|
+
Reply with exactly: send-auto-wt-SUFFIX
|
|
685
|
+
[embed]
|
|
686
|
+
🌳 **Worktree: opencode/kimaki-rply-wth-exctly-snd-at-wt-l8kct**
|
|
687
|
+
📁 \`/tmp/worktrees/WORKTREE_NAME\`
|
|
688
|
+
🌿 Branch: \`opencode/kimaki-rply-wth-exctly-snd-at-wt-l8kct\`
|
|
689
|
+
*using deterministic-provider/deterministic-v2*
|
|
690
|
+
⬥ ok"
|
|
691
|
+
`);
|
|
692
|
+
// Verify DB has worktree info
|
|
693
|
+
const worktreeInfo = await getThreadWorktreeOrWorkspace(threadData.id);
|
|
694
|
+
expect(worktreeInfo?.status).toBe('ready');
|
|
695
|
+
expect(worktreeInfo?.workspace_directory).toBeTruthy();
|
|
696
|
+
// Verify runtime is in the worktree directory
|
|
697
|
+
const runtime = getRuntime(threadData.id);
|
|
698
|
+
expect(runtime).toBeDefined();
|
|
699
|
+
expect(runtime.sdkDirectory).toContain(`${path.sep}worktrees${path.sep}`);
|
|
700
|
+
expect(runtime.sdkDirectory).not.toBe(directories.projectDirectory);
|
|
701
|
+
}, 35_000);
|
|
633
702
|
});
|
package/dist/worktrees.js
CHANGED
|
@@ -713,6 +713,69 @@ export async function mergeWorktree({ worktreeDir, mainRepoDir, worktreeName, ta
|
|
|
713
713
|
shortSha: shortSha instanceof Error ? 'unknown' : shortSha,
|
|
714
714
|
};
|
|
715
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* Resolve the best git ref for a base branch by checking remote tracking refs.
|
|
718
|
+
* Prefers upstream/<branch> over origin/<branch> over local <branch>.
|
|
719
|
+
* Fetches the remote first so tracking refs are up to date.
|
|
720
|
+
*
|
|
721
|
+
* If the remote is strictly ahead of local, returns the remote ref.
|
|
722
|
+
* If local and remote have diverged (local is both ahead and behind),
|
|
723
|
+
* returns the local branch to avoid needing a merge/rebase.
|
|
724
|
+
* If the branch is already an explicit remote ref (e.g. `origin/main`), skips resolution.
|
|
725
|
+
* Uses `git remote` to distinguish real remote prefixes from local branches
|
|
726
|
+
* containing `/` like `feature/foo`.
|
|
727
|
+
*/
|
|
728
|
+
export async function resolveBestBaseRef({ directory, branch, }) {
|
|
729
|
+
// Check if branch is already an explicit remote ref like "origin/main".
|
|
730
|
+
// Local branches can contain `/` (e.g. "feature/foo"), so we check
|
|
731
|
+
// against actual remote names instead of a naive slash check.
|
|
732
|
+
if (branch.includes('/')) {
|
|
733
|
+
const remotes = await git(directory, 'remote');
|
|
734
|
+
if (!(remotes instanceof Error)) {
|
|
735
|
+
const prefix = branch.slice(0, branch.indexOf('/'));
|
|
736
|
+
if (remotes.split('\n').some((r) => r.trim() === prefix)) {
|
|
737
|
+
return branch;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
for (const remote of ['upstream', 'origin']) {
|
|
742
|
+
// Best-effort fetch with short timeout
|
|
743
|
+
const fetchResult = await git(directory, `fetch ${remote} ${branch}`, {
|
|
744
|
+
timeout: 15_000,
|
|
745
|
+
});
|
|
746
|
+
if (fetchResult instanceof Error)
|
|
747
|
+
continue;
|
|
748
|
+
const remoteRef = `${remote}/${branch}`;
|
|
749
|
+
const refExists = await git(directory, `rev-parse --verify refs/remotes/${remoteRef}`);
|
|
750
|
+
if (refExists instanceof Error)
|
|
751
|
+
continue;
|
|
752
|
+
// Check if local branch exists
|
|
753
|
+
const localExists = await git(directory, `rev-parse --verify refs/heads/${branch}`);
|
|
754
|
+
if (localExists instanceof Error) {
|
|
755
|
+
// No local branch but remote exists — use remote
|
|
756
|
+
return remoteRef;
|
|
757
|
+
}
|
|
758
|
+
// Count commits: remote ahead of local, and local ahead of remote
|
|
759
|
+
const [remoteAhead, localAhead] = await Promise.all([
|
|
760
|
+
git(directory, `rev-list --count refs/heads/${branch}..refs/remotes/${remoteRef}`),
|
|
761
|
+
git(directory, `rev-list --count refs/remotes/${remoteRef}..refs/heads/${branch}`),
|
|
762
|
+
]);
|
|
763
|
+
if (remoteAhead instanceof Error || localAhead instanceof Error)
|
|
764
|
+
continue;
|
|
765
|
+
const remoteAheadCount = parseInt(remoteAhead, 10);
|
|
766
|
+
const localAheadCount = parseInt(localAhead, 10);
|
|
767
|
+
// Diverged (both ahead): use local to avoid needing a merge
|
|
768
|
+
if (remoteAheadCount > 0 && localAheadCount > 0)
|
|
769
|
+
return branch;
|
|
770
|
+
// Remote is strictly ahead: use remote ref
|
|
771
|
+
if (remoteAheadCount > 0)
|
|
772
|
+
return remoteRef;
|
|
773
|
+
// Equal or local is ahead — check next remote instead of returning early,
|
|
774
|
+
// because origin might be ahead even when upstream is equal.
|
|
775
|
+
}
|
|
776
|
+
// No usable remote found — use local branch as-is
|
|
777
|
+
return branch;
|
|
778
|
+
}
|
|
716
779
|
/**
|
|
717
780
|
* List branches sorted by most recent commit date.
|
|
718
781
|
* Returns branch short names (e.g. "main", "origin/feature-x").
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.22.0",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"exports": {
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"lintcn": "^0.7.1",
|
|
32
32
|
"tsx": "^4.20.5",
|
|
33
33
|
"undici": "^8.0.2",
|
|
34
|
-
"db": "^0.0.0",
|
|
35
34
|
"discord-digital-twin": "^0.1.1",
|
|
35
|
+
"opencode-deterministic-provider": "^0.0.1",
|
|
36
36
|
"opencode-cached-provider": "^0.0.1",
|
|
37
|
-
"
|
|
37
|
+
"db": "^0.0.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@ai-sdk/google": "^3.0.53",
|
|
@@ -70,9 +70,9 @@
|
|
|
70
70
|
"zod": "^4.3.6",
|
|
71
71
|
"zustand": "^5.0.11",
|
|
72
72
|
"errore": "^0.14.1",
|
|
73
|
-
"
|
|
73
|
+
"traforo": "^0.7.1",
|
|
74
74
|
"opencode-injection-guard": "^0.2.1",
|
|
75
|
-
"
|
|
75
|
+
"libsqlproxy": "^0.1.0"
|
|
76
76
|
},
|
|
77
77
|
"optionalDependencies": {
|
|
78
78
|
"@snazzah/davey": "^0.1.10",
|
package/skills/holocron/SKILL.md
CHANGED
|
@@ -705,6 +705,29 @@ Accordion apply `no-bleed` automatically to keep descendants inside their frame.
|
|
|
705
705
|
|
|
706
706
|
See https://holocron.so/docs/customize/bleed.md for the full reference.
|
|
707
707
|
|
|
708
|
+
## Custom CSS — prefer CSS variables over class selectors
|
|
709
|
+
|
|
710
|
+
When customizing the look of a Holocron site, **always use CSS variable overrides** in `:root` instead of targeting internal class names, DOM structure, or aria labels. Holocron exposes a set of documented CSS variables for colors, typography, spacing, code blocks, blockquotes, and sidebar navigation. These are stable across Holocron updates; internal markup is not.
|
|
711
|
+
|
|
712
|
+
```css
|
|
713
|
+
/* style.css */
|
|
714
|
+
:root {
|
|
715
|
+
--weight-heading: 700;
|
|
716
|
+
--code-block-background: var(--muted);
|
|
717
|
+
--code-block-border: 1px solid var(--border-subtle);
|
|
718
|
+
--sidebar-link-radius: var(--radius-sm);
|
|
719
|
+
--blockquote-border-color: var(--border-subtle);
|
|
720
|
+
}
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Fetch the full list of available tokens:
|
|
724
|
+
|
|
725
|
+
```bash
|
|
726
|
+
curl -fsSL https://holocron.so/docs/customize/theme.md
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
If a user wants to customize something that has no CSS variable, **do not hack it with fragile selectors**. Instead, open an issue on the Holocron repo (https://github.com/remorses/holocron/issues) requesting a new CSS variable for that use case. Holocron actively adds new variables based on user needs. Internal selectors like `.slot-sidebar-left nav > div:first-child > a` or `figure[class~='group/code'] button[aria-label='Copy code']` will break on any refactor and should never be used.
|
|
730
|
+
|
|
708
731
|
## Agent rules
|
|
709
732
|
|
|
710
733
|
- **Always place MDX pages inside the Holocron `pagesDir`.** Before creating or
|