@imdeadpool/guardex 7.0.26 → 7.0.31

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.
@@ -31,6 +31,7 @@ const {
31
31
  } = require('../sandbox');
32
32
  const { ensureOmxScaffold, configureHooks } = require('../scaffold');
33
33
  const { detectRecoverableAutoFinishConflict, printAutoFinishSummary } = require('../output');
34
+ const { autoCommitWorktreeForFinish } = require('../finish');
34
35
 
35
36
  /**
36
37
  * @typedef {Object} SandboxMetadata
@@ -887,23 +888,25 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
887
888
  return summary;
888
889
  }
889
890
 
890
- if (!hasOriginRemote(repoRoot)) {
891
- summary.enabled = false;
892
- summary.details.push('Skipped auto-finish sweep (origin remote missing).');
893
- return summary;
894
- }
891
+ const originAvailable = hasOriginRemote(repoRoot);
895
892
  const explicitGhBin = Boolean(String(process.env.GUARDEX_GH_BIN || '').trim());
896
- if (!explicitGhBin && !originRemoteLooksLikeGithub(repoRoot)) {
897
- summary.enabled = false;
898
- summary.details.push('Skipped auto-finish sweep (origin remote is not GitHub).');
899
- return summary;
900
- }
901
-
902
893
  const ghBin = process.env.GUARDEX_GH_BIN || 'gh';
903
- if (run(ghBin, ['--version']).status !== 0) {
904
- summary.enabled = false;
905
- summary.details.push(`Skipped auto-finish sweep (${ghBin} not available).`);
906
- return summary;
894
+ const ghAvailable =
895
+ originAvailable &&
896
+ (explicitGhBin || originRemoteLooksLikeGithub(repoRoot)) &&
897
+ run(ghBin, ['--version']).status === 0;
898
+
899
+ let fallbackMode = '';
900
+ if (!originAvailable) {
901
+ fallbackMode = 'local';
902
+ summary.details.push('origin remote missing; falling back to local direct merge (no push, no PR).');
903
+ } else if (!ghAvailable) {
904
+ fallbackMode = 'direct';
905
+ if (!explicitGhBin && !originRemoteLooksLikeGithub(repoRoot)) {
906
+ summary.details.push('origin remote is not GitHub; falling back to direct merge + push.');
907
+ } else {
908
+ summary.details.push(`${ghBin} not available; falling back to direct merge + push.`);
909
+ }
907
910
  }
908
911
 
909
912
  const branchWorktrees = mapWorktreePathsByBranch(repoRoot);
@@ -936,16 +939,29 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
936
939
  continue;
937
940
  }
938
941
 
942
+ const branchWorktree = branchWorktrees.get(branch) || '';
943
+ if (branchWorktree && hasSignificantWorkingTreeChanges(branchWorktree)) {
944
+ try {
945
+ const commitResult = autoCommitWorktreeForFinish(repoRoot, branchWorktree, branch, {});
946
+ if (commitResult.committed) {
947
+ counts = aheadBehind(repoRoot, branch, baseBranch);
948
+ }
949
+ } catch (error) {
950
+ summary.failed += 1;
951
+ summary.details.push(`[fail] ${branch}: auto-commit failed (${error.message}).`);
952
+ continue;
953
+ }
954
+ }
955
+
939
956
  if (counts.ahead <= 0) {
940
957
  summary.skipped += 1;
941
958
  summary.details.push(`[skip] ${branch}: already merged into ${baseBranch}.`);
942
959
  continue;
943
960
  }
944
961
 
945
- const branchWorktree = branchWorktrees.get(branch) || '';
946
962
  if (branchWorktree && hasSignificantWorkingTreeChanges(branchWorktree)) {
947
963
  summary.skipped += 1;
948
- summary.details.push(`[skip] ${branch}: dirty worktree (${branchWorktree}).`);
964
+ summary.details.push(`[skip] ${branch}: dirty worktree after auto-commit (${branchWorktree}).`);
949
965
  continue;
950
966
  }
951
967
 
@@ -955,10 +971,16 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
955
971
  branch,
956
972
  '--base',
957
973
  baseBranch,
958
- '--via-pr',
959
- waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge',
960
- '--cleanup',
961
974
  ];
975
+ if (fallbackMode === 'local') {
976
+ finishArgs.push('--direct-only', '--no-push');
977
+ } else if (fallbackMode === 'direct') {
978
+ finishArgs.push('--direct-only');
979
+ } else {
980
+ finishArgs.push('--via-pr');
981
+ }
982
+ finishArgs.push(waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge');
983
+ finishArgs.push('--cleanup');
962
984
  const finishResult = runPackageAsset('branchFinish', finishArgs, { cwd: repoRoot });
963
985
  const combinedOutput = [finishResult.stdout || '', finishResult.stderr || ''].join('\n').trim();
964
986
 
@@ -521,4 +521,5 @@ module.exports = {
521
521
  merge,
522
522
  finish,
523
523
  sync,
524
+ autoCommitWorktreeForFinish,
524
525
  };
@@ -6,6 +6,8 @@ const {
6
6
  LEGACY_NAMES,
7
7
  GUARDEX_REPO_TOGGLE_ENV,
8
8
  CLI_COMMAND_DESCRIPTIONS,
9
+ CLI_COMMAND_GROUPS,
10
+ CLI_QUICKSTART_STEPS,
9
11
  AGENT_BOT_DESCRIPTIONS,
10
12
  DOCTOR_AUTO_FINISH_DETAIL_LIMIT,
11
13
  DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX,
@@ -166,6 +168,41 @@ function commandCatalogLines(indent = ' ') {
166
168
  );
167
169
  }
168
170
 
171
+ // groupedCommandCatalogLines renders CLI_COMMAND_GROUPS as a nested list with
172
+ // group headers separated by blank lines. It accepts an optional `colorize`
173
+ // callback so the caller can decide whether to decorate the group label (tty
174
+ // mode) or leave it plain (non-tty / NO_COLOR). Returns an array of lines;
175
+ // `null` entries mean "emit a blank line" so tree renderers can echo pipe
176
+ // characters on the separator rows.
177
+ function groupedCommandCatalogLines(indent = ' ', options = {}) {
178
+ const colorizeLabel = typeof options.colorizeLabel === 'function'
179
+ ? options.colorizeLabel
180
+ : (text) => text;
181
+ const maxCommandLength = CLI_COMMAND_DESCRIPTIONS.reduce(
182
+ (max, [command]) => Math.max(max, command.length),
183
+ 0,
184
+ );
185
+ const lines = [];
186
+ for (let groupIndex = 0; groupIndex < CLI_COMMAND_GROUPS.length; groupIndex += 1) {
187
+ const group = CLI_COMMAND_GROUPS[groupIndex];
188
+ const header = group.description
189
+ ? `${colorizeLabel(group.label)} — ${group.description}`
190
+ : colorizeLabel(group.label);
191
+ lines.push(`${indent}${header}`);
192
+ for (const [command, description] of group.commands) {
193
+ lines.push(`${indent} ${command.padEnd(maxCommandLength + 2)}${description}`);
194
+ }
195
+ if (groupIndex < CLI_COMMAND_GROUPS.length - 1) {
196
+ lines.push(null);
197
+ }
198
+ }
199
+ return lines;
200
+ }
201
+
202
+ function quickstartLines(indent = ' ') {
203
+ return CLI_QUICKSTART_STEPS.map((step, index) => `${indent}${index + 1}. ${step}`);
204
+ }
205
+
169
206
  function agentBotCatalogLines(indent = ' ') {
170
207
  const maxCommandLength = AGENT_BOT_DESCRIPTIONS.reduce(
171
208
  (max, [command]) => Math.max(max, command.length),
@@ -182,19 +219,43 @@ function repoToggleLines(indent = ' ') {
182
219
  ];
183
220
  }
184
221
 
185
- function printToolLogsSummary() {
186
- const usageLine = ` $ ${SHORT_TOOL_NAME} <command> [options]`;
187
- const commandDetails = commandCatalogLines(' ');
222
+ const KNOWN_CLI_BASENAMES = new Set(['gx', 'gitguardex', 'guardex']);
223
+
224
+ function getInvokedCliName() {
225
+ const raw = path.basename(String(process.argv[1] || '')).replace(/\.js$/, '');
226
+ if (!KNOWN_CLI_BASENAMES.has(raw)) {
227
+ return SHORT_TOOL_NAME;
228
+ }
229
+ return raw;
230
+ }
231
+
232
+ function printToolLogsSummary(options = {}) {
233
+ const invoked = options.invokedBasename || getInvokedCliName();
234
+ const compact = Boolean(options.compact);
235
+
236
+ if (compact) {
237
+ const helpLine = `Try '${invoked} help' for commands, or '${invoked} status --verbose' for full service details.`;
238
+ console.log(`[${TOOL_NAME}] ${colorize(helpLine, '2')}`);
239
+ return;
240
+ }
241
+
242
+ const usageLine = ` $ ${invoked} <command> [options]`;
243
+ const quickstartDetails = quickstartLines(' ');
188
244
  const agentBotDetails = agentBotCatalogLines(' ');
189
245
  const repoToggleDetails = repoToggleLines(' ');
190
246
 
191
247
  if (!supportsAnsiColors()) {
192
- console.log(`${TOOL_NAME}-tools logs:`);
248
+ const commandDetails = groupedCommandCatalogLines(' ');
249
+ console.log(`${invoked} help:`);
193
250
  console.log(' USAGE');
194
251
  console.log(usageLine);
252
+ console.log(' QUICKSTART');
253
+ for (const line of quickstartDetails) {
254
+ console.log(line);
255
+ }
195
256
  console.log(' COMMANDS');
196
257
  for (const line of commandDetails) {
197
- console.log(line);
258
+ console.log(line ?? '');
198
259
  }
199
260
  console.log(' AGENT BOT');
200
261
  for (const line of agentBotDetails) {
@@ -204,24 +265,33 @@ function printToolLogsSummary() {
204
265
  for (const line of repoToggleDetails) {
205
266
  console.log(line);
206
267
  }
268
+ console.log(` Try '${invoked} doctor' for one-step repair + verification.`);
207
269
  return;
208
270
  }
209
271
 
210
- const title = colorize(`${TOOL_NAME}-tools logs`, '1;36');
272
+ const title = colorize(`${invoked} help`, '1;36');
211
273
  const usageHeader = colorize('USAGE', '1');
274
+ const quickstartHeader = colorize('QUICKSTART', '1');
212
275
  const commandsHeader = colorize('COMMANDS', '1');
213
276
  const agentBotHeader = colorize('AGENT BOT', '1');
214
277
  const repoToggleHeader = colorize('REPO TOGGLE', '1');
215
278
  const pipe = colorize('│', '90');
216
279
  const tee = colorize('├', '90');
217
280
  const corner = colorize('└', '90');
281
+ const commandDetails = groupedCommandCatalogLines(' ', {
282
+ colorizeLabel: (text) => colorize(text, '1;36'),
283
+ });
218
284
 
219
285
  console.log(`${title}:`);
220
286
  console.log(` ${tee}─ ${usageHeader}`);
221
287
  console.log(` ${pipe}${usageLine}`);
288
+ console.log(` ${tee}─ ${quickstartHeader}`);
289
+ for (const line of quickstartDetails) {
290
+ console.log(` ${pipe}${line.slice(2)}`);
291
+ }
222
292
  console.log(` ${tee}─ ${commandsHeader}`);
223
293
  for (const line of commandDetails) {
224
- if (!line) {
294
+ if (line == null) {
225
295
  console.log(` ${pipe}`);
226
296
  continue;
227
297
  }
@@ -243,11 +313,18 @@ function printToolLogsSummary() {
243
313
  }
244
314
  console.log(` ${pipe}${line.slice(2)}`);
245
315
  }
246
- console.log(` ${corner}─ ${colorize(`Try '${TOOL_NAME} doctor' for one-step repair + verification.`, '2')}`);
316
+ console.log(` ${corner}─ ${colorize(`Try '${invoked} doctor' for one-step repair + verification.`, '2')}`);
247
317
  }
248
318
 
249
319
  function usage(options = {}) {
250
320
  const { outsideGitRepo = false } = options;
321
+ const invoked = options.invokedBasename || getInvokedCliName();
322
+
323
+ const groupedCommandLines = groupedCommandCatalogLines(' ', {
324
+ colorizeLabel: (text) => colorize(text, '1;36'),
325
+ })
326
+ .map((line) => (line == null ? '' : line))
327
+ .join('\n');
251
328
 
252
329
  console.log(`A command-line tool that sets up hardened multi-agent safety for git repositories.
253
330
 
@@ -255,10 +332,13 @@ VERSION
255
332
  ${runtimeVersion()}
256
333
 
257
334
  USAGE
258
- $ ${SHORT_TOOL_NAME} <command> [options]
335
+ $ ${invoked} <command> [options]
336
+
337
+ QUICKSTART
338
+ ${quickstartLines().join('\n')}
259
339
 
260
340
  COMMANDS
261
- ${commandCatalogLines().join('\n')}
341
+ ${groupedCommandLines}
262
342
 
263
343
  AGENT BOT
264
344
  ${agentBotCatalogLines().join('\n')}
@@ -267,19 +347,20 @@ REPO TOGGLE
267
347
  ${repoToggleLines().join('\n')}
268
348
 
269
349
  NOTES
270
- - No command = ${SHORT_TOOL_NAME} status. ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup.
350
+ - No command = ${invoked} status (compact in a TTY; pass --verbose for full services + help tree).
351
+ - ${invoked} init is an alias of ${invoked} setup.
271
352
  - Global installs need Y/N approval; GitHub CLI (gh) is required for PR automation.
272
- - Target another repo: ${SHORT_TOOL_NAME} <cmd> --target <repo-path>.
353
+ - Target another repo: ${invoked} <cmd> --target <repo-path>.
273
354
  - On protected main, setup/install/fix/doctor auto-sandbox via agent branch + PR flow.
274
- - Run '${SHORT_TOOL_NAME} cleanup' to prune merged agent branches/worktrees.
355
+ - Run '${invoked} cleanup' to prune merged agent branches/worktrees.
275
356
  - Legacy aliases: ${LEGACY_NAMES.join(', ')}.`);
276
357
 
277
358
  if (outsideGitRepo) {
278
359
  console.log(`
279
360
  [${TOOL_NAME}] No git repository detected in current directory.
280
361
  [${TOOL_NAME}] Start from a repo root, or pass an explicit target:
281
- ${TOOL_NAME} setup --target <path-to-git-repo>
282
- ${TOOL_NAME} doctor --target <path-to-git-repo>`);
362
+ ${invoked} setup --target <path-to-git-repo>
363
+ ${invoked} doctor --target <path-to-git-repo>`);
283
364
  }
284
365
  }
285
366
 
@@ -294,6 +375,59 @@ function formatElapsedDuration(ms) {
294
375
  return `${Math.round(durationMs / 1000)}s`;
295
376
  }
296
377
 
378
+ function startTransientSpinner(message, options = {}) {
379
+ const stream = options.stream || process.stdout;
380
+ if (!stream || !stream.isTTY || typeof stream.write !== 'function') {
381
+ return {
382
+ stop() {},
383
+ };
384
+ }
385
+
386
+ const frames = supportsAnsiColors()
387
+ ? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
388
+ : ['-', '\\', '|', '/'];
389
+ const intervalMs = Number.isFinite(options.intervalMs) ? Math.max(60, options.intervalMs) : 80;
390
+ const prefix = String(options.prefix || `[${TOOL_NAME}]`).trim();
391
+ const text = String(message || '').trim();
392
+ let frameIndex = 0;
393
+ let stopped = false;
394
+
395
+ const render = () => {
396
+ const frame = frames[frameIndex % frames.length];
397
+ frameIndex += 1;
398
+ const indicator = supportsAnsiColors() ? colorize(frame, '36') : frame;
399
+ stream.write(`\r${prefix} ${indicator} ${text}`);
400
+ };
401
+
402
+ const clear = () => {
403
+ stream.write('\r');
404
+ if (typeof stream.clearLine === 'function') {
405
+ stream.clearLine(0);
406
+ }
407
+ if (typeof stream.cursorTo === 'function') {
408
+ stream.cursorTo(0);
409
+ }
410
+ };
411
+
412
+ render();
413
+ const timer = setInterval(render, intervalMs);
414
+ if (typeof timer.unref === 'function') {
415
+ timer.unref();
416
+ }
417
+
418
+ return {
419
+ stop(finalLine = '') {
420
+ if (stopped) return;
421
+ stopped = true;
422
+ clearInterval(timer);
423
+ clear();
424
+ if (finalLine) {
425
+ stream.write(`${finalLine}\n`);
426
+ }
427
+ },
428
+ };
429
+ }
430
+
297
431
  function truncateMiddle(value, maxLength) {
298
432
  const text = String(value || '');
299
433
  const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0;
@@ -454,8 +588,10 @@ module.exports = {
454
588
  agentBotCatalogLines,
455
589
  repoToggleLines,
456
590
  printToolLogsSummary,
591
+ getInvokedCliName,
457
592
  usage,
458
593
  formatElapsedDuration,
594
+ startTransientSpinner,
459
595
  truncateMiddle,
460
596
  truncateTail,
461
597
  compactAutoFinishPathSegments,
@@ -545,6 +545,10 @@ function installGlobalToolchain(options) {
545
545
  };
546
546
  }
547
547
 
548
+ return performCompanionInstall(missingPackages, missingLocalTools);
549
+ }
550
+
551
+ function performCompanionInstall(missingPackages, missingLocalTools) {
548
552
  const installed = [];
549
553
  if (missingPackages.length > 0) {
550
554
  console.log(
@@ -593,6 +597,7 @@ module.exports = {
593
597
  formatGlobalToolchainServiceName,
594
598
  describeMissingGlobalDependencyWarnings,
595
599
  describeCompanionInstallCommands,
600
+ buildMissingCompanionInstallPrompt,
596
601
  detectGlobalToolchainPackages,
597
602
  detectRequiredSystemTools,
598
603
  detectOptionalLocalCompanionTools,
@@ -600,4 +605,5 @@ module.exports = {
600
605
  maybeSelfUpdateBeforeStatus,
601
606
  maybeOpenSpecUpdateBeforeStatus,
602
607
  installGlobalToolchain,
608
+ performCompanionInstall,
603
609
  };
@@ -277,6 +277,10 @@ created_source_probe=0
277
277
  source_probe_path=""
278
278
  integration_worktree=""
279
279
  integration_branch=""
280
+ merge_completed=0
281
+ merge_status="pr"
282
+ direct_push_error=""
283
+ pr_url=""
280
284
 
281
285
  cleanup() {
282
286
  if [[ -n "$integration_worktree" && -d "$integration_worktree" ]]; then
@@ -358,22 +362,6 @@ if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify -
358
362
  fi
359
363
  fi
360
364
 
361
- integration_stamp="$(date +%Y%m%d-%H%M%S)"
362
- integration_worktree_base="${temp_worktree_root}/__integrate-${BASE_BRANCH//\//__}-${integration_stamp}"
363
- integration_branch_base="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
364
- integration_worktree="$integration_worktree_base"
365
- integration_branch="$integration_branch_base"
366
- integration_suffix=1
367
- while [[ -e "$integration_worktree" ]] || git -C "$repo_root" show-ref --verify --quiet "refs/heads/${integration_branch}"; do
368
- integration_worktree="${integration_worktree_base}-${integration_suffix}"
369
- integration_branch="${integration_branch_base}_${integration_suffix}"
370
- integration_suffix=$((integration_suffix + 1))
371
- done
372
- mkdir -p "$(dirname "$integration_worktree")"
373
-
374
- git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null
375
- git -C "$integration_worktree" checkout -b "$integration_branch" >/dev/null
376
-
377
365
  if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
378
366
  git -C "$source_worktree" fetch origin "$BASE_BRANCH" --quiet
379
367
 
@@ -395,16 +383,52 @@ if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRA
395
383
  git -C "$source_worktree" merge --abort >/dev/null 2>&1 || true
396
384
  fi
397
385
 
398
- if ! git -C "$integration_worktree" merge --no-ff --no-edit "$SOURCE_BRANCH"; then
399
- echo "[agent-branch-finish] Merge conflict detected while merging '${SOURCE_BRANCH}' into '${BASE_BRANCH}'." >&2
400
- git -C "$integration_worktree" merge --abort >/dev/null 2>&1 || true
401
- exit 1
386
+ should_create_integration_helper=1
387
+ if [[ "$MERGE_MODE" == "pr" && "$PUSH_ENABLED" -eq 1 ]]; then
388
+ should_create_integration_helper=0
402
389
  fi
403
390
 
404
- merge_completed=1
405
- merge_status="direct"
406
- direct_push_error=""
407
- pr_url=""
391
+ if [[ "$should_create_integration_helper" -eq 1 ]]; then
392
+ existing_base_worktree=""
393
+ if [[ "$PUSH_ENABLED" -eq 0 ]]; then
394
+ existing_base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")"
395
+ fi
396
+
397
+ if [[ -n "$existing_base_worktree" ]] && is_clean_worktree "$existing_base_worktree"; then
398
+ if ! git -C "$existing_base_worktree" merge --no-ff --no-edit "$SOURCE_BRANCH"; then
399
+ echo "[agent-branch-finish] Merge conflict detected while merging '${SOURCE_BRANCH}' into '${BASE_BRANCH}'." >&2
400
+ git -C "$existing_base_worktree" merge --abort >/dev/null 2>&1 || true
401
+ exit 1
402
+ fi
403
+ merge_completed=1
404
+ merge_status="direct"
405
+ else
406
+ integration_stamp="$(date +%Y%m%d-%H%M%S)"
407
+ integration_worktree_base="${temp_worktree_root}/__integrate-${BASE_BRANCH//\//__}-${integration_stamp}"
408
+ integration_branch_base="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
409
+ integration_worktree="$integration_worktree_base"
410
+ integration_branch="$integration_branch_base"
411
+ integration_suffix=1
412
+ while [[ -e "$integration_worktree" ]] || git -C "$repo_root" show-ref --verify --quiet "refs/heads/${integration_branch}"; do
413
+ integration_worktree="${integration_worktree_base}-${integration_suffix}"
414
+ integration_branch="${integration_branch_base}_${integration_suffix}"
415
+ integration_suffix=$((integration_suffix + 1))
416
+ done
417
+ mkdir -p "$(dirname "$integration_worktree")"
418
+
419
+ git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null
420
+ git -C "$integration_worktree" checkout -b "$integration_branch" >/dev/null
421
+
422
+ if ! git -C "$integration_worktree" merge --no-ff --no-edit "$SOURCE_BRANCH"; then
423
+ echo "[agent-branch-finish] Merge conflict detected while merging '${SOURCE_BRANCH}' into '${BASE_BRANCH}'." >&2
424
+ git -C "$integration_worktree" merge --abort >/dev/null 2>&1 || true
425
+ exit 1
426
+ fi
427
+
428
+ merge_completed=1
429
+ merge_status="direct"
430
+ fi
431
+ fi
408
432
 
409
433
  is_local_branch_delete_error() {
410
434
  local output="$1"
@@ -340,16 +340,30 @@ resolve_openspec_capability_slug() {
340
340
  sanitize_slug "$task_slug" "general-behavior"
341
341
  }
342
342
 
343
+ resolve_repo_prefix() {
344
+ local root
345
+ root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
346
+ basename "$root"
347
+ }
348
+
343
349
  resolve_worktree_leaf() {
344
350
  local branch_name="$1"
345
351
  local agent_slug="$2"
346
352
  local masterplan_label=""
347
353
  local branch_leaf=""
354
+ local repo_prefix
355
+ repo_prefix="$(resolve_repo_prefix)"
348
356
 
349
357
  masterplan_label="$(resolve_openspec_masterplan_label)"
350
358
  if [[ -n "$masterplan_label" ]] && [[ "$branch_name" == "agent/${agent_slug}/"* ]]; then
351
359
  branch_leaf="${branch_name#agent/${agent_slug}/}"
352
- printf 'agent__%s__%s__%s' "$agent_slug" "$masterplan_label" "$branch_leaf"
360
+ printf '%s__%s__%s__%s' "$repo_prefix" "$agent_slug" "$masterplan_label" "$branch_leaf"
361
+ return 0
362
+ fi
363
+
364
+ if [[ "$branch_name" == agent/*/* ]]; then
365
+ local without_agent="${branch_name#agent/}"
366
+ printf '%s__%s' "$repo_prefix" "${without_agent//\//__}"
353
367
  return 0
354
368
  fi
355
369
 
@@ -370,6 +384,19 @@ has_local_changes() {
370
384
  return 1
371
385
  }
372
386
 
387
+ resolve_stash_ref_by_message() {
388
+ local root="$1"
389
+ local message="$2"
390
+ local stash_list
391
+ stash_list="$(git -C "$root" stash list 2>/dev/null || true)"
392
+ if [[ -z "$stash_list" ]]; then
393
+ printf ''
394
+ return 0
395
+ fi
396
+
397
+ awk -v msg="$message" '$0 ~ msg { ref=$1; sub(/:$/, "", ref); print ref; exit }' <<<"$stash_list"
398
+ }
399
+
373
400
  resolve_protected_branches() {
374
401
  local root="$1"
375
402
  local raw
@@ -616,10 +643,7 @@ if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]] && is_protected_bra
616
643
  if has_local_changes "$repo_root"; then
617
644
  auto_transfer_message="guardex-auto-transfer-${timestamp}-${agent_slug}-${task_slug}"
618
645
  if git -C "$repo_root" stash push --include-untracked --message "$auto_transfer_message" >/dev/null 2>&1; then
619
- auto_transfer_stash_ref="$(
620
- git -C "$repo_root" stash list \
621
- | awk -v msg="$auto_transfer_message" '$0 ~ msg { ref=$1; sub(/:$/, "", ref); print ref; exit }'
622
- )"
646
+ auto_transfer_stash_ref="$(resolve_stash_ref_by_message "$repo_root" "$auto_transfer_message")"
623
647
  if [[ -n "$auto_transfer_stash_ref" ]]; then
624
648
  auto_transfer_source_branch="$current_branch"
625
649
  echo "[agent-branch-start] Detected local changes on protected branch '${current_branch}'. Moving them to '${branch_name}'..."
@@ -372,17 +372,32 @@ resolve_openspec_capability_slug() {
372
372
  sanitize_slug "$task_slug" "general-behavior"
373
373
  }
374
374
 
375
+ resolve_repo_prefix() {
376
+ local root
377
+ root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
378
+ basename "$root"
379
+ }
380
+
375
381
  resolve_worktree_leaf() {
376
382
  local branch_name="$1"
377
383
  local masterplan_label=""
378
384
  local branch_role=""
379
385
  local branch_leaf=""
386
+ local repo_prefix
387
+ repo_prefix="$(resolve_repo_prefix)"
380
388
 
381
389
  masterplan_label="$(resolve_openspec_masterplan_label)"
382
390
  if [[ -n "$masterplan_label" ]] && [[ "$branch_name" =~ ^agent/([^/]+)/(.+)$ ]]; then
383
391
  branch_role="${BASH_REMATCH[1]}"
384
392
  branch_leaf="${BASH_REMATCH[2]}"
385
- printf 'agent__%s__%s__%s' "$branch_role" "$masterplan_label" "$branch_leaf"
393
+ printf '%s__%s__%s__%s' "$repo_prefix" "$branch_role" "$masterplan_label" "$branch_leaf"
394
+ return 0
395
+ fi
396
+
397
+ if [[ "$branch_name" =~ ^agent/([^/]+)/(.+)$ ]]; then
398
+ branch_role="${BASH_REMATCH[1]}"
399
+ branch_leaf="${BASH_REMATCH[2]}"
400
+ printf '%s__%s__%s' "$repo_prefix" "$branch_role" "$branch_leaf"
386
401
  return 0
387
402
  fi
388
403