@otto-assistant/bridge 0.4.101 → 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 +24 -1
  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 +31 -1
  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
@@ -748,6 +748,115 @@ e2eTest('thread message queue ordering', () => {
748
748
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
749
749
  `);
750
750
  }, 12_000);
751
+ test('/clear-queue position clears only that queued message', async () => {
752
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
753
+ content: 'Reply with exactly: clear-queue-setup',
754
+ });
755
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
756
+ timeout: 4_000,
757
+ predicate: (t) => {
758
+ return t.name === 'Reply with exactly: clear-queue-setup';
759
+ },
760
+ });
761
+ const th = discord.thread(thread.id);
762
+ await th.waitForBotReply({ timeout: 4_000 });
763
+ await waitForFooterMessage({
764
+ discord,
765
+ threadId: thread.id,
766
+ timeout: 4_000,
767
+ });
768
+ await th.user(TEST_USER_ID).runSlashCommand({
769
+ name: 'queue',
770
+ options: [{ name: 'message', type: 3, value: 'Reply with exactly: race-final' }],
771
+ });
772
+ const { id: secondQueueInteractionId } = await th.user(TEST_USER_ID)
773
+ .runSlashCommand({
774
+ name: 'queue',
775
+ options: [{ name: 'message', type: 3, value: 'Reply with exactly: removed-queued-message' }],
776
+ });
777
+ const secondQueueAck = await th.waitForInteractionAck({
778
+ interactionId: secondQueueInteractionId,
779
+ timeout: 4_000,
780
+ });
781
+ if (!secondQueueAck.messageId) {
782
+ throw new Error('Expected second /queue response message id');
783
+ }
784
+ const secondQueueAckMessage = await waitForMessageById({
785
+ discord,
786
+ threadId: thread.id,
787
+ messageId: secondQueueAck.messageId,
788
+ timeout: 4_000,
789
+ });
790
+ expect(secondQueueAckMessage.content).toContain('Queued message (position 1)');
791
+ const { id: thirdQueueInteractionId } = await th.user(TEST_USER_ID).runSlashCommand({
792
+ name: 'queue',
793
+ options: [{ name: 'message', type: 3, value: 'Reply with exactly: kept-queued-message' }],
794
+ });
795
+ const thirdQueueAck = await th.waitForInteractionAck({
796
+ interactionId: thirdQueueInteractionId,
797
+ timeout: 4_000,
798
+ });
799
+ if (!thirdQueueAck.messageId) {
800
+ throw new Error('Expected third /queue response message id');
801
+ }
802
+ const thirdQueueAckMessage = await waitForMessageById({
803
+ discord,
804
+ threadId: thread.id,
805
+ messageId: thirdQueueAck.messageId,
806
+ timeout: 4_000,
807
+ });
808
+ expect(thirdQueueAckMessage.content).toContain('Queued message (position 2)');
809
+ const { id: clearInteractionId } = await th.user(TEST_USER_ID).runSlashCommand({
810
+ name: 'clear-queue',
811
+ options: [{ name: 'position', type: 4, value: 1 }],
812
+ });
813
+ const clearAck = await th.waitForInteractionAck({
814
+ interactionId: clearInteractionId,
815
+ timeout: 4_000,
816
+ });
817
+ if (!clearAck.messageId) {
818
+ throw new Error('Expected /clear-queue response message id');
819
+ }
820
+ const clearAckMessage = await waitForMessageById({
821
+ discord,
822
+ threadId: thread.id,
823
+ messageId: clearAck.messageId,
824
+ timeout: 4_000,
825
+ });
826
+ expect(clearAckMessage.content).toBe('Cleared queued message at position 1');
827
+ await waitForBotMessageContaining({
828
+ discord,
829
+ threadId: thread.id,
830
+ userId: TEST_USER_ID,
831
+ text: '» **queue-tester:** Reply with exactly: kept-queued-message',
832
+ afterMessageId: clearAckMessage.id,
833
+ timeout: 8_000,
834
+ });
835
+ await waitForFooterMessage({
836
+ discord,
837
+ threadId: thread.id,
838
+ timeout: 8_000,
839
+ afterMessageIncludes: '⬥ ok',
840
+ afterAuthorId: discord.botUserId,
841
+ });
842
+ const threadText = await th.text();
843
+ expect(threadText).toMatchInlineSnapshot(`
844
+ "--- from: user (queue-tester)
845
+ Reply with exactly: clear-queue-setup
846
+ --- from: assistant (TestBot)
847
+ ⬥ ok
848
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
849
+ » **queue-tester:** Reply with exactly: race-final
850
+ Queued message (position 1)
851
+ Queued message (position 2)
852
+ Cleared queued message at position 1
853
+ ⬥ race-final
854
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
855
+ » **queue-tester:** Reply with exactly: kept-queued-message"
856
+ `);
857
+ expect(threadText).not.toContain('removed-queued-message');
858
+ expect(threadText).toContain('kept-queued-message');
859
+ }, 12_000);
751
860
  test('queued message waits for running session and then processes next', async () => {
752
861
  // When a new message arrives while a session is running, it queues and
753
862
  // runs after the in-flight request completes.
@@ -293,7 +293,10 @@ describe('worktree lifecycle', () => {
293
293
  expect(runtimeAfter).toBe(runtimeBefore);
294
294
  // sdkDirectory should now point to the worktree path
295
295
  expect(runtimeAfter.sdkDirectory).not.toBe(directories.projectDirectory);
296
- expect(runtimeAfter.sdkDirectory).toContain(`kimaki-${WORKTREE_NAME}`);
296
+ // Folder name drops the `opencode-kimaki-` prefix (branch name keeps it).
297
+ // See getManagedWorktreeDirectory in worktrees.ts.
298
+ expect(runtimeAfter.sdkDirectory).toContain(WORKTREE_NAME);
299
+ expect(runtimeAfter.sdkDirectory).toContain(`${path.sep}worktrees${path.sep}`);
297
300
  // Snapshot uses dynamic worktree name so we verify structure, not exact text
298
301
  const text = await th.text();
299
302
  expect(text).toContain('Reply with exactly: before-worktree');
package/dist/worktrees.js CHANGED
@@ -3,9 +3,9 @@
3
3
  // submodule initialization, and git diff transfer utilities.
4
4
  import crypto from 'node:crypto';
5
5
  import fs from 'node:fs';
6
- import os from 'node:os';
7
6
  import path from 'node:path';
8
7
  import * as errore from 'errore';
8
+ import { getDataDir } from './config.js';
9
9
  import { execAsync } from './exec-async.js';
10
10
  import { createLogger, LogPrefix } from './logger.js';
11
11
  export { execAsync } from './exec-async.js';
@@ -396,10 +396,32 @@ async function validateSubmodulePointers(directory) {
396
396
  async function resolveDefaultWorktreeTarget(directory) {
397
397
  return 'HEAD';
398
398
  }
399
- function getManagedWorktreeDirectory({ directory, name, }) {
400
- const projectHash = crypto.createHash('sha1').update(directory).digest('hex');
401
- const safeName = name.replaceAll('/', '-');
402
- return path.join(os.homedir(), '.local', 'share', 'opencode', 'worktree', projectHash, safeName);
399
+ /**
400
+ * Build the on-disk directory for a managed worktree.
401
+ *
402
+ * Layout: `<kimakiDataDir>/worktrees/<8charProjectHash>/<basename>`
403
+ *
404
+ * - Lives under the kimaki data dir instead of the long
405
+ * `~/.local/share/opencode/worktree/<40-char-hash>/<name>` path so folder
406
+ * names stay short and readable (agents tend to give up and reuse the old
407
+ * worktree when paths get absurdly long).
408
+ * - The 8-char project hash keeps worktrees from different projects that
409
+ * happen to share a slug from colliding.
410
+ * - Strips the `opencode/kimaki-` (or `opencode-kimaki-`) prefix from the
411
+ * folder name since it's redundant noise on disk. The git branch name
412
+ * itself still uses `opencode/kimaki-<slug>` so merge/cleanup logic is
413
+ * unchanged.
414
+ */
415
+ export function getManagedWorktreeDirectory({ directory, name, }) {
416
+ const projectHash = crypto
417
+ .createHash('sha1')
418
+ .update(directory)
419
+ .digest('hex')
420
+ .slice(0, 8);
421
+ const withoutPrefix = name
422
+ .replace(/^opencode\/kimaki-/, '')
423
+ .replaceAll('/', '-');
424
+ return path.join(getDataDir(), 'worktrees', projectHash, withoutPrefix);
403
425
  }
404
426
  /**
405
427
  * Create a worktree using git and initialize git submodules.
@@ -527,19 +549,22 @@ export async function deleteWorktree({ projectDirectory, worktreeDirectory, work
527
549
  }
528
550
  }
529
551
  if (removeResult instanceof Error) {
530
- return new Error(`Failed to remove worktree ${worktreeName}`, {
552
+ return new Error(`Failed to remove worktree ${worktreeName || worktreeDirectory}`, {
531
553
  cause: removeResult,
532
554
  });
533
555
  }
534
- const deleteBranchResult = await git(projectDirectory, `branch -d ${JSON.stringify(worktreeName)}`);
535
- if (deleteBranchResult instanceof Error) {
536
- return new Error(`Failed to delete branch ${worktreeName}`, {
537
- cause: deleteBranchResult,
538
- });
556
+ // Skip branch deletion for detached HEAD worktrees (no branch to delete)
557
+ if (worktreeName) {
558
+ const deleteBranchResult = await git(projectDirectory, `branch -d ${JSON.stringify(worktreeName)}`);
559
+ if (deleteBranchResult instanceof Error) {
560
+ return new Error(`Failed to delete branch ${worktreeName}`, {
561
+ cause: deleteBranchResult,
562
+ });
563
+ }
539
564
  }
540
565
  const pruneResult = await git(projectDirectory, 'worktree prune');
541
566
  if (pruneResult instanceof Error) {
542
- logger.warn(`Failed to prune worktrees after deleting ${worktreeName}`);
567
+ logger.warn(`Failed to prune worktrees after deleting ${worktreeName || worktreeDirectory}`);
543
568
  }
544
569
  }
545
570
  export async function isDirty(dir, opts) {
@@ -894,3 +919,72 @@ export async function validateWorktreeDirectory({ projectDirectory, candidatePat
894
919
  }
895
920
  return absoluteCandidate;
896
921
  }
922
+ function flushGitWorktreeEntry(current) {
923
+ if (!current.directory) {
924
+ return null;
925
+ }
926
+ return {
927
+ directory: current.directory,
928
+ branch: current.branch ?? null,
929
+ head: current.head ?? '',
930
+ detached: current.detached ?? false,
931
+ locked: current.locked ?? false,
932
+ prunable: current.prunable ?? false,
933
+ };
934
+ }
935
+ // Parse `git worktree list --porcelain` output into structured entries.
936
+ // Skips the first entry (the main checkout) since that's the project root.
937
+ export function parseGitWorktreeListPorcelain(output) {
938
+ const entries = [];
939
+ let current = {};
940
+ for (const line of output.split('\n')) {
941
+ if (line.startsWith('worktree ')) {
942
+ const flushed = flushGitWorktreeEntry(current);
943
+ if (flushed) {
944
+ entries.push(flushed);
945
+ }
946
+ current = { directory: line.slice('worktree '.length) };
947
+ continue;
948
+ }
949
+ if (line.startsWith('HEAD ')) {
950
+ current.head = line.slice('HEAD '.length);
951
+ continue;
952
+ }
953
+ if (line.startsWith('branch ')) {
954
+ // "branch refs/heads/opencode/kimaki-foo" → "opencode/kimaki-foo"
955
+ current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
956
+ continue;
957
+ }
958
+ if (line === 'detached') {
959
+ current.detached = true;
960
+ continue;
961
+ }
962
+ // "locked" or "locked <reason>"
963
+ if (line === 'locked' || line.startsWith('locked ')) {
964
+ current.locked = true;
965
+ continue;
966
+ }
967
+ if (line.startsWith('prunable')) {
968
+ current.prunable = true;
969
+ continue;
970
+ }
971
+ }
972
+ // Flush last entry
973
+ const flushed = flushGitWorktreeEntry(current);
974
+ if (flushed) {
975
+ entries.push(flushed);
976
+ }
977
+ // Skip the first entry — it's the main checkout (project root)
978
+ return entries.slice(1);
979
+ }
980
+ // List all git worktrees for a project directory (excluding the main checkout).
981
+ // Returns Error on git failure, empty array if no worktrees exist.
982
+ export async function listGitWorktrees({ projectDirectory, timeout, }) {
983
+ const result = await git(projectDirectory, 'worktree list --porcelain', {
984
+ timeout,
985
+ });
986
+ if (result instanceof Error) {
987
+ return result;
988
+ }
989
+ return parseGitWorktreeListPorcelain(result);
990
+ }
@@ -3,17 +3,17 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { describe, expect, test } from 'vitest';
6
- import { buildSubmoduleReferencePlan, createWorktreeWithSubmodules, execAsync, parseGitmodulesFileContent, } from './worktrees.js';
6
+ import { buildSubmoduleReferencePlan, createWorktreeWithSubmodules, execAsync, getManagedWorktreeDirectory, parseGitmodulesFileContent, parseGitWorktreeListPorcelain, } from './worktrees.js';
7
+ import { formatAutoWorktreeName, formatWorktreeName, shortenWorktreeSlug, } from './commands/new-worktree.js';
8
+ import { setDataDir } from './config.js';
7
9
  const GIT_TIMEOUT_MS = 60_000;
8
- function gitCommand(args) {
9
- return `git ${args
10
+ async function git({ cwd, args, }) {
11
+ const command = `git ${args
10
12
  .map((arg) => {
11
13
  return JSON.stringify(arg);
12
14
  })
13
15
  .join(' ')}`;
14
- }
15
- async function git({ cwd, args, }) {
16
- const result = await execAsync(gitCommand(args), {
16
+ const result = await execAsync(command, {
17
17
  cwd,
18
18
  timeout: GIT_TIMEOUT_MS,
19
19
  });
@@ -186,4 +186,230 @@ describe('worktrees', () => {
186
186
  fs.rmSync(sandbox, { recursive: true, force: true });
187
187
  }
188
188
  });
189
+ test('createWorktreeWithSubmodules uses current HEAD even when origin does not have the commit', async () => {
190
+ const sandbox = createTestRoot();
191
+ const parentRemote = path.join(sandbox, 'parent-remote.git');
192
+ const parentLocal = path.join(sandbox, 'parent-local');
193
+ const worktreeName = `opencode/kimaki-local-head-${Date.now()}`;
194
+ let createdWorktreeDirectory = '';
195
+ try {
196
+ await git({ cwd: sandbox, args: ['init', '--bare', '-b', 'main', parentRemote] });
197
+ await git({ cwd: sandbox, args: ['clone', parentRemote, parentLocal] });
198
+ await git({
199
+ cwd: parentLocal,
200
+ args: ['config', 'user.email', 'kimaki-tests@example.com'],
201
+ });
202
+ await git({
203
+ cwd: parentLocal,
204
+ args: ['config', 'user.name', 'Kimaki Tests'],
205
+ });
206
+ fs.writeFileSync(path.join(parentLocal, 'README.md'), 'v1\n', 'utf-8');
207
+ await git({ cwd: parentLocal, args: ['add', 'README.md'] });
208
+ await git({ cwd: parentLocal, args: ['commit', '-m', 'v1'] });
209
+ await git({ cwd: parentLocal, args: ['push', 'origin', 'HEAD:main'] });
210
+ fs.writeFileSync(path.join(parentLocal, 'README.md'), 'v2-local-only\n', 'utf-8');
211
+ await git({ cwd: parentLocal, args: ['commit', '-am', 'v2 local only'] });
212
+ const localHeadSha = await git({
213
+ cwd: parentLocal,
214
+ args: ['rev-parse', 'HEAD'],
215
+ });
216
+ const originHeadSha = await git({
217
+ cwd: parentLocal,
218
+ args: ['rev-parse', 'origin/main'],
219
+ });
220
+ const worktreeResult = await createWorktreeWithSubmodules({
221
+ directory: parentLocal,
222
+ name: worktreeName,
223
+ });
224
+ if (worktreeResult instanceof Error) {
225
+ throw worktreeResult;
226
+ }
227
+ createdWorktreeDirectory = worktreeResult.directory;
228
+ const worktreeHeadSha = await git({
229
+ cwd: createdWorktreeDirectory,
230
+ args: ['rev-parse', 'HEAD'],
231
+ });
232
+ expect({
233
+ localHeadShaLength: localHeadSha.length,
234
+ originHeadShaLength: originHeadSha.length,
235
+ worktreeHeadShaLength: worktreeHeadSha.length,
236
+ usesLocalOnlyHead: localHeadSha === worktreeHeadSha,
237
+ differsFromOrigin: localHeadSha !== originHeadSha,
238
+ }).toMatchInlineSnapshot(`
239
+ {
240
+ "differsFromOrigin": true,
241
+ "localHeadShaLength": 40,
242
+ "originHeadShaLength": 40,
243
+ "usesLocalOnlyHead": true,
244
+ "worktreeHeadShaLength": 40,
245
+ }
246
+ `);
247
+ }
248
+ finally {
249
+ if (createdWorktreeDirectory) {
250
+ await git({
251
+ cwd: parentLocal,
252
+ args: ['worktree', 'remove', '--force', createdWorktreeDirectory],
253
+ }).catch(() => {
254
+ return '';
255
+ });
256
+ }
257
+ fs.rmSync(sandbox, { recursive: true, force: true });
258
+ }
259
+ });
260
+ test('shortenWorktreeSlug leaves short slugs alone', () => {
261
+ expect(shortenWorktreeSlug('short-name')).toMatchInlineSnapshot(`"short-name"`);
262
+ expect(shortenWorktreeSlug('exactly-twenty-chars')).toMatchInlineSnapshot(`"exactly-twenty-chars"`);
263
+ });
264
+ test('shortenWorktreeSlug strips vowels from long slugs', () => {
265
+ expect(shortenWorktreeSlug('configurable-sidebar-width-by-component')).toMatchInlineSnapshot(`"cnfgrbl-sdbr-wdth-by-cmpnnt"`);
266
+ expect(shortenWorktreeSlug('add-dark-mode-toggle-to-settings-page')).toMatchInlineSnapshot(`"add-drk-md-tggl-t-sttngs-pg"`);
267
+ });
268
+ test('formatWorktreeName keeps user-provided slugs verbatim', () => {
269
+ expect(formatWorktreeName('Configurable sidebar width by component')).toMatchInlineSnapshot(`"opencode/kimaki-configurable-sidebar-width-by-component"`);
270
+ expect(formatWorktreeName('my-feature')).toMatchInlineSnapshot(`"opencode/kimaki-my-feature"`);
271
+ });
272
+ test('formatAutoWorktreeName compresses long auto-derived slugs', () => {
273
+ expect(formatAutoWorktreeName('Configurable sidebar width by component')).toMatchInlineSnapshot(`"opencode/kimaki-cnfgrbl-sdbr-wdth-by-cmpnnt"`);
274
+ expect(formatAutoWorktreeName('my-feature')).toMatchInlineSnapshot(`"opencode/kimaki-my-feature"`);
275
+ });
276
+ test('getManagedWorktreeDirectory writes under kimaki data dir and strips prefix', () => {
277
+ const sandbox = createTestRoot();
278
+ try {
279
+ setDataDir(sandbox);
280
+ const dir = getManagedWorktreeDirectory({
281
+ directory: '/Users/test/projects/my-app',
282
+ name: 'opencode/kimaki-cnfgrbl-sdbr-wdth-by-cmpnnt',
283
+ });
284
+ // Must sit inside <dataDir>/worktrees/<8hash>/<basename>
285
+ const rel = path.relative(sandbox, dir);
286
+ const parts = rel.split(path.sep);
287
+ expect({
288
+ topLevel: parts[0],
289
+ hashLength: parts[1]?.length,
290
+ basename: parts[2],
291
+ partsCount: parts.length,
292
+ }).toMatchInlineSnapshot(`
293
+ {
294
+ "basename": "cnfgrbl-sdbr-wdth-by-cmpnnt",
295
+ "hashLength": 8,
296
+ "partsCount": 3,
297
+ "topLevel": "worktrees",
298
+ }
299
+ `);
300
+ }
301
+ finally {
302
+ fs.rmSync(sandbox, { recursive: true, force: true });
303
+ }
304
+ });
305
+ });
306
+ describe('parseGitWorktreeListPorcelain', () => {
307
+ test('parses porcelain output, skips main worktree', () => {
308
+ const output = [
309
+ 'worktree /Users/me/project',
310
+ 'HEAD abc123',
311
+ 'branch refs/heads/main',
312
+ '',
313
+ 'worktree /Users/me/.local/share/opencode/worktree/hash/opencode-kimaki-feature',
314
+ 'HEAD def456',
315
+ 'branch refs/heads/opencode/kimaki-feature',
316
+ '',
317
+ 'worktree /Users/me/project-manual-wt',
318
+ 'HEAD 789abc',
319
+ 'branch refs/heads/my-branch',
320
+ '',
321
+ ].join('\n');
322
+ expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`
323
+ [
324
+ {
325
+ "branch": "opencode/kimaki-feature",
326
+ "detached": false,
327
+ "directory": "/Users/me/.local/share/opencode/worktree/hash/opencode-kimaki-feature",
328
+ "head": "def456",
329
+ "locked": false,
330
+ "prunable": false,
331
+ },
332
+ {
333
+ "branch": "my-branch",
334
+ "detached": false,
335
+ "directory": "/Users/me/project-manual-wt",
336
+ "head": "789abc",
337
+ "locked": false,
338
+ "prunable": false,
339
+ },
340
+ ]
341
+ `);
342
+ });
343
+ test('handles detached HEAD worktrees', () => {
344
+ const output = [
345
+ 'worktree /Users/me/project',
346
+ 'HEAD abc123',
347
+ 'branch refs/heads/main',
348
+ '',
349
+ 'worktree /Users/me/detached-wt',
350
+ 'HEAD deadbeef',
351
+ 'detached',
352
+ '',
353
+ ].join('\n');
354
+ const result = parseGitWorktreeListPorcelain(output);
355
+ expect(result).toMatchInlineSnapshot(`
356
+ [
357
+ {
358
+ "branch": null,
359
+ "detached": true,
360
+ "directory": "/Users/me/detached-wt",
361
+ "head": "deadbeef",
362
+ "locked": false,
363
+ "prunable": false,
364
+ },
365
+ ]
366
+ `);
367
+ });
368
+ test('parses locked and prunable flags', () => {
369
+ const output = [
370
+ 'worktree /Users/me/project',
371
+ 'HEAD abc123',
372
+ 'branch refs/heads/main',
373
+ '',
374
+ 'worktree /Users/me/locked-wt',
375
+ 'HEAD aaa111',
376
+ 'branch refs/heads/feature-locked',
377
+ 'locked portable disk',
378
+ '',
379
+ 'worktree /Users/me/prunable-wt',
380
+ 'HEAD bbb222',
381
+ 'branch refs/heads/stale-branch',
382
+ 'prunable gitdir file points to non-existent location',
383
+ '',
384
+ ].join('\n');
385
+ expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`
386
+ [
387
+ {
388
+ "branch": "feature-locked",
389
+ "detached": false,
390
+ "directory": "/Users/me/locked-wt",
391
+ "head": "aaa111",
392
+ "locked": true,
393
+ "prunable": false,
394
+ },
395
+ {
396
+ "branch": "stale-branch",
397
+ "detached": false,
398
+ "directory": "/Users/me/prunable-wt",
399
+ "head": "bbb222",
400
+ "locked": false,
401
+ "prunable": true,
402
+ },
403
+ ]
404
+ `);
405
+ });
406
+ test('returns empty array when only main worktree exists', () => {
407
+ const output = [
408
+ 'worktree /Users/me/project',
409
+ 'HEAD abc123',
410
+ 'branch refs/heads/main',
411
+ '',
412
+ ].join('\n');
413
+ expect(parseGitWorktreeListPorcelain(output)).toMatchInlineSnapshot(`[]`);
414
+ });
189
415
  });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@otto-assistant/bridge",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.101",
5
+ "version": "0.4.103",
6
6
  "scripts": {
7
7
  "dev": "tsx src/bin.ts",
8
8
  "prepublishOnly": "pnpm generate && pnpm tsc",
@@ -70,7 +70,7 @@
70
70
  "discord.js": "^14.25.1",
71
71
  "domhandler": "^6.0.1",
72
72
  "errore": "workspace:^",
73
- "goke": "^6.3.2",
73
+ "goke": "^6.6.0",
74
74
  "htmlparser2": "^12.0.0",
75
75
  "kitty-graphics-agent": "^0.0.5",
76
76
  "libsql": "^0.5.22",