borgmcp 1.0.6 → 1.0.7

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 (157) hide show
  1. package/dist/assimilate-cmd.js +39 -511
  2. package/dist/assimilate-deps.js +3 -177
  3. package/dist/assimilate-welcome.js +2 -24
  4. package/dist/auth-env.js +1 -107
  5. package/dist/auth.js +23 -612
  6. package/dist/claude.js +11 -281
  7. package/dist/cli-help.js +29 -50
  8. package/dist/cli-platform.js +4 -94
  9. package/dist/codex-app-server.js +4 -228
  10. package/dist/codex-app-wake.js +2 -122
  11. package/dist/codex-launch.js +1 -81
  12. package/dist/codex-remote.js +1 -250
  13. package/dist/config-utils.js +3 -385
  14. package/dist/config.js +1 -190
  15. package/dist/console-prefix.js +1 -86
  16. package/dist/cube-name.js +1 -65
  17. package/dist/cubes.js +4 -269
  18. package/dist/debug.js +1 -71
  19. package/dist/device-auth.js +1 -167
  20. package/dist/direct-log.js +1 -11
  21. package/dist/health-beat.js +1 -168
  22. package/dist/inbox-monitor.js +1 -129
  23. package/dist/index.js +26 -1378
  24. package/dist/lifecycle-log-guard.js +2 -93
  25. package/dist/list-roles-render.js +6 -39
  26. package/dist/log-audit.js +3 -186
  27. package/dist/log-stream.js +9 -848
  28. package/dist/name-validator.js +1 -22
  29. package/dist/parse-assimilate-args.js +1 -82
  30. package/dist/postinstall.js +8 -22
  31. package/dist/regen-format.js +11 -337
  32. package/dist/regen.js +5 -83
  33. package/dist/remote-client.js +1 -695
  34. package/dist/role-resolver.js +1 -36
  35. package/dist/role-section.js +8 -208
  36. package/dist/roster-render.js +3 -96
  37. package/dist/setup.js +36 -251
  38. package/dist/shell-escape.js +1 -22
  39. package/dist/spawn.js +10 -29
  40. package/dist/stale-version-check.js +1 -102
  41. package/dist/stream-owner.js +2 -202
  42. package/dist/stream-status.js +3 -211
  43. package/dist/subscription-retry.js +1 -23
  44. package/dist/sync-roles-render.js +3 -118
  45. package/dist/sync.js +22 -286
  46. package/dist/templates.js +120 -626
  47. package/dist/terminal-title.js +1 -68
  48. package/dist/token-crypto.js +1 -91
  49. package/dist/token-store.js +1 -222
  50. package/dist/types.js +0 -5
  51. package/dist/version.js +2 -78
  52. package/dist/worktree-lifecycle.js +2 -173
  53. package/package.json +11 -2
  54. package/dist/assimilate-cmd.d.ts.map +0 -1
  55. package/dist/assimilate-cmd.js.map +0 -1
  56. package/dist/assimilate-deps.d.ts.map +0 -1
  57. package/dist/assimilate-deps.js.map +0 -1
  58. package/dist/assimilate-welcome.d.ts.map +0 -1
  59. package/dist/assimilate-welcome.js.map +0 -1
  60. package/dist/auth-env.d.ts.map +0 -1
  61. package/dist/auth-env.js.map +0 -1
  62. package/dist/auth.d.ts.map +0 -1
  63. package/dist/auth.js.map +0 -1
  64. package/dist/claude.d.ts.map +0 -1
  65. package/dist/claude.js.map +0 -1
  66. package/dist/cli-help.d.ts.map +0 -1
  67. package/dist/cli-help.js.map +0 -1
  68. package/dist/cli-platform.d.ts.map +0 -1
  69. package/dist/cli-platform.js.map +0 -1
  70. package/dist/codex-app-server.d.ts.map +0 -1
  71. package/dist/codex-app-server.js.map +0 -1
  72. package/dist/codex-app-wake.d.ts.map +0 -1
  73. package/dist/codex-app-wake.js.map +0 -1
  74. package/dist/codex-launch.d.ts.map +0 -1
  75. package/dist/codex-launch.js.map +0 -1
  76. package/dist/codex-remote.d.ts.map +0 -1
  77. package/dist/codex-remote.js.map +0 -1
  78. package/dist/config-utils.d.ts.map +0 -1
  79. package/dist/config-utils.js.map +0 -1
  80. package/dist/config.d.ts.map +0 -1
  81. package/dist/config.js.map +0 -1
  82. package/dist/console-prefix.d.ts.map +0 -1
  83. package/dist/console-prefix.js.map +0 -1
  84. package/dist/cube-name.d.ts.map +0 -1
  85. package/dist/cube-name.js.map +0 -1
  86. package/dist/cubes.d.ts.map +0 -1
  87. package/dist/cubes.js.map +0 -1
  88. package/dist/debug.d.ts.map +0 -1
  89. package/dist/debug.js.map +0 -1
  90. package/dist/device-auth.d.ts.map +0 -1
  91. package/dist/device-auth.js.map +0 -1
  92. package/dist/direct-log.d.ts.map +0 -1
  93. package/dist/direct-log.js.map +0 -1
  94. package/dist/health-beat.d.ts.map +0 -1
  95. package/dist/health-beat.js.map +0 -1
  96. package/dist/inbox-monitor.d.ts.map +0 -1
  97. package/dist/inbox-monitor.js.map +0 -1
  98. package/dist/index.d.ts.map +0 -1
  99. package/dist/index.js.map +0 -1
  100. package/dist/lifecycle-log-guard.d.ts.map +0 -1
  101. package/dist/lifecycle-log-guard.js.map +0 -1
  102. package/dist/list-roles-render.d.ts.map +0 -1
  103. package/dist/list-roles-render.js.map +0 -1
  104. package/dist/log-audit.d.ts.map +0 -1
  105. package/dist/log-audit.js.map +0 -1
  106. package/dist/log-stream.d.ts.map +0 -1
  107. package/dist/log-stream.js.map +0 -1
  108. package/dist/name-validator.d.ts.map +0 -1
  109. package/dist/name-validator.js.map +0 -1
  110. package/dist/parse-assimilate-args.d.ts.map +0 -1
  111. package/dist/parse-assimilate-args.js.map +0 -1
  112. package/dist/postinstall.d.ts.map +0 -1
  113. package/dist/postinstall.js.map +0 -1
  114. package/dist/regen-format.d.ts.map +0 -1
  115. package/dist/regen-format.js.map +0 -1
  116. package/dist/regen.d.ts.map +0 -1
  117. package/dist/regen.js.map +0 -1
  118. package/dist/remote-client.d.ts.map +0 -1
  119. package/dist/remote-client.js.map +0 -1
  120. package/dist/role-resolver.d.ts.map +0 -1
  121. package/dist/role-resolver.js.map +0 -1
  122. package/dist/role-section.d.ts.map +0 -1
  123. package/dist/role-section.js.map +0 -1
  124. package/dist/roster-render.d.ts.map +0 -1
  125. package/dist/roster-render.js.map +0 -1
  126. package/dist/setup.d.ts.map +0 -1
  127. package/dist/setup.js.map +0 -1
  128. package/dist/shell-escape.d.ts.map +0 -1
  129. package/dist/shell-escape.js.map +0 -1
  130. package/dist/spawn.d.ts.map +0 -1
  131. package/dist/spawn.js.map +0 -1
  132. package/dist/stale-version-check.d.ts.map +0 -1
  133. package/dist/stale-version-check.js.map +0 -1
  134. package/dist/stream-owner.d.ts.map +0 -1
  135. package/dist/stream-owner.js.map +0 -1
  136. package/dist/stream-status.d.ts.map +0 -1
  137. package/dist/stream-status.js.map +0 -1
  138. package/dist/subscription-retry.d.ts.map +0 -1
  139. package/dist/subscription-retry.js.map +0 -1
  140. package/dist/sync-roles-render.d.ts.map +0 -1
  141. package/dist/sync-roles-render.js.map +0 -1
  142. package/dist/sync.d.ts.map +0 -1
  143. package/dist/sync.js.map +0 -1
  144. package/dist/templates.d.ts.map +0 -1
  145. package/dist/templates.js.map +0 -1
  146. package/dist/terminal-title.d.ts.map +0 -1
  147. package/dist/terminal-title.js.map +0 -1
  148. package/dist/token-crypto.d.ts.map +0 -1
  149. package/dist/token-crypto.js.map +0 -1
  150. package/dist/token-store.d.ts.map +0 -1
  151. package/dist/token-store.js.map +0 -1
  152. package/dist/types.d.ts.map +0 -1
  153. package/dist/types.js.map +0 -1
  154. package/dist/version.d.ts.map +0 -1
  155. package/dist/version.js.map +0 -1
  156. package/dist/worktree-lifecycle.d.ts.map +0 -1
  157. package/dist/worktree-lifecycle.js.map +0 -1
@@ -1,511 +1,39 @@
1
- import { dirname, basename, join } from 'node:path';
2
- import { randomUUID } from 'node:crypto';
3
- import { roleSlug, matchRoleByName, pickDefaultRole } from './role-resolver.js';
4
- import { deriveCubeName, parseGitRemote, sanitizeRemoteUrl } from './cube-name.js';
5
- import { validateName } from './name-validator.js';
6
- import { renderAssimilationWelcome } from './assimilate-welcome.js';
7
- import { shellEscape } from './shell-escape.js';
8
- import { withCodexCwdArg } from './codex-remote.js';
9
- import { buildAgentKickoffPrompt, recordCodexWakeTarget, socketPathFromRemoteArgs, } from './codex-launch.js';
10
- import { perWorktreeBranchName, adoptWorktree } from './worktree-lifecycle.js';
11
- export async function runAssimilate(args, deps) {
12
- // ----- Input validation (before any subprocess work) -----
13
- if (args.role !== undefined) {
14
- const v = validateName(args.role);
15
- if (!v.ok) {
16
- deps.stderr(v.error + '\n');
17
- return 1;
18
- }
19
- }
20
- if (args.flags.worktree !== undefined) {
21
- const v = validateName(args.flags.worktree);
22
- if (!v.ok) {
23
- deps.stderr(v.error + '\n');
24
- return 1;
25
- }
26
- }
27
- // ----- Step 1: Auth check -----
28
- let auth = await deps.getCachedAuth();
29
- if (!auth) {
30
- if (!deps.isTTY() && !args.flags.yes) {
31
- deps.stderr('borg setup required and stdin is non-interactive. Run `borg setup` first in an interactive terminal, then `borg assimilate`.\n');
32
- return 1;
33
- }
34
- auth = await deps.runSetup();
35
- }
36
- // ----- Step 2: Cube-name derivation -----
37
- const projectRoot = deps.findProjectRoot(deps.cwd());
38
- let cubeName;
39
- if (args.flags.cubeName) {
40
- cubeName = args.flags.cubeName;
41
- }
42
- else {
43
- const remoteResult = deps.runSync('git', ['remote', 'get-url', 'origin'], projectRoot);
44
- const remoteUrl = remoteResult.status === 0 ? remoteResult.stdout : null;
45
- cubeName = deriveCubeName(projectRoot, remoteUrl);
46
- // UX-F4 (drone-7 Phase E review): emit a stderr nudge when the
47
- // remote was readable but didn't parse, so the user has a signal
48
- // that auto-derivation diverged from their git remote.
49
- if (remoteUrl) {
50
- const sanitized = sanitizeRemoteUrl(remoteUrl);
51
- const parsedRepo = sanitized ? parseGitRemote(sanitized) : null;
52
- if (sanitized && !parsedRepo && cubeName) {
53
- deps.stderr(`couldn't parse git remote '${sanitized}' — using directory name '${cubeName}' as cube name\n`);
54
- }
55
- }
56
- }
57
- // gh#293: detect cross-account cube reference (owner-email:cube-name format).
58
- let crossAccountRef = null;
59
- if (cubeName && cubeName.includes('@') && cubeName.includes(':')) {
60
- const colonIdx = cubeName.lastIndexOf(':');
61
- crossAccountRef = {
62
- ownerEmail: cubeName.substring(0, colonIdx),
63
- cubeName: cubeName.substring(colonIdx + 1),
64
- };
65
- cubeName = crossAccountRef.cubeName;
66
- }
67
- // ----- Sprint 19 (gh#184): Reorder for strict-rollback semantics. -----
68
- // The previous flow created a sibling worktree (FS state) BEFORE
69
- // role resolution + API assimilate. Any early-return between
70
- // worktree-spawn and API success orphaned the worktree (gh#184
71
- // canonical case: unknown role arg). The new flow defers all FS
72
- // state until AFTER the API assimilate succeeds — early-return at
73
- // role resolution / listCubes / createCube / template-prompt /
74
- // template-invalid-choice is now structurally clean (no orphan
75
- // class possible). Worktree rollback narrows to the single
76
- // setActiveCube failure path post-worktree-creation.
77
- // Sprint 18: capture pre-chdir cwd for the post-exit shell-cd hint
78
- // (no chdir has happened yet; this is a stable starting point).
79
- const originalCwd = deps.cwd();
80
- // ----- Step 3: Cube existence check (with auto-refresh on auth failure) -----
81
- let allCubes;
82
- try {
83
- allCubes = await deps.listCubes(auth.apiUrl, auth.token);
84
- }
85
- catch (err) {
86
- const msg = err instanceof Error ? err.message : String(err);
87
- if (msg.includes('Authentication required') || msg.includes('Authentication expired')) {
88
- deps.stderr('Re-authenticating...\n');
89
- auth = await deps.runSetup();
90
- allCubes = await deps.listCubes(auth.apiUrl, auth.token);
91
- }
92
- else {
93
- throw err;
94
- }
95
- }
96
- const existingCube = allCubes.find((c) => c.name === cubeName);
97
- // gh#312: cross-account typo guard. If the user typed owner@example.com:cube-name
98
- // and the cube isn't found, error out — don't silently create a new cube.
99
- if (!existingCube && crossAccountRef) {
100
- deps.stderr(`No cube named '${crossAccountRef.cubeName}' accessible to you owned by '${crossAccountRef.ownerEmail}'. Did you accept their invite? See borgmcp.ai/dashboard.\n`);
101
- return 1;
102
- }
103
- // ----- Step 4: Fetch detail OR create cube -----
104
- let cubeDetail;
105
- let isFirstDrone;
106
- if (existingCube) {
107
- cubeDetail = await deps.getCube(auth.apiUrl, auth.token, existingCube.id);
108
- isFirstDrone = false;
109
- }
110
- else {
111
- // ----- Step 4a: First-drone bootstrap (template selection) -----
112
- let chosenTemplate;
113
- if (args.flags.template) {
114
- chosenTemplate = args.flags.template;
115
- }
116
- else if (args.flags.noTemplate) {
117
- chosenTemplate = undefined;
118
- }
119
- else if (!deps.isTTY()) {
120
- if (!args.flags.yes) {
121
- deps.stderr('cube creation needs a template choice but stdin is non-interactive.\n' +
122
- 'Pass --template <name>, --no-template, or --yes (defaults to starter).\n');
123
- return 1;
124
- }
125
- chosenTemplate = 'starter';
126
- }
127
- else if (args.flags.yes) {
128
- chosenTemplate = 'starter';
129
- }
130
- else {
131
- const templates = await deps.listTemplates(auth.apiUrl, auth.token);
132
- const lines = ['First drone joining a new cube. Apply a template?'];
133
- templates.forEach((t, i) => {
134
- const tag = i === 0 ? ' (default)' : '';
135
- lines.push(` ${i + 1}) ${t.name}${tag} — ${t.description}`);
136
- });
137
- lines.push(` ${templates.length + 1}) skip — no template`);
138
- const answer = (await deps.prompt(lines.join('\n') + '\n[1]: ')).trim();
139
- const choice = answer === '' ? 1 : parseInt(answer, 10);
140
- if (Number.isNaN(choice) || choice < 1 || choice > templates.length + 1) {
141
- deps.stderr(`invalid choice "${answer}"\n`);
142
- return 1;
143
- }
144
- chosenTemplate = choice <= templates.length ? templates[choice - 1].name : undefined;
145
- }
146
- cubeDetail = await deps.createCube(auth.apiUrl, auth.token, chosenTemplate ? { name: cubeName ?? undefined, template: chosenTemplate } : { name: cubeName ?? undefined });
147
- isFirstDrone = true;
148
- }
149
- // ----- Step 5: Role resolution -----
150
- let resolvedRole;
151
- if (args.role !== undefined) {
152
- resolvedRole = matchRoleByName(cubeDetail.roles, args.role);
153
- if (!resolvedRole) {
154
- // Sprint 19 (gh#184) + drone-7 metaphor argument: include a
155
- // fuzzy-match "did you mean ...?" suggestion to serve Queen's
156
- // "more user-friendly" intent without violating the
157
- // Borg-collective metaphor (collective defines roles; drones
158
- // slot in). Levenshtein distance ≤2 on the cube's role names.
159
- const available = cubeDetail.roles.map((r) => r.name).join(', ');
160
- const suggestion = suggestRoleName(args.role, cubeDetail.roles.map((r) => r.name));
161
- const suggestionLine = suggestion ? ` Did you mean "${suggestion}"?` : '';
162
- deps.stderr(`no role matching "${args.role}" in cube "${cubeDetail.name}". Available: ${available}.${suggestionLine}\n` +
163
- `(Use --template <name> on first-drone setup or run \`borg:create-role\` from inside Claude.)\n`);
164
- return 1;
165
- }
166
- }
167
- else {
168
- resolvedRole = pickDefaultRole(cubeDetail.roles, { isFirstDrone });
169
- if (!resolvedRole) {
170
- deps.stderr(`cube "${cubeDetail.name}" has no default or human-seat role; cannot infer a role. ` +
171
- `Either pass a role argument explicitly (e.g. \`borg assimilate builder\`) or ` +
172
- `run \`borg:create-role\` from inside Claude to set up roles.\n`);
173
- return 1;
174
- }
175
- }
176
- // ----- Step 6: API assimilate (no FS state yet — clean exit on failure) -----
177
- let result;
178
- try {
179
- result = await deps.assimilate(auth.apiUrl, auth.token, {
180
- cube_id: cubeDetail.id,
181
- role_id: resolvedRole.id,
182
- hostname: deps.getHostname(),
183
- });
184
- }
185
- catch (err) {
186
- const message = err instanceof Error ? err.message : String(err);
187
- deps.stderr(`assimilate failed: ${message}\n`);
188
- return 1;
189
- }
190
- // The server may assimilate a member into a DIFFERENT role than the client's
191
- // auto-picked default (gh#700 fallback: when the member's invite doesn't
192
- // grant the default role, the server picks one of their GRANTED roles).
193
- // Resolve the role the SERVER ACTUALLY assigned (result.role_id) and use it
194
- // for all human-facing display + naming below — not the client's pre-pick.
195
- // The drone label / session token are already server-truth; this aligns the
196
- // displayed role name + worktree slug with what was actually assigned.
197
- const assignedRole = cubeDetail.roles.find((r) => r.id === result.role_id) ?? resolvedRole;
198
- if (assignedRole.id !== resolvedRole.id) {
199
- deps.stderr(`Note: your invite didn't grant the "${resolvedRole.name}" role — ` +
200
- `assimilated as "${assignedRole.name}" instead.\n`);
201
- }
202
- // ----- Step 7: Worktree decision (FS state ONLY after API success) -----
203
- const existing = await deps.getActiveCube();
204
- const wantSibling = args.flags.worktree !== undefined || existing !== null;
205
- let spawnedWorktreePath = null;
206
- if (wantSibling) {
207
- if (existing && args.flags.here) {
208
- deps.stderr(`this directory already hosts an active drone; remove --here or run from a fresh worktree\n`);
209
- return 1;
210
- }
211
- // BUG-4 / gh#150 fix (v0.9.5): `git worktree add --detach <path>`
212
- // fails with "fatal: not a valid object name: 'HEAD'" when the
213
- // repo has no commits yet (unborn HEAD). Detect explicitly via
214
- // `git rev-parse --verify HEAD` so we surface an actionable
215
- // prerequisite error rather than git's cryptic internal message.
216
- const headProbe = deps.runSync('git', ['rev-parse', '--verify', 'HEAD'], projectRoot);
217
- if (headProbe.status !== 0) {
218
- deps.stderr(`sibling worktree spawn requires HEAD pointing at a commit.\n` +
219
- ` Fix: create at least one commit (\`git commit --allow-empty -m "initial"\`)\n` +
220
- ` OR: pass --here to skip the sibling spawn and use the current directory\n`);
221
- return 1;
222
- }
223
- // gh#238: fetch origin so the new worktree starts on the latest
224
- // remote HEAD, not the (possibly stale) local HEAD.
225
- deps.runSync('git', ['fetch', 'origin'], projectRoot);
226
- // Resolve the default branch on origin (main or master).
227
- let startRef = 'origin/main';
228
- const mainProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/main'], projectRoot);
229
- if (mainProbe.status !== 0) {
230
- const masterProbe = deps.runSync('git', ['rev-parse', '--verify', 'origin/master'], projectRoot);
231
- if (masterProbe.status === 0) {
232
- startRef = 'origin/master';
233
- }
234
- }
235
- // Warn if local HEAD diverges from the remote default branch.
236
- const localHead = headProbe.stdout.trim();
237
- const remoteHead = deps.runSync('git', ['rev-parse', startRef], projectRoot).stdout.trim();
238
- if (localHead !== remoteHead) {
239
- deps.stderr(`note: local HEAD (${localHead.slice(0, 7)}) differs from ${startRef} (${remoteHead.slice(0, 7)}); ` +
240
- `new worktree will start on ${startRef}\n`);
241
- }
242
- const parent = dirname(projectRoot);
243
- const repoBase = basename(projectRoot);
244
- const suffix = args.flags.worktree ?? roleSlug(assignedRole.name);
245
- let candidate = join(parent, `${repoBase}-${suffix}`);
246
- let n = 2;
247
- while (deps.pathExists(candidate) || worktreeRegistered(deps, projectRoot, candidate)) {
248
- candidate = join(parent, `${repoBase}-${suffix}-${n}`);
249
- n++;
250
- }
251
- // gh#33 (Q1/Q4): spawn on a named per-worktree branch (wt-<suffix>),
252
- // NOT detached HEAD. The named branch is current with startRef and is
253
- // where the drone's feature branches get cut from. Uniform for every
254
- // role incl. coordinator — main is never a working branch (Q4).
255
- const wtBranch = perWorktreeBranchName(basename(candidate), repoBase);
256
- const wt = deps.runSync('git', ['worktree', 'add', '-b', wtBranch, candidate, startRef], projectRoot);
257
- if (wt.status !== 0) {
258
- deps.stderr(`git worktree add failed: ${safeStderr(wt.stderr)}\n`);
259
- return 1;
260
- }
261
- deps.stderr(`spawned sibling worktree at ${candidate} on branch ${wtBranch} (${startRef}); ` +
262
- `original dir is registered as active (edit ~/.config/borgmcp/cubes.json if stale).\n`);
263
- deps.chdir(candidate);
264
- deps.stderr(renderWorktreeSteeringNote(candidate, wtBranch, projectRoot));
265
- spawnedWorktreePath = deps.cwd();
266
- }
267
- // ----- Step 8: setActiveCube (narrow rollback — worktree exists if spawned) -----
268
- try {
269
- await deps.setActiveCube({
270
- cubeId: result.cube_id,
271
- droneId: result.drone_id,
272
- name: cubeDetail.name,
273
- sessionToken: result.session_token,
274
- droneLabel: result.drone_label,
275
- apiUrl: auth.apiUrl,
276
- });
277
- }
278
- catch (err) {
279
- const message = err instanceof Error ? err.message : String(err);
280
- deps.stderr(`setActiveCube failed: ${message}\n`);
281
- if (spawnedWorktreePath) {
282
- const rm = deps.runSync('git', ['worktree', 'remove', '--force', spawnedWorktreePath], projectRoot);
283
- if (rm.status === 0) {
284
- deps.stderr(`rolled back spawned worktree at ${spawnedWorktreePath}\n`);
285
- }
286
- else {
287
- deps.stderr(`manual cleanup needed: \`git worktree remove --force ${spawnedWorktreePath}\` ` +
288
- `(rollback attempt failed: ${safeStderr(rm.stderr).trim() || 'unknown'})\n`);
289
- }
290
- }
291
- return 1;
292
- }
293
- // ----- Step 8: Launch selected agent CLI -----
294
- // Mirrors the kickoff invocation from claude.ts (no-args path): the agent
295
- // picks up the newly-persisted ActiveCube via the MCP stdio server on
296
- // startup. The kickoff prompt re-enters /loop borg:regen so the new
297
- // drone bootstraps into the cube cleanly. The monitor clause (CR-PE-F1)
298
- // arms the inbox tail so the new drone wakes on peer log entries in
299
- // real time — without this, drones miss real-time wake events during
300
- // the bootstrap window and only self-heal at the /loop heartbeat.
301
- deps.setTerminalTitle(result.drone_label, cubeDetail.name);
302
- // Pedagogical hint to stdout before Claude takes over the terminal.
303
- // Ink does not enter alt-screen-buffer (verified empirically via PTY
304
- // probe 2026-05-19), so lines printed here remain visible in the
305
- // user's terminal scrollback above Claude's interactive UI. Color is
306
- // gated on TTY + NO_COLOR/CI env-var conventions; the welcome shape
307
- // itself is cube-agnostic so non-default templates render identically.
308
- const useColor = deps.isTTY() && !process.env.NO_COLOR && !process.env.CI;
309
- deps.stdout(renderAssimilationWelcome(assignedRole.name, cubeDetail.name, useColor));
310
- const cli = await deps.resolveCli(args.flags.cli);
311
- const agentCwd = deps.cwd(); // post-chdir if step 3 spawned a worktree
312
- // gh#33 (Q2/Q4/Q6): in-place wt- adoption. A freshly-spawned worktree is
313
- // already at origin/main on a fresh wt- branch, so only the in-place /
314
- // --here path (running in an existing checkout) needs handling. ADOPT
315
- // the per-worktree branch — switch the checkout onto wt-<suffix> at
316
- // origin/main when clean + merged. This both moves the drone off main
317
- // (Q4: main is never a working branch) AND brings the branch current,
318
- // which a bare ff-sync would not do (it would leave a main checkout on
319
- // main — the gap two-of-four-CR 27af1001 + QA c7a0c615 caught). Dirty ->
320
- // skip + surface; unmerged HEAD -> block + surface; NEVER discards.
321
- // Using adoptWorktree (HEAD-merged check + explicit `switch -C wtBranch
322
- // ref`) also closes one-of-four-CR's compute-name-vs-current-branch NIT.
323
- // Best-effort: a skip/block surfaces but never blocks the launch.
324
- if (!spawnedWorktreePath) {
325
- deps.runSync('git', ['fetch', 'origin', '--prune'], agentCwd);
326
- const wtBranch = perWorktreeBranchName(basename(agentCwd), basename(projectRoot));
327
- const adopt = adoptWorktree(deps.runSync, agentCwd, wtBranch, 'origin/main');
328
- if (adopt.action === 'adopted') {
329
- deps.stderr(`worktree: adopted branch ${wtBranch} at origin/main\n`);
330
- deps.stderr(renderInPlaceWorktreeNote(agentCwd, wtBranch));
331
- }
332
- else if (adopt.message) {
333
- deps.stderr(`worktree sync: ${adopt.message}\n`);
334
- }
335
- }
336
- // BUG-5 / v0.9.3: probe MCP readiness before launching claude so
337
- // the launched session sees tools at startup. Non-blocking: probe
338
- // failure surfaces a stderr warning but the launch proceeds (the
339
- // kickoff text's ToolSearch recovery clause is the second line of
340
- // defense).
341
- const mcpReady = await deps.probeMcpReady();
342
- if (!mcpReady) {
343
- deps.stderr(`warning: borg-mcp readiness probe did not complete within the timeout; ` +
344
- `launching ${cli} anyway — the kickoff prompt's ToolSearch fallback ` +
345
- `will recover if the MCP server takes longer to start.\n`);
346
- }
347
- const inboxPath = deps.getInboxPath(result.cube_id, result.drone_id);
348
- const codexWakeNonce = cli === 'codex' ? `borg-wake-${randomUUID()}` : null;
349
- const monitorClause = cli === 'claude'
350
- ? `If you haven't yet, arm a persistent Monitor running the command ` +
351
- `\`borg-inbox-monitor ${inboxPath}\` ` +
352
- `so each event's task-notification title summarizes the new cube log entry ` +
353
- `(drone label, role, and first ~80 chars of the message body) — letting you ` +
354
- `triage events without reading the full body. `
355
- : '';
356
- let codexWakePathClause;
357
- let remoteArgs = [];
358
- let launchArgs;
359
- let launchEnv;
360
- let codexSocketPath = null;
361
- let codexServerCleanup = null;
362
- if (cli === 'codex') {
363
- const remote = await deps.prepareCodexRemoteLaunch();
364
- if (remote.warning) {
365
- deps.stderr(`warning: ${remote.warning}\n`);
366
- codexWakePathClause =
367
- `⚠ Codex wake-path capability check failed: remote-control is unavailable for this session. Run borg:regen manually whenever you return, and expect only fallback wakeups until relaunch.`;
368
- }
369
- else {
370
- codexWakePathClause =
371
- `Codex wake-path capability check passed: remote-control socket established for this session.`;
372
- }
373
- remoteArgs = remote.args;
374
- launchEnv = Object.keys(remote.env).length > 0 ? remote.env : undefined;
375
- codexSocketPath = socketPathFromRemoteArgs(remote.args);
376
- codexServerCleanup = remote.server?.cleanup ?? null;
377
- }
378
- const kickoff = buildAgentKickoffPrompt({
379
- cli,
380
- codexWakeNonce,
381
- monitorClause,
382
- codexWakePathClause,
383
- });
384
- launchArgs = [kickoff];
385
- if (cli === 'codex') {
386
- launchArgs = [...remoteArgs, ...withCodexCwdArg(launchArgs, agentCwd)];
387
- }
388
- const exitPromise = deps.exec(cli, launchArgs, agentCwd, launchEnv);
389
- if (cli === 'codex' && codexSocketPath && codexWakeNonce) {
390
- void recordCodexWakeTarget({
391
- deps,
392
- cubeId: result.cube_id,
393
- droneId: result.drone_id,
394
- socketPath: codexSocketPath,
395
- cwd: agentCwd,
396
- previewNeedle: codexWakeNonce,
397
- launchedAtSeconds: Math.floor(Date.now() / 1000),
398
- });
399
- }
400
- const exitCode = await exitPromise;
401
- // gh#528: kill the borg-owned Codex app-server when the assimilate-launched
402
- // session exits, so it isn't left orphaned (live → not pruned by pid liveness).
403
- if (codexServerCleanup) {
404
- try {
405
- codexServerCleanup();
406
- }
407
- catch {
408
- // best-effort
409
- }
410
- }
411
- // Sprint 18: when a sibling worktree was spawned, the user's shell
412
- // returns to their original cwd after Claude exits (process.chdir
413
- // doesn't propagate to the parent). Emit a stderr hint so they know
414
- // how to get back into the worktree. shellEscape defangs any shell
415
- // metachars in the path against paste-injection (drone-11 SR-LANE).
416
- // Skip the hint when no worktree was spawned (--here / no-worktree
417
- // flow) or when originalCwd already matches the worktree path
418
- // (defensive against the no-op edge case drone-9 UX-LANE flagged).
419
- if (spawnedWorktreePath && originalCwd !== spawnedWorktreePath) {
420
- deps.stderr(`\nAgent exited. You were working in ${spawnedWorktreePath}; your shell is back in ${originalCwd}.\n` +
421
- `To return:\n` +
422
- ` cd ${shellEscape(spawnedWorktreePath)}\n`);
423
- }
424
- return exitCode;
425
- }
426
- function renderWorktreeSteeringNote(worktreePath, wtBranch, primaryPath) {
427
- return (`\nWORKTREE STEERING: You are in worktree ${worktreePath} on branch ${wtBranch}. ` +
428
- `Do ALL work HERE — cut your feature branch (fix/.../feat/...) off ${wtBranch} in THIS worktree, ` +
429
- `use relative paths / your cwd. NEVER \`git -C ${primaryPath}\` or operate on the primary checkout ${primaryPath}: ` +
430
- `the same branch can't be checked out in two worktrees, so work created in the primary won't reach your wt-branch ` +
431
- `without manual surgery (cherry-pick/merge).\n`);
432
- }
433
- function renderInPlaceWorktreeNote(worktreePath, wtBranch) {
434
- return (`\nWORKTREE STEERING: This checkout is now on branch ${wtBranch}. ` +
435
- `Do ALL work HERE in ${worktreePath} — cut your feature branch (fix/.../feat/...) off ${wtBranch}, ` +
436
- `use relative paths / your cwd.\n`);
437
- }
438
- /**
439
- * Sprint 4 / gh#147 (drone-8 SR-PE-FINDING-1): strip ASCII control
440
- * characters before interpolating subprocess stderr into operator-
441
- * facing messages. Defense-in-depth against a local attacker editing
442
- * `.git/config` to embed ANSI escapes (e.g. `\x1b[2J` cursor moves,
443
- * `\x1b]0;...\x07` title injection) — git command stderr then carries
444
- * them, and unfiltered orchestrator output corrupts the terminal.
445
- *
446
- * Strips `[\x00-\x1F\x7F]` (NUL, all C0 controls, DEL). ASCII
447
- * whitespace inside C0 (tab, newline, CR) gets stripped too — the
448
- * orchestrator only ever interpolates short status fragments where
449
- * preserving multi-line layout isn't load-bearing; over-strip
450
- * trade-off accepted for shape simplicity.
451
- */
452
- export function safeStderr(msg) {
453
- return msg.replace(/[\x00-\x1F\x7F]/g, '');
454
- }
455
- function worktreeRegistered(deps, projectRoot, candidate) {
456
- const res = deps.runSync('git', ['worktree', 'list', '--porcelain'], projectRoot);
457
- if (res.status !== 0)
458
- return false;
459
- return res.stdout.split('\n').some((line) => line === `worktree ${candidate}`);
460
- }
461
- /**
462
- * Sprint 19 (gh#184): suggest the closest cube-role name for a misspelled
463
- * CLI role argument. Levenshtein distance ≤2 against the cube's role
464
- * names; case-insensitive. Returns null when no close match exists.
465
- *
466
- * Serves Queen's "more user-friendly" intent without violating the
467
- * Borg-collective metaphor (collective defines roles; drones slot in).
468
- * The original strict-failure semantic is preserved; the suggestion
469
- * is an additive nudge in the error message, not a fallback path.
470
- */
471
- export function suggestRoleName(input, candidates) {
472
- if (candidates.length === 0)
473
- return null;
474
- const inputLower = input.toLowerCase();
475
- let best = null;
476
- for (const candidate of candidates) {
477
- const distance = levenshtein(inputLower, candidate.toLowerCase());
478
- if (distance <= 2 && (best === null || distance < best.distance)) {
479
- best = { name: candidate, distance };
480
- }
481
- }
482
- return best ? best.name : null;
483
- }
484
- /**
485
- * Minimal Levenshtein distance implementation. Used only by
486
- * `suggestRoleName` for the fuzzy-match nudge; intentionally
487
- * unexported and not a general-purpose helper.
488
- */
489
- function levenshtein(a, b) {
490
- if (a === b)
491
- return 0;
492
- if (a.length === 0)
493
- return b.length;
494
- if (b.length === 0)
495
- return a.length;
496
- const prev = new Array(b.length + 1);
497
- const curr = new Array(b.length + 1);
498
- for (let j = 0; j <= b.length; j++)
499
- prev[j] = j;
500
- for (let i = 1; i <= a.length; i++) {
501
- curr[0] = i;
502
- for (let j = 1; j <= b.length; j++) {
503
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
504
- curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
505
- }
506
- for (let j = 0; j <= b.length; j++)
507
- prev[j] = curr[j];
508
- }
509
- return prev[b.length];
510
- }
511
- //# sourceMappingURL=assimilate-cmd.js.map
1
+ import{dirname as K,basename as C,join as W}from"node:path";import{randomUUID as V}from"node:crypto";import{roleSlug as J,matchRoleByName as Q,pickDefaultRole as X}from"./role-resolver.js";import{deriveCubeName as Z,parseGitRemote as ee,sanitizeRemoteUrl as te}from"./cube-name.js";import{validateName as H}from"./name-validator.js";import{renderAssimilationWelcome as re}from"./assimilate-welcome.js";import{shellEscape as ne}from"./shell-escape.js";import{withCodexCwdArg as oe}from"./codex-remote.js";import{buildAgentKickoffPrompt as ie,recordCodexWakeTarget as ae,socketPathFromRemoteArgs as se}from"./codex-launch.js";import{perWorktreeBranchName as O,adoptWorktree as le}from"./worktree-lifecycle.js";async function Ee(r,e){if(r.role!==void 0){const t=H(r.role);if(!t.ok)return e.stderr(t.error+`
2
+ `),1}if(r.flags.worktree!==void 0){const t=H(r.flags.worktree);if(!t.ok)return e.stderr(t.error+`
3
+ `),1}let o=await e.getCachedAuth();if(!o){if(!e.isTTY()&&!r.flags.yes)return e.stderr("borg setup required and stdin is non-interactive. Run `borg setup` first in an interactive terminal, then `borg assimilate`.\n"),1;o=await e.runSetup()}const a=e.findProjectRoot(e.cwd());let i;if(r.flags.cubeName)i=r.flags.cubeName;else{const t=e.runSync("git",["remote","get-url","origin"],a),n=t.status===0?t.stdout:null;if(i=Z(a,n),n){const l=te(n),u=l?ee(l):null;l&&!u&&i&&e.stderr(`couldn't parse git remote '${l}' \u2014 using directory name '${i}' as cube name
4
+ `)}}let s=null;if(i&&i.includes("@")&&i.includes(":")){const t=i.lastIndexOf(":");s={ownerEmail:i.substring(0,t),cubeName:i.substring(t+1)},i=s.cubeName}const p=e.cwd();let R;try{R=await e.listCubes(o.apiUrl,o.token)}catch(t){const n=t instanceof Error?t.message:String(t);if(n.includes("Authentication required")||n.includes("Authentication expired"))e.stderr(`Re-authenticating...
5
+ `),o=await e.runSetup(),R=await e.listCubes(o.apiUrl,o.token);else throw t}const S=R.find(t=>t.name===i);if(!S&&s)return e.stderr(`No cube named '${s.cubeName}' accessible to you owned by '${s.ownerEmail}'. Did you accept their invite? See borgmcp.ai/dashboard.
6
+ `),1;let c,E;if(S)c=await e.getCube(o.apiUrl,o.token,S.id),E=!1;else{let t;if(r.flags.template)t=r.flags.template;else if(r.flags.noTemplate)t=void 0;else if(e.isTTY())if(r.flags.yes)t="starter";else{const n=await e.listTemplates(o.apiUrl,o.token),l=["First drone joining a new cube. Apply a template?"];n.forEach((y,k)=>{const x=k===0?" (default)":"";l.push(` ${k+1}) ${y.name}${x} \u2014 ${y.description}`)}),l.push(` ${n.length+1}) skip \u2014 no template`);const u=(await e.prompt(l.join(`
7
+ `)+`
8
+ [1]: `)).trim(),h=u===""?1:parseInt(u,10);if(Number.isNaN(h)||h<1||h>n.length+1)return e.stderr(`invalid choice "${u}"
9
+ `),1;t=h<=n.length?n[h-1].name:void 0}else{if(!r.flags.yes)return e.stderr(`cube creation needs a template choice but stdin is non-interactive.
10
+ Pass --template <name>, --no-template, or --yes (defaults to starter).
11
+ `),1;t="starter"}c=await e.createCube(o.apiUrl,o.token,t?{name:i??void 0,template:t}:{name:i??void 0}),E=!0}let d;if(r.role!==void 0){if(d=Q(c.roles,r.role),!d){const t=c.roles.map(u=>u.name).join(", "),n=fe(r.role,c.roles.map(u=>u.name)),l=n?` Did you mean "${n}"?`:"";return e.stderr(`no role matching "${r.role}" in cube "${c.name}". Available: ${t}.${l}
12
+ (Use --template <name> on first-drone setup or run \`borg:create-role\` from inside Claude.)
13
+ `),1}}else if(d=X(c.roles,{isFirstDrone:E}),!d)return e.stderr(`cube "${c.name}" has no default or human-seat role; cannot infer a role. Either pass a role argument explicitly (e.g. \`borg assimilate builder\`) or run \`borg:create-role\` from inside Claude to set up roles.
14
+ `),1;let m;try{m=await e.assimilate(o.apiUrl,o.token,{cube_id:c.id,role_id:d.id,hostname:e.getHostname()})}catch(t){const n=t instanceof Error?t.message:String(t);return e.stderr(`assimilate failed: ${n}
15
+ `),1}const $=c.roles.find(t=>t.id===m.role_id)??d;$.id!==d.id&&e.stderr(`Note: your invite didn't grant the "${d.name}" role \u2014 assimilated as "${$.name}" instead.
16
+ `);const U=await e.getActiveCube(),M=r.flags.worktree!==void 0||U!==null;let f=null;if(M){if(U&&r.flags.here)return e.stderr(`this directory already hosts an active drone; remove --here or run from a fresh worktree
17
+ `),1;const t=e.runSync("git",["rev-parse","--verify","HEAD"],a);if(t.status!==0)return e.stderr(`sibling worktree spawn requires HEAD pointing at a commit.
18
+ Fix: create at least one commit (\`git commit --allow-empty -m "initial"\`)
19
+ OR: pass --here to skip the sibling spawn and use the current directory
20
+ `),1;e.runSync("git",["fetch","origin"],a);let n="origin/main";e.runSync("git",["rev-parse","--verify","origin/main"],a).status!==0&&e.runSync("git",["rev-parse","--verify","origin/master"],a).status===0&&(n="origin/master");const u=t.stdout.trim(),h=e.runSync("git",["rev-parse",n],a).stdout.trim();u!==h&&e.stderr(`note: local HEAD (${u.slice(0,7)}) differs from ${n} (${h.slice(0,7)}); new worktree will start on ${n}
21
+ `);const y=K(a),k=C(a),x=r.flags.worktree??J($.name);let w=W(y,`${k}-${x}`),j=2;for(;e.pathExists(w)||me(e,a,w);)w=W(y,`${k}-${x}-${j}`),j++;const I=O(C(w),k),L=e.runSync("git",["worktree","add","-b",I,w,n],a);if(L.status!==0)return e.stderr(`git worktree add failed: ${F(L.stderr)}
22
+ `),1;e.stderr(`spawned sibling worktree at ${w} on branch ${I} (${n}); original dir is registered as active (edit ~/.config/borgmcp/cubes.json if stale).
23
+ `),e.chdir(w),e.stderr(ce(w,I,a)),f=e.cwd()}try{await e.setActiveCube({cubeId:m.cube_id,droneId:m.drone_id,name:c.name,sessionToken:m.session_token,droneLabel:m.drone_label,apiUrl:o.apiUrl})}catch(t){const n=t instanceof Error?t.message:String(t);if(e.stderr(`setActiveCube failed: ${n}
24
+ `),f){const l=e.runSync("git",["worktree","remove","--force",f],a);l.status===0?e.stderr(`rolled back spawned worktree at ${f}
25
+ `):e.stderr(`manual cleanup needed: \`git worktree remove --force ${f}\` (rollback attempt failed: ${F(l.stderr).trim()||"unknown"})
26
+ `)}return 1}e.setTerminalTitle(m.drone_label,c.name);const Y=e.isTTY()&&!process.env.NO_COLOR&&!process.env.CI;e.stdout(re($.name,c.name,Y));const g=await e.resolveCli(r.flags.cli),b=e.cwd();if(!f){e.runSync("git",["fetch","origin","--prune"],b);const t=O(C(b),C(a)),n=le(e.runSync,b,t,"origin/main");n.action==="adopted"?(e.stderr(`worktree: adopted branch ${t} at origin/main
27
+ `),e.stderr(ue(b,t))):n.message&&e.stderr(`worktree sync: ${n.message}
28
+ `)}await e.probeMcpReady()||e.stderr(`warning: borg-mcp readiness probe did not complete within the timeout; launching ${g} anyway \u2014 the kickoff prompt's ToolSearch fallback will recover if the MCP server takes longer to start.
29
+ `);const q=e.getInboxPath(m.cube_id,m.drone_id),A=g==="codex"?`borg-wake-${V()}`:null,z=g==="claude"?`If you haven't yet, arm a persistent Monitor running the command \`borg-inbox-monitor ${q}\` so each event's task-notification title summarizes the new cube log entry (drone label, role, and first ~80 chars of the message body) \u2014 letting you triage events without reading the full body. `:"";let N,_=[],v,D,T=null,P=null;if(g==="codex"){const t=await e.prepareCodexRemoteLaunch();t.warning?(e.stderr(`warning: ${t.warning}
30
+ `),N="\u26A0 Codex wake-path capability check failed: remote-control is unavailable for this session. Run borg:regen manually whenever you return, and expect only fallback wakeups until relaunch."):N="Codex wake-path capability check passed: remote-control socket established for this session.",_=t.args,D=Object.keys(t.env).length>0?t.env:void 0,T=se(t.args),P=t.server?.cleanup??null}v=[ie({cli:g,codexWakeNonce:A,monitorClause:z,codexWakePathClause:N})],g==="codex"&&(v=[..._,...oe(v,b)]);const B=e.exec(g,v,b,D);g==="codex"&&T&&A&&ae({deps:e,cubeId:m.cube_id,droneId:m.drone_id,socketPath:T,cwd:b,previewNeedle:A,launchedAtSeconds:Math.floor(Date.now()/1e3)});const G=await B;if(P)try{P()}catch{}return f&&p!==f&&e.stderr(`
31
+ Agent exited. You were working in ${f}; your shell is back in ${p}.
32
+ To return:
33
+ cd ${ne(f)}
34
+ `),G}function ce(r,e,o){return`
35
+ WORKTREE STEERING: You are in worktree ${r} on branch ${e}. Do ALL work HERE \u2014 cut your feature branch (fix/.../feat/...) off ${e} in THIS worktree, use relative paths / your cwd. NEVER \`git -C ${o}\` or operate on the primary checkout ${o}: the same branch can't be checked out in two worktrees, so work created in the primary won't reach your wt-branch without manual surgery (cherry-pick/merge).
36
+ `}function ue(r,e){return`
37
+ WORKTREE STEERING: This checkout is now on branch ${e}. Do ALL work HERE in ${r} \u2014 cut your feature branch (fix/.../feat/...) off ${e}, use relative paths / your cwd.
38
+ `}function F(r){return r.replace(/[\x00-\x1F\x7F]/g,"")}function me(r,e,o){const a=r.runSync("git",["worktree","list","--porcelain"],e);return a.status!==0?!1:a.stdout.split(`
39
+ `).some(i=>i===`worktree ${o}`)}function fe(r,e){if(e.length===0)return null;const o=r.toLowerCase();let a=null;for(const i of e){const s=de(o,i.toLowerCase());s<=2&&(a===null||s<a.distance)&&(a={name:i,distance:s})}return a?a.name:null}function de(r,e){if(r===e)return 0;if(r.length===0)return e.length;if(e.length===0)return r.length;const o=new Array(e.length+1),a=new Array(e.length+1);for(let i=0;i<=e.length;i++)o[i]=i;for(let i=1;i<=r.length;i++){a[0]=i;for(let s=1;s<=e.length;s++){const p=r[i-1]===e[s-1]?0:1;a[s]=Math.min(a[s-1]+1,o[s]+1,o[s-1]+p)}for(let s=0;s<=e.length;s++)o[s]=a[s]}return o[e.length]}export{Ee as runAssimilate,F as safeStderr,fe as suggestRoleName};