infra-kit 0.1.105 → 0.1.108

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 (50) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-36d91435-50b4-442f-9f4e-3cc68d776236.jsonl +2 -0
  3. package/.omc/state/agent-replay-afc6290b-40d3-4bef-b3b6-14484c034ab9.jsonl +14 -0
  4. package/.omc/state/agent-replay-e947a3c6-989d-4a60-91dd-6b0ddd827b2d.jsonl +3 -0
  5. package/.omc/state/idle-notif-cooldown.json +1 -1
  6. package/.omc/state/sessions/36d91435-50b4-442f-9f4e-3cc68d776236/pre-tool-advisory-throttle.json +18 -0
  7. package/.omc/state/sessions/36d91435-50b4-442f-9f4e-3cc68d776236/subagent-tracking-state.json +17 -0
  8. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/last-tool-error-state.json +7 -0
  9. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/mission-state.json +89 -0
  10. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/pre-tool-advisory-throttle.json +34 -0
  11. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/ralph-state.json +13 -0
  12. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/skill-active-state.json +15 -0
  13. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/subagent-tracking-state.json +35 -0
  14. package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/ultrawork-state.json +11 -0
  15. package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/last-tool-error-state.json +7 -0
  16. package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/pre-tool-advisory-throttle.json +10 -0
  17. package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/subagent-tracking-state.json +26 -0
  18. package/.turbo/turbo-build.log +2 -2
  19. package/.turbo/turbo-test.log +43 -6
  20. package/dist/cli.js +61 -54
  21. package/dist/cli.js.map +4 -4
  22. package/dist/mcp.js +31 -31
  23. package/dist/mcp.js.map +4 -4
  24. package/package.json +1 -1
  25. package/src/commands/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/pre-tool-advisory-throttle.json +14 -0
  26. package/src/commands/doctor/__tests__/agent-files.test.ts +110 -0
  27. package/src/commands/doctor/doctor.ts +66 -1
  28. package/src/commands/init/__tests__/agent-files.test.ts +147 -0
  29. package/src/commands/init/agent-files.ts +199 -0
  30. package/src/commands/init/index.ts +7 -0
  31. package/src/commands/init/init.ts +34 -25
  32. package/src/commands/worktrees-add/worktrees-add.ts +13 -1
  33. package/src/commands/worktrees-open/__tests__/open-cmux.test.ts +96 -0
  34. package/src/commands/worktrees-open/worktrees-open.ts +10 -3
  35. package/src/entry/cli.ts +1 -1
  36. package/src/integrations/cmux/__tests__/canonicalize-cmux-title.test.ts +56 -0
  37. package/src/integrations/cmux/__tests__/close-workspace-by-title.test.ts +63 -0
  38. package/src/integrations/cmux/__tests__/list-workspace-titles.test.ts +65 -0
  39. package/src/integrations/cmux/canonicalize-cmux-title.ts +31 -0
  40. package/src/integrations/cmux/close-workspace-by-title.ts +12 -4
  41. package/src/integrations/cmux/index.ts +1 -0
  42. package/src/integrations/cmux/list-workspace-titles.ts +11 -6
  43. package/src/integrations/cmux/open-workspace-with-layout.ts +1 -1
  44. package/src/integrations/gh/gh-release-prs/.omc/state/sessions/c6ed6186-1aac-48e0-aa2a-edc6da8e0410/pre-tool-advisory-throttle.json +10 -0
  45. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +89 -6
  46. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +1 -1
  47. package/src/lib/managed-block/__tests__/managed-block.test.ts +121 -0
  48. package/src/lib/managed-block/index.ts +8 -0
  49. package/src/lib/managed-block/managed-block.ts +145 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -3,7 +3,9 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
 
5
5
  import { logger } from 'src/lib/logger'
6
+ import { removeManagedBlock, upsertManagedBlock } from 'src/lib/managed-block'
6
7
 
8
+ import { writeAgentFiles } from './agent-files'
7
9
  import { migrateLegacyConfig } from './migrate-config'
8
10
 
9
11
  export const MARKER_START = '# -- infra-kit:begin --'
@@ -55,16 +57,22 @@ const USER_GLOBAL_CONFIG_EXAMPLE = `// infra-kit user-global config — ~/.infra
55
57
  */
56
58
  export const init = async (): Promise<void> => {
57
59
  const zshrcPath = path.join(os.homedir(), '.zshrc')
58
- const shellBlock = buildShellBlock()
59
60
 
60
- if (fs.existsSync(zshrcPath)) {
61
- const content = fs.readFileSync(zshrcPath, 'utf-8')
62
- const cleaned = removeExistingBlock(content)
61
+ // Strip any prior block (current or legacy markers) anywhere in the file, then
62
+ // append a fresh block at end-of-file via the shared managed-block utility —
63
+ // the historical `removeExistingBlock` + append behavior, now centralized.
64
+ const existing = fs.existsSync(zshrcPath) ? removeExistingBlock(fs.readFileSync(zshrcPath, 'utf-8')) : ''
63
65
 
64
- fs.writeFileSync(zshrcPath, cleaned)
65
- }
66
+ const updated = upsertManagedBlock({
67
+ content: existing,
68
+ body: buildShellBody(),
69
+ startMarker: MARKER_START,
70
+ endMarker: MARKER_END,
71
+ placement: 'append-end',
72
+ })
73
+
74
+ fs.writeFileSync(zshrcPath, updated)
66
75
 
67
- fs.appendFileSync(zshrcPath, `\n${shellBlock}\n`)
68
76
  logger.info(`Added infra-kit shell functions to ${zshrcPath}`)
69
77
 
70
78
  // Convert any legacy infra-kit.yml config layers to JSON before seeding, so a
@@ -73,6 +81,10 @@ export const init = async (): Promise<void> => {
73
81
 
74
82
  seedUserGlobalConfig()
75
83
 
84
+ // Best-effort, non-fatal, repo-gated: keep the agent-instruction files in sync
85
+ // with the CLI surface. A no-op outside an infra-kit repo.
86
+ await writeAgentFiles()
87
+
76
88
  logger.info('Run `source ~/.zshrc` or open a new terminal to activate.')
77
89
  }
78
90
 
@@ -125,28 +137,15 @@ const isBlockLine = (line: string): boolean => {
125
137
  )
126
138
  }
127
139
 
128
- const removeBetween = (content: string, start: string, end: string): string | null => {
129
- const startIdx = content.indexOf(start)
130
- const endIdx = content.indexOf(end)
131
-
132
- if (startIdx === -1 || endIdx === -1) return null
133
-
134
- // eslint-disable-next-line sonarjs/slow-regex
135
- const before = content.slice(0, startIdx).replace(/\n+$/, '')
136
- const after = content.slice(endIdx + end.length).replace(/^\n+/, '')
137
-
138
- return before + (after ? `\n${after}` : '')
139
- }
140
-
141
140
  const removeExistingBlock = (content: string): string => {
142
141
  // 1. Current markers
143
- const result = removeBetween(content, MARKER_START, MARKER_END)
142
+ const result = removeManagedBlock(content, MARKER_START, MARKER_END)
144
143
 
145
144
  if (result !== null) return result
146
145
 
147
146
  // 2. Legacy paired markers (# region / # endregion)
148
147
  for (const [start, end] of LEGACY_PAIRED) {
149
- const legacyResult = removeBetween(content, start, end)
148
+ const legacyResult = removeManagedBlock(content, start, end)
150
149
 
151
150
  if (legacyResult !== null) return legacyResult
152
151
  }
@@ -171,11 +170,14 @@ const removeExistingBlock = (content: string): string => {
171
170
  return before + (remaining ? `\n${remaining}` : '')
172
171
  }
173
172
 
174
- export const buildShellBlock = (): string => {
173
+ /**
174
+ * The inner shell-function lines (no markers). Composed into the full marked
175
+ * block by {@link buildShellBlock} and fed to `upsertManagedBlock` by `init()`.
176
+ */
177
+ export const buildShellBody = (): string => {
175
178
  const runCmd = 'pnpm exec infra-kit'
176
179
 
177
180
  return [
178
- MARKER_START,
179
181
  'zmodload zsh/stat 2>/dev/null',
180
182
  'zmodload zsh/datetime 2>/dev/null',
181
183
  // eslint-disable-next-line no-template-curly-in-string
@@ -223,6 +225,13 @@ export const buildShellBlock = (): string => {
223
225
  'if (( _INFRA_KIT_SHELL_STARTED > 0 )); then',
224
226
  ' add-zsh-hook precmd _infra_kit_autoload',
225
227
  'fi',
226
- MARKER_END,
227
228
  ].join('\n')
228
229
  }
230
+
231
+ /**
232
+ * The full marker-delimited shell block (`MARKER_START … MARKER_END`). Kept as
233
+ * a single composed string so `doctor`'s exact-match freshness check stays valid.
234
+ */
235
+ export const buildShellBlock = (): string => {
236
+ return `${MARKER_START}\n${buildShellBody()}\n${MARKER_END}`
237
+ }
@@ -6,7 +6,12 @@ import process from 'node:process'
6
6
  import { z } from 'zod'
7
7
  import { $ } from 'zx'
8
8
 
9
- import { buildCmuxWorkspaceTitle, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
9
+ import {
10
+ buildCmuxWorkspaceTitle,
11
+ canonicalizeCmuxTitle,
12
+ listCmuxWorkspaceTitles,
13
+ openCmuxWorkspaceWithLayout,
14
+ } from 'src/integrations/cmux'
10
15
  import { addFoldersToCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
11
16
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
12
17
  import { commandEcho } from 'src/lib/command-echo'
@@ -227,10 +232,17 @@ export const worktreesAdd = async (options: WorktreeManagementArgs) => {
227
232
 
228
233
  if (openInCmux) {
229
234
  const repoName = await getRepoName()
235
+ const existingTitles = await listCmuxWorkspaceTitles()
230
236
 
231
237
  for (const branch of createdWorktrees) {
232
238
  const title = buildCmuxWorkspaceTitle({ repoName, branch })
233
239
 
240
+ // Skip branches whose cmux workspace is already open (canonical match),
241
+ // so re-running worktrees-add never duplicates an existing workspace.
242
+ if (existingTitles.has(canonicalizeCmuxTitle(title))) {
243
+ continue
244
+ }
245
+
234
246
  await openCmuxWorkspaceWithLayout({
235
247
  cwd: `${worktreeDir}/${branch}`,
236
248
  title,
@@ -0,0 +1,96 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { listCmuxWorkspaceTitles, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
4
+ import { getRepoName } from 'src/lib/git-utils'
5
+
6
+ import { openCmux } from '../worktrees-open'
7
+
8
+ vi.mock('src/integrations/cmux', async (importActual) => {
9
+ const actual = await importActual<typeof import('src/integrations/cmux')>()
10
+
11
+ return {
12
+ ...actual,
13
+ listCmuxWorkspaceTitles: vi.fn(),
14
+ openCmuxWorkspaceWithLayout: vi.fn(),
15
+ }
16
+ })
17
+
18
+ vi.mock('src/lib/git-utils', () => {
19
+ return {
20
+ getRepoName: vi.fn(),
21
+ getProjectRoot: vi.fn(),
22
+ getCurrentWorktrees: vi.fn(),
23
+ }
24
+ })
25
+
26
+ const REPO = 'hulyo-monorepo'
27
+ const WORKTREE_DIR = '/repos/project-worktrees'
28
+ const BRANCHES = ['release/v1.48.0', 'release/n/checkout-redesign']
29
+
30
+ const titleFor = {
31
+ versioned: `${REPO} 1.48.0`,
32
+ named: `${REPO} checkout-redesign`,
33
+ }
34
+
35
+ describe('openCmux dedup', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks()
38
+ vi.mocked(getRepoName).mockResolvedValue(REPO)
39
+ vi.mocked(openCmuxWorkspaceWithLayout).mockResolvedValue(undefined)
40
+ })
41
+
42
+ it('opens a workspace for every branch when none are open', async () => {
43
+ vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set())
44
+
45
+ const result = await openCmux({ worktreeDir: WORKTREE_DIR, currentBranches: BRANCHES })
46
+
47
+ expect(result.opened).toEqual([titleFor.versioned, titleFor.named])
48
+ expect(result.skipped).toEqual([])
49
+ expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledTimes(2)
50
+ })
51
+
52
+ it('skips every branch when all are already open (no duplicates)', async () => {
53
+ vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set([titleFor.versioned, titleFor.named]))
54
+
55
+ const result = await openCmux({ worktreeDir: WORKTREE_DIR, currentBranches: BRANCHES })
56
+
57
+ expect(result.opened).toEqual([])
58
+ expect(result.skipped).toEqual([titleFor.versioned, titleFor.named])
59
+ expect(openCmuxWorkspaceWithLayout).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('opens only the missing branch in a mixed state', async () => {
63
+ vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set([titleFor.versioned]))
64
+
65
+ const result = await openCmux({ worktreeDir: WORKTREE_DIR, currentBranches: BRANCHES })
66
+
67
+ expect(result.skipped).toEqual([titleFor.versioned])
68
+ expect(result.opened).toEqual([titleFor.named])
69
+ expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledTimes(1)
70
+ expect(openCmuxWorkspaceWithLayout).toHaveBeenCalledWith({
71
+ cwd: `${WORKTREE_DIR}/release/n/checkout-redesign`,
72
+ title: titleFor.named,
73
+ })
74
+ })
75
+
76
+ it('skips a versioned branch whose workspace was stored under an old v-prefixed title', async () => {
77
+ // listCmuxWorkspaceTitles already returns canonical keys, so an old
78
+ // "hulyo-monorepo v1.48.0" workspace is surfaced as the canonical key below.
79
+ vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set([titleFor.versioned]))
80
+
81
+ const result = await openCmux({ worktreeDir: WORKTREE_DIR, currentBranches: ['release/v1.48.0'] })
82
+
83
+ expect(result.opened).toEqual([])
84
+ expect(result.skipped).toEqual([titleFor.versioned])
85
+ expect(openCmuxWorkspaceWithLayout).not.toHaveBeenCalled()
86
+ })
87
+
88
+ it('returns empty outcome when there are no worktrees', async () => {
89
+ vi.mocked(listCmuxWorkspaceTitles).mockResolvedValue(new Set())
90
+
91
+ const result = await openCmux({ worktreeDir: WORKTREE_DIR, currentBranches: [] })
92
+
93
+ expect(result).toEqual({ ran: true, opened: [], skipped: [] })
94
+ expect(listCmuxWorkspaceTitles).not.toHaveBeenCalled()
95
+ })
96
+ })
@@ -1,7 +1,12 @@
1
1
  import { z } from 'zod'
2
2
  import { $ } from 'zx'
3
3
 
4
- import { buildCmuxWorkspaceTitle, listCmuxWorkspaceTitles, openCmuxWorkspaceWithLayout } from 'src/integrations/cmux'
4
+ import {
5
+ buildCmuxWorkspaceTitle,
6
+ canonicalizeCmuxTitle,
7
+ listCmuxWorkspaceTitles,
8
+ openCmuxWorkspaceWithLayout,
9
+ } from 'src/integrations/cmux'
5
10
  import { reconcileCursorWorkspaceFolders, resolveCursorWorkspacePath } from 'src/integrations/cursor'
6
11
  import { commandEcho } from 'src/lib/command-echo'
7
12
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
@@ -113,7 +118,7 @@ interface OpenCmuxOutcome {
113
118
  skipped: string[]
114
119
  }
115
120
 
116
- const openCmux = async (args: OpenCmuxArgs): Promise<OpenCmuxOutcome> => {
121
+ export const openCmux = async (args: OpenCmuxArgs): Promise<OpenCmuxOutcome> => {
117
122
  const { worktreeDir, currentBranches } = args
118
123
 
119
124
  if (currentBranches.length === 0) {
@@ -129,7 +134,9 @@ const openCmux = async (args: OpenCmuxArgs): Promise<OpenCmuxOutcome> => {
129
134
  for (const branch of currentBranches) {
130
135
  const title = buildCmuxWorkspaceTitle({ repoName, branch })
131
136
 
132
- if (existingTitles.has(title)) {
137
+ // existingTitles holds canonical keys; match the built title the same way so
138
+ // dedup survives whitespace / cross-CLI-version title drift (no duplicates).
139
+ if (existingTitles.has(canonicalizeCmuxTitle(title))) {
133
140
  skipped.push(title)
134
141
  continue
135
142
  }
package/src/entry/cli.ts CHANGED
@@ -286,7 +286,7 @@ program
286
286
 
287
287
  program
288
288
  .command('init')
289
- .description('Inject shell integration into your profile .zshrc')
289
+ .description('Inject shell integration into .zshrc and sync repo agent-instruction files')
290
290
  .action(async () => {
291
291
  await init()
292
292
  })
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { buildCmuxWorkspaceTitle } from 'src/integrations/cmux'
4
+
5
+ import { canonicalizeCmuxTitle } from '../canonicalize-cmux-title'
6
+
7
+ describe('canonicalizeCmuxTitle', () => {
8
+ it('trims and collapses internal whitespace', () => {
9
+ expect(canonicalizeCmuxTitle(' hulyo-monorepo 1.48.0 ')).toBe('hulyo-monorepo 1.48.0')
10
+ })
11
+
12
+ it('normalizes a v-prefixed semver token to its bare form', () => {
13
+ expect(canonicalizeCmuxTitle('hulyo-monorepo v1.48.0')).toBe('hulyo-monorepo 1.48.0')
14
+ })
15
+
16
+ it('treats the v-prefixed and bare semver titles as the same key (cross-version drift)', () => {
17
+ expect(canonicalizeCmuxTitle('hulyo-monorepo v1.48.0')).toBe(canonicalizeCmuxTitle('hulyo-monorepo 1.48.0'))
18
+ })
19
+
20
+ it('does NOT strip a leading v from a named release', () => {
21
+ expect(canonicalizeCmuxTitle('hulyo-monorepo vega-redesign')).toBe('hulyo-monorepo vega-redesign')
22
+ })
23
+
24
+ it('leaves a non-release fallback title containing a slash untouched', () => {
25
+ expect(canonicalizeCmuxTitle('hulyo-monorepo feature/foo')).toBe('hulyo-monorepo feature/foo')
26
+ })
27
+
28
+ it('does not corrupt a non-semver token that merely starts with v', () => {
29
+ expect(canonicalizeCmuxTitle('v8-engine 1.2.3')).toBe('v8-engine 1.2.3')
30
+ expect(canonicalizeCmuxTitle('appv1.2.3 main')).toBe('appv1.2.3 main')
31
+ })
32
+
33
+ it('is idempotent', () => {
34
+ const once = canonicalizeCmuxTitle('hulyo-monorepo v1.48.0 ')
35
+
36
+ expect(canonicalizeCmuxTitle(once)).toBe(once)
37
+ })
38
+
39
+ // The invariant that generalizes the whole fix: the title a workspace is
40
+ // created/renamed with must round-trip through canonicalization to the same
41
+ // key the dedup/close check builds — regardless of which CLI version stored it.
42
+ describe('round-trip invariant', () => {
43
+ it('built title canonicalizes to a stable key for a versioned release', () => {
44
+ const built = buildCmuxWorkspaceTitle({ repoName: 'hulyo-monorepo', branch: 'release/v1.48.0' })
45
+ const storedByOldCli = 'hulyo-monorepo v1.48.0'
46
+
47
+ expect(canonicalizeCmuxTitle(built)).toBe(canonicalizeCmuxTitle(storedByOldCli))
48
+ })
49
+
50
+ it('built title canonicalizes to a stable key for a named release', () => {
51
+ const built = buildCmuxWorkspaceTitle({ repoName: 'hulyo-monorepo', branch: 'release/n/checkout-redesign' })
52
+
53
+ expect(canonicalizeCmuxTitle(built)).toBe('hulyo-monorepo checkout-redesign')
54
+ })
55
+ })
56
+ })
@@ -0,0 +1,63 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { $ } from 'zx'
3
+
4
+ import { closeCmuxWorkspaceByTitle } from '../close-workspace-by-title'
5
+
6
+ const listOutput = vi.hoisted(() => {
7
+ return { value: '' }
8
+ })
9
+
10
+ vi.mock('zx', () => {
11
+ function makeResult(stdout: string) {
12
+ return Object.assign(Promise.resolve({ stdout }), {
13
+ quiet: () => {
14
+ return Promise.resolve({ stdout })
15
+ },
16
+ })
17
+ }
18
+
19
+ return {
20
+ $: vi.fn((strings: TemplateStringsArray) => {
21
+ return strings.join('').includes('list-workspaces') ? makeResult(listOutput.value) : makeResult('')
22
+ }),
23
+ }
24
+ })
25
+
26
+ type CmuxCall = [TemplateStringsArray, ...string[]]
27
+
28
+ function isCloseCall(call: CmuxCall): boolean {
29
+ return call[0].join('').includes('close-workspace')
30
+ }
31
+
32
+ const closeCallRef = (): string | undefined => {
33
+ const calls = vi.mocked($).mock.calls as unknown as CmuxCall[]
34
+
35
+ return calls.find(isCloseCall)?.[1]
36
+ }
37
+
38
+ describe('closeCmuxWorkspaceByTitle', () => {
39
+ beforeEach(() => {
40
+ vi.clearAllMocks()
41
+ listOutput.value = ''
42
+ })
43
+
44
+ it('closes a workspace stored under an old v-prefixed title when asked with the bare-semver title', async () => {
45
+ listOutput.value = [
46
+ ' workspace:8 hulyo-monorepo v1.48.0',
47
+ '* workspace:6 obsidian-workspace [selected]',
48
+ '',
49
+ ].join('\n')
50
+
51
+ await closeCmuxWorkspaceByTitle('hulyo-monorepo 1.48.0')
52
+
53
+ expect(closeCallRef()).toBe('workspace:8')
54
+ })
55
+
56
+ it('does not close anything when no title matches', async () => {
57
+ listOutput.value = ' workspace:8 hulyo-monorepo 2.0.0\n'
58
+
59
+ await closeCmuxWorkspaceByTitle('hulyo-monorepo 1.48.0')
60
+
61
+ expect(closeCallRef()).toBeUndefined()
62
+ })
63
+ })
@@ -0,0 +1,65 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { listCmuxWorkspaceTitles } from '../list-workspace-titles'
4
+
5
+ const listOutput = vi.hoisted(() => {
6
+ return { value: '' }
7
+ })
8
+
9
+ vi.mock('zx', () => {
10
+ function makeResult() {
11
+ return Object.assign(Promise.resolve({ stdout: listOutput.value }), {
12
+ quiet: () => {
13
+ return Promise.resolve({ stdout: listOutput.value })
14
+ },
15
+ })
16
+ }
17
+
18
+ return {
19
+ $: vi.fn(makeResult),
20
+ }
21
+ })
22
+
23
+ describe('listCmuxWorkspaceTitles', () => {
24
+ beforeEach(() => {
25
+ listOutput.value = ''
26
+ })
27
+
28
+ it('parses titles, stripping the [selected] suffix and leading * marker', async () => {
29
+ listOutput.value = [
30
+ ' workspace:8 hulyo-monorepo 1.48.0',
31
+ '* workspace:6 obsidian-workspace [selected]',
32
+ '',
33
+ ].join('\n')
34
+
35
+ const titles = await listCmuxWorkspaceTitles()
36
+
37
+ expect(titles.has('hulyo-monorepo 1.48.0')).toBe(true)
38
+ expect(titles.has('obsidian-workspace')).toBe(true)
39
+ })
40
+
41
+ it('canonicalizes a v-prefixed (old CLI) stored title to the bare-semver key', async () => {
42
+ listOutput.value = ' workspace:8 hulyo-monorepo v1.48.0\n'
43
+
44
+ const titles = await listCmuxWorkspaceTitles()
45
+
46
+ expect(titles.has('hulyo-monorepo 1.48.0')).toBe(true)
47
+ expect(titles.has('hulyo-monorepo v1.48.0')).toBe(false)
48
+ })
49
+
50
+ it('collapses extra internal whitespace into the canonical key', async () => {
51
+ listOutput.value = ' workspace:3 hulyo-monorepo 1.48.0\n'
52
+
53
+ const titles = await listCmuxWorkspaceTitles()
54
+
55
+ expect(titles.has('hulyo-monorepo 1.48.0')).toBe(true)
56
+ })
57
+
58
+ it('returns an empty set when cmux output is empty', async () => {
59
+ listOutput.value = ''
60
+
61
+ const titles = await listCmuxWorkspaceTitles()
62
+
63
+ expect(titles.size).toBe(0)
64
+ })
65
+ })
@@ -0,0 +1,31 @@
1
+ /** Matches a `v`-prefixed semver token (e.g. `v1.48.0`) anchored on shape. */
2
+ const V_SEMVER_TOKEN_RE = /\bv(\d+\.\d+\.\d+)\b/g
3
+
4
+ /**
5
+ * Canonicalizes a cmux workspace title into a stable dedup/close key.
6
+ *
7
+ * cmux workspace titles are human display strings built by
8
+ * `buildCmuxWorkspaceTitle`, so the value stored when a workspace is created can
9
+ * drift from the value rebuilt later — across whitespace and across CLI versions
10
+ * (an older build titled version releases `v1.48.0`; the current build titles
11
+ * them `1.48.0`). Keying dedup or close on the raw title silently creates
12
+ * duplicate / unclosable workspaces whenever that drift occurs.
13
+ *
14
+ * Canonicalization collapses the known drift axes so both sides round-trip to an
15
+ * equal key:
16
+ * - trims and collapses internal whitespace to single spaces;
17
+ * - normalizes a `v`-prefixed semver token to its bare form
18
+ * (`v1.48.0` → `1.48.0`), anchored on semver shape so named releases that
19
+ * merely start with `v` (e.g. `vega-redesign`) are left untouched.
20
+ *
21
+ * Non-release fallback titles (which may contain `/`, e.g. `feature/foo`) are
22
+ * preserved as-is apart from whitespace normalization.
23
+ *
24
+ * @example
25
+ * canonicalizeCmuxTitle('hulyo-monorepo v1.48.0') // => 'hulyo-monorepo 1.48.0'
26
+ * canonicalizeCmuxTitle('hulyo-monorepo 1.48.0') // => 'hulyo-monorepo 1.48.0'
27
+ * canonicalizeCmuxTitle('hulyo-monorepo vega-redesign') // => 'hulyo-monorepo vega-redesign'
28
+ */
29
+ export const canonicalizeCmuxTitle = (raw: string): string => {
30
+ return raw.trim().replace(/\s+/g, ' ').replace(V_SEMVER_TOKEN_RE, '$1')
31
+ }
@@ -2,8 +2,11 @@ import { $ } from 'zx'
2
2
 
3
3
  import { logger } from 'src/lib/logger'
4
4
 
5
+ import { canonicalizeCmuxTitle } from './canonicalize-cmux-title'
6
+
5
7
  /**
6
- * Best-effort close of the cmux workspace whose title exactly matches `title`.
8
+ * Best-effort close of the cmux workspace whose title matches `title` (compared
9
+ * via {@link canonicalizeCmuxTitle}, so a drifted stored title still resolves).
7
10
  * Silently no-ops if cmux isn't running, the workspace isn't found, or close fails.
8
11
  */
9
12
  export const closeCmuxWorkspaceByTitle = async (title: string): Promise<void> => {
@@ -24,13 +27,18 @@ export const closeCmuxWorkspaceByTitle = async (title: string): Promise<void> =>
24
27
 
25
28
  /**
26
29
  * Parses `cmux list-workspaces` output and returns the workspace ref whose
27
- * title exactly matches `title`, or undefined if no match.
30
+ * title matches `title`, or undefined if no match. Both sides are compared via
31
+ * {@link canonicalizeCmuxTitle} so a workspace stored under a drifted title
32
+ * (whitespace, or an older CLI's `v`-prefixed semver) is still found — keeping
33
+ * close symmetric with the dedup in `worktrees-open`.
28
34
  *
29
35
  * Each line looks like:
30
- * " workspace:8 hulyo-monorepo v1.48.0"
36
+ * " workspace:8 hulyo-monorepo 1.48.0"
31
37
  * "* workspace:6 obsidian-workspace [selected]"
32
38
  */
33
39
  const findWorkspaceRefByTitle = (output: string, title: string): string | undefined => {
40
+ const target = canonicalizeCmuxTitle(title)
41
+
34
42
  for (const rawLine of output.split('\n')) {
35
43
  // eslint-disable-next-line sonarjs/slow-regex, regexp/no-super-linear-backtracking
36
44
  const match = rawLine.match(/^[* ]\s*(workspace:\d+)\s+(.+?)(?:\s+\[selected\])?\s*$/)
@@ -42,7 +50,7 @@ const findWorkspaceRefByTitle = (output: string, title: string): string | undefi
42
50
  const ref = match[1]
43
51
  const lineTitle = match[2]?.trim() ?? ''
44
52
 
45
- if (lineTitle === title) {
53
+ if (canonicalizeCmuxTitle(lineTitle) === target) {
46
54
  return ref
47
55
  }
48
56
  }
@@ -1,3 +1,4 @@
1
+ export { canonicalizeCmuxTitle } from './canonicalize-cmux-title'
1
2
  export { closeCmuxWorkspaceByTitle } from './close-workspace-by-title'
2
3
  export { listCmuxWorkspaceTitles } from './list-workspace-titles'
3
4
  export { openCmuxWorkspaceWithLayout } from './open-workspace-with-layout'
@@ -2,14 +2,19 @@ import { $ } from 'zx'
2
2
 
3
3
  import { logger } from 'src/lib/logger'
4
4
 
5
+ import { canonicalizeCmuxTitle } from './canonicalize-cmux-title'
6
+
5
7
  /**
6
- * Returns the set of titles for all currently-open cmux workspaces.
7
- * Returns an empty set if cmux isn't running, the call fails, or the
8
- * output can't be parsed callers should treat "empty" as "unknown,
9
- * proceed as if nothing is open".
8
+ * Returns the set of **canonical** titles for all currently-open cmux
9
+ * workspaces (see {@link canonicalizeCmuxTitle}). Keying on the canonical form
10
+ * lets callers match a workspace even when its stored title drifted from the
11
+ * title they rebuild (whitespace, or an older CLI's `v`-prefixed semver).
12
+ * Returns an empty set if cmux isn't running, the call fails, or the output
13
+ * can't be parsed — callers should treat "empty" as "unknown, proceed as if
14
+ * nothing is open".
10
15
  *
11
16
  * Each line of `cmux list-workspaces` looks like:
12
- * " workspace:8 hulyo-monorepo v1.48.0"
17
+ * " workspace:8 hulyo-monorepo 1.48.0"
13
18
  * "* workspace:6 obsidian-workspace [selected]"
14
19
  */
15
20
  export const listCmuxWorkspaceTitles = async (): Promise<Set<string>> => {
@@ -29,7 +34,7 @@ export const listCmuxWorkspaceTitles = async (): Promise<Set<string>> => {
29
34
  const title = match[1]?.trim()
30
35
 
31
36
  if (title) {
32
- titles.add(title)
37
+ titles.add(canonicalizeCmuxTitle(title))
33
38
  }
34
39
  }
35
40
 
@@ -26,7 +26,7 @@ export const openCmuxWorkspaceWithLayout = async (args: OpenCmuxWorkspaceArgs):
26
26
  await $`cmux new-split down --workspace ${workspaceRef} --surface ${leftTopRef}`
27
27
 
28
28
  if (title) {
29
- await $`cmux workspace rename --workspace ${workspaceRef} ${title}`
29
+ await $`cmux workspace rename --workspace ${workspaceRef} --title ${title}`
30
30
  }
31
31
  }
32
32
 
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "entries": {
4
+ "79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
5
+ "last_emitted_at_ms": 1781518823336,
6
+ "message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
7
+ }
8
+ },
9
+ "updated_at": "2026-06-15T10:20:23.336Z"
10
+ }