@imdeadpool/guardex 7.0.27 → 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.
package/README.md CHANGED
@@ -708,6 +708,12 @@ npm pack --dry-run
708
708
  <details>
709
709
  <summary><strong>v7.x</strong></summary>
710
710
 
711
+ ### v7.0.28
712
+ - Bumped `@imdeadpool/guardex` from `7.0.27` to `7.0.28` so the CLI help redesign can ship on a fresh npm version.
713
+ - `gx --help` and `gx` (no args) now render commands as a grouped catalog (Setup & health / Branch workflow / Coordination / Agents & reports / Meta) with short group descriptions, so the top of the help screen shows the newcomer path instead of a flat 20-row list.
714
+ - Added a three-step Quickstart block (`gx setup` → `gx branch start "<task>" "<agent>"` → `gx branch finish --via-pr --wait-for-merge --cleanup`) to both help surfaces so the intended install/setup sequence is visible before the full command reference.
715
+ - Exposed `CLI_COMMAND_GROUPS` and `CLI_QUICKSTART_STEPS` from `src/context.js` (with `CLI_COMMAND_DESCRIPTIONS` derived from the grouped source of truth), giving future help tooling and integrations a structured way to iterate the catalog without re-parsing flat rows.
716
+
711
717
  ### v7.0.27
712
718
  - Bumped `@imdeadpool/guardex` from `7.0.26` to `7.0.27` so npm can publish a fresh version after `7.0.26` was already taken on the registry.
713
719
  - The shipped `agent-branch-start.sh` copies now keep the startup auto-transfer path alive under `set -o pipefail`, so Guardex can still restore moved changes back to the protected checkout when branch startup hits a later failure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "7.0.27",
3
+ "version": "7.0.31",
4
4
  "description": "Guardian T-Rex for your multi-agent repo. Isolated worktrees, file locks, and PR-only merges stop parallel Codex & Claude agents from overwriting each other's work. Auto-wires Oh My Codex, Oh My Claude, OpenSpec, and Caveman.",
5
5
  "license": "MIT",
6
6
  "preferGlobal": true,
package/src/cli/main.js CHANGED
@@ -146,8 +146,10 @@ const {
146
146
  colorizeDoctorOutput,
147
147
  statusDot,
148
148
  printToolLogsSummary,
149
+ getInvokedCliName,
149
150
  usage,
150
151
  formatElapsedDuration,
152
+ startTransientSpinner,
151
153
  compactAutoFinishPathSegments,
152
154
  detectRecoverableAutoFinishConflict,
153
155
  printAutoFinishSummary,
@@ -889,6 +891,80 @@ function parseBooleanLike(raw) {
889
891
  return null;
890
892
  }
891
893
 
894
+ function autoDoctorEnabledForCurrentSession() {
895
+ const explicit = parseBooleanLike(process.env.GUARDEX_AUTO_DOCTOR);
896
+ if (explicit != null) {
897
+ return explicit;
898
+ }
899
+ return isInteractiveTerminal();
900
+ }
901
+
902
+ function shouldAutoRunDoctorFromStatus(statusPayload) {
903
+ const repo = statusPayload?.repo || {};
904
+ return Boolean(
905
+ autoDoctorEnabledForCurrentSession()
906
+ && repo.inGitRepo
907
+ && repo.guardexEnabled !== false
908
+ && repo.serviceStatus === 'degraded'
909
+ && repo.scan
910
+ && Number(repo.scan.findings || 0) > 0,
911
+ );
912
+ }
913
+
914
+ function runCliSubprocessWithSpinner(args, options = {}) {
915
+ return new Promise((resolve, reject) => {
916
+ const spinner = options.spinnerMessage
917
+ ? startTransientSpinner(options.spinnerMessage, {
918
+ prefix: options.spinnerPrefix || `[${TOOL_NAME}]`,
919
+ })
920
+ : { stop() {} };
921
+ const child = cp.spawn(process.execPath, [path.resolve(__filename), ...args], {
922
+ cwd: options.cwd || process.cwd(),
923
+ env: {
924
+ ...process.env,
925
+ GUARDEX_AUTO_DOCTOR: '0',
926
+ },
927
+ stdio: ['inherit', 'pipe', 'pipe'],
928
+ });
929
+
930
+ const stopSpinner = () => spinner.stop();
931
+ child.stdout.on('data', (chunk) => {
932
+ stopSpinner();
933
+ process.stdout.write(chunk);
934
+ });
935
+ child.stderr.on('data', (chunk) => {
936
+ stopSpinner();
937
+ process.stderr.write(chunk);
938
+ });
939
+ child.on('error', (error) => {
940
+ stopSpinner();
941
+ reject(error);
942
+ });
943
+ child.on('close', (code) => {
944
+ stopSpinner();
945
+ resolve(typeof code === 'number' ? code : 1);
946
+ });
947
+ });
948
+ }
949
+
950
+ async function maybeAutoRunDoctorFromDefaultStatus(statusPayload) {
951
+ if (!shouldAutoRunDoctorFromStatus(statusPayload)) {
952
+ return false;
953
+ }
954
+
955
+ const target = statusPayload?.repo?.target || process.cwd();
956
+ console.log(`[${TOOL_NAME}] Auto-repair: repo safety is degraded. Running '${SHORT_TOOL_NAME} doctor --current' now.`);
957
+ process.exitCode = await runCliSubprocessWithSpinner(
958
+ ['doctor', '--target', target, '--current'],
959
+ {
960
+ cwd: target,
961
+ spinnerPrefix: `[${TOOL_NAME}] Auto-repair:`,
962
+ spinnerMessage: 'preparing doctor workspace',
963
+ },
964
+ );
965
+ return true;
966
+ }
967
+
892
968
  function parseDotenvAssignmentValue(raw) {
893
969
  let value = String(raw || '').trim();
894
970
  if (!value) return '';
@@ -1672,12 +1748,62 @@ function setExitCodeFromScan(scan) {
1672
1748
  process.exitCode = 0;
1673
1749
  }
1674
1750
 
1675
- function status(rawArgs) {
1676
- const options = parseCommonArgs(rawArgs, {
1677
- target: process.cwd(),
1678
- json: false,
1679
- });
1751
+ function printStatusRepairHint(scanResult) {
1752
+ if (!scanResult || scanResult.guardexEnabled === false) {
1753
+ return;
1754
+ }
1755
+ if (scanResult.errors === 0 && scanResult.warnings === 0) {
1756
+ return;
1757
+ }
1758
+
1759
+ const scanHint = scanResult.errors === 0
1760
+ ? `review warning details with '${SHORT_TOOL_NAME} scan'`
1761
+ : `inspect detailed findings with '${SHORT_TOOL_NAME} scan'`;
1762
+ console.log(
1763
+ `[${TOOL_NAME}] Quick fix: run '${SHORT_TOOL_NAME} doctor' to repair drift, or ${scanHint}.`,
1764
+ );
1765
+ }
1766
+
1767
+ function countAgentWorktrees(repoRoot) {
1768
+ if (!repoRoot || typeof repoRoot !== 'string') return 0;
1769
+ const relPaths = ['.omc/agent-worktrees', '.omx/agent-worktrees'];
1770
+ let count = 0;
1771
+ for (const rel of relPaths) {
1772
+ try {
1773
+ const entries = fs.readdirSync(path.join(repoRoot, rel), { withFileTypes: true });
1774
+ count += entries.filter((entry) => entry.isDirectory()).length;
1775
+ } catch (_err) {
1776
+ // missing dir or permission error; not an active-agent signal
1777
+ }
1778
+ }
1779
+ return count;
1780
+ }
1781
+
1782
+ function deriveNextStepHint({ scanResult, worktreeCount, invoked, inGitRepo }) {
1783
+ if (!inGitRepo) {
1784
+ return `${invoked} setup --target <path-to-git-repo> # initialize guardrails in a repo`;
1785
+ }
1786
+ if (!scanResult) {
1787
+ return `${invoked} setup # bootstrap repo guardrails`;
1788
+ }
1789
+ if (scanResult.guardexEnabled === false) {
1790
+ return `set GUARDEX_ON=1 in .env # re-enable guardrails, then '${invoked} doctor'`;
1791
+ }
1792
+ const branch = scanResult.branch || '';
1793
+ if (branch.startsWith('agent/')) {
1794
+ return `${invoked} branch finish --branch "${branch}" --via-pr --wait-for-merge --cleanup`;
1795
+ }
1796
+ if (worktreeCount > 0) {
1797
+ const plural = worktreeCount === 1 ? 'worktree' : 'worktrees';
1798
+ return `${invoked} finish --all # ${worktreeCount} active agent ${plural}`;
1799
+ }
1800
+ if (scanResult.errors > 0 || scanResult.warnings > 0) {
1801
+ return `${invoked} doctor # repair drift`;
1802
+ }
1803
+ return `${invoked} branch start "<task>" "<agent-name>" # start a sandboxed agent task`;
1804
+ }
1680
1805
 
1806
+ function collectServicesSnapshot() {
1681
1807
  const toolchain = toolchainModule.detectGlobalToolchainPackages();
1682
1808
  const npmServices = GLOBAL_TOOLCHAIN_PACKAGES.map((pkg) => {
1683
1809
  const service = toolchainModule.getGlobalToolchainService(pkg);
@@ -1701,18 +1827,103 @@ function status(rawArgs) {
1701
1827
  const localCompanionServices = toolchainModule.detectOptionalLocalCompanionTools().map((tool) => ({
1702
1828
  name: tool.name,
1703
1829
  displayName: tool.displayName || tool.name,
1830
+ installCommand: tool.installCommand,
1831
+ installArgs: Array.isArray(tool.installArgs) ? [...tool.installArgs] : [],
1704
1832
  status: tool.status,
1705
1833
  }));
1706
1834
  const requiredSystemTools = toolchainModule.detectRequiredSystemTools();
1707
1835
  const services = [
1708
1836
  ...npmServices,
1709
- ...localCompanionServices,
1837
+ ...localCompanionServices.map((tool) => ({
1838
+ name: tool.name,
1839
+ displayName: tool.displayName,
1840
+ status: tool.status,
1841
+ })),
1710
1842
  ...requiredSystemTools.map((tool) => ({
1711
1843
  name: tool.name,
1712
1844
  displayName: tool.displayName || tool.name,
1713
1845
  status: tool.status,
1714
1846
  })),
1715
1847
  ];
1848
+ return { toolchain, npmServices, localCompanionServices, requiredSystemTools, services };
1849
+ }
1850
+
1851
+ function maybePromptInstallMissingCompanions(snapshot) {
1852
+ if (envFlagIsTruthy(process.env.GUARDEX_SKIP_COMPANION_PROMPT)) {
1853
+ return { handled: false, installed: false };
1854
+ }
1855
+ const interactive = Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
1856
+ const autoApproval = toolchainModule.parseAutoApproval('GUARDEX_AUTO_COMPANION_APPROVAL');
1857
+ if (!interactive && autoApproval == null) {
1858
+ return { handled: false, installed: false };
1859
+ }
1860
+ if (!snapshot.toolchain.ok) {
1861
+ return { handled: false, installed: false };
1862
+ }
1863
+
1864
+ const missingPackages = snapshot.npmServices
1865
+ .filter((service) => service.status !== 'active')
1866
+ .map((service) => service.packageName);
1867
+ const missingLocalTools = snapshot.localCompanionServices.filter((tool) => tool.status !== 'active');
1868
+ if (missingPackages.length === 0 && missingLocalTools.length === 0) {
1869
+ return { handled: false, installed: false };
1870
+ }
1871
+
1872
+ const missingNames = [
1873
+ ...missingPackages.map((pkg) => toolchainModule.formatGlobalToolchainServiceName(pkg)),
1874
+ ...missingLocalTools.map((tool) => tool.displayName || tool.name),
1875
+ ];
1876
+ console.log(`[${TOOL_NAME}] Missing companion tools: ${missingNames.join(', ')}.`);
1877
+
1878
+ const promptText = toolchainModule.buildMissingCompanionInstallPrompt(missingPackages, missingLocalTools);
1879
+ const approved = interactive
1880
+ ? toolchainModule.promptYesNoStrict(promptText)
1881
+ : autoApproval;
1882
+
1883
+ if (!approved) {
1884
+ console.log(
1885
+ `[${TOOL_NAME}] Skipped companion install. Set GUARDEX_SKIP_COMPANION_PROMPT=1 to silence this prompt, ` +
1886
+ `or run '${getInvokedCliName()} setup --install-only' later to install manually.`,
1887
+ );
1888
+ return { handled: true, installed: false };
1889
+ }
1890
+
1891
+ const result = toolchainModule.performCompanionInstall(missingPackages, missingLocalTools);
1892
+ if (result.status === 'installed') {
1893
+ console.log(
1894
+ `[${TOOL_NAME}] ✅ Companion tools installed (${(result.packages || []).join(', ')}).`,
1895
+ );
1896
+ return { handled: true, installed: true };
1897
+ }
1898
+ if (result.status === 'failed') {
1899
+ console.log(
1900
+ `[${TOOL_NAME}] ⚠️ Companion install failed: ${result.reason}. ` +
1901
+ `Retry with '${getInvokedCliName()} setup --install-only'.`,
1902
+ );
1903
+ return { handled: true, installed: false };
1904
+ }
1905
+ return { handled: true, installed: false };
1906
+ }
1907
+
1908
+ function status(rawArgs) {
1909
+ const { found: verboseFlag, remaining: afterVerbose } = extractFlag(rawArgs, '--verbose');
1910
+ const options = parseCommonArgs(afterVerbose, {
1911
+ target: process.cwd(),
1912
+ json: false,
1913
+ });
1914
+ const forceCompact = envFlagIsTruthy(process.env.GUARDEX_COMPACT_STATUS);
1915
+ const forceExpand = envFlagIsTruthy(process.env.GUARDEX_VERBOSE_STATUS) || verboseFlag;
1916
+ const interactive = Boolean(process.stdout.isTTY);
1917
+ const invokedBasename = getInvokedCliName();
1918
+
1919
+ let snapshot = collectServicesSnapshot();
1920
+ if (!options.json) {
1921
+ const result = maybePromptInstallMissingCompanions(snapshot);
1922
+ if (result.installed) {
1923
+ snapshot = collectServicesSnapshot();
1924
+ }
1925
+ }
1926
+ let { toolchain, npmServices, localCompanionServices, requiredSystemTools, services } = snapshot;
1716
1927
 
1717
1928
  const targetPath = path.resolve(options.target);
1718
1929
  const inGitRepo = isGitRepo(targetPath);
@@ -1752,18 +1963,27 @@ function status(rawArgs) {
1752
1963
  if (options.json) {
1753
1964
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1754
1965
  process.exitCode = 0;
1755
- return;
1966
+ return payload;
1756
1967
  }
1757
1968
 
1969
+ const allServicesActive = toolchain.ok && services.every((service) => service.status === 'active');
1970
+ const compact = !forceExpand && (forceCompact || (interactive && allServicesActive));
1971
+
1758
1972
  console.log(`[${TOOL_NAME}] CLI: ${payload.cli.runtime}`);
1759
1973
  if (!toolchain.ok) {
1760
1974
  console.log(`[${TOOL_NAME}] ⚠️ Could not detect global services: ${toolchain.error}`);
1761
1975
  }
1762
1976
 
1763
- console.log(`[${TOOL_NAME}] Global services:`);
1764
- for (const service of services) {
1765
- const serviceLabel = service.displayName || service.name;
1766
- console.log(` - ${statusDot(service.status)} ${serviceLabel}: ${service.status}`);
1977
+ if (compact) {
1978
+ console.log(
1979
+ `[${TOOL_NAME}] Global services: ${services.length}/${services.length} ${statusDot('active')} active`,
1980
+ );
1981
+ } else {
1982
+ console.log(`[${TOOL_NAME}] Global services:`);
1983
+ for (const service of services) {
1984
+ const serviceLabel = service.displayName || service.name;
1985
+ console.log(` - ${statusDot(service.status)} ${serviceLabel}: ${service.status}`);
1986
+ }
1767
1987
  }
1768
1988
  const inactiveOptionalCompanions = [...npmServices, ...localCompanionServices]
1769
1989
  .filter((service) => service.status !== 'active')
@@ -1799,8 +2019,16 @@ function status(rawArgs) {
1799
2019
  console.log(
1800
2020
  `[${TOOL_NAME}] Repo safety service: ${statusDot('inactive')} inactive (no git repository at target).`,
1801
2021
  );
2022
+ const inactiveHint = deriveNextStepHint({
2023
+ scanResult: null,
2024
+ worktreeCount: 0,
2025
+ invoked: invokedBasename,
2026
+ inGitRepo,
2027
+ });
2028
+ console.log(`[${TOOL_NAME}] Next: ${inactiveHint}`);
2029
+ printToolLogsSummary({ invokedBasename, compact });
1802
2030
  process.exitCode = 0;
1803
- return;
2031
+ return payload;
1804
2032
  }
1805
2033
 
1806
2034
  if (scanResult.guardexEnabled === false) {
@@ -1809,9 +2037,23 @@ function status(rawArgs) {
1809
2037
  );
1810
2038
  console.log(`[${TOOL_NAME}] Repo: ${scanResult.repoRoot}`);
1811
2039
  console.log(`[${TOOL_NAME}] Branch: ${scanResult.branch}`);
1812
- printToolLogsSummary();
2040
+ const worktreeCountDisabled = countAgentWorktrees(scanResult.repoRoot);
2041
+ if (worktreeCountDisabled > 0) {
2042
+ const plural = worktreeCountDisabled === 1 ? 'worktree' : 'worktrees';
2043
+ console.log(
2044
+ `[${TOOL_NAME}] ⚠ ${worktreeCountDisabled} active agent ${plural} under .omc/agent-worktrees or .omx/agent-worktrees.`,
2045
+ );
2046
+ }
2047
+ const disabledHint = deriveNextStepHint({
2048
+ scanResult,
2049
+ worktreeCount: worktreeCountDisabled,
2050
+ invoked: invokedBasename,
2051
+ inGitRepo,
2052
+ });
2053
+ console.log(`[${TOOL_NAME}] Next: ${disabledHint}`);
2054
+ printToolLogsSummary({ invokedBasename, compact });
1813
2055
  process.exitCode = 0;
1814
- return;
2056
+ return payload;
1815
2057
  }
1816
2058
 
1817
2059
  if (scanResult.errors === 0 && scanResult.warnings === 0) {
@@ -1820,23 +2062,36 @@ function status(rawArgs) {
1820
2062
  console.log(
1821
2063
  `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.warnings} warning(s)).`,
1822
2064
  );
1823
- console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' to review warning details.`);
1824
2065
  } else if (scanResult.warnings === 0) {
1825
2066
  console.log(
1826
2067
  `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s)).`,
1827
2068
  );
1828
- console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' for detailed findings.`);
1829
2069
  } else {
1830
2070
  console.log(
1831
2071
  `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`,
1832
2072
  );
1833
- console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' for detailed findings.`);
1834
2073
  }
2074
+ printStatusRepairHint(scanResult);
1835
2075
  console.log(`[${TOOL_NAME}] Repo: ${scanResult.repoRoot}`);
1836
2076
  console.log(`[${TOOL_NAME}] Branch: ${scanResult.branch}`);
1837
- printToolLogsSummary();
2077
+ const worktreeCountActive = countAgentWorktrees(scanResult.repoRoot);
2078
+ if (worktreeCountActive > 0) {
2079
+ const plural = worktreeCountActive === 1 ? 'worktree' : 'worktrees';
2080
+ console.log(
2081
+ `[${TOOL_NAME}] ⚠ ${worktreeCountActive} active agent ${plural} → ${invokedBasename} finish --all`,
2082
+ );
2083
+ }
2084
+ const activeHint = deriveNextStepHint({
2085
+ scanResult,
2086
+ worktreeCount: worktreeCountActive,
2087
+ invoked: invokedBasename,
2088
+ inGitRepo,
2089
+ });
2090
+ console.log(`[${TOOL_NAME}] Next: ${activeHint}`);
2091
+ printToolLogsSummary({ invokedBasename, compact });
1838
2092
 
1839
2093
  process.exitCode = 0;
2094
+ return payload;
1840
2095
  }
1841
2096
 
1842
2097
  function install(rawArgs) {
@@ -3246,13 +3501,14 @@ function protect(rawArgs) {
3246
3501
  throw new Error(`Unknown protect subcommand: ${subcommand}`);
3247
3502
  }
3248
3503
 
3249
- function main() {
3504
+ async function main() {
3250
3505
  const args = process.argv.slice(2);
3251
3506
 
3252
3507
  if (args.length === 0) {
3253
3508
  toolchainModule.maybeSelfUpdateBeforeStatus();
3254
3509
  toolchainModule.maybeOpenSpecUpdateBeforeStatus();
3255
- status([]);
3510
+ const statusPayload = status([]);
3511
+ await maybeAutoRunDoctorFromDefaultStatus(statusPayload);
3256
3512
  return;
3257
3513
  }
3258
3514
 
@@ -3322,9 +3578,9 @@ function main() {
3322
3578
  throw new Error(`Unknown command: ${command}`);
3323
3579
  }
3324
3580
 
3325
- function runFromBin() {
3581
+ async function runFromBin() {
3326
3582
  try {
3327
- main();
3583
+ await main();
3328
3584
  } catch (error) {
3329
3585
  console.error(`[${TOOL_NAME}] ${error.message}`);
3330
3586
  process.exitCode = 1;
@@ -3332,7 +3588,7 @@ function runFromBin() {
3332
3588
  }
3333
3589
 
3334
3590
  if (require.main === module) {
3335
- runFromBin();
3591
+ void runFromBin();
3336
3592
  }
3337
3593
 
3338
3594
  module.exports = {
package/src/context.js CHANGED
@@ -363,27 +363,68 @@ const SUGGESTIBLE_COMMANDS = [
363
363
  'print-agents-snippet',
364
364
  'release',
365
365
  ];
366
- const CLI_COMMAND_DESCRIPTIONS = [
367
- ['status', 'Show GitGuardex CLI + service health without modifying files'],
368
- ['setup', 'Install, repair, and verify guardrails (flags: --repair, --install-only, --target, --current)'],
369
- ['doctor', 'Repair drift + verify (flags: --target, --current; auto-sandboxes on protected main)'],
370
- ['branch', 'CLI-owned branch workflow surface (start/finish/merge)'],
371
- ['locks', 'CLI-owned file lock surface (claim/allow-delete/release/status/validate)'],
372
- ['worktree', 'CLI-owned worktree cleanup surface (prune)'],
373
- ['hook', 'Hook dispatch/install surface used by managed shims'],
374
- ['migrate', 'Convert legacy repo-local installs to the zero-copy CLI-owned surface'],
375
- ['install-agent-skills', 'Install Guardex Codex/Claude skills into the user home'],
376
- ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
377
- ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'],
378
- ['sync', 'Sync agent branches with origin/<base>'],
379
- ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
380
- ['cleanup', 'Prune merged/stale agent branches and worktrees'],
381
- ['release', 'Create or update the current GitHub release with README-generated notes'],
382
- ['agents', 'Start/stop repo-scoped review + cleanup bots'],
383
- ['prompt', 'Print AI setup checklist or named slices (--exec, --part, --list-parts, --snippet)'],
384
- ['report', 'Security/safety reports (e.g. OpenSSF scorecard, session severity)'],
385
- ['help', 'Show this help output'],
386
- ['version', 'Print GitGuardex version'],
366
+ // CLI_COMMAND_GROUPS is the grouped source of truth the `gx --help` /
367
+ // `gx` no-args renderer uses. Each group is ordered roughly by how often a
368
+ // user reaches for it so the help screen answers "what do I run first?"
369
+ // before "what else can this do?". CLI_COMMAND_DESCRIPTIONS preserves the
370
+ // flat export for callers that still want the ungrouped list.
371
+ const CLI_COMMAND_GROUPS = [
372
+ {
373
+ label: 'Setup & health',
374
+ description: 'Install, repair, and check a repo. Run these first on a new clone.',
375
+ commands: [
376
+ ['setup', 'Install, repair, and verify guardrails (flags: --repair, --install-only, --target, --current)'],
377
+ ['doctor', 'Repair drift + verify (flags: --target, --current; auto-sandboxes on protected main)'],
378
+ ['status', 'Show GitGuardex CLI + service health without modifying files'],
379
+ ['migrate', 'Convert legacy repo-local installs to the zero-copy CLI-owned surface'],
380
+ ],
381
+ },
382
+ {
383
+ label: 'Branch workflow',
384
+ description: 'The sandbox commit PR → merge loop for agent-owned branches.',
385
+ commands: [
386
+ ['branch', 'CLI-owned branch workflow surface (start/finish/merge)'],
387
+ ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
388
+ ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'],
389
+ ['sync', 'Sync agent branches with origin/<base>'],
390
+ ['cleanup', 'Prune merged/stale agent branches and worktrees'],
391
+ ],
392
+ },
393
+ {
394
+ label: 'Coordination',
395
+ description: 'File locks, worktrees, hooks, and protected-branch policy.',
396
+ commands: [
397
+ ['locks', 'CLI-owned file lock surface (claim/allow-delete/release/status/validate)'],
398
+ ['worktree', 'CLI-owned worktree cleanup surface (prune)'],
399
+ ['hook', 'Hook dispatch/install surface used by managed shims'],
400
+ ['protect', 'Manage protected branches (list/add/remove/set/reset)'],
401
+ ],
402
+ },
403
+ {
404
+ label: 'Agents & reports',
405
+ description: 'Review / cleanup bots, AI setup prompts, and safety reports.',
406
+ commands: [
407
+ ['agents', 'Start/stop repo-scoped review + cleanup bots'],
408
+ ['install-agent-skills', 'Install Guardex Codex/Claude skills into the user home'],
409
+ ['prompt', 'Print AI setup checklist or named slices (--exec, --part, --list-parts, --snippet)'],
410
+ ['report', 'Security/safety reports (e.g. OpenSSF scorecard, session severity)'],
411
+ ['release', 'Create or update the current GitHub release with README-generated notes'],
412
+ ],
413
+ },
414
+ {
415
+ label: 'Meta',
416
+ description: 'Version + help.',
417
+ commands: [
418
+ ['help', 'Show this help output'],
419
+ ['version', 'Print GitGuardex version'],
420
+ ],
421
+ },
422
+ ];
423
+ const CLI_COMMAND_DESCRIPTIONS = CLI_COMMAND_GROUPS.flatMap((group) => group.commands);
424
+ const CLI_QUICKSTART_STEPS = [
425
+ 'gx setup',
426
+ 'gx branch start "<task>" "<agent>"',
427
+ 'gx branch finish --via-pr --wait-for-merge --cleanup',
387
428
  ];
388
429
  const DEPRECATED_COMMAND_ALIASES = new Map([
389
430
  ['init', { target: 'setup', hint: 'gx setup' }],
@@ -686,6 +727,8 @@ module.exports = {
686
727
  COMMAND_TYPO_ALIASES,
687
728
  SUGGESTIBLE_COMMANDS,
688
729
  CLI_COMMAND_DESCRIPTIONS,
730
+ CLI_COMMAND_GROUPS,
731
+ CLI_QUICKSTART_STEPS,
689
732
  DEPRECATED_COMMAND_ALIASES,
690
733
  AGENT_BOT_DESCRIPTIONS,
691
734
  DOCTOR_AUTO_FINISH_DETAIL_LIMIT,
@@ -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
  };
@@ -389,30 +389,45 @@ if [[ "$MERGE_MODE" == "pr" && "$PUSH_ENABLED" -eq 1 ]]; then
389
389
  fi
390
390
 
391
391
  if [[ "$should_create_integration_helper" -eq 1 ]]; then
392
- integration_stamp="$(date +%Y%m%d-%H%M%S)"
393
- integration_worktree_base="${temp_worktree_root}/__integrate-${BASE_BRANCH//\//__}-${integration_stamp}"
394
- integration_branch_base="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
395
- integration_worktree="$integration_worktree_base"
396
- integration_branch="$integration_branch_base"
397
- integration_suffix=1
398
- while [[ -e "$integration_worktree" ]] || git -C "$repo_root" show-ref --verify --quiet "refs/heads/${integration_branch}"; do
399
- integration_worktree="${integration_worktree_base}-${integration_suffix}"
400
- integration_branch="${integration_branch_base}_${integration_suffix}"
401
- integration_suffix=$((integration_suffix + 1))
402
- done
403
- mkdir -p "$(dirname "$integration_worktree")"
392
+ existing_base_worktree=""
393
+ if [[ "$PUSH_ENABLED" -eq 0 ]]; then
394
+ existing_base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")"
395
+ fi
404
396
 
405
- git -C "$repo_root" worktree add "$integration_worktree" "$start_ref" >/dev/null
406
- git -C "$integration_worktree" checkout -b "$integration_branch" >/dev/null
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
407
427
 
408
- if ! git -C "$integration_worktree" merge --no-ff --no-edit "$SOURCE_BRANCH"; then
409
- echo "[agent-branch-finish] Merge conflict detected while merging '${SOURCE_BRANCH}' into '${BASE_BRANCH}'." >&2
410
- git -C "$integration_worktree" merge --abort >/dev/null 2>&1 || true
411
- exit 1
428
+ merge_completed=1
429
+ merge_status="direct"
412
430
  fi
413
-
414
- merge_completed=1
415
- merge_status="direct"
416
431
  fi
417
432
 
418
433
  is_local_branch_delete_error() {
@@ -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
 
@@ -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
 
@@ -56,6 +56,7 @@ const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [
56
56
  const SESSION_ACTIVITY_GROUPS = [
57
57
  { kind: 'blocked', label: 'BLOCKED' },
58
58
  { kind: 'working', label: 'WORKING NOW' },
59
+ { kind: 'finished', label: 'FINISHED' },
59
60
  { kind: 'idle', label: 'THINKING' },
60
61
  { kind: 'stalled', label: 'STALLED' },
61
62
  { kind: 'dead', label: 'DEAD' },
@@ -63,6 +64,7 @@ const SESSION_ACTIVITY_GROUPS = [
63
64
  const SESSION_ACTIVITY_ICON_IDS = {
64
65
  blocked: 'warning',
65
66
  working: 'loading~spin',
67
+ finished: 'pass-filled',
66
68
  idle: 'comment-discussion',
67
69
  stalled: 'clock',
68
70
  dead: 'error',
@@ -108,6 +110,10 @@ function iconColorId(iconId) {
108
110
  return 'terminal.ansiCyan';
109
111
  case 'list-tree':
110
112
  return 'terminal.ansiBlue';
113
+ case 'pass-filled':
114
+ case 'pass':
115
+ case 'check':
116
+ return 'testing.iconPassed';
111
117
  default:
112
118
  return '';
113
119
  }
@@ -468,9 +474,15 @@ function agentBadgeFromBranch(branch) {
468
474
 
469
475
  function buildActiveAgentsStatusSummary(summary) {
470
476
  const workingCount = summary?.workingCount || 0;
477
+ const finishedCount = summary?.finishedCount || 0;
471
478
  const idleCount = summary?.idleCount || 0;
472
- if (workingCount > 0 || idleCount > 0) {
473
- return `$(git-branch) ${workingCount} working · ${idleCount} idle`;
479
+ if (workingCount > 0 || finishedCount > 0 || idleCount > 0) {
480
+ const parts = [`${workingCount} working`];
481
+ if (finishedCount > 0) {
482
+ parts.push(`${finishedCount} finished`);
483
+ }
484
+ parts.push(`${idleCount} idle`);
485
+ return `$(git-branch) ${parts.join(' · ')}`;
474
486
  }
475
487
  return `$(git-branch) ${formatCountLabel(summary?.sessionCount || 0, 'tracked session')}`;
476
488
  }
@@ -490,6 +502,7 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) {
490
502
  return [
491
503
  formatCountLabel(activeCount, 'active agent'),
492
504
  formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'),
505
+ formatCountLabel(summary?.finishedCount || 0, 'finished session'),
493
506
  formatCountLabel(summary?.idleCount || 0, 'idle session'),
494
507
  formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'),
495
508
  formatCountLabel(summary?.lockedFileCount || 0, 'locked file'),
@@ -534,6 +547,10 @@ function countWorkingSessions(sessions) {
534
547
  )).length;
535
548
  }
536
549
 
550
+ function countFinishedSessions(sessions) {
551
+ return sessions.filter((session) => session.activityKind === 'finished').length;
552
+ }
553
+
537
554
  function countIdleSessions(sessions) {
538
555
  return sessions.filter((session) => (
539
556
  session.activityKind === 'idle' || session.activityKind === 'stalled'
@@ -571,6 +588,9 @@ function sessionFreshnessLabel(session, now = Date.now()) {
571
588
  if (session.activityKind === 'blocked') {
572
589
  return 'Needs attention';
573
590
  }
591
+ if (session.activityKind === 'finished') {
592
+ return 'Finished';
593
+ }
574
594
  if (session.activityKind === 'stalled') {
575
595
  return 'Possibly stale';
576
596
  }
@@ -598,6 +618,8 @@ function sessionStatusLabel(session) {
598
618
  return 'Blocked';
599
619
  case 'working':
600
620
  return 'Working';
621
+ case 'finished':
622
+ return 'Finished';
601
623
  case 'idle':
602
624
  return 'Idle';
603
625
  case 'stalled':
@@ -804,6 +826,7 @@ function buildWorktreeBranchDescription(sessions) {
804
826
  function buildOverviewDescription(summary) {
805
827
  return [
806
828
  formatCountLabel(summary?.workingCount || 0, 'working agent'),
829
+ formatCountLabel(summary?.finishedCount || 0, 'finished agent'),
807
830
  formatCountLabel(summary?.idleCount || 0, 'idle agent'),
808
831
  formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'),
809
832
  formatCountLabel(summary?.lockedFileCount || 0, 'locked file'),
@@ -923,6 +946,9 @@ function workingSessionSortKey(session) {
923
946
  if (session.deltaLabel === 'New') {
924
947
  return 3;
925
948
  }
949
+ if (session.activityKind === 'finished') {
950
+ return 5;
951
+ }
926
952
  return 4;
927
953
  }
928
954
 
@@ -2490,6 +2516,7 @@ function buildRepoOverview(sessions, unassignedChanges, lockEntries) {
2490
2516
  return {
2491
2517
  sessionCount: sessions.length,
2492
2518
  workingCount: countWorkingSessions(sessions),
2519
+ finishedCount: countFinishedSessions(sessions),
2493
2520
  idleCount: countIdleSessions(sessions),
2494
2521
  unassignedChangeCount: (unassignedChanges || []).length,
2495
2522
  lockedFileCount: Array.isArray(lockEntries) ? lockEntries.length : 0,
@@ -2860,6 +2887,7 @@ class ActiveAgentsProvider {
2860
2887
  this.viewSummary = {
2861
2888
  sessionCount: 0,
2862
2889
  workingCount: 0,
2890
+ finishedCount: 0,
2863
2891
  idleCount: 0,
2864
2892
  unassignedChangeCount: 0,
2865
2893
  lockedFileCount: 0,
@@ -2878,6 +2906,7 @@ class ActiveAgentsProvider {
2878
2906
  this.updateViewState({
2879
2907
  sessionCount: 0,
2880
2908
  workingCount: 0,
2909
+ finishedCount: 0,
2881
2910
  idleCount: 0,
2882
2911
  unassignedChangeCount: 0,
2883
2912
  lockedFileCount: 0,
@@ -3000,6 +3029,10 @@ class ActiveAgentsProvider {
3000
3029
  const summary = {
3001
3030
  sessionCount: repoEntries.reduce((total, entry) => total + entry.sessions.length, 0),
3002
3031
  workingCount: repoEntries.reduce((total, entry) => total + entry.overview.workingCount, 0),
3032
+ finishedCount: repoEntries.reduce(
3033
+ (total, entry) => total + (entry.overview.finishedCount || 0),
3034
+ 0,
3035
+ ),
3003
3036
  idleCount: repoEntries.reduce((total, entry) => total + entry.overview.idleCount, 0),
3004
3037
  unassignedChangeCount: repoEntries.reduce(
3005
3038
  (total, entry) => total + entry.overview.unassignedChangeCount,
@@ -3049,6 +3082,7 @@ class ActiveAgentsProvider {
3049
3082
  }),
3050
3083
  ], {
3051
3084
  description: '1',
3085
+ collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
3052
3086
  }),
3053
3087
  ];
3054
3088
 
@@ -3056,6 +3090,7 @@ class ActiveAgentsProvider {
3056
3090
  if (workingNowItems.length > 0) {
3057
3091
  sectionItems.push(new SectionItem('Working now', workingNowItems, {
3058
3092
  description: String(workingNowItems.length),
3093
+ collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
3059
3094
  iconId: 'loading~spin',
3060
3095
  }));
3061
3096
  }
@@ -3096,7 +3131,7 @@ class ActiveAgentsProvider {
3096
3131
  if (advancedItems.length > 0) {
3097
3132
  sectionItems.push(new SectionItem('Advanced details', advancedItems, {
3098
3133
  description: String(advancedItems.length),
3099
- collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
3134
+ collapsedState: vscode.TreeItemCollapsibleState.Expanded,
3100
3135
  iconId: 'list-tree',
3101
3136
  }));
3102
3137
  }
@@ -3,7 +3,7 @@
3
3
  "displayName": "GitGuardex Active Agents",
4
4
  "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.",
5
5
  "publisher": "recodeee",
6
- "version": "0.0.18",
6
+ "version": "0.0.19",
7
7
  "license": "MIT",
8
8
  "icon": "icon.png",
9
9
  "engines": {
@@ -700,17 +700,35 @@ function deriveSessionActivity(session, options = {}) {
700
700
  .filter(Boolean))]
701
701
  .sort((left, right) => left.localeCompare(right));
702
702
 
703
+ const workingLatestFileActivityMs = deriveLatestWorktreeFileActivity(session.worktreePath, {
704
+ now,
705
+ useCache: options.useCache,
706
+ });
707
+ const workingLastFileActivityAt = Number.isFinite(workingLatestFileActivityMs)
708
+ ? new Date(workingLatestFileActivityMs).toISOString()
709
+ : '';
710
+ const workingLastFileActivityLabel = workingLastFileActivityAt
711
+ ? formatElapsedFrom(workingLastFileActivityAt, now)
712
+ : '';
713
+ const workingFileActivityAgeMs = Number.isFinite(workingLatestFileActivityMs)
714
+ ? Math.max(0, now - workingLatestFileActivityMs)
715
+ : null;
716
+ const isFinishedUncommitted = workingFileActivityAgeMs !== null
717
+ && workingFileActivityAgeMs > IDLE_ACTIVITY_WINDOW_MS;
718
+
703
719
  return {
704
- activityKind: 'working',
705
- activityLabel: 'working',
720
+ activityKind: isFinishedUncommitted ? 'finished' : 'working',
721
+ activityLabel: isFinishedUncommitted ? 'finished' : 'working',
706
722
  activityCountLabel: formatFileCount(worktreeChangedPaths.length),
707
- activitySummary: previewChangedPaths(worktreeChangedPaths),
723
+ activitySummary: isFinishedUncommitted && workingLastFileActivityLabel
724
+ ? `${previewChangedPaths(worktreeChangedPaths)} · idle ${workingLastFileActivityLabel}`
725
+ : previewChangedPaths(worktreeChangedPaths),
708
726
  changeCount: worktreeChangedPaths.length,
709
727
  changedPaths,
710
728
  worktreeChangedPaths: worktreeRelativePaths,
711
729
  pidAlive,
712
- lastFileActivityAt: '',
713
- lastFileActivityLabel: '',
730
+ lastFileActivityAt: workingLastFileActivityAt,
731
+ lastFileActivityLabel: workingLastFileActivityLabel,
714
732
  };
715
733
  }
716
734