@imdeadpool/guardex 7.0.41 → 7.1.0

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 (118) hide show
  1. package/README.md +94 -13
  2. package/package.json +3 -1
  3. package/skills/gitguardex/SKILL.md +13 -0
  4. package/skills/guardex-merge-skills-to-dev/SKILL.md +59 -0
  5. package/skills/gx-act/SKILL.md +82 -0
  6. package/src/agents/cleanup-sessions.js +126 -0
  7. package/src/agents/finish.js +172 -0
  8. package/src/agents/inspect.js +202 -0
  9. package/src/agents/launch.js +249 -0
  10. package/src/agents/registry.js +133 -0
  11. package/src/agents/selection-panel.js +571 -0
  12. package/src/agents/sessions.js +151 -0
  13. package/src/agents/start.js +591 -0
  14. package/src/agents/status.js +146 -0
  15. package/src/agents/terminal.js +152 -0
  16. package/src/budget/index.js +344 -0
  17. package/src/ci-init/index.js +265 -0
  18. package/src/cli/args.js +357 -3
  19. package/src/cli/commands/agents.js +364 -0
  20. package/src/cli/commands/bootstrap.js +92 -0
  21. package/src/cli/commands/branch.js +127 -0
  22. package/src/cli/commands/claude.js +674 -0
  23. package/src/cli/commands/doctor.js +268 -0
  24. package/src/cli/commands/finish.js +26 -0
  25. package/src/cli/commands/mcp.js +122 -0
  26. package/src/cli/commands/misc.js +304 -0
  27. package/src/cli/commands/pr.js +439 -0
  28. package/src/cli/commands/prompt.js +92 -0
  29. package/src/cli/commands/release.js +305 -0
  30. package/src/cli/commands/report.js +244 -0
  31. package/src/cli/commands/review.js +32 -0
  32. package/src/cli/commands/setup.js +242 -0
  33. package/src/cli/commands/status.js +338 -0
  34. package/src/cli/commands/watch.js +234 -0
  35. package/src/cli/main.js +85 -3613
  36. package/src/cli/shared/repo-env.js +161 -0
  37. package/src/cli/shared/sandbox.js +417 -0
  38. package/src/cli/shared/scaffolding.js +535 -0
  39. package/src/cli/shared/toolchain-shims.js +420 -0
  40. package/src/cockpit/action-runner.js +3 -0
  41. package/src/cockpit/actions.js +80 -0
  42. package/src/cockpit/control.js +1121 -0
  43. package/src/cockpit/index.js +426 -0
  44. package/src/cockpit/kitty-layout.js +549 -0
  45. package/src/cockpit/kitty-tree.js +144 -0
  46. package/src/cockpit/logs-reader.js +182 -0
  47. package/src/cockpit/menu.js +204 -0
  48. package/src/cockpit/pane-actions.js +597 -0
  49. package/src/cockpit/pane-menu.js +387 -0
  50. package/src/cockpit/projects-finder.js +178 -0
  51. package/src/cockpit/render.js +215 -0
  52. package/src/cockpit/settings-render.js +128 -0
  53. package/src/cockpit/settings.js +124 -0
  54. package/src/cockpit/shortcuts.js +24 -0
  55. package/src/cockpit/sidebar.js +311 -0
  56. package/src/cockpit/state.js +72 -0
  57. package/src/cockpit/theme.js +128 -0
  58. package/src/cockpit/welcome.js +266 -0
  59. package/src/context.js +304 -43
  60. package/src/core/runtime.js +6 -1
  61. package/src/doctor/index.js +45 -15
  62. package/src/finish/index.js +186 -7
  63. package/src/finish/preflight.js +177 -0
  64. package/src/finish/review-gate.js +182 -0
  65. package/src/git/index.js +511 -4
  66. package/src/hooks/index.js +0 -64
  67. package/src/kitty/command.js +101 -0
  68. package/src/kitty/runtime.js +250 -0
  69. package/src/mcp/collect.js +370 -0
  70. package/src/mcp/server.js +157 -0
  71. package/src/output/index.js +68 -2
  72. package/src/pr-review.js +264 -0
  73. package/src/pr.js +381 -0
  74. package/src/sandbox/index.js +13 -2
  75. package/src/scaffold/agent-worktree-prep.js +213 -0
  76. package/src/scaffold/index.js +127 -10
  77. package/src/speckit/index.js +226 -0
  78. package/src/submodule/index.js +288 -0
  79. package/src/terminal/index.js +45 -0
  80. package/src/terminal/kitty.js +622 -0
  81. package/src/terminal/tmux.js +125 -0
  82. package/src/tmux/command.js +27 -0
  83. package/src/tmux/session.js +89 -0
  84. package/src/toolchain/index.js +20 -0
  85. package/templates/AGENTS.monorepo-apps.md +26 -0
  86. package/templates/AGENTS.multiagent-safety.md +63 -323
  87. package/templates/AGENTS.multiagent-safety.min.md +11 -0
  88. package/templates/codex/skills/gitguardex/SKILL.md +2 -0
  89. package/templates/codex/skills/gx-act/SKILL.md +82 -0
  90. package/templates/githooks/pre-commit +44 -20
  91. package/templates/github/workflows/README.md +87 -0
  92. package/templates/github/workflows/ci-full.yml +55 -0
  93. package/templates/github/workflows/ci.yml +56 -0
  94. package/templates/github/workflows/cr.yml +20 -1
  95. package/templates/scripts/agent-branch-finish.sh +519 -23
  96. package/templates/scripts/agent-branch-merge.sh +4 -1
  97. package/templates/scripts/agent-branch-start.sh +176 -24
  98. package/templates/scripts/agent-preflight.sh +115 -0
  99. package/templates/scripts/agent-worktree-prune.sh +96 -5
  100. package/templates/scripts/codex-agent.sh +41 -97
  101. package/templates/scripts/openspec/init-plan-workspace.sh +43 -0
  102. package/templates/scripts/review-bot-watch.sh +31 -2
  103. package/templates/scripts/agent-session-state.js +0 -171
  104. package/templates/scripts/install-vscode-active-agents-extension.js +0 -135
  105. package/templates/vscode/guardex-active-agents/README.md +0 -34
  106. package/templates/vscode/guardex-active-agents/extension.js +0 -3782
  107. package/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json +0 -54
  108. package/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg +0 -5
  109. package/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg +0 -7
  110. package/templates/vscode/guardex-active-agents/fileicons/icons/config.svg +0 -4
  111. package/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg +0 -4
  112. package/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg +0 -5
  113. package/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg +0 -4
  114. package/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg +0 -5
  115. package/templates/vscode/guardex-active-agents/icon.png +0 -0
  116. package/templates/vscode/guardex-active-agents/media/active-agents-hivemind.svg +0 -14
  117. package/templates/vscode/guardex-active-agents/package.json +0 -169
  118. package/templates/vscode/guardex-active-agents/session-schema.js +0 -1348
package/src/git/index.js CHANGED
@@ -1,6 +1,8 @@
1
+ // @ts-check
1
2
  const fs = require('node:fs');
2
3
  const {
3
4
  path,
5
+ cachedSpawn,
4
6
  TOOL_NAME,
5
7
  GIT_PROTECTED_BRANCHES_KEY,
6
8
  GIT_BASE_BRANCH_KEY,
@@ -11,8 +13,109 @@ const {
11
13
  COMPOSE_HINT_FILES,
12
14
  LOCK_FILE_RELATIVE,
13
15
  } = require('../context');
14
- const { run } = require('../core/runtime');
15
16
 
17
+ /**
18
+ * Result of a synchronous child-process spawn.
19
+ *
20
+ * Mirrors the relevant subset of Node's `child_process.SpawnSyncReturns`
21
+ * without pulling in `@types/node`, so this typedef stays usable under
22
+ * `@ts-check` even when type definitions are not installed.
23
+ *
24
+ * @typedef {Object} SpawnResult
25
+ * @property {number|null} status Exit code, or `null` if the process was killed by a signal.
26
+ * @property {string} [stdout] Captured stdout (utf-8).
27
+ * @property {string} [stderr] Captured stderr (utf-8).
28
+ * @property {Error} [error] Spawn-level error (e.g., ENOENT, timeout).
29
+ */
30
+
31
+ /**
32
+ * Options accepted by {@link run}.
33
+ *
34
+ * @typedef {Object} RunOptions
35
+ * @property {string} [stdio] Stdio mode passed to spawnSync ('pipe' by default).
36
+ * @property {string} [cwd] Working directory for the child process.
37
+ * @property {Record<string, string|undefined>} [env] Extra env vars merged on top of `process.env`.
38
+ * @property {number} [timeout] Kill the child after this many milliseconds.
39
+ */
40
+
41
+ /**
42
+ * Outcome of an additive setup operation reported by setup-style helpers.
43
+ *
44
+ * @typedef {Object} SetupOperation
45
+ * @property {string} status Operation outcome (e.g., 'unchanged', 'updated', 'set', 'would-set', 'synced', 'failed').
46
+ * @property {string} file Human-readable identifier for the touched resource (often `git config <key>`).
47
+ * @property {string} [note] Optional explanation surfaced in setup output.
48
+ */
49
+
50
+ /**
51
+ * Porcelain status of the agent file-lock registry inside the working tree.
52
+ *
53
+ * @typedef {Object} LockRegistryStatus
54
+ * @property {boolean} dirty Lock file has uncommitted or untracked changes.
55
+ * @property {boolean} untracked Lock file is present but not tracked by git.
56
+ */
57
+
58
+ /**
59
+ * Ahead/behind counts between a branch and a base ref.
60
+ *
61
+ * @typedef {Object} AheadBehindCounts
62
+ * @property {number} ahead Commits on `branchRef` not on `baseRef`.
63
+ * @property {number} behind Commits on `baseRef` not on `branchRef`.
64
+ */
65
+
66
+ /**
67
+ * Outcome of attempting to switch the primary checkout onto a branch.
68
+ *
69
+ * @typedef {Object} EnsureRepoBranchResult
70
+ * @property {boolean} ok True when the working tree is on (or moved to) the requested branch.
71
+ * @property {boolean} changed True only when this call performed a checkout.
72
+ * @property {string} [stdout] Checkout stdout when the operation failed.
73
+ * @property {string} [stderr] Checkout stderr when the operation failed.
74
+ */
75
+
76
+ /**
77
+ * Worktree entry describing an `agent/*` branch checked out on disk.
78
+ *
79
+ * @typedef {Object} AgentWorktreeEntry
80
+ * @property {string} worktreePath Absolute filesystem path of the worktree.
81
+ * @property {string} branch Branch name (without the `refs/heads/` prefix).
82
+ */
83
+
84
+ /**
85
+ * Spawn `cmd` synchronously and capture its output as utf-8 text.
86
+ *
87
+ * Routes every call through the process-scoped probe cache in `context.js`
88
+ * so repeated read-only probes (e.g., `git rev-parse`, `git config --get`)
89
+ * answer from cache. `cachedSpawn` falls through to `cp.spawnSync` for any
90
+ * command not on its read-only allowlist (writes like `git commit`,
91
+ * `git push`, `git checkout`, `git worktree add/remove`), so this is a
92
+ * strict perf optimization preserving the `(cmd, args, opts) -> spawnSync
93
+ * result` signature.
94
+ *
95
+ * @param {string} cmd Executable to invoke.
96
+ * @param {ReadonlyArray<string>} args Argument vector.
97
+ * @param {RunOptions} [options] Spawn options (see {@link RunOptions}).
98
+ * @returns {SpawnResult} Result of the spawn.
99
+ */
100
+ function run(cmd, args, options = {}) {
101
+ return cachedSpawn(cmd, args, {
102
+ encoding: 'utf8',
103
+ stdio: options.stdio || 'pipe',
104
+ cwd: options.cwd,
105
+ env: options.env ? { ...process.env, ...options.env } : process.env,
106
+ timeout: options.timeout,
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Run `git -C <repoRoot> <args>` and throw unless `allowFailure` is set.
112
+ *
113
+ * @param {string} repoRoot Repository root the command should target.
114
+ * @param {ReadonlyArray<string>} args Argument vector passed after `-C <repoRoot>`.
115
+ * @param {{ allowFailure?: boolean }} [opts] When `allowFailure` is true, non-zero exits are returned to the caller instead of throwing.
116
+ * @returns {SpawnResult} The spawn result (always returned; only thrown on failure when `allowFailure` is false).
117
+ * @throws {Error} When git exits non-zero and `allowFailure` is not set.
118
+ */
16
119
  function gitRun(repoRoot, args, { allowFailure = false } = {}) {
17
120
  const result = run('git', ['-C', repoRoot, ...args]);
18
121
  if (!allowFailure && result.status !== 0) {
@@ -21,6 +124,13 @@ function gitRun(repoRoot, args, { allowFailure = false } = {}) {
21
124
  return result;
22
125
  }
23
126
 
127
+ /**
128
+ * Resolve the absolute git toplevel for `targetPath` (or `process.cwd()`).
129
+ *
130
+ * @param {string} [targetPath] Filesystem path inside a git repo. Defaults to the current working directory.
131
+ * @returns {string} Absolute path to the git toplevel.
132
+ * @throws {Error} When `targetPath` is not inside a git repository.
133
+ */
24
134
  function resolveRepoRoot(targetPath) {
25
135
  const resolvedTarget = path.resolve(targetPath || process.cwd());
26
136
  const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']);
@@ -33,6 +143,12 @@ function resolveRepoRoot(targetPath) {
33
143
  return result.stdout.trim();
34
144
  }
35
145
 
146
+ /**
147
+ * Test whether `targetPath` (or cwd) resolves inside a git repository.
148
+ *
149
+ * @param {string} [targetPath] Filesystem path to probe. Defaults to the current working directory.
150
+ * @returns {boolean} True if the path is inside a git repo.
151
+ */
36
152
  function isGitRepo(targetPath) {
37
153
  const resolvedTarget = path.resolve(targetPath || process.cwd());
38
154
  const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']);
@@ -61,6 +177,21 @@ function resolveGitCommonDir(repoPath) {
61
177
  return path.resolve(repoPath, raw);
62
178
  }
63
179
 
180
+ /**
181
+ * Walk `rootPath` and return every distinct git working tree found within it.
182
+ *
183
+ * Skips common heavy/disposable directories (`node_modules`, `dist`, etc.),
184
+ * excludes the root's own `.git`, and by default treats submodules
185
+ * (where `.git` is a file, not a directory) as part of the root unless
186
+ * `includeSubmodules` is set. Filters out worktree children that share the
187
+ * root's `git-common-dir` so additional worktrees of the same repo do not
188
+ * show up as nested repos.
189
+ *
190
+ * @param {string} rootPath Path to search.
191
+ * @param {{ maxDepth?: number, extraSkip?: ReadonlyArray<string>, includeSubmodules?: boolean, skipRelativeDirs?: ReadonlyArray<string> }} [opts] Walk tuning knobs.
192
+ * @returns {string[]} Sorted list of repo paths, with `rootPath` first.
193
+ * @throws {Error} When `rootPath` is not itself a git repository.
194
+ */
64
195
  function discoverNestedGitRepos(rootPath, opts = {}) {
65
196
  const maxDepth = Number.isFinite(opts.maxDepth)
66
197
  ? Math.max(1, opts.maxDepth)
@@ -124,6 +255,12 @@ function discoverNestedGitRepos(rootPath, opts = {}) {
124
255
  return root ? [root, ...rest] : [];
125
256
  }
126
257
 
258
+ /**
259
+ * Split a whitespace/comma-delimited branch list into a deduped array.
260
+ *
261
+ * @param {unknown} rawValue Raw config value (string-coerced; non-strings yield `[]`).
262
+ * @returns {string[]} Branch names with empty entries removed.
263
+ */
127
264
  function parseBranchList(rawValue) {
128
265
  return String(rawValue || '')
129
266
  .split(/[\s,]+/)
@@ -131,8 +268,17 @@ function parseBranchList(rawValue) {
131
268
  .filter(Boolean);
132
269
  }
133
270
 
271
+ /**
272
+ * Return `items` with duplicates removed, preserving first-seen order.
273
+ *
274
+ * @template T
275
+ * @param {ReadonlyArray<T>} items Input list.
276
+ * @returns {T[]} Deduped list.
277
+ */
134
278
  function uniquePreserveOrder(items) {
279
+ /** @type {Set<T>} */
135
280
  const seen = new Set();
281
+ /** @type {T[]} */
136
282
  const result = [];
137
283
  for (const item of items) {
138
284
  if (seen.has(item)) continue;
@@ -142,6 +288,12 @@ function uniquePreserveOrder(items) {
142
288
  return result;
143
289
  }
144
290
 
291
+ /**
292
+ * Read the `GIT_PROTECTED_BRANCHES_KEY` config value as a deduped list.
293
+ *
294
+ * @param {string} repoRoot Repo to inspect.
295
+ * @returns {string[]|null} Parsed branches, or `null` when the key is unset / empty.
296
+ */
145
297
  function readConfiguredProtectedBranches(repoRoot) {
146
298
  const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
147
299
  if (result.status !== 0) {
@@ -154,6 +306,14 @@ function readConfiguredProtectedBranches(repoRoot) {
154
306
  return parsed;
155
307
  }
156
308
 
309
+ /**
310
+ * Enumerate local branches that look like user branches (not `agent/*`, not
311
+ * in `DEFAULT_PROTECTED_BRANCHES`). Falls back to the currently checked-out
312
+ * branch when no other user branches exist.
313
+ *
314
+ * @param {string} repoRoot Repo to inspect.
315
+ * @returns {string[]} User branch names.
316
+ */
157
317
  function listLocalUserBranches(repoRoot) {
158
318
  const result = gitRun(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { allowFailure: true });
159
319
  const branchNames = result.status === 0
@@ -191,6 +351,12 @@ function listLocalUserBranches(repoRoot) {
191
351
  return [branchName];
192
352
  }
193
353
 
354
+ /**
355
+ * Enumerate local branches under `refs/heads/agent/`.
356
+ *
357
+ * @param {string} repoRoot Repo to inspect.
358
+ * @returns {string[]} Agent branch names (deduped, in for-each-ref order).
359
+ */
194
360
  function listLocalAgentBranches(repoRoot) {
195
361
  const result = gitRun(
196
362
  repoRoot,
@@ -208,6 +374,12 @@ function listLocalAgentBranches(repoRoot) {
208
374
  );
209
375
  }
210
376
 
377
+ /**
378
+ * Build a `branch -> worktreePath` map from `git worktree list --porcelain`.
379
+ *
380
+ * @param {string} repoRoot Repo to inspect.
381
+ * @returns {Map<string, string>} Branch -> absolute worktree path; empty on failure.
382
+ */
211
383
  function mapWorktreePathsByBranch(repoRoot) {
212
384
  const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
213
385
  const map = new Map();
@@ -232,10 +404,24 @@ function mapWorktreePathsByBranch(repoRoot) {
232
404
  return map;
233
405
  }
234
406
 
407
+ /**
408
+ * Test whether `ref` resolves via `git show-ref --verify`.
409
+ *
410
+ * @param {string} repoRoot Repo to inspect.
411
+ * @param {string} ref Fully-qualified ref (e.g., `refs/heads/main`).
412
+ * @returns {boolean} True when the ref exists.
413
+ */
235
414
  function gitRefExists(repoRoot, ref) {
236
415
  return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0;
237
416
  }
238
417
 
418
+ /**
419
+ * Report whether `worktreePath` has working-tree changes the finish flow
420
+ * would consider significant, ignoring noise from the lock-registry file.
421
+ *
422
+ * @param {string} worktreePath Worktree to inspect.
423
+ * @returns {boolean} True when meaningful changes are present.
424
+ */
239
425
  function hasSignificantWorkingTreeChanges(worktreePath) {
240
426
  const result = run('git', [
241
427
  '-C',
@@ -265,6 +451,12 @@ function hasSignificantWorkingTreeChanges(worktreePath) {
265
451
  return false;
266
452
  }
267
453
 
454
+ /**
455
+ * Resolve the effective protected-branches list (config override or default).
456
+ *
457
+ * @param {string} repoRoot Repo to inspect.
458
+ * @returns {string[]} Protected branch names.
459
+ */
268
460
  function readProtectedBranches(repoRoot) {
269
461
  const result = gitRun(repoRoot, ['config', '--get', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
270
462
  if (result.status !== 0) {
@@ -278,6 +470,14 @@ function readProtectedBranches(repoRoot) {
278
470
  return parsed;
279
471
  }
280
472
 
473
+ /**
474
+ * Add any local user branches that are missing from the protected-branches
475
+ * config. In dry-run mode the config is left untouched.
476
+ *
477
+ * @param {string} repoRoot Repo to inspect.
478
+ * @param {boolean} dryRun When true, report intended changes without writing.
479
+ * @returns {SetupOperation} Operation outcome (`unchanged`, `would-update`, or `updated`).
480
+ */
281
481
  function ensureSetupProtectedBranches(repoRoot, dryRun) {
282
482
  const localUserBranches = listLocalUserBranches(repoRoot);
283
483
  if (localUserBranches.length === 0) {
@@ -311,6 +511,14 @@ function ensureSetupProtectedBranches(repoRoot, dryRun) {
311
511
  };
312
512
  }
313
513
 
514
+ /**
515
+ * Persist the protected-branches list into git config, replacing the prior
516
+ * value. An empty array unsets the key entirely.
517
+ *
518
+ * @param {string} repoRoot Repo to update.
519
+ * @param {ReadonlyArray<string>} branches Branch names to record.
520
+ * @returns {void}
521
+ */
314
522
  function writeProtectedBranches(repoRoot, branches) {
315
523
  if (branches.length === 0) {
316
524
  gitRun(repoRoot, ['config', '--unset-all', GIT_PROTECTED_BRANCHES_KEY], { allowFailure: true });
@@ -319,6 +527,86 @@ function writeProtectedBranches(repoRoot, branches) {
319
527
  gitRun(repoRoot, ['config', GIT_PROTECTED_BRANCHES_KEY, branches.join(' ')]);
320
528
  }
321
529
 
530
+ const SUBMODULE_AUTO_SYNC_CONFIGS = [
531
+ {
532
+ key: 'pull.recurseSubmodules',
533
+ value: 'true',
534
+ note: 'auto-update submodule working dirs on `git pull`',
535
+ },
536
+ {
537
+ key: 'fetch.recurseSubmodules',
538
+ value: 'on-demand',
539
+ note: 'fetch submodule commits as parent pointers move',
540
+ },
541
+ ];
542
+
543
+ /**
544
+ * Wire git config so submodules auto-update on pull/fetch and snap working
545
+ * dirs to the parent index. No-op (returns `[]`) when the repo has no
546
+ * `.gitmodules` file. Existing config values are respected, not overwritten.
547
+ *
548
+ * @param {string} repoRoot Repo to configure.
549
+ * @param {boolean} dryRun When true, report intended changes without writing.
550
+ * @returns {SetupOperation[]} One operation per config key plus the recursive submodule sync result.
551
+ */
552
+ function ensureSubmoduleAutoSync(repoRoot, dryRun) {
553
+ const gitmodulesPath = path.join(repoRoot, '.gitmodules');
554
+ if (!fs.existsSync(gitmodulesPath)) {
555
+ return [];
556
+ }
557
+
558
+ const operations = [];
559
+ for (const { key, value, note } of SUBMODULE_AUTO_SYNC_CONFIGS) {
560
+ const existing = readGitConfig(repoRoot, key);
561
+ if (existing) {
562
+ operations.push({
563
+ status: 'unchanged',
564
+ file: `git config ${key}`,
565
+ note: `respected pre-existing value: ${existing}`,
566
+ });
567
+ continue;
568
+ }
569
+ if (!dryRun) {
570
+ gitRun(repoRoot, ['config', key, value]);
571
+ }
572
+ operations.push({
573
+ status: dryRun ? 'would-set' : 'set',
574
+ file: `git config ${key}`,
575
+ note: `${value} (${note})`,
576
+ });
577
+ }
578
+
579
+ if (dryRun) {
580
+ operations.push({
581
+ status: 'would-sync',
582
+ file: 'git submodule update --init --recursive',
583
+ note: 'snap submodule working dirs to parent index',
584
+ });
585
+ return operations;
586
+ }
587
+
588
+ const result = gitRun(
589
+ repoRoot,
590
+ ['submodule', 'update', '--init', '--recursive'],
591
+ { allowFailure: true },
592
+ );
593
+ operations.push({
594
+ status: result.status === 0 ? 'synced' : 'failed',
595
+ file: 'git submodule update --init --recursive',
596
+ note: result.status === 0
597
+ ? 'submodule working dirs snapped to parent index'
598
+ : `failed: ${(result.stderr || '').trim().split('\n')[0] || 'unknown'}`,
599
+ });
600
+ return operations;
601
+ }
602
+
603
+ /**
604
+ * Read a single git config value as a trimmed string.
605
+ *
606
+ * @param {string} repoRoot Repo to inspect.
607
+ * @param {string} key Config key to read.
608
+ * @returns {string} Trimmed value, or '' when the key is unset.
609
+ */
322
610
  function readGitConfig(repoRoot, key) {
323
611
  const result = gitRun(repoRoot, ['config', '--get', key], { allowFailure: true });
324
612
  if (result.status !== 0) {
@@ -327,14 +615,65 @@ function readGitConfig(repoRoot, key) {
327
615
  return (result.stdout || '').trim();
328
616
  }
329
617
 
618
+ /**
619
+ * Detect the repository's real default branch when nothing is configured.
620
+ *
621
+ * Prefers the symbolic `origin/HEAD` (what the remote calls its default),
622
+ * then the first existing branch among `main`, `master`, `dev` (local or
623
+ * on origin), and only then falls back to the hardcoded `DEFAULT_BASE_BRANCH`.
624
+ * This stops the finish/PR flow from targeting a non-existent `dev` on repos
625
+ * whose base is actually `main`.
626
+ *
627
+ * @param {string} repoRoot Repo to inspect.
628
+ * @returns {string} Detected default base branch name.
629
+ */
630
+ function detectDefaultBaseBranch(repoRoot) {
631
+ const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], {
632
+ allowFailure: true,
633
+ });
634
+ if (symbolic.status === 0) {
635
+ const ref = String(symbolic.stdout || '').trim().replace(/^origin\//, '');
636
+ if (ref) {
637
+ return ref;
638
+ }
639
+ }
640
+
641
+ for (const candidate of ['main', 'master', 'dev']) {
642
+ if (
643
+ gitRefExists(repoRoot, `refs/heads/${candidate}`) ||
644
+ gitRefExists(repoRoot, `refs/remotes/origin/${candidate}`)
645
+ ) {
646
+ return candidate;
647
+ }
648
+ }
649
+
650
+ return DEFAULT_BASE_BRANCH;
651
+ }
652
+
653
+ /**
654
+ * Resolve the base branch to use (explicit CLI value wins; otherwise config
655
+ * key `GIT_BASE_BRANCH_KEY`; otherwise the repo's detected default branch).
656
+ *
657
+ * @param {string} repoRoot Repo to inspect.
658
+ * @param {string} [explicitBase] Value passed on the CLI, if any.
659
+ * @returns {string} Resolved base branch name.
660
+ */
330
661
  function resolveBaseBranch(repoRoot, explicitBase) {
331
662
  if (explicitBase) {
332
663
  return explicitBase;
333
664
  }
334
665
  const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
335
- return configured || DEFAULT_BASE_BRANCH;
666
+ return configured || detectDefaultBaseBranch(repoRoot);
336
667
  }
337
668
 
669
+ /**
670
+ * Resolve the sync strategy to use, validated against the allowed set.
671
+ *
672
+ * @param {string} repoRoot Repo to inspect.
673
+ * @param {string} [explicitStrategy] Value passed on the CLI, if any.
674
+ * @returns {'rebase'|'merge'} Resolved strategy (lower-cased).
675
+ * @throws {Error} When the resolved value is not `rebase` or `merge`.
676
+ */
338
677
  function resolveSyncStrategy(repoRoot, explicitStrategy) {
339
678
  const strategy = (explicitStrategy || readGitConfig(repoRoot, GIT_SYNC_STRATEGY_KEY) || DEFAULT_SYNC_STRATEGY)
340
679
  .trim()
@@ -345,6 +684,13 @@ function resolveSyncStrategy(repoRoot, explicitStrategy) {
345
684
  return strategy;
346
685
  }
347
686
 
687
+ /**
688
+ * Return the currently checked-out branch name.
689
+ *
690
+ * @param {string} repoRoot Repo to inspect.
691
+ * @returns {string} Current branch name.
692
+ * @throws {Error} When `git branch --show-current` fails or HEAD is detached.
693
+ */
348
694
  function currentBranchName(repoRoot) {
349
695
  const result = gitRun(repoRoot, ['branch', '--show-current'], { allowFailure: true });
350
696
  if (result.status !== 0) {
@@ -357,10 +703,23 @@ function currentBranchName(repoRoot) {
357
703
  return branch;
358
704
  }
359
705
 
706
+ /**
707
+ * Test whether `HEAD` resolves to a commit (i.e., repo is not unborn).
708
+ *
709
+ * @param {string} repoRoot Repo to inspect.
710
+ * @returns {boolean} True when HEAD has a commit.
711
+ */
360
712
  function repoHasHeadCommit(repoRoot) {
361
713
  return gitRun(repoRoot, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true }).status === 0;
362
714
  }
363
715
 
716
+ /**
717
+ * Produce a human-readable description of HEAD: branch name, branch name
718
+ * annotated as unborn, a detached short SHA, or `(unknown)`.
719
+ *
720
+ * @param {string} repoRoot Repo to inspect.
721
+ * @returns {string} Display label for the current HEAD.
722
+ */
364
723
  function readBranchDisplayName(repoRoot) {
365
724
  const symbolic = gitRun(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
366
725
  if (symbolic.status === 0) {
@@ -378,14 +737,35 @@ function readBranchDisplayName(repoRoot) {
378
737
  return '(unknown)';
379
738
  }
380
739
 
740
+ /**
741
+ * Test whether the repo has an `origin` remote configured.
742
+ *
743
+ * @param {string} repoRoot Repo to inspect.
744
+ * @returns {boolean} True when `git remote get-url origin` succeeds.
745
+ */
381
746
  function hasOriginRemote(repoRoot) {
382
747
  return gitRun(repoRoot, ['remote', 'get-url', 'origin'], { allowFailure: true }).status === 0;
383
748
  }
384
749
 
750
+ /**
751
+ * Return the subset of `COMPOSE_HINT_FILES` that exist under `repoRoot`.
752
+ *
753
+ * @param {string} repoRoot Repo to inspect.
754
+ * @returns {string[]} Relative paths of compose-hint files present on disk.
755
+ */
385
756
  function detectComposeHintFiles(repoRoot) {
386
757
  return COMPOSE_HINT_FILES.filter((relativePath) => fs.existsSync(path.join(repoRoot, relativePath)));
387
758
  }
388
759
 
760
+ /**
761
+ * Print onboarding hints for fresh repos (no HEAD commit, no origin, or
762
+ * docker-compose detected). Silent when none of those conditions apply.
763
+ *
764
+ * @param {string} repoRoot Repo to describe.
765
+ * @param {string} baseBranch Base branch to reference in suggested commands.
766
+ * @param {string} [repoLabel] Optional label injected into log lines (useful when iterating multiple repos).
767
+ * @returns {void}
768
+ */
389
769
  function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
390
770
  const branchDisplay = readBranchDisplayName(repoRoot);
391
771
  const hasHeadCommit = repoHasHeadCommit(repoRoot);
@@ -417,6 +797,14 @@ function printSetupRepoHints(repoRoot, baseBranch, repoLabel = '') {
417
797
  }
418
798
  }
419
799
 
800
+ /**
801
+ * Test whether the working tree at `repoRoot` has changes outside the
802
+ * lock-registry file.
803
+ *
804
+ * @param {string} repoRoot Repo to inspect.
805
+ * @returns {boolean} True when meaningful changes are present.
806
+ * @throws {Error} When `git status --porcelain` fails.
807
+ */
420
808
  function workingTreeIsDirty(repoRoot) {
421
809
  const result = gitRun(repoRoot, ['status', '--porcelain'], { allowFailure: true });
422
810
  if (result.status !== 0) {
@@ -434,6 +822,15 @@ function workingTreeIsDirty(repoRoot) {
434
822
  return significant.length > 0;
435
823
  }
436
824
 
825
+ /**
826
+ * Ensure `repoRoot` is checked out on `branch`, performing a checkout if
827
+ * needed. Failures are reported via the returned object rather than thrown
828
+ * so callers can decide how to surface them.
829
+ *
830
+ * @param {string} repoRoot Repo to operate on.
831
+ * @param {string} branch Branch to check out.
832
+ * @returns {EnsureRepoBranchResult} Outcome of the operation.
833
+ */
437
834
  function ensureRepoBranch(repoRoot, branch) {
438
835
  const current = currentBranchName(repoRoot);
439
836
  if (current === branch) {
@@ -461,6 +858,14 @@ function ensureRepoBranch(repoRoot, branch) {
461
858
  return { ok: true, changed: true };
462
859
  }
463
860
 
861
+ /**
862
+ * Fetch `origin/<baseBranch>` and verify the remote ref now exists.
863
+ *
864
+ * @param {string} repoRoot Repo to operate on.
865
+ * @param {string} baseBranch Base branch name (without `origin/` prefix).
866
+ * @returns {void}
867
+ * @throws {Error} When the fetch fails or the remote base ref is missing.
868
+ */
464
869
  function ensureOriginBaseRef(repoRoot, baseBranch) {
465
870
  const fetch = gitRun(repoRoot, ['fetch', 'origin', baseBranch, '--quiet'], { allowFailure: true });
466
871
  if (fetch.status !== 0) {
@@ -476,6 +881,15 @@ function ensureOriginBaseRef(repoRoot, baseBranch) {
476
881
  }
477
882
  }
478
883
 
884
+ /**
885
+ * Compute the ahead/behind commit counts between two refs.
886
+ *
887
+ * @param {string} repoRoot Repo to inspect.
888
+ * @param {string} branchRef Branch ref (left side of `...`).
889
+ * @param {string} baseRef Base ref (right side of `...`).
890
+ * @returns {AheadBehindCounts} Commits ahead of and behind the base.
891
+ * @throws {Error} When `git rev-list` cannot compute the comparison.
892
+ */
479
893
  function aheadBehind(repoRoot, branchRef, baseRef) {
480
894
  const result = gitRun(repoRoot, ['rev-list', '--left-right', '--count', `${branchRef}...${baseRef}`], {
481
895
  allowFailure: true,
@@ -489,6 +903,12 @@ function aheadBehind(repoRoot, branchRef, baseRef) {
489
903
  return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 };
490
904
  }
491
905
 
906
+ /**
907
+ * Inspect `git status --porcelain` for just the lock-registry file.
908
+ *
909
+ * @param {string} repoRoot Repo to inspect.
910
+ * @returns {LockRegistryStatus} Whether the lock file is dirty and/or untracked.
911
+ */
492
912
  function lockRegistryStatus(repoRoot) {
493
913
  const result = gitRun(repoRoot, ['status', '--porcelain', '--', LOCK_FILE_RELATIVE], { allowFailure: true });
494
914
  if (result.status !== 0) {
@@ -502,6 +922,13 @@ function lockRegistryStatus(repoRoot) {
502
922
  return { dirty: true, untracked };
503
923
  }
504
924
 
925
+ /**
926
+ * Enumerate worktrees whose branch lives under `refs/heads/agent/`.
927
+ *
928
+ * @param {string} repoRoot Repo to inspect.
929
+ * @returns {AgentWorktreeEntry[]} One entry per agent worktree.
930
+ * @throws {Error} When `git worktree list` cannot run.
931
+ */
505
932
  function listAgentWorktrees(repoRoot) {
506
933
  const result = gitRun(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true });
507
934
  if (result.status !== 0) {
@@ -543,12 +970,28 @@ function listAgentWorktrees(repoRoot) {
543
970
  return entries;
544
971
  }
545
972
 
973
+ /**
974
+ * Like {@link listLocalAgentBranches} but restricted to entries starting
975
+ * with `agent/`. Used as the candidate list for batch-finish flows.
976
+ *
977
+ * @param {string} repoRoot Repo to inspect.
978
+ * @returns {string[]} Agent branch names.
979
+ */
546
980
  function listLocalAgentBranchesForFinish(repoRoot) {
547
981
  return uniquePreserveOrder(
548
982
  listLocalAgentBranches(repoRoot).filter((line) => line.startsWith('agent/')),
549
983
  );
550
984
  }
551
985
 
986
+ /**
987
+ * Interpret a `git diff --quiet`-style command: 0 = no changes, 1 = changes,
988
+ * anything else is a real error.
989
+ *
990
+ * @param {string} worktreePath Worktree to invoke git in.
991
+ * @param {ReadonlyArray<string>} args Arguments after `-C <worktreePath>`.
992
+ * @returns {boolean} True when the diff reports changes.
993
+ * @throws {Error} When git exits with a status other than 0 or 1.
994
+ */
552
995
  function gitQuietChangeResult(worktreePath, args) {
553
996
  const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
554
997
  if (result.status === 0) {
@@ -564,6 +1007,14 @@ function gitQuietChangeResult(worktreePath, args) {
564
1007
  );
565
1008
  }
566
1009
 
1010
+ /**
1011
+ * Detect any pending local work in `worktreePath` (unstaged, staged, or
1012
+ * untracked), ignoring noise from the agent file-locks state file.
1013
+ *
1014
+ * @param {string} worktreePath Worktree to inspect.
1015
+ * @returns {boolean} True when local changes are present.
1016
+ * @throws {Error} When git commands invoked during the probe fail.
1017
+ */
567
1018
  function worktreeHasLocalChanges(worktreePath) {
568
1019
  const hasUnstaged = gitQuietChangeResult(worktreePath, [
569
1020
  'diff',
@@ -597,6 +1048,15 @@ function worktreeHasLocalChanges(worktreePath) {
597
1048
  return String(untracked.stdout || '').trim().length > 0;
598
1049
  }
599
1050
 
1051
+ /**
1052
+ * Run `git -C <worktreePath> <args>` and return stdout split into trimmed
1053
+ * non-empty lines.
1054
+ *
1055
+ * @param {string} worktreePath Worktree to invoke git in.
1056
+ * @param {ReadonlyArray<string>} args Arguments after `-C <worktreePath>`.
1057
+ * @returns {string[]} Trimmed stdout lines (empties removed).
1058
+ * @throws {Error} When git exits non-zero.
1059
+ */
600
1060
  function gitOutputLines(worktreePath, args) {
601
1061
  const result = run('git', ['-C', worktreePath, ...args], { stdio: 'pipe' });
602
1062
  if (result.status !== 0) {
@@ -612,6 +1072,13 @@ function gitOutputLines(worktreePath, args) {
612
1072
  .filter(Boolean);
613
1073
  }
614
1074
 
1075
+ /**
1076
+ * Test whether `refs/heads/<branch>` exists locally.
1077
+ *
1078
+ * @param {string} repoRoot Repo to inspect.
1079
+ * @param {string} branch Branch name (without `refs/heads/` prefix).
1080
+ * @returns {boolean} True when the local branch exists.
1081
+ */
615
1082
  function branchExists(repoRoot, branch) {
616
1083
  const result = gitRun(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
617
1084
  allowFailure: true,
@@ -619,19 +1086,46 @@ function branchExists(repoRoot, branch) {
619
1086
  return result.status === 0;
620
1087
  }
621
1088
 
622
- function resolveFinishBaseBranch(repoRoot, _sourceBranch, explicitBase) {
1089
+ /**
1090
+ * Resolve the base branch for the finish flow: CLI override wins; otherwise
1091
+ * the per-branch `branch.<source>.guardexBase` recorded at branch-start;
1092
+ * otherwise the repo-wide configured base; otherwise the repo's detected
1093
+ * default branch.
1094
+ *
1095
+ * @param {string} repoRoot Repo to inspect.
1096
+ * @param {string} sourceBranch Source agent branch (used for per-branch base).
1097
+ * @param {string} [explicitBase] CLI override, if any.
1098
+ * @returns {string} Resolved base branch name.
1099
+ */
1100
+ function resolveFinishBaseBranch(repoRoot, sourceBranch, explicitBase) {
623
1101
  if (explicitBase) {
624
1102
  return explicitBase;
625
1103
  }
626
1104
 
1105
+ if (sourceBranch) {
1106
+ const perBranch = readGitConfig(repoRoot, `branch.${sourceBranch}.guardexBase`);
1107
+ if (perBranch) {
1108
+ return perBranch;
1109
+ }
1110
+ }
1111
+
627
1112
  const configured = readGitConfig(repoRoot, GIT_BASE_BRANCH_KEY);
628
1113
  if (configured) {
629
1114
  return configured;
630
1115
  }
631
1116
 
632
- return DEFAULT_BASE_BRANCH;
1117
+ return detectDefaultBaseBranch(repoRoot);
633
1118
  }
634
1119
 
1120
+ /**
1121
+ * Test whether `branch` is an ancestor of `baseBranch` (i.e., already merged).
1122
+ *
1123
+ * @param {string} repoRoot Repo to inspect.
1124
+ * @param {string} branch Branch to check.
1125
+ * @param {string} baseBranch Base branch to compare against.
1126
+ * @returns {boolean} True when `branch` is an ancestor of `baseBranch`.
1127
+ * @throws {Error} When git returns an unexpected status (anything other than 0 or 1).
1128
+ */
635
1129
  function branchMergedIntoBase(repoRoot, branch, baseBranch) {
636
1130
  if (!branchExists(repoRoot, baseBranch)) {
637
1131
  return false;
@@ -648,6 +1142,17 @@ function branchMergedIntoBase(repoRoot, branch, baseBranch) {
648
1142
  throw new Error(`Unable to determine merge status for ${branch} -> ${baseBranch}`);
649
1143
  }
650
1144
 
1145
+ /**
1146
+ * Run a sync against `baseRef` using the requested strategy. On failure,
1147
+ * surface git output plus a hint for resolving an in-progress rebase/merge.
1148
+ *
1149
+ * @param {string} repoRoot Repo to operate on.
1150
+ * @param {'rebase'|'merge'} strategy Sync strategy.
1151
+ * @param {string} baseRef Ref to rebase onto / merge from.
1152
+ * @param {boolean} ffOnly Pass `--ff-only` to merge; rejected for rebase.
1153
+ * @returns {void}
1154
+ * @throws {Error} When the rebase/merge fails or when `ffOnly` is combined with `rebase`.
1155
+ */
651
1156
  function syncOperation(repoRoot, strategy, baseRef, ffOnly) {
652
1157
  if (strategy === 'rebase') {
653
1158
  if (ffOnly) {
@@ -697,8 +1202,10 @@ module.exports = {
697
1202
  hasSignificantWorkingTreeChanges,
698
1203
  readProtectedBranches,
699
1204
  ensureSetupProtectedBranches,
1205
+ ensureSubmoduleAutoSync,
700
1206
  writeProtectedBranches,
701
1207
  readGitConfig,
1208
+ detectDefaultBaseBranch,
702
1209
  resolveBaseBranch,
703
1210
  resolveSyncStrategy,
704
1211
  currentBranchName,