@otto-assistant/bridge 0.4.102 → 0.4.103
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/agent-model.e2e.test.js +1 -0
- package/dist/anthropic-auth-plugin.js +22 -1
- package/dist/anthropic-auth-state.js +31 -0
- package/dist/btw-prefix-detection.js +17 -0
- package/dist/btw-prefix-detection.test.js +63 -0
- package/dist/cli.js +101 -15
- package/dist/commands/agent.js +21 -2
- package/dist/commands/ask-question.js +50 -4
- package/dist/commands/ask-question.test.js +92 -0
- package/dist/commands/btw.js +71 -66
- package/dist/commands/new-worktree.js +92 -35
- package/dist/commands/queue.js +17 -0
- package/dist/commands/worktrees.js +196 -139
- package/dist/context-awareness-plugin.js +16 -8
- package/dist/context-awareness-plugin.test.js +4 -2
- package/dist/discord-bot.js +35 -2
- package/dist/discord-command-registration.js +9 -2
- package/dist/memory-overview-plugin.js +3 -1
- package/dist/opencode.js +9 -0
- package/dist/queue-question-select-drain.e2e.test.js +135 -10
- package/dist/session-handler/thread-runtime-state.js +27 -0
- package/dist/session-handler/thread-session-runtime.js +58 -28
- package/dist/session-title-rename.test.js +12 -0
- package/dist/skill-filter.js +31 -0
- package/dist/skill-filter.test.js +65 -0
- package/dist/store.js +2 -0
- package/dist/system-message.js +12 -3
- package/dist/system-message.test.js +10 -6
- package/dist/thread-message-queue.e2e.test.js +109 -0
- package/dist/worktree-lifecycle.e2e.test.js +4 -1
- package/dist/worktrees.js +106 -12
- package/dist/worktrees.test.js +232 -6
- package/package.json +2 -2
- package/skills/goke/SKILL.md +13 -619
- package/skills/new-skill/SKILL.md +34 -10
- package/skills/npm-package/SKILL.md +336 -2
- package/skills/profano/SKILL.md +24 -0
- package/skills/zele/SKILL.md +50 -21
- package/src/agent-model.e2e.test.ts +1 -0
- package/src/anthropic-auth-plugin.ts +24 -4
- package/src/anthropic-auth-state.ts +45 -0
- package/src/btw-prefix-detection.test.ts +73 -0
- package/src/btw-prefix-detection.ts +23 -0
- package/src/cli.ts +138 -46
- package/src/commands/agent.ts +24 -2
- package/src/commands/ask-question.test.ts +111 -0
- package/src/commands/ask-question.ts +69 -4
- package/src/commands/btw.ts +105 -85
- package/src/commands/new-worktree.ts +107 -40
- package/src/commands/queue.ts +22 -0
- package/src/commands/worktrees.ts +246 -154
- package/src/context-awareness-plugin.test.ts +4 -2
- package/src/context-awareness-plugin.ts +16 -8
- package/src/discord-bot.ts +40 -2
- package/src/discord-command-registration.ts +12 -2
- package/src/memory-overview-plugin.ts +3 -1
- package/src/opencode.ts +9 -0
- package/src/queue-question-select-drain.e2e.test.ts +174 -10
- package/src/session-handler/thread-runtime-state.ts +36 -1
- package/src/session-handler/thread-session-runtime.ts +72 -32
- package/src/session-title-rename.test.ts +18 -0
- package/src/skill-filter.test.ts +83 -0
- package/src/skill-filter.ts +42 -0
- package/src/store.ts +17 -0
- package/src/system-message.test.ts +10 -6
- package/src/system-message.ts +12 -3
- package/src/thread-message-queue.e2e.test.ts +126 -0
- package/src/worktree-lifecycle.e2e.test.ts +6 -1
- package/src/worktrees.test.ts +274 -9
- package/src/worktrees.ts +144 -23
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Computes opencode permission.skill rules from kimaki's --enable-skill /
|
|
2
|
+
// --disable-skill CLI flags.
|
|
3
|
+
//
|
|
4
|
+
// OpenCode filters skills available to the model via
|
|
5
|
+
// Permission.evaluate("skill", skill.name, agent.permission). We inject a
|
|
6
|
+
// top-level permission.skill ruleset into the generated opencode-config.json
|
|
7
|
+
// so every agent inherits the same whitelist/blacklist via Permission.merge.
|
|
8
|
+
//
|
|
9
|
+
// Whitelist mode: { '*': 'deny', 'name': 'allow', ... }
|
|
10
|
+
// Blacklist mode: { 'name': 'deny', ... }
|
|
11
|
+
// Neither set: undefined (skills are unfiltered)
|
|
12
|
+
//
|
|
13
|
+
// cli.ts validates mutual exclusion of the two flags at startup, so this
|
|
14
|
+
// helper assumes at most one of the two arrays is non-empty.
|
|
15
|
+
|
|
16
|
+
type PermissionAction = 'ask' | 'allow' | 'deny'
|
|
17
|
+
|
|
18
|
+
export type SkillPermissionRule = Record<string, PermissionAction>
|
|
19
|
+
|
|
20
|
+
export function computeSkillPermission({
|
|
21
|
+
enabledSkills,
|
|
22
|
+
disabledSkills,
|
|
23
|
+
}: {
|
|
24
|
+
enabledSkills: string[]
|
|
25
|
+
disabledSkills: string[]
|
|
26
|
+
}): SkillPermissionRule | undefined {
|
|
27
|
+
if (enabledSkills.length > 0) {
|
|
28
|
+
const rules: SkillPermissionRule = { '*': 'deny' }
|
|
29
|
+
for (const name of enabledSkills) {
|
|
30
|
+
rules[name] = 'allow'
|
|
31
|
+
}
|
|
32
|
+
return rules
|
|
33
|
+
}
|
|
34
|
+
if (disabledSkills.length > 0) {
|
|
35
|
+
const rules: SkillPermissionRule = {}
|
|
36
|
+
for (const name of disabledSkills) {
|
|
37
|
+
rules[name] = 'deny'
|
|
38
|
+
}
|
|
39
|
+
return rules
|
|
40
|
+
}
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -65,6 +65,21 @@ export type KimakiState = {
|
|
|
65
65
|
// Read by: system-message.ts (conditionally appends critique instructions).
|
|
66
66
|
critiqueEnabled: boolean
|
|
67
67
|
|
|
68
|
+
// User-specified skill whitelist. When non-empty, only these skill names
|
|
69
|
+
// are injected into the model's system prompt (all others are hidden
|
|
70
|
+
// behind an opencode permission.skill deny-all rule). Mutually exclusive
|
|
71
|
+
// with disabledSkills — cli.ts enforces this at startup.
|
|
72
|
+
// Changes: set once at startup from --enable-skill CLI flag.
|
|
73
|
+
// Read by: opencode.ts when building opencode-config.json.
|
|
74
|
+
enabledSkills: string[]
|
|
75
|
+
|
|
76
|
+
// User-specified skill blacklist. Skills listed here are hidden from the
|
|
77
|
+
// model via opencode permission.skill deny rules. Mutually exclusive with
|
|
78
|
+
// enabledSkills — cli.ts enforces this at startup.
|
|
79
|
+
// Changes: set once at startup from --disable-skill CLI flag.
|
|
80
|
+
// Read by: opencode.ts when building opencode-config.json.
|
|
81
|
+
disabledSkills: string[]
|
|
82
|
+
|
|
68
83
|
// Base URL for Discord REST API calls (default https://discord.com).
|
|
69
84
|
// Overridden when using a gateway-proxy or gateway Discord mode.
|
|
70
85
|
// Changes: set by getBotTokenWithMode() which runs at startup and on
|
|
@@ -114,6 +129,8 @@ export const store = createStore<KimakiState>(() => ({
|
|
|
114
129
|
defaultVerbosity: 'text_and_essential_tools',
|
|
115
130
|
defaultMentionMode: false,
|
|
116
131
|
critiqueEnabled: true,
|
|
132
|
+
enabledSkills: [],
|
|
133
|
+
disabledSkills: [],
|
|
117
134
|
discordBaseUrl: 'https://discord.com',
|
|
118
135
|
gatewayToken: null,
|
|
119
136
|
registeredUserCommands: [],
|
|
@@ -37,7 +37,7 @@ describe('system-message', () => {
|
|
|
37
37
|
Your current Discord thread ID is: thread_123
|
|
38
38
|
Your current Discord guild ID is: guild_123
|
|
39
39
|
|
|
40
|
-
Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts.
|
|
40
|
+
Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts.
|
|
41
41
|
|
|
42
42
|
## permissions
|
|
43
43
|
|
|
@@ -188,6 +188,8 @@ describe('system-message', () => {
|
|
|
188
188
|
- Replace \`@username\` with the relevant user from the current thread context.
|
|
189
189
|
- Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
|
|
190
190
|
- With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
|
|
191
|
+
- 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.
|
|
192
|
+
- Example no-op cleanup command: \`kimaki session archive --session ses_123\`
|
|
191
193
|
|
|
192
194
|
Manage scheduled tasks with:
|
|
193
195
|
|
|
@@ -203,6 +205,7 @@ describe('system-message', () => {
|
|
|
203
205
|
- Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention @username only when failures require review".
|
|
204
206
|
- Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
|
|
205
207
|
- 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.
|
|
208
|
+
- 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.
|
|
206
209
|
- 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:
|
|
207
210
|
|
|
208
211
|
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>
|
|
@@ -615,12 +618,13 @@ describe('system-message', () => {
|
|
|
615
618
|
</system-reminder>
|
|
616
619
|
|
|
617
620
|
<system-reminder>
|
|
618
|
-
This session is running inside a git worktree.
|
|
619
|
-
-
|
|
621
|
+
This session is running inside a git worktree. The working directory (cwd / pwd) has changed. The user expects you to edit files in the new cwd. You MUST operate inside the new worktree from now on.
|
|
622
|
+
- New worktree path (new cwd / pwd, edit files here): /repo/.worktrees/prompt-cache
|
|
620
623
|
- Branch: prompt-cache
|
|
621
|
-
- Main repo: /repo
|
|
622
|
-
Run checks
|
|
623
|
-
</system-reminder>
|
|
624
|
+
- Main repo path (previous folder, DO NOT TOUCH): /repo
|
|
625
|
+
You MUST read, write, and edit files only under the new worktree path /repo/.worktrees/prompt-cache. You MUST NOT read, write, or edit any files under the main repo path /repo — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.
|
|
626
|
+
</system-reminder>
|
|
627
|
+
"
|
|
624
628
|
`)
|
|
625
629
|
})
|
|
626
630
|
})
|
package/src/system-message.ts
CHANGED
|
@@ -324,11 +324,17 @@ ${escapePromptText(repliedMessage.text)}
|
|
|
324
324
|
: []),
|
|
325
325
|
...(worktree && worktreeChanged
|
|
326
326
|
? [
|
|
327
|
-
`<system-reminder>\nThis session is running inside a git worktree.\n-
|
|
327
|
+
`<system-reminder>\nThis session is running inside a git worktree. The working directory (cwd / pwd) has changed. The user expects you to edit files in the new cwd. You MUST operate inside the new worktree from now on.\n- New worktree path (new cwd / pwd, edit files here): ${worktree.worktreeDirectory}\n- Branch: ${worktree.branch}\n- Main repo path (previous folder, DO NOT TOUCH): ${worktree.mainRepoDirectory}\nYou MUST read, write, and edit files only under the new worktree path ${worktree.worktreeDirectory}. You MUST NOT read, write, or edit any files under the main repo path ${worktree.mainRepoDirectory} — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.\n</system-reminder>`,
|
|
328
328
|
]
|
|
329
329
|
: []),
|
|
330
330
|
]
|
|
331
|
-
|
|
331
|
+
if (sections.length === 0) {
|
|
332
|
+
return ''
|
|
333
|
+
}
|
|
334
|
+
// Always end synthetic context with a trailing newline so it does not fuse
|
|
335
|
+
// with the next text part (for example the user's actual prompt) when the
|
|
336
|
+
// model concatenates message parts.
|
|
337
|
+
return `${sections.join('\n\n')}\n`
|
|
332
338
|
}
|
|
333
339
|
|
|
334
340
|
export function getOpencodeSystemMessage({
|
|
@@ -374,7 +380,7 @@ This is required to distinguish essential bash calls from read-only ones in low-
|
|
|
374
380
|
|
|
375
381
|
Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}${threadId ? `\nYour current Discord thread ID is: ${threadId}` : ''}${guildId ? `\nYour current Discord guild ID is: ${guildId}` : ''}
|
|
376
382
|
|
|
377
|
-
Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts.
|
|
383
|
+
Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts.
|
|
378
384
|
|
|
379
385
|
## permissions
|
|
380
386
|
|
|
@@ -523,6 +529,8 @@ Notification strategy for scheduled tasks:
|
|
|
523
529
|
- Replace \`@username\` with the relevant user from the current thread context.
|
|
524
530
|
- Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
|
|
525
531
|
- With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
|
|
532
|
+
- 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.
|
|
533
|
+
- Example no-op cleanup command: \`kimaki session archive --session ${sessionId}\`
|
|
526
534
|
|
|
527
535
|
Manage scheduled tasks with:
|
|
528
536
|
|
|
@@ -538,6 +546,7 @@ Use case patterns:
|
|
|
538
546
|
- Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention @username only when failures require review".
|
|
539
547
|
- Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
|
|
540
548
|
- 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.
|
|
549
|
+
- 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 ${sessionId}\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
|
|
541
550
|
- 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:
|
|
542
551
|
|
|
543
552
|
kimaki send --session ${sessionId} --prompt "Reminder: <@USER_ID> you asked to be reminded about this thread." --send-at "<future_UTC_time>" --notify-only --agent <current_agent>
|
|
@@ -919,6 +919,132 @@ e2eTest('thread message queue ordering', () => {
|
|
|
919
919
|
12_000,
|
|
920
920
|
)
|
|
921
921
|
|
|
922
|
+
test(
|
|
923
|
+
'/clear-queue position clears only that queued message',
|
|
924
|
+
async () => {
|
|
925
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
926
|
+
content: 'Reply with exactly: clear-queue-setup',
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
930
|
+
timeout: 4_000,
|
|
931
|
+
predicate: (t) => {
|
|
932
|
+
return t.name === 'Reply with exactly: clear-queue-setup'
|
|
933
|
+
},
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
const th = discord.thread(thread.id)
|
|
937
|
+
await th.waitForBotReply({ timeout: 4_000 })
|
|
938
|
+
await waitForFooterMessage({
|
|
939
|
+
discord,
|
|
940
|
+
threadId: thread.id,
|
|
941
|
+
timeout: 4_000,
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
await th.user(TEST_USER_ID).runSlashCommand({
|
|
945
|
+
name: 'queue',
|
|
946
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: race-final' }],
|
|
947
|
+
})
|
|
948
|
+
|
|
949
|
+
const { id: secondQueueInteractionId } = await th.user(TEST_USER_ID)
|
|
950
|
+
.runSlashCommand({
|
|
951
|
+
name: 'queue',
|
|
952
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: removed-queued-message' }],
|
|
953
|
+
})
|
|
954
|
+
const secondQueueAck = await th.waitForInteractionAck({
|
|
955
|
+
interactionId: secondQueueInteractionId,
|
|
956
|
+
timeout: 4_000,
|
|
957
|
+
})
|
|
958
|
+
if (!secondQueueAck.messageId) {
|
|
959
|
+
throw new Error('Expected second /queue response message id')
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const secondQueueAckMessage = await waitForMessageById({
|
|
963
|
+
discord,
|
|
964
|
+
threadId: thread.id,
|
|
965
|
+
messageId: secondQueueAck.messageId,
|
|
966
|
+
timeout: 4_000,
|
|
967
|
+
})
|
|
968
|
+
expect(secondQueueAckMessage.content).toContain('Queued message (position 1)')
|
|
969
|
+
|
|
970
|
+
const { id: thirdQueueInteractionId } = await th.user(TEST_USER_ID).runSlashCommand({
|
|
971
|
+
name: 'queue',
|
|
972
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: kept-queued-message' }],
|
|
973
|
+
})
|
|
974
|
+
const thirdQueueAck = await th.waitForInteractionAck({
|
|
975
|
+
interactionId: thirdQueueInteractionId,
|
|
976
|
+
timeout: 4_000,
|
|
977
|
+
})
|
|
978
|
+
if (!thirdQueueAck.messageId) {
|
|
979
|
+
throw new Error('Expected third /queue response message id')
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const thirdQueueAckMessage = await waitForMessageById({
|
|
983
|
+
discord,
|
|
984
|
+
threadId: thread.id,
|
|
985
|
+
messageId: thirdQueueAck.messageId,
|
|
986
|
+
timeout: 4_000,
|
|
987
|
+
})
|
|
988
|
+
expect(thirdQueueAckMessage.content).toContain('Queued message (position 2)')
|
|
989
|
+
|
|
990
|
+
const { id: clearInteractionId } = await th.user(TEST_USER_ID).runSlashCommand({
|
|
991
|
+
name: 'clear-queue',
|
|
992
|
+
options: [{ name: 'position', type: 4, value: 1 }],
|
|
993
|
+
})
|
|
994
|
+
const clearAck = await th.waitForInteractionAck({
|
|
995
|
+
interactionId: clearInteractionId,
|
|
996
|
+
timeout: 4_000,
|
|
997
|
+
})
|
|
998
|
+
if (!clearAck.messageId) {
|
|
999
|
+
throw new Error('Expected /clear-queue response message id')
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const clearAckMessage = await waitForMessageById({
|
|
1003
|
+
discord,
|
|
1004
|
+
threadId: thread.id,
|
|
1005
|
+
messageId: clearAck.messageId,
|
|
1006
|
+
timeout: 4_000,
|
|
1007
|
+
})
|
|
1008
|
+
expect(clearAckMessage.content).toBe('Cleared queued message at position 1')
|
|
1009
|
+
|
|
1010
|
+
await waitForBotMessageContaining({
|
|
1011
|
+
discord,
|
|
1012
|
+
threadId: thread.id,
|
|
1013
|
+
userId: TEST_USER_ID,
|
|
1014
|
+
text: '» **queue-tester:** Reply with exactly: kept-queued-message',
|
|
1015
|
+
afterMessageId: clearAckMessage.id,
|
|
1016
|
+
timeout: 8_000,
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
await waitForFooterMessage({
|
|
1020
|
+
discord,
|
|
1021
|
+
threadId: thread.id,
|
|
1022
|
+
timeout: 8_000,
|
|
1023
|
+
afterMessageIncludes: '⬥ ok',
|
|
1024
|
+
afterAuthorId: discord.botUserId,
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
const threadText = await th.text()
|
|
1028
|
+
expect(threadText).toMatchInlineSnapshot(`
|
|
1029
|
+
"--- from: user (queue-tester)
|
|
1030
|
+
Reply with exactly: clear-queue-setup
|
|
1031
|
+
--- from: assistant (TestBot)
|
|
1032
|
+
⬥ ok
|
|
1033
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
1034
|
+
» **queue-tester:** Reply with exactly: race-final
|
|
1035
|
+
Queued message (position 1)
|
|
1036
|
+
Queued message (position 2)
|
|
1037
|
+
Cleared queued message at position 1
|
|
1038
|
+
⬥ race-final
|
|
1039
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
1040
|
+
» **queue-tester:** Reply with exactly: kept-queued-message"
|
|
1041
|
+
`)
|
|
1042
|
+
expect(threadText).not.toContain('removed-queued-message')
|
|
1043
|
+
expect(threadText).toContain('kept-queued-message')
|
|
1044
|
+
},
|
|
1045
|
+
12_000,
|
|
1046
|
+
)
|
|
1047
|
+
|
|
922
1048
|
test(
|
|
923
1049
|
'queued message waits for running session and then processes next',
|
|
924
1050
|
async () => {
|
|
@@ -373,7 +373,12 @@ describe('worktree lifecycle', () => {
|
|
|
373
373
|
|
|
374
374
|
// sdkDirectory should now point to the worktree path
|
|
375
375
|
expect(runtimeAfter!.sdkDirectory).not.toBe(directories.projectDirectory)
|
|
376
|
-
|
|
376
|
+
// Folder name drops the `opencode-kimaki-` prefix (branch name keeps it).
|
|
377
|
+
// See getManagedWorktreeDirectory in worktrees.ts.
|
|
378
|
+
expect(runtimeAfter!.sdkDirectory).toContain(WORKTREE_NAME)
|
|
379
|
+
expect(runtimeAfter!.sdkDirectory).toContain(
|
|
380
|
+
`${path.sep}worktrees${path.sep}`,
|
|
381
|
+
)
|
|
377
382
|
|
|
378
383
|
// Snapshot uses dynamic worktree name so we verify structure, not exact text
|
|
379
384
|
const text = await th.text()
|
package/src/worktrees.test.ts
CHANGED
|
@@ -8,19 +8,19 @@ import {
|
|
|
8
8
|
buildSubmoduleReferencePlan,
|
|
9
9
|
createWorktreeWithSubmodules,
|
|
10
10
|
execAsync,
|
|
11
|
+
getManagedWorktreeDirectory,
|
|
11
12
|
parseGitmodulesFileContent,
|
|
13
|
+
parseGitWorktreeListPorcelain,
|
|
12
14
|
} from './worktrees.js'
|
|
15
|
+
import {
|
|
16
|
+
formatAutoWorktreeName,
|
|
17
|
+
formatWorktreeName,
|
|
18
|
+
shortenWorktreeSlug,
|
|
19
|
+
} from './commands/new-worktree.js'
|
|
20
|
+
import { setDataDir } from './config.js'
|
|
13
21
|
|
|
14
22
|
const GIT_TIMEOUT_MS = 60_000
|
|
15
23
|
|
|
16
|
-
function gitCommand(args: string[]): string {
|
|
17
|
-
return `git ${args
|
|
18
|
-
.map((arg) => {
|
|
19
|
-
return JSON.stringify(arg)
|
|
20
|
-
})
|
|
21
|
-
.join(' ')}`
|
|
22
|
-
}
|
|
23
|
-
|
|
24
24
|
async function git({
|
|
25
25
|
cwd,
|
|
26
26
|
args,
|
|
@@ -28,7 +28,13 @@ async function git({
|
|
|
28
28
|
cwd: string
|
|
29
29
|
args: string[]
|
|
30
30
|
}): Promise<string> {
|
|
31
|
-
const
|
|
31
|
+
const command = `git ${args
|
|
32
|
+
.map((arg) => {
|
|
33
|
+
return JSON.stringify(arg)
|
|
34
|
+
})
|
|
35
|
+
.join(' ')}`
|
|
36
|
+
|
|
37
|
+
const result = await execAsync(command, {
|
|
32
38
|
cwd,
|
|
33
39
|
timeout: GIT_TIMEOUT_MS,
|
|
34
40
|
})
|
|
@@ -221,4 +227,263 @@ describe('worktrees', () => {
|
|
|
221
227
|
}
|
|
222
228
|
})
|
|
223
229
|
|
|
230
|
+
test('createWorktreeWithSubmodules uses current HEAD even when origin does not have the commit', async () => {
|
|
231
|
+
const sandbox = createTestRoot()
|
|
232
|
+
const parentRemote = path.join(sandbox, 'parent-remote.git')
|
|
233
|
+
const parentLocal = path.join(sandbox, 'parent-local')
|
|
234
|
+
const worktreeName = `opencode/kimaki-local-head-${Date.now()}`
|
|
235
|
+
|
|
236
|
+
let createdWorktreeDirectory = ''
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
await git({ cwd: sandbox, args: ['init', '--bare', '-b', 'main', parentRemote] })
|
|
240
|
+
await git({ cwd: sandbox, args: ['clone', parentRemote, parentLocal] })
|
|
241
|
+
|
|
242
|
+
await git({
|
|
243
|
+
cwd: parentLocal,
|
|
244
|
+
args: ['config', 'user.email', 'kimaki-tests@example.com'],
|
|
245
|
+
})
|
|
246
|
+
await git({
|
|
247
|
+
cwd: parentLocal,
|
|
248
|
+
args: ['config', 'user.name', 'Kimaki Tests'],
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
fs.writeFileSync(path.join(parentLocal, 'README.md'), 'v1\n', 'utf-8')
|
|
252
|
+
await git({ cwd: parentLocal, args: ['add', 'README.md'] })
|
|
253
|
+
await git({ cwd: parentLocal, args: ['commit', '-m', 'v1'] })
|
|
254
|
+
await git({ cwd: parentLocal, args: ['push', 'origin', 'HEAD:main'] })
|
|
255
|
+
|
|
256
|
+
fs.writeFileSync(path.join(parentLocal, 'README.md'), 'v2-local-only\n', 'utf-8')
|
|
257
|
+
await git({ cwd: parentLocal, args: ['commit', '-am', 'v2 local only'] })
|
|
258
|
+
|
|
259
|
+
const localHeadSha = await git({
|
|
260
|
+
cwd: parentLocal,
|
|
261
|
+
args: ['rev-parse', 'HEAD'],
|
|
262
|
+
})
|
|
263
|
+
const originHeadSha = await git({
|
|
264
|
+
cwd: parentLocal,
|
|
265
|
+
args: ['rev-parse', 'origin/main'],
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const worktreeResult = await createWorktreeWithSubmodules({
|
|
269
|
+
directory: parentLocal,
|
|
270
|
+
name: worktreeName,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
if (worktreeResult instanceof Error) {
|
|
274
|
+
throw worktreeResult
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
createdWorktreeDirectory = worktreeResult.directory
|
|
278
|
+
const worktreeHeadSha = await git({
|
|
279
|
+
cwd: createdWorktreeDirectory,
|
|
280
|
+
args: ['rev-parse', 'HEAD'],
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
expect({
|
|
284
|
+
localHeadShaLength: localHeadSha.length,
|
|
285
|
+
originHeadShaLength: originHeadSha.length,
|
|
286
|
+
worktreeHeadShaLength: worktreeHeadSha.length,
|
|
287
|
+
usesLocalOnlyHead: localHeadSha === worktreeHeadSha,
|
|
288
|
+
differsFromOrigin: localHeadSha !== originHeadSha,
|
|
289
|
+
}).toMatchInlineSnapshot(`
|
|
290
|
+
{
|
|
291
|
+
"differsFromOrigin": true,
|
|
292
|
+
"localHeadShaLength": 40,
|
|
293
|
+
"originHeadShaLength": 40,
|
|
294
|
+
"usesLocalOnlyHead": true,
|
|
295
|
+
"worktreeHeadShaLength": 40,
|
|
296
|
+
}
|
|
297
|
+
`)
|
|
298
|
+
} finally {
|
|
299
|
+
if (createdWorktreeDirectory) {
|
|
300
|
+
await git({
|
|
301
|
+
cwd: parentLocal,
|
|
302
|
+
args: ['worktree', 'remove', '--force', createdWorktreeDirectory],
|
|
303
|
+
}).catch(() => {
|
|
304
|
+
return ''
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
fs.rmSync(sandbox, { recursive: true, force: true })
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('shortenWorktreeSlug leaves short slugs alone', () => {
|
|
312
|
+
expect(shortenWorktreeSlug('short-name')).toMatchInlineSnapshot(
|
|
313
|
+
`"short-name"`,
|
|
314
|
+
)
|
|
315
|
+
expect(shortenWorktreeSlug('exactly-twenty-chars')).toMatchInlineSnapshot(
|
|
316
|
+
`"exactly-twenty-chars"`,
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('shortenWorktreeSlug strips vowels from long slugs', () => {
|
|
321
|
+
expect(
|
|
322
|
+
shortenWorktreeSlug('configurable-sidebar-width-by-component'),
|
|
323
|
+
).toMatchInlineSnapshot(`"cnfgrbl-sdbr-wdth-by-cmpnnt"`)
|
|
324
|
+
expect(
|
|
325
|
+
shortenWorktreeSlug('add-dark-mode-toggle-to-settings-page'),
|
|
326
|
+
).toMatchInlineSnapshot(`"add-drk-md-tggl-t-sttngs-pg"`)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('formatWorktreeName keeps user-provided slugs verbatim', () => {
|
|
330
|
+
expect(
|
|
331
|
+
formatWorktreeName('Configurable sidebar width by component'),
|
|
332
|
+
).toMatchInlineSnapshot(`"opencode/kimaki-configurable-sidebar-width-by-component"`)
|
|
333
|
+
expect(formatWorktreeName('my-feature')).toMatchInlineSnapshot(`"opencode/kimaki-my-feature"`)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('formatAutoWorktreeName compresses long auto-derived slugs', () => {
|
|
337
|
+
expect(
|
|
338
|
+
formatAutoWorktreeName('Configurable sidebar width by component'),
|
|
339
|
+
).toMatchInlineSnapshot(`"opencode/kimaki-cnfgrbl-sdbr-wdth-by-cmpnnt"`)
|
|
340
|
+
expect(formatAutoWorktreeName('my-feature')).toMatchInlineSnapshot(`"opencode/kimaki-my-feature"`)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('getManagedWorktreeDirectory writes under kimaki data dir and strips prefix', () => {
|
|
344
|
+
const sandbox = createTestRoot()
|
|
345
|
+
try {
|
|
346
|
+
setDataDir(sandbox)
|
|
347
|
+
const dir = getManagedWorktreeDirectory({
|
|
348
|
+
directory: '/Users/test/projects/my-app',
|
|
349
|
+
name: 'opencode/kimaki-cnfgrbl-sdbr-wdth-by-cmpnnt',
|
|
350
|
+
})
|
|
351
|
+
// Must sit inside <dataDir>/worktrees/<8hash>/<basename>
|
|
352
|
+
const rel = path.relative(sandbox, dir)
|
|
353
|
+
const parts = rel.split(path.sep)
|
|
354
|
+
expect({
|
|
355
|
+
topLevel: parts[0],
|
|
356
|
+
hashLength: parts[1]?.length,
|
|
357
|
+
basename: parts[2],
|
|
358
|
+
partsCount: parts.length,
|
|
359
|
+
}).toMatchInlineSnapshot(`
|
|
360
|
+
{
|
|
361
|
+
"basename": "cnfgrbl-sdbr-wdth-by-cmpnnt",
|
|
362
|
+
"hashLength": 8,
|
|
363
|
+
"partsCount": 3,
|
|
364
|
+
"topLevel": "worktrees",
|
|
365
|
+
}
|
|
366
|
+
`)
|
|
367
|
+
} finally {
|
|
368
|
+
fs.rmSync(sandbox, { recursive: true, force: true })
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
describe('parseGitWorktreeListPorcelain', () => {
|
|
374
|
+
test('parses porcelain output, skips main worktree', () => {
|
|
375
|
+
const output = [
|
|
376
|
+
'worktree /Users/me/project',
|
|
377
|
+
'HEAD abc123',
|
|
378
|
+
'branch refs/heads/main',
|
|
379
|
+
'',
|
|
380
|
+
'worktree /Users/me/.local/share/opencode/worktree/hash/opencode-kimaki-feature',
|
|
381
|
+
'HEAD def456',
|
|
382
|
+
'branch refs/heads/opencode/kimaki-feature',
|
|
383
|
+
'',
|
|
384
|
+
'worktree /Users/me/project-manual-wt',
|
|
385
|
+
'HEAD 789abc',
|
|
386
|
+
'branch refs/heads/my-branch',
|
|
387
|
+
'',
|
|
388
|
+
].join('\n')
|
|
389
|
+
|
|
390
|
+
expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`
|
|
391
|
+
[
|
|
392
|
+
{
|
|
393
|
+
"branch": "opencode/kimaki-feature",
|
|
394
|
+
"detached": false,
|
|
395
|
+
"directory": "/Users/me/.local/share/opencode/worktree/hash/opencode-kimaki-feature",
|
|
396
|
+
"head": "def456",
|
|
397
|
+
"locked": false,
|
|
398
|
+
"prunable": false,
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"branch": "my-branch",
|
|
402
|
+
"detached": false,
|
|
403
|
+
"directory": "/Users/me/project-manual-wt",
|
|
404
|
+
"head": "789abc",
|
|
405
|
+
"locked": false,
|
|
406
|
+
"prunable": false,
|
|
407
|
+
},
|
|
408
|
+
]
|
|
409
|
+
`)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('handles detached HEAD worktrees', () => {
|
|
413
|
+
const output = [
|
|
414
|
+
'worktree /Users/me/project',
|
|
415
|
+
'HEAD abc123',
|
|
416
|
+
'branch refs/heads/main',
|
|
417
|
+
'',
|
|
418
|
+
'worktree /Users/me/detached-wt',
|
|
419
|
+
'HEAD deadbeef',
|
|
420
|
+
'detached',
|
|
421
|
+
'',
|
|
422
|
+
].join('\n')
|
|
423
|
+
|
|
424
|
+
const result = parseGitWorktreeListPorcelain(output)
|
|
425
|
+
expect(result).toMatchInlineSnapshot(`
|
|
426
|
+
[
|
|
427
|
+
{
|
|
428
|
+
"branch": null,
|
|
429
|
+
"detached": true,
|
|
430
|
+
"directory": "/Users/me/detached-wt",
|
|
431
|
+
"head": "deadbeef",
|
|
432
|
+
"locked": false,
|
|
433
|
+
"prunable": false,
|
|
434
|
+
},
|
|
435
|
+
]
|
|
436
|
+
`)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test('parses locked and prunable flags', () => {
|
|
440
|
+
const output = [
|
|
441
|
+
'worktree /Users/me/project',
|
|
442
|
+
'HEAD abc123',
|
|
443
|
+
'branch refs/heads/main',
|
|
444
|
+
'',
|
|
445
|
+
'worktree /Users/me/locked-wt',
|
|
446
|
+
'HEAD aaa111',
|
|
447
|
+
'branch refs/heads/feature-locked',
|
|
448
|
+
'locked portable disk',
|
|
449
|
+
'',
|
|
450
|
+
'worktree /Users/me/prunable-wt',
|
|
451
|
+
'HEAD bbb222',
|
|
452
|
+
'branch refs/heads/stale-branch',
|
|
453
|
+
'prunable gitdir file points to non-existent location',
|
|
454
|
+
'',
|
|
455
|
+
].join('\n')
|
|
456
|
+
|
|
457
|
+
expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`
|
|
458
|
+
[
|
|
459
|
+
{
|
|
460
|
+
"branch": "feature-locked",
|
|
461
|
+
"detached": false,
|
|
462
|
+
"directory": "/Users/me/locked-wt",
|
|
463
|
+
"head": "aaa111",
|
|
464
|
+
"locked": true,
|
|
465
|
+
"prunable": false,
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
"branch": "stale-branch",
|
|
469
|
+
"detached": false,
|
|
470
|
+
"directory": "/Users/me/prunable-wt",
|
|
471
|
+
"head": "bbb222",
|
|
472
|
+
"locked": false,
|
|
473
|
+
"prunable": true,
|
|
474
|
+
},
|
|
475
|
+
]
|
|
476
|
+
`)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test('returns empty array when only main worktree exists', () => {
|
|
480
|
+
const output = [
|
|
481
|
+
'worktree /Users/me/project',
|
|
482
|
+
'HEAD abc123',
|
|
483
|
+
'branch refs/heads/main',
|
|
484
|
+
'',
|
|
485
|
+
].join('\n')
|
|
486
|
+
|
|
487
|
+
expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`[]`)
|
|
488
|
+
})
|
|
224
489
|
})
|