@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.
Files changed (70) hide show
  1. package/dist/agent-model.e2e.test.js +1 -0
  2. package/dist/anthropic-auth-plugin.js +22 -1
  3. package/dist/anthropic-auth-state.js +31 -0
  4. package/dist/btw-prefix-detection.js +17 -0
  5. package/dist/btw-prefix-detection.test.js +63 -0
  6. package/dist/cli.js +101 -15
  7. package/dist/commands/agent.js +21 -2
  8. package/dist/commands/ask-question.js +50 -4
  9. package/dist/commands/ask-question.test.js +92 -0
  10. package/dist/commands/btw.js +71 -66
  11. package/dist/commands/new-worktree.js +92 -35
  12. package/dist/commands/queue.js +17 -0
  13. package/dist/commands/worktrees.js +196 -139
  14. package/dist/context-awareness-plugin.js +16 -8
  15. package/dist/context-awareness-plugin.test.js +4 -2
  16. package/dist/discord-bot.js +35 -2
  17. package/dist/discord-command-registration.js +9 -2
  18. package/dist/memory-overview-plugin.js +3 -1
  19. package/dist/opencode.js +9 -0
  20. package/dist/queue-question-select-drain.e2e.test.js +135 -10
  21. package/dist/session-handler/thread-runtime-state.js +27 -0
  22. package/dist/session-handler/thread-session-runtime.js +58 -28
  23. package/dist/session-title-rename.test.js +12 -0
  24. package/dist/skill-filter.js +31 -0
  25. package/dist/skill-filter.test.js +65 -0
  26. package/dist/store.js +2 -0
  27. package/dist/system-message.js +12 -3
  28. package/dist/system-message.test.js +10 -6
  29. package/dist/thread-message-queue.e2e.test.js +109 -0
  30. package/dist/worktree-lifecycle.e2e.test.js +4 -1
  31. package/dist/worktrees.js +106 -12
  32. package/dist/worktrees.test.js +232 -6
  33. package/package.json +2 -2
  34. package/skills/goke/SKILL.md +13 -619
  35. package/skills/new-skill/SKILL.md +34 -10
  36. package/skills/npm-package/SKILL.md +336 -2
  37. package/skills/profano/SKILL.md +24 -0
  38. package/skills/zele/SKILL.md +50 -21
  39. package/src/agent-model.e2e.test.ts +1 -0
  40. package/src/anthropic-auth-plugin.ts +24 -4
  41. package/src/anthropic-auth-state.ts +45 -0
  42. package/src/btw-prefix-detection.test.ts +73 -0
  43. package/src/btw-prefix-detection.ts +23 -0
  44. package/src/cli.ts +138 -46
  45. package/src/commands/agent.ts +24 -2
  46. package/src/commands/ask-question.test.ts +111 -0
  47. package/src/commands/ask-question.ts +69 -4
  48. package/src/commands/btw.ts +105 -85
  49. package/src/commands/new-worktree.ts +107 -40
  50. package/src/commands/queue.ts +22 -0
  51. package/src/commands/worktrees.ts +246 -154
  52. package/src/context-awareness-plugin.test.ts +4 -2
  53. package/src/context-awareness-plugin.ts +16 -8
  54. package/src/discord-bot.ts +40 -2
  55. package/src/discord-command-registration.ts +12 -2
  56. package/src/memory-overview-plugin.ts +3 -1
  57. package/src/opencode.ts +9 -0
  58. package/src/queue-question-select-drain.e2e.test.ts +174 -10
  59. package/src/session-handler/thread-runtime-state.ts +36 -1
  60. package/src/session-handler/thread-session-runtime.ts +72 -32
  61. package/src/session-title-rename.test.ts +18 -0
  62. package/src/skill-filter.test.ts +83 -0
  63. package/src/skill-filter.ts +42 -0
  64. package/src/store.ts +17 -0
  65. package/src/system-message.test.ts +10 -6
  66. package/src/system-message.ts +12 -3
  67. package/src/thread-message-queue.e2e.test.ts +126 -0
  68. package/src/worktree-lifecycle.e2e.test.ts +6 -1
  69. package/src/worktrees.test.ts +274 -9
  70. 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. Worktree reminders are emitted only when the worktree changes.
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
- - Worktree path: /repo/.worktrees/prompt-cache
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 in this worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.
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
  })
@@ -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- Worktree path: ${worktree.worktreeDirectory}\n- Branch: ${worktree.branch}\n- Main repo: ${worktree.mainRepoDirectory}\nRun checks in this worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.\n</system-reminder>`,
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
- return sections.join('\n\n')
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. Worktree reminders are emitted only when the worktree changes.
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
- expect(runtimeAfter!.sdkDirectory).toContain(`kimaki-${WORKTREE_NAME}`)
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()
@@ -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 result = await execAsync(gitCommand(args), {
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
  })