This file is a merged representation of the entire codebase, combined into a single document by Repomix.

# File Summary

## Purpose
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.

## File Format
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Repository files (if enabled)
5. Multiple file entries, each consisting of:
  a. A header with the file path (## File: path/to/file)
  b. The full contents of the file in a code block

## Usage Guidelines
- This file should be treated as read-only. Any changes should be made to the
  original repository files, not this packed version.
- When processing this file, use the file path to distinguish
  between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
  the same level of security as you would the original repository.

## Notes
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Files are sorted by Git change count (files with more changes are at the bottom)

# Directory Structure
```
.cursor/
  rules/
    project-level.mdc
  skills/
    gitnexus/
      gitnexus-cli/
        SKILL.md
      gitnexus-debugging/
        SKILL.md
      gitnexus-exploring/
        SKILL.md
      gitnexus-guide/
        SKILL.md
      gitnexus-impact-analysis/
        SKILL.md
      gitnexus-refactoring/
        SKILL.md
    humanizer/
      SKILL.md
    metagit-bootstrap/
      scripts/
        bootstrap-config.zsh
      SKILL.md
    metagit-cli/
      metagit-cli/
        SKILL.md
      SKILL.md
    metagit-config-refresh/
      SKILL.md
    metagit-control-center/
      scripts/
        control-cycle.zsh
      SKILL.md
    metagit-gating/
      scripts/
        gate-status.zsh
      SKILL.md
    metagit-gitnexus/
      scripts/
        analyze-targets.zsh
      SKILL.md
    metagit-multi-repo/
      SKILL.md
    metagit-projects/
      SKILL.md
    metagit-release-audit/
      SKILL.md
    metagit-repo-impact/
      SKILL.md
    metagit-upstream-scan/
      scripts/
        upstream-scan.zsh
      SKILL.md
    metagit-upstream-triage/
      SKILL.md
    metagit-workspace-scope/
      SKILL.md
    metagit-workspace-sync/
      SKILL.md
  mcp.json
.github/
  ISSUE_TEMPLATE/
    bug_report.yml
    config.yml
    feature_request.yml
  workflows/
    docker.yaml
    docs.yaml
    release.yaml
    semantic-release.yaml
    test.yaml
  CODEOWNERS
  dependabot.yml
  FUNDING.yml
  PULL_REQUEST_TEMPLATE.md
.mex/
  context/
    architecture.md
    conventions.md
    decisions.md
    mcp-runtime.md
    setup.md
    stack.md
  patterns/
    add-cli-command.md
    add-managed-repo-search.md
    add-mcp-tool.md
    bootstrap-metagit-config.md
    debug-mcp-runtime.md
    debug-workspace-discovery.md
    INDEX.md
    mcp-cross-project-dependencies.md
    mcp-project-context.md
    metagit-web-api.md
    README.md
    run-graphify-analysis.md
    update-release-workflow.md
  AGENTS.md
  config.json
  ROUTER.md
  SETUP.md
  SYNC.md
docs/
  reference/
    metagit-config.full-example.yml
    metagit-config.md
    metagit-web.md
    workspace-layout-api.md
  app.logic.md
  architecture-diagram.py
  cli_reference.md
  development.md
  hermes-iac-workspace-guide.md
  hermes-orchestrator-workspace.md
  install.md
  llm.txt
  repository_detection.md
  secrets.analysis.md
  secrets.definitions.yml
  terminology.md
examples/
  hermes-orchestrator/
    .metagit.yml
    answers.example.yml
  appconfig_example.yml
  categorize_directory.py
  cli_record_example.py
  datetime_serialization_fix_example.py
  detection_config_example.yml
  detection_manager_config_example.py
  detection_manager_example.py
  enhanced_fuzzyfinder_demo.py
  fuzzyfinder_comprehensive_test.py
  fuzzyfinder_custom_colors_demo.py
  fuzzyfinder_debug_test.py
  fuzzyfinder_preview_test.py
  fuzzyfinder_simple_colors.py
  fuzzyfinder_simple_test.py
  get_git_files.py
  gitcache_differences_example.py
  gitcache_example.py
  load_config_from_yaml.py
  metagit.umbrella.yml
  provider_example.py
  README_fuzzyfinder_tests.md
  record_conversion_advanced_example.py
  record_conversion_example.py
  record_manager_example.py
  repository_analysis_example.py
  repository_detection.py
  test_appconfig_env.py
  test_record_manager_simple.py
schemas/
  backend_metadata.yml
  metagit_appconfig.schema.json
  metagit_config.schema.json
  repo_metadata.yml
scripts/
  prepush-gate.py
  prepush-gate.zsh
  validate_skills.py
skills/
  metagit-bootstrap/
    scripts/
      bootstrap-config.zsh
    SKILL.md
  metagit-cli/
    metagit-cli/
      SKILL.md
    SKILL.md
  metagit-config-refresh/
    SKILL.md
  metagit-control-center/
    scripts/
      control-cycle.zsh
    SKILL.md
  metagit-gating/
    scripts/
      gate-status.zsh
    SKILL.md
  metagit-gitnexus/
    scripts/
      analyze-targets.zsh
    SKILL.md
  metagit-multi-repo/
    SKILL.md
  metagit-projects/
    SKILL.md
  metagit-release-audit/
    SKILL.md
  metagit-repo-impact/
    SKILL.md
  metagit-upstream-scan/
    scripts/
      upstream-scan.zsh
    SKILL.md
  metagit-upstream-triage/
    SKILL.md
  metagit-workspace-scope/
    SKILL.md
  metagit-workspace-sync/
    SKILL.md
  README.md
src/
  metagit/
    cli/
      commands/
        __init__.py
        api.py
        appconfig.py
        config.py
        detect.py
        gitcache.py
        init.py
        mcp.py
        project_repo.py
        project_source.py
        project.py
        prompt.py
        record.py
        search.py
        skills.py
        web.py
        workspace.py
      __init__.py
      config_patch_ops.py
      json_output.py
      main.py
    core/
      api/
        __init__.py
        catalog_handler.py
        layout_handler.py
        server.py
      appconfig/
        __init__.py
        agent_mode.py
        display.py
        models.py
      config/
        __init__.py
        documentation_models.py
        example_generator.py
        graph_cypher_export.py
        graph_models.py
        graph_resolver.py
        manager.py
        models.py
        patch_service.py
        yaml_display.py
      detect/
        detectors/
          docker.py
          git.py
          python.py
          terraform.py
        __init__.py
        manager.py
        models.py
      flows/
        detect_flow/
          crews/
            project_understanding_crew/
              config/
                agents.yaml
                tasks.yaml
              project_understanding_crew.py
          tools/
            __init__.py
            human.py
            index_tools.py
          __init__.py
          main.py
      gitcache/
        __init__.py
        config.py
        manager.py
        README.md
      init/
        __init__.py
        models.py
        prompts.py
        registry.py
        renderer.py
        service.py
      mcp/
        services/
          bootstrap_sampling.py
          cross_project_dependencies.py
          discovery_context.py
          gitnexus_registry.py
          import_hint_scanner.py
          ops_log.py
          project_context.py
          repo_git_stats.py
          repo_ops.py
          session_store.py
          upstream_hints.py
          workspace_health.py
          workspace_index.py
          workspace_search.py
          workspace_semantic_search.py
          workspace_snapshot.py
          workspace_sync.py
          workspace_template.py
        tools/
          bootstrap_plan_only.py
          workspace_status.py
        __init__.py
        gate.py
        models.py
        protocols.py
        resources.py
        root_resolver.py
        runtime.py
        tool_registry.py
      project/
        manager.py
        models.py
        search_models.py
        search_service.py
        source_models.py
        source_sync.py
      prompt/
        __init__.py
        catalog.py
        models.py
        service.py
      providers/
        __init__.py
        base.py
        github.py
        gitlab.py
      record/
        __init__.py
        manager.py
        models.py
      skills/
        __init__.py
        installer.py
      utils/
        click.py
        common.py
        files.py
        fuzzyfinder.py
        logging.py
        userprompt.py
        yaml_class.py
      web/
        __init__.py
        config_handler.py
        config_preview.py
        graph_service.py
        job_store.py
        models.py
        ops_handler.py
        schema_tree.py
        server.py
        static_handler.py
      workspace/
        __init__.py
        agent_instructions.py
        catalog_models.py
        catalog_service.py
        context_models.py
        dedupe_resolver.py
        dependency_models.py
        health_models.py
        hydrate.py
        layout_context.py
        layout_executor.py
        layout_models.py
        layout_resolver.py
        layout_service.py
        manager.py
        models.py
        workspace_dedupe.py
    data/
      init-templates/
        application/
          .metagit.yml.tpl
          template.yaml
        hermes-orchestrator/
          .metagit.yml.tpl
          AGENTS.md.tpl
          template.yaml
        umbrella/
          .metagit.yml.tpl
          template.yaml
      prompts/
        gemini_prompt_example.md
        gemini_prompt_filled.md
        gemini_prompt.md
      skills/
        metagit-bootstrap/
          scripts/
            bootstrap-config.zsh
          SKILL.md
        metagit-cli/
          SKILL.md
        metagit-config-refresh/
          SKILL.md
        metagit-control-center/
          scripts/
            control-cycle.zsh
          SKILL.md
        metagit-gating/
          scripts/
            gate-status.zsh
          SKILL.md
        metagit-gitnexus/
          scripts/
            analyze-targets.zsh
          SKILL.md
        metagit-multi-repo/
          SKILL.md
        metagit-projects/
          SKILL.md
        metagit-release-audit/
          SKILL.md
        metagit-repo-impact/
          SKILL.md
        metagit-upstream-scan/
          scripts/
            upstream-scan.zsh
          SKILL.md
        metagit-upstream-triage/
          SKILL.md
        metagit-workspace-scope/
          SKILL.md
        metagit-workspace-sync/
          SKILL.md
        README.md
      templates/
        agent-standard/
          AGENTS.md.fragment
        hermes-orchestrator/
          AGENTS.md.fragment
          README.md
      web/
        assets/
          index-B315j_NF.css
          index-DOullneW.js
        favicon.svg
        icons.svg
        index.html
      build-files.yaml
      cd-files.json
      ci-files.json
      config-example-overrides.yml
      file-types.json
      metagit.config.yaml
      package-managers.json
      README.md
    __init__.py
    __main__.py
tasks/
  Taskfile.api.yml
  Taskfile.docker.yml
  Taskfile.gemini.yml
  Taskfile.git.yml
  Taskfile.github.yml
  Taskfile.mcp.yml
  Taskfile.workspace.yml
tests/
  api/
    test_catalog_api.py
    test_layout_api.py
    test_repo_search_api.py
  cli/
    commands/
      test_api.py
      test_config_patch.py
      test_init.py
      test_mcp.py
      test_project_repo.py
      test_project_source.py
      test_search.py
      test_skills.py
      test_web.py
  core/
    config/
      test_graph_cypher_export.py
      test_patch_service.py
    init/
      test_init_service.py
    mcp/
      services/
        test_bootstrap_sampling.py
        test_cross_project_dependencies.py
        test_import_hint_scanner.py
        test_project_context.py
        test_repo_ops.py
        test_session_store.py
        test_upstream_hints.py
        test_workspace_health.py
        test_workspace_index.py
        test_workspace_search.py
        test_workspace_semantic_search.py
        test_workspace_snapshot.py
        test_workspace_sync.py
        test_workspace_template.py
      test_gate.py
      test_models.py
      test_resources.py
      test_root_resolver.py
      test_runtime.py
      test_tool_registry.py
    prompt/
      test_prompt_service.py
    web/
      __init__.py
      test_config_handler.py
      test_config_preview.py
      test_graph_service.py
      test_job_store.py
      test_ops_handler.py
      test_schema_tree.py
    workspace/
      test_agent_instructions.py
      test_catalog_service.py
      test_context_models.py
      test_dedupe_resolver.py
      test_hydrate.py
      test_layout_service.py
  integration/
    test_mcp_workspace_flow.py
  scripts/
    test_prepush_gate_security.py
  conftest.py
  test_appconfig_display.py
  test_appconfig_init.disabled
  test_appconfig_models.py
  test_config_example_generator.py
  test_config_models.py
  test_config_yaml_display.py
  test_detect_manager.disabled
  test_documentation_graph_models.py
  test_gitcache.py
  test_project_manager_dedupe.py
  test_project_manager_prune.py
  test_project_manager_select_repo.py
  test_project_search_service.py
  test_project_source_models.py
  test_project_source_sync.py
  test_record_conversion.py
  test_record_manager.py
  test_record_models.py
  test_skills_installer.py
  test_utils_common_integration.py
  test_utils_common.py
  test_utils_files.py
  test_utils_fuzzyfinder.py
  test_utils_logging.py
  test_utils_userprompt.py
  test_utils_yaml_class.py
  test_workspace_dedupe.py
  test_workspace_index_service.py
web/
  public/
    favicon.svg
    icons.svg
  src/
    api/
      client.ts
    assets/
      hero.png
      react.svg
      vite.svg
    components/
      ConfigPreview.module.css
      ConfigPreview.tsx
      FieldEditor.module.css
      FieldEditor.tsx
      GraphDiagram.module.css
      GraphDiagram.tsx
      Layout.module.css
      Layout.tsx
      OpsPanel.module.css
      OpsPanel.tsx
      RepoTable.module.css
      RepoTable.tsx
      SchemaTree.module.css
      SchemaTree.tsx
      SyncDialog.module.css
      SyncDialog.tsx
    pages/
      AppconfigPage.tsx
      ConfigPage.module.css
      ConfigPage.tsx
      configQueries.ts
      graphQueries.ts
      MetagitConfigPage.tsx
      WorkspacePage.module.css
      WorkspacePage.tsx
      workspaceQueries.ts
    theme/
      ThemeProvider.tsx
      tokens.css
      useThemeStore.ts
    App.css
    App.tsx
    index.css
    main.tsx
  .gitignore
  eslint.config.js
  index.html
  package.json
  README.md
  tsconfig.app.json
  tsconfig.json
  tsconfig.node.json
  vite.config.ts
.cursorignore
.cursorrules
.dockerignore
.editorconfig
.env.example
.envrc
.gitignore
.gitleaksignore
.gitsecrets.lock
.metagit.example.yml
.metagit.yml
.python-version
AGENTS.md
CHANGELOG.md
configure.sh
Dockerfile
LICENSE.md
MANIFEST.in
metagit.config.yaml
mise.toml
mkdocs.yml
pyproject.toml
README.md
Secretfile.yml
Taskfile.yml
```

# Files

## File: .cursor/mcp.json
`````json
{
  "mcpServers": {
    "sequentialthinking": {
      "command": "docker",
      "args": [
        "run",
        "--rm",
        "-i",
        "mcp/sequentialthinking"
      ]
    }
    // "playwrite": {
    //   "command": "npx",
    //   "args": [
    //     "-y",
    //     "@playwright/mcp@latest"
    //   ]
    // },
    // "github": {
    //   "command": "docker",
    //   "args": [
    //     "run",
    //     "-i",
    //     "--rm",
    //     "-e",
    //     "GITHUB_PERSONAL_ACCESS_TOKEN",
    //     "-e",
    //     "GITHUB_READ_ONLY=1",
    //     "-e",
    //     "GITHUB_DYNAMIC_TOOLSETS=1",
    //     "ghcr.io/github/github-mcp-server"
    //   ]
    // }
  }
}
`````

## File: .github/ISSUE_TEMPLATE/bug_report.yml
`````yaml
name: Bug Report
description: Report a bug in the Metagit CLI
title: "[BUG] "
labels: ["bug", "triage"]
assignees: []
body:
  - type: markdown
    attributes:
      value: |
        Thanks for taking the time to fill out this bug report for Metagit CLI!
  - type: checkboxes
    id: "checks"
    attributes:
      label: "Checks"
      options:
        - label: "I have updated to the lastest minor and patch version of Metagit CLI"
          required: true
        - label: "I have checked the documentation and this is not expected behavior"
          required: true
        - label: "I have searched [./issues](./issues?q=) and there are no duplicates of my issue"
          required: true
  - type: input
    id: metagit-version
    attributes:
      label: Metagit Version
      description: Which version of Metagit CLI are you using?
      placeholder: e.g., 0.5.2
    validations:
      required: true
  - type: input
    id: python-version
    attributes:
      label: Python Version
      description: Which version of Python are you using?
      placeholder: e.g., 3.10.5
    validations:
      required: true
  - type: input
    id: os
    attributes:
      label: Operating System
      description: Which operating system are you using?
      placeholder: e.g., macOS 12.6
    validations:
      required: true
  - type: dropdown
    id: installation-method
    attributes:
      label: Installation Method
      description: How did you install Metagit?
      options:
        - pip
        - git clone
        - binary
        - other
    validations:
      required: true
  - type: textarea
    id: steps-to-reproduce
    attributes:
      label: Steps to Reproduce
      description: Detailed steps to reproduce the behavior
      placeholder: |
        1. Install Metagit using...
        2. Run the command...
        3. See error...
    validations:
      required: true
  - type: textarea
    id: expected-behavior
    attributes:
      label: Expected Behavior
      description: A clear description of what you expected to happen
    validations:
      required: true
  - type: textarea
    id: actual-behavior
    attributes:
      label: Actual Behavior
      description: What actually happened
    validations:
      required: true
  - type: textarea
    id: additional-context
    attributes:
      label: Additional Context
      description: Any other relevant information, logs, screenshots, etc.
  - type: textarea
    id: possible-solution
    attributes:
      label: Possible Solution
      description: Optional - If you have suggestions on how to fix the bug
  - type: input
    id: related-issues
    attributes:
      label: Related Issues
      description: Optional - Link to related issues if applicable
`````

## File: .github/ISSUE_TEMPLATE/config.yml
`````yaml
blank_issues_enabled: false
`````

## File: .github/ISSUE_TEMPLATE/feature_request.yml
`````yaml
name: Feature Request
description: Suggest a new feature or enhancement for Metagit CLI
title: "[FEATURE] "
labels: ["enhancement", "triage"]
assignees: []
body:
  - type: markdown
    attributes:
      value: |
        Thanks for suggesting a new feature for Metagit CLI!
  - type: textarea
    id: problem-statement
    attributes:
      label: Problem Statement
      description: Describe the problem you're trying to solve. What is currently difficult or impossible to do?
      placeholder: I would like Metagit to...
    validations:
      required: true
  - type: textarea
    id: proposed-solution
    attributes:
      label: Proposed Solution
      description: Optional - Describe your proposed solution in detail. How would this feature work?
  - type: textarea
    id: use-case
    attributes:
      label: Use Case
      description: Provide specific use cases for the feature. How would people use it?
      placeholder: This would help with...
    validations:
      required: true
  - type: textarea
    id: alternatives-solutions
    attributes:
      label: Alternatives Solutions
      description: Optional - Have you considered alternative approaches? What are their pros and cons?
  - type: textarea
    id: additional-context
    attributes:
      label: Additional Context
      description: Include any other context, screenshots, code examples, or references that might help understand the feature request.
`````

## File: .github/CODEOWNERS
`````
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence,
# @metagit-ai/contributors will be requested for
# review when someone opens a pull request.
*       @metagit-ai/maintainers
`````

## File: .github/dependabot.yml
`````yaml
version: 2
updates:
- package-ecosystem: pip
  directory: "/"
  schedule:
    interval: daily
  groups:
    python-packages:
      patterns:
        - "*"
`````

## File: .github/FUNDING.yml
`````yaml
github: [zloeber]
`````

## File: .github/PULL_REQUEST_TEMPLATE.md
`````markdown
## Description
[Provide a detailed description of the changes in this PR]

## Related Issues
[Link to related issues using #issue-number format]

## Documentation PR
[Link to related associated PR in the repo]

## Type of Change
- Bug fix
- New feature
- Breaking change
- Documentation update
- Other (please describe):

[Choose one of the above types of changes]

## Testing
[How have you tested the change?]

## Checklist
- [ ] I have added tests that prove my fix is effective or my feature works
- [ ] I have updated the documentation accordingly
- [ ] I have added an appropriate example to the documentation to outline the feature
- [ ] My changes generate no new warnings
- [ ] Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
`````

## File: docs/llm.txt
`````
This file is a merged representation of the entire codebase, combined into a single document by Repomix.
The content has been processed where content has been compressed (code blocks are separated by ⋮---- delimiter).

# File Summary

## Purpose
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.

## File Format
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Repository files (if enabled)
5. Multiple file entries, each consisting of:
  a. A header with the file path (## File: path/to/file)
  b. The full contents of the file in a code block

## Usage Guidelines
- This file should be treated as read-only. Any changes should be made to the
  original repository files, not this packed version.
- When processing this file, use the file path to distinguish
  between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
  the same level of security as you would the original repository.

## Notes
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Content has been compressed - code blocks are separated by ⋮---- delimiter
- Files are sorted by Git change count (files with more changes are at the bottom)

# Directory Structure
```
.cursor/
  rules/
    project-level.mdc
  skills/
    gitnexus/
      gitnexus-cli/
        SKILL.md
      gitnexus-debugging/
        SKILL.md
      gitnexus-exploring/
        SKILL.md
      gitnexus-guide/
        SKILL.md
      gitnexus-impact-analysis/
        SKILL.md
      gitnexus-refactoring/
        SKILL.md
    humanizer/
      SKILL.md
    metagit-bootstrap/
      scripts/
        bootstrap-config.zsh
      SKILL.md
    metagit-cli/
      metagit-cli/
        SKILL.md
      SKILL.md
    metagit-config-refresh/
      SKILL.md
    metagit-control-center/
      scripts/
        control-cycle.zsh
      SKILL.md
    metagit-gating/
      scripts/
        gate-status.zsh
      SKILL.md
    metagit-gitnexus/
      scripts/
        analyze-targets.zsh
      SKILL.md
    metagit-multi-repo/
      SKILL.md
    metagit-projects/
      SKILL.md
    metagit-release-audit/
      SKILL.md
    metagit-repo-impact/
      SKILL.md
    metagit-upstream-scan/
      scripts/
        upstream-scan.zsh
      SKILL.md
    metagit-upstream-triage/
      SKILL.md
    metagit-workspace-scope/
      SKILL.md
    metagit-workspace-sync/
      SKILL.md
  mcp.json
.github/
  ISSUE_TEMPLATE/
    bug_report.yml
    config.yml
    feature_request.yml
  workflows/
    docker.yaml
    docs.yaml
    release.yaml
    semantic-release.yaml
    test.yaml
  CODEOWNERS
  dependabot.yml
  FUNDING.yml
  PULL_REQUEST_TEMPLATE.md
.mex/
  context/
    architecture.md
    conventions.md
    decisions.md
    mcp-runtime.md
    setup.md
    stack.md
  patterns/
    add-cli-command.md
    add-managed-repo-search.md
    add-mcp-tool.md
    bootstrap-metagit-config.md
    debug-mcp-runtime.md
    debug-workspace-discovery.md
    INDEX.md
    mcp-cross-project-dependencies.md
    mcp-project-context.md
    metagit-web-api.md
    README.md
    run-graphify-analysis.md
    update-release-workflow.md
  AGENTS.md
  config.json
  ROUTER.md
  SETUP.md
  SYNC.md
docs/
  reference/
    metagit-config.full-example.yml
    metagit-config.md
    metagit-web.md
    workspace-layout-api.md
  app.logic.md
  architecture-diagram.py
  cli_reference.md
  development.md
  hermes-iac-workspace-guide.md
  hermes-orchestrator-workspace.md
  install.md
  repository_detection.md
  secrets.analysis.md
  secrets.definitions.yml
  terminology.md
examples/
  hermes-orchestrator/
    .metagit.yml
    answers.example.yml
  appconfig_example.yml
  categorize_directory.py
  cli_record_example.py
  datetime_serialization_fix_example.py
  detection_config_example.yml
  detection_manager_config_example.py
  detection_manager_example.py
  enhanced_fuzzyfinder_demo.py
  fuzzyfinder_comprehensive_test.py
  fuzzyfinder_custom_colors_demo.py
  fuzzyfinder_debug_test.py
  fuzzyfinder_preview_test.py
  fuzzyfinder_simple_colors.py
  fuzzyfinder_simple_test.py
  get_git_files.py
  gitcache_differences_example.py
  gitcache_example.py
  load_config_from_yaml.py
  metagit.umbrella.yml
  provider_example.py
  README_fuzzyfinder_tests.md
  record_conversion_advanced_example.py
  record_conversion_example.py
  record_manager_example.py
  repository_analysis_example.py
  repository_detection.py
  test_appconfig_env.py
  test_record_manager_simple.py
schemas/
  backend_metadata.yml
  metagit_appconfig.schema.json
  metagit_config.schema.json
  repo_metadata.yml
scripts/
  prepush-gate.py
  prepush-gate.zsh
  validate_skills.py
skills/
  metagit-bootstrap/
    scripts/
      bootstrap-config.zsh
    SKILL.md
  metagit-cli/
    metagit-cli/
      SKILL.md
    SKILL.md
  metagit-config-refresh/
    SKILL.md
  metagit-control-center/
    scripts/
      control-cycle.zsh
    SKILL.md
  metagit-gating/
    scripts/
      gate-status.zsh
    SKILL.md
  metagit-gitnexus/
    scripts/
      analyze-targets.zsh
    SKILL.md
  metagit-multi-repo/
    SKILL.md
  metagit-projects/
    SKILL.md
  metagit-release-audit/
    SKILL.md
  metagit-repo-impact/
    SKILL.md
  metagit-upstream-scan/
    scripts/
      upstream-scan.zsh
    SKILL.md
  metagit-upstream-triage/
    SKILL.md
  metagit-workspace-scope/
    SKILL.md
  metagit-workspace-sync/
    SKILL.md
  README.md
src/
  metagit/
    cli/
      commands/
        __init__.py
        api.py
        appconfig.py
        config.py
        detect.py
        gitcache.py
        init.py
        mcp.py
        project_repo.py
        project_source.py
        project.py
        prompt.py
        record.py
        search.py
        skills.py
        web.py
        workspace.py
      __init__.py
      config_patch_ops.py
      json_output.py
      main.py
    core/
      api/
        __init__.py
        catalog_handler.py
        layout_handler.py
        server.py
      appconfig/
        __init__.py
        agent_mode.py
        display.py
        models.py
      config/
        __init__.py
        documentation_models.py
        example_generator.py
        graph_cypher_export.py
        graph_models.py
        graph_resolver.py
        manager.py
        models.py
        patch_service.py
        yaml_display.py
      detect/
        detectors/
          docker.py
          git.py
          python.py
          terraform.py
        __init__.py
        manager.py
        models.py
      flows/
        detect_flow/
          crews/
            project_understanding_crew/
              config/
                agents.yaml
                tasks.yaml
              project_understanding_crew.py
          tools/
            __init__.py
            human.py
            index_tools.py
          __init__.py
          main.py
      gitcache/
        __init__.py
        config.py
        manager.py
        README.md
      init/
        __init__.py
        models.py
        prompts.py
        registry.py
        renderer.py
        service.py
      mcp/
        services/
          bootstrap_sampling.py
          cross_project_dependencies.py
          discovery_context.py
          gitnexus_registry.py
          import_hint_scanner.py
          ops_log.py
          project_context.py
          repo_git_stats.py
          repo_ops.py
          session_store.py
          upstream_hints.py
          workspace_health.py
          workspace_index.py
          workspace_search.py
          workspace_semantic_search.py
          workspace_snapshot.py
          workspace_sync.py
          workspace_template.py
        tools/
          bootstrap_plan_only.py
          workspace_status.py
        __init__.py
        gate.py
        models.py
        protocols.py
        resources.py
        root_resolver.py
        runtime.py
        tool_registry.py
      project/
        manager.py
        models.py
        search_models.py
        search_service.py
        source_models.py
        source_sync.py
      prompt/
        __init__.py
        catalog.py
        models.py
        service.py
      providers/
        __init__.py
        base.py
        github.py
        gitlab.py
      record/
        __init__.py
        manager.py
        models.py
      skills/
        __init__.py
        installer.py
      utils/
        click.py
        common.py
        files.py
        fuzzyfinder.py
        logging.py
        userprompt.py
        yaml_class.py
      web/
        __init__.py
        config_handler.py
        config_preview.py
        graph_service.py
        job_store.py
        models.py
        ops_handler.py
        schema_tree.py
        server.py
        static_handler.py
      workspace/
        __init__.py
        agent_instructions.py
        catalog_models.py
        catalog_service.py
        context_models.py
        dedupe_resolver.py
        dependency_models.py
        health_models.py
        hydrate.py
        layout_context.py
        layout_executor.py
        layout_models.py
        layout_resolver.py
        layout_service.py
        manager.py
        models.py
        workspace_dedupe.py
    data/
      init-templates/
        application/
          .metagit.yml.tpl
          template.yaml
        hermes-orchestrator/
          .metagit.yml.tpl
          AGENTS.md.tpl
          template.yaml
        umbrella/
          .metagit.yml.tpl
          template.yaml
      prompts/
        gemini_prompt_example.md
        gemini_prompt_filled.md
        gemini_prompt.md
      skills/
        metagit-bootstrap/
          scripts/
            bootstrap-config.zsh
          SKILL.md
        metagit-cli/
          SKILL.md
        metagit-config-refresh/
          SKILL.md
        metagit-control-center/
          scripts/
            control-cycle.zsh
          SKILL.md
        metagit-gating/
          scripts/
            gate-status.zsh
          SKILL.md
        metagit-gitnexus/
          scripts/
            analyze-targets.zsh
          SKILL.md
        metagit-multi-repo/
          SKILL.md
        metagit-projects/
          SKILL.md
        metagit-release-audit/
          SKILL.md
        metagit-repo-impact/
          SKILL.md
        metagit-upstream-scan/
          scripts/
            upstream-scan.zsh
          SKILL.md
        metagit-upstream-triage/
          SKILL.md
        metagit-workspace-scope/
          SKILL.md
        metagit-workspace-sync/
          SKILL.md
        README.md
      templates/
        agent-standard/
          AGENTS.md.fragment
        hermes-orchestrator/
          AGENTS.md.fragment
          README.md
      web/
        assets/
          index-B315j_NF.css
          index-DOullneW.js
        favicon.svg
        icons.svg
        index.html
      build-files.yaml
      cd-files.json
      ci-files.json
      config-example-overrides.yml
      file-types.json
      metagit.config.yaml
      package-managers.json
      README.md
    __init__.py
    __main__.py
tasks/
  Taskfile.api.yml
  Taskfile.docker.yml
  Taskfile.gemini.yml
  Taskfile.git.yml
  Taskfile.github.yml
  Taskfile.mcp.yml
  Taskfile.workspace.yml
tests/
  api/
    test_catalog_api.py
    test_layout_api.py
    test_repo_search_api.py
  cli/
    commands/
      test_api.py
      test_config_patch.py
      test_init.py
      test_mcp.py
      test_project_repo.py
      test_project_source.py
      test_search.py
      test_skills.py
      test_web.py
  core/
    config/
      test_graph_cypher_export.py
      test_patch_service.py
    init/
      test_init_service.py
    mcp/
      services/
        test_bootstrap_sampling.py
        test_cross_project_dependencies.py
        test_import_hint_scanner.py
        test_project_context.py
        test_repo_ops.py
        test_session_store.py
        test_upstream_hints.py
        test_workspace_health.py
        test_workspace_index.py
        test_workspace_search.py
        test_workspace_semantic_search.py
        test_workspace_snapshot.py
        test_workspace_sync.py
        test_workspace_template.py
      test_gate.py
      test_models.py
      test_resources.py
      test_root_resolver.py
      test_runtime.py
      test_tool_registry.py
    prompt/
      test_prompt_service.py
    web/
      __init__.py
      test_config_handler.py
      test_config_preview.py
      test_graph_service.py
      test_job_store.py
      test_ops_handler.py
      test_schema_tree.py
    workspace/
      test_agent_instructions.py
      test_catalog_service.py
      test_context_models.py
      test_dedupe_resolver.py
      test_hydrate.py
      test_layout_service.py
  integration/
    test_mcp_workspace_flow.py
  scripts/
    test_prepush_gate_security.py
  conftest.py
  test_appconfig_display.py
  test_appconfig_init.disabled
  test_appconfig_models.py
  test_config_example_generator.py
  test_config_models.py
  test_config_yaml_display.py
  test_detect_manager.disabled
  test_documentation_graph_models.py
  test_gitcache.py
  test_project_manager_dedupe.py
  test_project_manager_prune.py
  test_project_manager_select_repo.py
  test_project_search_service.py
  test_project_source_models.py
  test_project_source_sync.py
  test_record_conversion.py
  test_record_manager.py
  test_record_models.py
  test_skills_installer.py
  test_utils_common_integration.py
  test_utils_common.py
  test_utils_files.py
  test_utils_fuzzyfinder.py
  test_utils_logging.py
  test_utils_userprompt.py
  test_utils_yaml_class.py
  test_workspace_dedupe.py
  test_workspace_index_service.py
web/
  public/
    favicon.svg
    icons.svg
  src/
    api/
      client.ts
    assets/
      hero.png
      react.svg
      vite.svg
    components/
      ConfigPreview.module.css
      ConfigPreview.tsx
      FieldEditor.module.css
      FieldEditor.tsx
      GraphDiagram.module.css
      GraphDiagram.tsx
      Layout.module.css
      Layout.tsx
      OpsPanel.module.css
      OpsPanel.tsx
      RepoTable.module.css
      RepoTable.tsx
      SchemaTree.module.css
      SchemaTree.tsx
      SyncDialog.module.css
      SyncDialog.tsx
    pages/
      AppconfigPage.tsx
      ConfigPage.module.css
      ConfigPage.tsx
      configQueries.ts
      graphQueries.ts
      MetagitConfigPage.tsx
      WorkspacePage.module.css
      WorkspacePage.tsx
      workspaceQueries.ts
    theme/
      ThemeProvider.tsx
      tokens.css
      useThemeStore.ts
    App.css
    App.tsx
    index.css
    main.tsx
  .gitignore
  eslint.config.js
  index.html
  package.json
  README.md
  tsconfig.app.json
  tsconfig.json
  tsconfig.node.json
  vite.config.ts
.cursorignore
.cursorrules
.dockerignore
.editorconfig
.env.example
.envrc
.gitignore
.gitleaksignore
.gitsecrets.lock
.metagit.example.yml
.metagit.yml
.python-version
AGENTS.md
CHANGELOG.md
configure.sh
Dockerfile
LICENSE.md
MANIFEST.in
metagit.config.yaml
mise.toml
mkdocs.yml
pyproject.toml
README.md
Secretfile.yml
Taskfile.yml
```

# Files

## File: .cursor/mcp.json
````json
{
  "mcpServers": {
    "sequentialthinking": {
      "command": "docker",
      "args": [
        "run",
        "--rm",
        "-i",
        "mcp/sequentialthinking"
      ]
    }
    // "playwrite": {
    //   "command": "npx",
    //   "args": [
    //     "-y",
    //     "@playwright/mcp@latest"
    //   ]
    // },
    // "github": {
    //   "command": "docker",
    //   "args": [
    //     "run",
    //     "-i",
    //     "--rm",
    //     "-e",
    //     "GITHUB_PERSONAL_ACCESS_TOKEN",
    //     "-e",
    //     "GITHUB_READ_ONLY=1",
    //     "-e",
    //     "GITHUB_DYNAMIC_TOOLSETS=1",
    //     "ghcr.io/github/github-mcp-server"
    //   ]
    // }
  }
}
````

## File: .github/ISSUE_TEMPLATE/bug_report.yml
````yaml
name: Bug Report
description: Report a bug in the Metagit CLI
title: "[BUG] "
labels: ["bug", "triage"]
assignees: []
body:
  - type: markdown
    attributes:
      value: |
        Thanks for taking the time to fill out this bug report for Metagit CLI!
  - type: checkboxes
    id: "checks"
    attributes:
      label: "Checks"
      options:
        - label: "I have updated to the lastest minor and patch version of Metagit CLI"
          required: true
        - label: "I have checked the documentation and this is not expected behavior"
          required: true
        - label: "I have searched [./issues](./issues?q=) and there are no duplicates of my issue"
          required: true
  - type: input
    id: metagit-version
    attributes:
      label: Metagit Version
      description: Which version of Metagit CLI are you using?
      placeholder: e.g., 0.5.2
    validations:
      required: true
  - type: input
    id: python-version
    attributes:
      label: Python Version
      description: Which version of Python are you using?
      placeholder: e.g., 3.10.5
    validations:
      required: true
  - type: input
    id: os
    attributes:
      label: Operating System
      description: Which operating system are you using?
      placeholder: e.g., macOS 12.6
    validations:
      required: true
  - type: dropdown
    id: installation-method
    attributes:
      label: Installation Method
      description: How did you install Metagit?
      options:
        - pip
        - git clone
        - binary
        - other
    validations:
      required: true
  - type: textarea
    id: steps-to-reproduce
    attributes:
      label: Steps to Reproduce
      description: Detailed steps to reproduce the behavior
      placeholder: |
        1. Install Metagit using...
        2. Run the command...
        3. See error...
    validations:
      required: true
  - type: textarea
    id: expected-behavior
    attributes:
      label: Expected Behavior
      description: A clear description of what you expected to happen
    validations:
      required: true
  - type: textarea
    id: actual-behavior
    attributes:
      label: Actual Behavior
      description: What actually happened
    validations:
      required: true
  - type: textarea
    id: additional-context
    attributes:
      label: Additional Context
      description: Any other relevant information, logs, screenshots, etc.
  - type: textarea
    id: possible-solution
    attributes:
      label: Possible Solution
      description: Optional - If you have suggestions on how to fix the bug
  - type: input
    id: related-issues
    attributes:
      label: Related Issues
      description: Optional - Link to related issues if applicable
````

## File: .github/ISSUE_TEMPLATE/config.yml
````yaml
blank_issues_enabled: false
````

## File: .github/ISSUE_TEMPLATE/feature_request.yml
````yaml
name: Feature Request
description: Suggest a new feature or enhancement for Metagit CLI
title: "[FEATURE] "
labels: ["enhancement", "triage"]
assignees: []
body:
  - type: markdown
    attributes:
      value: |
        Thanks for suggesting a new feature for Metagit CLI!
  - type: textarea
    id: problem-statement
    attributes:
      label: Problem Statement
      description: Describe the problem you're trying to solve. What is currently difficult or impossible to do?
      placeholder: I would like Metagit to...
    validations:
      required: true
  - type: textarea
    id: proposed-solution
    attributes:
      label: Proposed Solution
      description: Optional - Describe your proposed solution in detail. How would this feature work?
  - type: textarea
    id: use-case
    attributes:
      label: Use Case
      description: Provide specific use cases for the feature. How would people use it?
      placeholder: This would help with...
    validations:
      required: true
  - type: textarea
    id: alternatives-solutions
    attributes:
      label: Alternatives Solutions
      description: Optional - Have you considered alternative approaches? What are their pros and cons?
  - type: textarea
    id: additional-context
    attributes:
      label: Additional Context
      description: Include any other context, screenshots, code examples, or references that might help understand the feature request.
````

## File: .github/CODEOWNERS
````
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence,
# @metagit-ai/contributors will be requested for
# review when someone opens a pull request.
*       @metagit-ai/maintainers
````

## File: .github/dependabot.yml
````yaml
version: 2
updates:
- package-ecosystem: pip
  directory: "/"
  schedule:
    interval: daily
  groups:
    python-packages:
      patterns:
        - "*"
````

## File: .github/FUNDING.yml
````yaml
github: [zloeber]
````

## File: .github/PULL_REQUEST_TEMPLATE.md
````markdown
## Description
[Provide a detailed description of the changes in this PR]

## Related Issues
[Link to related issues using #issue-number format]

## Documentation PR
[Link to related associated PR in the repo]

## Type of Change
- Bug fix
- New feature
- Breaking change
- Documentation update
- Other (please describe):

[Choose one of the above types of changes]

## Testing
[How have you tested the change?]

## Checklist
- [ ] I have added tests that prove my fix is effective or my feature works
- [ ] I have updated the documentation accordingly
- [ ] I have added an appropriate example to the documentation to outline the feature
- [ ] My changes generate no new warnings
- [ ] Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
````

## File: docs/repository_detection.md
````markdown
# Repository Detection

The repository detection module provides comprehensive analysis of git repositories, including language detection, project classification, branch analysis, CI/CD detection, and metrics collection.

## Features

- **Language Detection**: Identifies primary and secondary programming languages, frameworks, and build tools
- **Project Classification**: Determines project type (application, library, CLI, etc.) and domain
- **Branch Analysis**: Detects branching strategies and analyzes branch patterns
- **CI/CD Detection**: Identifies CI/CD configurations and platforms
- **Metrics Collection**: Gathers repository metrics including stars, forks, issues, and contributor information
- **Git Provider Integration**: Supports real-time metrics from GitHub and GitLab APIs
- **AppConfig Integration**: Dynamic provider configuration through application settings

## Usage

### Basic Repository Analysis

```python
from metagit.core.detect import DetectionManager

# Analyze a local repository
analysis = DetectionManager.from_path("/path/to/repo")

# Analyze a remote repository (clones it temporarily)
analysis = DetectionManager.from_url("https://github.com/username/repo")

# Generate summary
summary = analysis.summary()
print(summary)

# Convert to MetagitConfig
config = analysis.to_metagit_config()
```

### CLI Usage

```bash
# Analyze current directory
metagit detect repository

# Analyze specific path
metagit detect repository --path /path/to/repo

# Analyze remote repository
metagit detect repository --url https://github.com/username/repo

# Save configuration to .metagit.yml
metagit detect repository --save

# Output in different formats
metagit detect repository --output yaml
metagit detect repository --output json
```

## Git Provider Plugins

The repository detection system supports git provider plugins that enable fetching real-time metrics from hosting platforms like GitHub and GitLab.

### Supported Providers

- **GitHub**: Fetches stars, forks, issues, pull requests, and contributor data
- **GitLab**: Fetches project statistics, merge requests, and member information

### Configuration Methods

#### 1. AppConfig (Recommended)

Configure providers through the application configuration file:

```yaml
# ~/.config/metagit/config.yml or metagit.config.yml
config:
  providers:
    github:
      enabled: true
      api_token: "ghp_your_github_token_here"
      base_url: "https://api.github.com"  # For GitHub Enterprise
    
    gitlab:
      enabled: false
      api_token: "glpat_your_gitlab_token_here"
      base_url: "https://gitlab.com/api/v4"  # For self-hosted GitLab
```

**Benefits:**
- Persistent configuration across sessions
- No need to set environment variables
- Easy to manage multiple environments
- Supports enterprise instances

#### 2. Environment Variables

Set API tokens as environment variables:

```bash
export GITHUB_TOKEN="your_github_personal_access_token"
export GITLAB_TOKEN="your_gitlab_personal_access_token"
```

#### 3. CLI Options

Override configuration for specific commands:

```bash
# Use GitHub token
metagit detect repository --github-token "your_token"

# Use GitLab token
metagit detect repository --gitlab-token "your_token"

# Custom API URLs (for self-hosted instances)
metagit detect repository --github-url "https://github.company.com/api/v3"
metagit detect repository --gitlab-url "https://gitlab.company.com/api/v4"

# Disable AppConfig and use environment variables only
metagit detect repository --use-app-config=false
```

### Configuration Priority

The system uses the following priority order for provider configuration:

1. **CLI Options** (highest priority) - Override all other settings
2. **AppConfig** - Persistent configuration from config files
3. **Environment Variables** - Fallback for legacy support

### Provider Features

#### GitHub Provider

- **Authentication**: Personal Access Token
- **Metrics**: Stars, forks, open issues, pull requests, contributors
- **Metadata**: Repository description, topics, creation date, license
- **URL Support**: github.com, GitHub Enterprise
- **Configuration**: `providers.github.enabled`, `providers.github.api_token`, `providers.github.base_url`

#### GitLab Provider

- **Authentication**: Personal Access Token
- **Metrics**: Star count, forks, open issues, merge requests, members
- **Metadata**: Project description, topics, visibility, namespace
- **URL Support**: gitlab.com, self-hosted GitLab
- **Configuration**: `providers.gitlab.enabled`, `providers.gitlab.api_token`, `providers.gitlab.base_url`

### Fallback Behavior

When no provider is available or API calls fail, the system falls back to git-based metrics:

- **Contributors**: Counted from git commit history
- **Commit Frequency**: Calculated from recent commit patterns
- **Stars/Forks/Issues**: Set to 0 (requires API access)

## Detection Components

### Language Detection

Analyzes file extensions and content to identify:

- **Primary Language**: Most dominant programming language
- **Secondary Languages**: Other languages present
- **Frameworks**: React, Vue, Angular, Terraform, Kubernetes, etc.
- **Package Managers**: npm, pip, cargo, go.mod, etc.
- **Build Tools**: Make, Gradle, Maven, etc.

### Project Type Detection

Classifies projects based on file patterns:

- **Application**: Web apps, mobile apps, desktop apps
- **Library**: Reusable code libraries
- **CLI**: Command-line tools
- **Microservice**: Containerized services
- **Data Science**: ML/AI projects with notebooks
- **Infrastructure as Code**: Terraform, CloudFormation, etc.

### Branch Analysis

Detects branching strategies:

- **Git Flow**: Feature, develop, release, hotfix branches
- **GitHub Flow**: Simple main branch with feature branches
- **GitLab Flow**: Environment-based branching
- **Trunk-Based Development**: Single main branch
- **Custom**: Other branching patterns

### CI/CD Detection

Identifies CI/CD configurations:

- **GitHub Actions**: `.github/workflows/`
- **GitLab CI**: `.gitlab-ci.yml`
- **CircleCI**: `.circleci/config.yml`
- **Jenkins**: `Jenkinsfile`
- **Travis CI**: `.travis.yml`

### Metrics Collection

Gathers repository statistics:

- **Stars**: Repository stars/watches
- **Forks**: Repository forks
- **Open Issues**: Number of open issues
- **Pull Requests**: Open and recently merged PRs
- **Contributors**: Number of contributors
- **Commit Frequency**: Daily, weekly, or monthly activity

## Output Formats

### Summary Output

Human-readable summary of all detected information:

```
Repository Analysis Summary
Path: /path/to/repo
URL: https://github.com/username/repo
Git Repository: True
Primary Language: Python
Secondary Languages: JavaScript, Shell
Frameworks: React, Terraform
Package Managers: pip, npm
Project Type: application
Domain: web
Confidence: 0.85
Branch Strategy: GitHub Flow
Number of Branches: 3
CI/CD Tool: GitHub Actions
Contributors: 5
Commit Frequency: weekly
Stars: 42
Forks: 12
Open Issues: 3
Open PRs: 1
PRs Merged (30d): 8
Metrics Source: GitHub API
Has Docker: True
Has Tests: True
Has Documentation: True
Has Infrastructure as Code: True
```

### YAML Output

Structured YAML configuration:

```yaml
name: "My Project"
description: "A sample project"
url: "https://github.com/username/repo"
kind: "application"
license:
  kind: "mit"
  file: "LICENSE"
maintainers:
  - name: "John Doe"
    email: "john@example.com"
    role: "Maintainer"
branch_strategy: "github_flow"
taskers:
  - kind: "taskfile"
branches:
  - name: "main"
  - name: "develop"
  - name: "feature/new-feature"
cicd:
  platform: "github"
  pipelines:
    - name: "CI"
      ref: ".github/workflows/ci.yml"
metrics:
  stars: 42
  forks: 12
  open_issues: 3
  pull_requests:
    open: 1
    merged_last_30d: 8
  contributors: 5
  commit_frequency: "weekly"
metadata:
  default_branch: "main"
  has_ci: true
  has_tests: true
  has_docs: true
  has_docker: true
  has_iac: true
  created_at: "2024-01-01T00:00:00"
  last_commit_at: "2024-01-15T12:00:00"
workspace:
  projects:
    - name: "default"
      repos:
        - name: "My Project"
          path: "/path/to/repo"
          url: "https://github.com/username/repo"
```

## Examples

### Basic Analysis

```python
from metagit.core.detect import DetectionManager

# Analyze current directory
analysis = DetectionManager.from_path(".")

# Print summary
print(analysis.summary())

# Get configuration
config = analysis.to_metagit_config()
```

### With AppConfig Integration

```python
from metagit.core.detect import DetectionManager
from metagit.core.appconfig import AppConfig
from metagit.core.providers import registry

# Load AppConfig and configure providers
app_config = AppConfig.load()
registry.configure_from_app_config(app_config)

# Analyze repository (will use configured providers for metrics)
analysis = DetectionManager.from_path(".")
print(analysis.summary())
```

### With Manual Provider Configuration

```python
from metagit.core.detect import DetectionManager
from metagit.core.providers.github import GitHubProvider
from metagit.core.providers import registry

# Setup GitHub provider manually
provider = GitHubProvider(api_token="ghp_...")
registry.register(provider)

# Analyze repository
analysis = DetectionManager.from_path(".")
print(analysis.summary())
```

### CLI with AppConfig

```bash
# Create AppConfig file
mkdir -p ~/.config/metagit
cat > ~/.config/metagit/config.yml << EOF
config:
  providers:
    github:
      enabled: true
      api_token: "ghp_..."
    gitlab:
      enabled: false
      api_token: ""
EOF

# Analyze with AppConfig providers
metagit detect repository --path /path/to/repo --output summary

# Save configuration with real metrics
metagit detect repository --path /path/to/repo --save
```

### CLI with Environment Variables

```bash
# Set environment variables
export GITHUB_TOKEN="ghp_..."
export GITLAB_TOKEN="glpat-..."

# Analyze with environment providers
metagit detect repository --path /path/to/repo --output summary

# Disable AppConfig and use environment only
metagit detect repository --use-app-config=false --path /path/to/repo
```

## Error Handling

The detection system gracefully handles errors:

- **Missing Files**: Skips analysis of missing files/directories
- **API Failures**: Falls back to git-based metrics
- **Invalid Repositories**: Returns appropriate error messages
- **Network Issues**: Continues with local analysis
- **Configuration Errors**: Falls back to environment variables or defaults

## Performance Considerations

- **Local Analysis**: Fast, no network required
- **Provider API Calls**: May add 1-3 seconds for metrics
- **Large Repositories**: Analysis time scales with repository size
- **Caching**: No built-in caching (consider implementing for repeated analysis)
- **Configuration Loading**: AppConfig is loaded once per command execution
````

## File: docs/terminology.md
````markdown
# Metagit Terminology

IT has a way with words doesn't it? This is a short list of metagit terms and their concise meaning to reduce confusion.

**Path (aka. Target)** - A folder within a git repository.

**Repo** - A git repository. If it happens to be a monorepo there maybe several targets within the repository with unique associated metadata.

**Project** - A collection of git repositories. In VSCode this is a workspace. We define a project at this higher level than the repository because we want a more holistic view of what your code entails as a whole. While a repo maybe produces a single app it may have several internal/external dependencies that make up the whole of what it requires to deploy it.

**Workspace** - A collection of projects. Fundamentally different than a VSCode workspace. For metagit the most important thing to understand is that this is the target folder where all projects and their repos will be cloned/copied into.

This creates a hierarchy like the following:

```
Workspace/
└── ProjectA/
    └── RepoA1/
        ├── Path1
        └── Path2
    └── RepoA2/
        └── Path1
└── ProjectB/
    └── RepoB1/
        ├── Path1
        └── Path2
    └── RepoB2/
      └── Path1
```

> **NOTE** It is entirely possible to have the same repo referenced in multiple projects. Local paths defined this way will simply be soft links. Multiple project repositories defined this way will be treated independently.
````

## File: examples/appconfig_example.yml
````yaml
config:
  version: "0.1.0"
  description: "Metagit configuration with provider plugins"
  editor: "code"
  
  # LLM Configuration
  llm:
    enabled: false
    provider: "openrouter"
    provider_model: "gpt-4o-mini"
    embedder: "ollama"
    embedder_model: "nomic-embed-text"
    api_key: ""
  
  # Workspace Configuration
  workspace:
    path: "./.metagit"
    default_project: "default"
  
  # Profiles Configuration
  profiles:
    profile_config_path: "~/.config/metagit/profiles"
    default_profile: "default"
    boundaries: []
  
  # Git Provider Plugin Configuration
  providers:
    github:
      enabled: true  # Set to true to enable GitHub provider
      api_token: "ghp_your_github_token_here"  # Your GitHub Personal Access Token
      base_url: "https://api.github.com"  # Default GitHub API URL
    
    gitlab:
      enabled: false  # Set to true to enable GitLab provider
      api_token: "glpat_your_gitlab_token_here"  # Your GitLab Personal Access Token
      base_url: "https://gitlab.com/api/v4"  # Default GitLab API URL
````

## File: examples/categorize_directory.py
````python
#!/usr/bin/env python3
"""
Example script to categorize directory contents using metagit utilities.

This script demonstrates how to use the directory_summary and directory_details
functions to analyze directory structure and output the results in YAML format.
"""
⋮----
# Add the parent directory to the path so we can import metagit modules
⋮----
def convert_namedtuple_to_dict(obj)
⋮----
"""Convert NamedTuple objects to dictionaries for YAML serialization."""
⋮----
# Handle NamedTuple
result = obj._asdict()
# Recursively convert nested NamedTuples
⋮----
# Handle nested dictionaries (like file_types)
⋮----
def categorize_directory(path: str, output_type: str, output_file: str)
⋮----
"""
    Categorize directory contents and output in YAML format.

    This script analyzes a directory structure and provides either a summary
    (file counts by extension) or detailed analysis (file types with names and categories).
    """
⋮----
# Validate the path
target_path = Path(path)
⋮----
# Generate the appropriate output based on type
⋮----
result = directory_summary(str(target_path))
# Convert Pydantic model to dict for YAML serialization
output_data = result.model_dump()
else:  # details
file_lookup = FileExtensionLookup()
result = directory_details(str(target_path), file_lookup)
# Convert NamedTuple to dict for YAML serialization
output_data = convert_namedtuple_to_dict(result)
⋮----
# Convert to YAML
yaml_output = yaml.dump(
⋮----
# Output the result
````

## File: examples/cli_record_example.py
````python
#!/usr/bin/env python
"""
Example script demonstrating the new record CLI commands.

This script shows how to use the record management commands programmatically.
"""
⋮----
def run_metagit_command(args: list) -> tuple[int, str, str]
⋮----
"""Run a metagit command and return the result."""
⋮----
result = subprocess.run(
⋮----
def create_sample_config()
⋮----
"""Create a sample .metagit.yml file for testing."""
config_content = """
⋮----
def example_record_commands()
⋮----
"""Demonstrate the record CLI commands."""
⋮----
# Create sample config
⋮----
# Test record create
⋮----
# Test record show (list all)
⋮----
# Test record search
⋮----
# Test record stats
⋮----
# Test record export
⋮----
# Test record import
⋮----
# Test OpenSearch backend (if available)
⋮----
def cleanup()
⋮----
"""Clean up test files."""
files_to_remove = [
````

## File: examples/datetime_serialization_fix_example.py
````python
#!/usr/bin/env python
"""
Example demonstrating the datetime serialization fix for metagit records.

This example shows how the DateTimeEncoder handles datetime objects when
serializing MetagitRecord objects to JSON, fixing the "Object of type datetime
is not JSON serializable" error.
"""
⋮----
def demonstrate_datetime_serialization_fix()
⋮----
"""Demonstrate the datetime serialization fix."""
⋮----
# Show the problem (without DateTimeEncoder)
⋮----
test_data = {
⋮----
# This would fail without DateTimeEncoder
⋮----
# Show the solution
⋮----
class DateTimeEncoder(json.JSONEncoder)
⋮----
"""Custom JSON encoder that handles datetime objects."""
⋮----
def default(self, obj)
⋮----
json_str = json.dumps(test_data, cls=DateTimeEncoder, indent=2)
⋮----
async def demonstrate_record_creation()
⋮----
"""Demonstrate record creation with datetime handling."""
⋮----
# Check if config file exists
config_path = Path("metagit.config.yaml")
⋮----
# Create a simple config for demonstration
⋮----
config = MetagitConfig(
⋮----
# Load existing config
config_manager = MetagitConfigManager(config_path=config_path)
config_result = config_manager.load_config()
⋮----
config = config_result
⋮----
# Create record manager with local storage
storage_dir = Path("./example_records")
backend = LocalFileStorageBackend(storage_dir)
logger = UnifiedLogger(LoggerConfig(log_level="INFO", minimal_console=True))
record_manager = MetagitRecordManager(
⋮----
# Create record from config
record = record_manager.create_record_from_config(
⋮----
# Store the record (this is where datetime serialization happens)
record_id = await record_manager.store_record(record)
⋮----
# Verify the stored record can be retrieved
retrieved_record = await record_manager.get_record(record_id)
⋮----
# Clean up
⋮----
def main()
⋮----
"""Run the datetime serialization fix demonstration."""
⋮----
# Demonstrate the fix
⋮----
# Demonstrate record creation
⋮----
success = asyncio.run(demonstrate_record_creation())
````

## File: examples/detection_config_example.yml
````yaml
# DetectionManager Configuration Example
# This file demonstrates how to configure which analysis methods are enabled

# Default configuration - all current methods enabled
default:
  branch_analysis_enabled: true
  ci_config_analysis_enabled: true
  directory_summary_enabled: true
  directory_details_enabled: true
  commit_analysis_enabled: false  # Future feature
  tag_analysis_enabled: false     # Future feature

# Minimal configuration - only essential methods
minimal:
  branch_analysis_enabled: true
  ci_config_analysis_enabled: true
  directory_summary_enabled: false
  directory_details_enabled: false
  commit_analysis_enabled: false
  tag_analysis_enabled: false

# Comprehensive configuration - all methods enabled
comprehensive:
  branch_analysis_enabled: true
  ci_config_analysis_enabled: true
  directory_summary_enabled: true
  directory_details_enabled: true
  commit_analysis_enabled: true
  tag_analysis_enabled: true

# CI/CD focused configuration - only CI/CD and branch analysis
cicd_focused:
  branch_analysis_enabled: true
  ci_config_analysis_enabled: true
  directory_summary_enabled: false
  directory_details_enabled: false
  commit_analysis_enabled: false
  tag_analysis_enabled: false

# Directory analysis focused configuration
directory_focused:
  branch_analysis_enabled: false
  ci_config_analysis_enabled: false
  directory_summary_enabled: true
  directory_details_enabled: true
  commit_analysis_enabled: false
  tag_analysis_enabled: false
````

## File: examples/detection_manager_config_example.py
````python
#!/usr/bin/env python3
⋮----
"""
Example demonstrating the use of DetectionManagerConfig to control which analysis methods are enabled.
"""
⋮----
# Add the metagit package to the path
⋮----
def example_basic_usage()
⋮----
"""Demonstrate basic usage with default configuration."""
⋮----
# Create a DetectionManager with default config (all enabled)
manager = DetectionManager.from_path("./")
⋮----
# Run all enabled analyses
result = manager.run_all()
⋮----
# Print summary
summary = manager.summary()
⋮----
def example_custom_config()
⋮----
"""Demonstrate usage with custom configuration."""
⋮----
# Create a custom configuration
config = DetectionManagerConfig(
⋮----
directory_summary_enabled=False,  # Disable directory summary
directory_details_enabled=False,  # Disable directory details
⋮----
# Create DetectionManager with custom config
manager = DetectionManager.from_path("./", config=config)
⋮----
# Run analyses
⋮----
def example_preset_configs()
⋮----
"""Demonstrate usage with preset configurations."""
⋮----
# Use minimal configuration
minimal_config = DetectionManagerConfig.minimal()
⋮----
# Use all enabled configuration
all_enabled_config = DetectionManagerConfig.all_enabled()
⋮----
def example_specific_method()
⋮----
"""Demonstrate running a specific analysis method."""
⋮----
# Create a configuration with only branch analysis enabled
⋮----
# Run only branch analysis
result = manager.run_specific("branch_analysis")
⋮----
def example_config_serialization()
⋮----
"""Demonstrate configuration serialization."""
⋮----
# Create a configuration
⋮----
# Convert to dict
config_dict = config.model_dump()
⋮----
# Create from dict
new_config = DetectionManagerConfig(**config_dict)
⋮----
def example_metagit_record_integration()
⋮----
"""Demonstrate MetagitRecord integration."""
⋮----
# Create DetectionManager (inherits from MetagitRecord)
⋮----
# Run analysis
⋮----
# Access MetagitRecord fields
⋮----
# Access detection-specific fields
⋮----
# Convert to YAML (includes both MetagitRecord and detection data)
yaml_output = manager.to_yaml()
````

## File: examples/detection_manager_example.py
````python
#!/usr/bin/env python3
⋮----
"""
Example demonstrating the updated DetectionManager that uses RepositoryAnalysis for all detection details.

This example shows how DetectionManager now uses RepositoryAnalysis as the single source for all
detection analysis results while maintaining the MetagitRecord interface.
"""
⋮----
# Add the metagit package to the path
⋮----
def example_local_repository_analysis()
⋮----
"""Demonstrate analyzing a local repository."""
⋮----
# Create DetectionManager for local path
manager = DetectionManager.from_path("./")
⋮----
# Run all analyses
result = manager.run_all()
⋮----
def example_remote_repository_analysis()
⋮----
"""Demonstrate analyzing a remote repository."""
⋮----
# Example repository URL
repo_url = "https://github.com/octocat/Hello-World.git"
⋮----
# Create DetectionManager for remote URL
manager = DetectionManager.from_url(repo_url)
⋮----
# Clean up cloned repository
⋮----
def example_configuration_options()
⋮----
"""Demonstrate different configuration options."""
⋮----
# Minimal configuration
⋮----
config = DetectionManagerConfig.minimal()
manager = DetectionManager.from_path("./", config=config)
⋮----
# All enabled configuration
⋮----
config = DetectionManagerConfig.all_enabled()
⋮----
# Custom configuration
⋮----
config = DetectionManagerConfig(
⋮----
def example_metagit_record_integration()
⋮----
"""Demonstrate MetagitRecord integration."""
⋮----
# Create DetectionManager
⋮----
# Run analysis
⋮----
# Access MetagitRecord fields
⋮----
# Access detection-specific fields
⋮----
# Access RepositoryAnalysis results
⋮----
# Access specific analysis results
⋮----
def example_output_formats()
⋮----
"""Demonstrate different output formats."""
⋮----
# Summary output
summary = manager.summary()
⋮----
# YAML output (includes all detection data)
yaml_output = manager.to_yaml()
⋮----
# JSON output (includes all detection data)
json_output = manager.to_json()
⋮----
def example_specific_analysis_methods()
⋮----
"""Demonstrate running specific analysis methods."""
⋮----
# Create DetectionManager with minimal config
⋮----
# Run specific methods
methods = ["branch_analysis", "ci_config_analysis"]
⋮----
result = manager.run_specific(method)
⋮----
# Test disabled method
⋮----
result = manager.run_specific("directory_summary")
⋮----
def example_repository_analysis_access()
⋮----
"""Demonstrate direct access to RepositoryAnalysis."""
⋮----
# Access RepositoryAnalysis directly
⋮----
repo_analysis = manager.repository_analysis
⋮----
# Access language detection
⋮----
# Access project type detection
⋮----
# Access file analysis
⋮----
# Access metrics
⋮----
def main()
⋮----
"""Run all examples."""
⋮----
# Run examples
⋮----
# Note: Remote analysis is commented out to avoid cloning during examples
# Uncomment the line below to test remote repository analysis
# example_remote_repository_analysis()
````

## File: examples/fuzzyfinder_comprehensive_test.py
````python
#!/usr/bin/env python
⋮----
"""
Comprehensive test script demonstrating all FuzzyFinder features.
This script shows various configurations and use cases for the FuzzyFinder.
"""
⋮----
# Add the metagit package to the path
⋮----
class FileItem
⋮----
"""Example object representing a file with multiple attributes."""
⋮----
def __str__(self) -> str
⋮----
def get_preview_text(self) -> str
⋮----
"""Get formatted preview text for this file."""
⋮----
def create_sample_files() -> List[FileItem]
⋮----
"""Create a list of sample files for testing."""
⋮----
"""Run a specific FuzzyFinder test configuration."""
⋮----
finder = FuzzyFinder(config)
result = finder.run()
⋮----
def main()
⋮----
"""Main function to demonstrate various FuzzyFinder configurations."""
⋮----
# Create sample data
files = create_sample_files()
⋮----
# Test 1: Basic functionality with preview
⋮----
config1 = FuzzyFinderConfig(
⋮----
result1 = run_fuzzyfinder_test(
⋮----
# Test 2: Different scorer
⋮----
config2 = FuzzyFinderConfig(
⋮----
result2 = run_fuzzyfinder_test(
⋮----
# Test 3: Case sensitive search
⋮----
config3 = FuzzyFinderConfig(
⋮----
result3 = run_fuzzyfinder_test(
⋮----
# Test 4: Higher threshold
⋮----
config4 = FuzzyFinderConfig(
⋮----
result4 = run_fuzzyfinder_test(
````

## File: examples/fuzzyfinder_debug_test.py
````python
#!/usr/bin/env python
⋮----
"""
Debug test script for FuzzyFinder to verify fixes.
This script tests basic functionality with minimal configuration.
"""
⋮----
# Add the metagit package to the path
⋮----
def main()
⋮----
"""Simple test to verify FuzzyFinder works."""
⋮----
# Simple string list
items = ["python", "javascript", "typescript", "golang", "rust"]
⋮----
# Basic configuration
config = FuzzyFinderConfig(
⋮----
enable_preview=False,  # Disable preview for basic test
⋮----
finder = FuzzyFinder(config)
result = finder.run()
````

## File: examples/fuzzyfinder_preview_test.py
````python
#!/usr/bin/env python
⋮----
"""
Test script demonstrating FuzzyFinder with object list and preview functionality.
This script shows how to use the FuzzyFinder with a list of objects that have
multiple fields, including a preview field that displays additional information
when an item is selected.
"""
⋮----
# Add the metagit package to the path
⋮----
class ProjectFile
⋮----
"""Example object representing a project file with multiple attributes."""
⋮----
def __init__(self, name: str, path: str, size: int, type: str, description: str)
⋮----
def __str__(self) -> str
⋮----
def create_sample_project_files() -> List[ProjectFile]
⋮----
"""Create a list of sample project files for testing."""
⋮----
def format_preview_text(file_obj: ProjectFile) -> str
⋮----
"""Format the preview text for a project file."""
⋮----
def main()
⋮----
"""Main function to demonstrate FuzzyFinder with preview functionality."""
⋮----
# Create sample data
project_files = create_sample_project_files()
⋮----
# Configure FuzzyFinder with preview enabled
config = FuzzyFinderConfig(
⋮----
display_field="name",  # Use the 'name' field for display/search
preview_field="description",  # Use 'description' for preview
⋮----
# Custom styling
⋮----
# Create and run the fuzzy finder
finder = FuzzyFinder(config)
⋮----
result = finder.run()
⋮----
selected_file = result
````

## File: examples/fuzzyfinder_simple_test.py
````python
#!/usr/bin/env python
⋮----
"""
Simple test script demonstrating basic FuzzyFinder functionality.
This script shows how to use the FuzzyFinder with a simple list of strings.
"""
⋮----
# Add the metagit package to the path
⋮----
def create_sample_strings() -> List[str]
⋮----
"""Create a list of sample strings for testing."""
⋮----
def main()
⋮----
"""Main function to demonstrate basic FuzzyFinder functionality."""
⋮----
# Create sample data
languages = create_sample_strings()
⋮----
# Configure FuzzyFinder
config = FuzzyFinderConfig(
⋮----
# Custom styling
⋮----
# Create and run the fuzzy finder
finder = FuzzyFinder(config)
⋮----
result = finder.run()
````

## File: examples/gitcache_differences_example.py
````python
#!/usr/bin/env python
"""
Example demonstrating git cache differences functionality.

This example shows how the GitCacheManager now checks for differences
between local and remote repositories before pulling updates, and
includes detailed difference information in cache entries.
"""
⋮----
def create_test_git_repo(path: Path) -> None
⋮----
"""Create a test git repository with some commits."""
⋮----
# Initialize git repository
⋮----
# Create initial file
⋮----
# Create another file
⋮----
def main()
⋮----
"""Demonstrate git cache differences functionality."""
⋮----
# Create temporary directories
⋮----
temp_path = Path(temp_dir)
cache_root = temp_path / "cache"
⋮----
# Create configuration
config = GitCacheConfig(
⋮----
# Create manager
manager = GitCacheManager(config)
⋮----
# Create a test git repository
test_repo_path = temp_path / "test_repo"
⋮----
entry = manager.cache_repository(str(test_repo_path), name="test-repo")
⋮----
details = manager.get_cache_entry_details("test-repo")
⋮----
if key not in ["metadata"]:  # Skip metadata for cleaner output
⋮----
# Add a new commit to the original repository
⋮----
entries = manager.list_cache_entries()
⋮----
stats = manager.get_cache_stats()
````

## File: examples/gitcache_example.py
````python
#!/usr/bin/env python
"""
Example script demonstrating the git cache management system.

This script shows how to use the GitCacheManager to cache both
git repositories and local directories with both sync and async operations.
"""
⋮----
def create_sample_local_directory() -> Path
⋮----
"""Create a sample local directory for testing."""
temp_dir = Path(tempfile.mkdtemp())
⋮----
# Create some sample files
⋮----
# Create a subdirectory
subdir = temp_dir / "src"
⋮----
def sync_example()
⋮----
"""Demonstrate synchronous git cache operations."""
⋮----
# Create configuration
config = GitCacheConfig(
⋮----
# Create manager
manager = GitCacheManager(config)
⋮----
# Example 1: Cache a git repository
⋮----
entry = manager.cache_repository("https://github.com/octocat/Hello-World.git")
⋮----
# Example 2: Cache a local directory
⋮----
sample_dir = create_sample_local_directory()
entry = manager.cache_repository(str(sample_dir), name="sample-project")
⋮----
# Example 3: List cache entries
⋮----
entries = manager.list_cache_entries()
⋮----
# Example 4: Get cache statistics
⋮----
stats = manager.get_cache_stats()
⋮----
# Example 5: Get cached repository path
⋮----
cache_path = manager.get_cached_repository("Hello-World")
⋮----
# Example 6: Refresh cache entry
⋮----
entry = manager.refresh_cache_entry("Hello-World")
⋮----
async def async_example()
⋮----
"""Demonstrate asynchronous git cache operations."""
⋮----
# Example 1: Cache multiple repositories concurrently
⋮----
repositories = [
⋮----
tasks = []
⋮----
task = manager.cache_repository_async(repo_url)
⋮----
results = await asyncio.gather(*tasks, return_exceptions=True)
⋮----
# Example 2: Cache local directories concurrently
⋮----
sample_dir1 = create_sample_local_directory()
sample_dir2 = create_sample_local_directory()
⋮----
# Add some unique content to distinguish them
⋮----
tasks = [
⋮----
# Example 3: Refresh multiple entries concurrently
⋮----
for entry in entries[:2]:  # Refresh first 2 entries
task = manager.refresh_cache_entry_async(entry.name)
⋮----
def cleanup_example()
⋮----
"""Demonstrate cache cleanup operations."""
⋮----
# Example 1: Remove specific cache entry
⋮----
result = manager.remove_cache_entry("Hello-World")
⋮----
# Example 2: Clear all cache
⋮----
result = manager.clear_cache()
⋮----
def main()
⋮----
"""Run all examples."""
⋮----
# Run synchronous examples
⋮----
# Run asynchronous examples
⋮----
# Run cleanup examples
````

## File: examples/load_config_from_yaml.py
````python
#!/usr/bin/env python3
⋮----
"""
Example demonstrating how to load DetectionManagerConfig from YAML files.
"""
⋮----
# Add the metagit package to the path
⋮----
"""
    Load a DetectionManagerConfig from a YAML file.

    Args:
        file_path: Path to the YAML configuration file
        config_name: Name of the configuration section to load

    Returns:
        DetectionManagerConfig instance
    """
⋮----
configs = yaml.safe_load(f)
⋮----
config_data = configs[config_name]
⋮----
# Fallback to default configuration
⋮----
def example_load_from_yaml()
⋮----
"""Demonstrate loading configurations from YAML file."""
⋮----
config_file = Path(__file__).parent / "detection_config_example.yml"
⋮----
# Load different configurations
configs_to_load = [
⋮----
config = load_config_from_yaml(str(config_file), config_name)
enabled_methods = config.get_enabled_methods()
⋮----
def example_use_loaded_config()
⋮----
"""Demonstrate using a loaded configuration with DetectionManager."""
⋮----
# Load minimal configuration
config = load_config_from_yaml(str(config_file), "minimal")
⋮----
# Create DetectionManager with loaded config
manager = DetectionManager(path="./", config=config)
⋮----
# Run analysis
result = manager.run_all()
⋮----
# Print summary
summary = manager.summary()
⋮----
def example_create_yaml_config()
⋮----
"""Demonstrate creating a YAML configuration file programmatically."""
⋮----
# Create different configurations
configs = {
⋮----
# Convert to YAML
yaml_data = {}
⋮----
# Print YAML
yaml_output = yaml.dump(yaml_data, default_flow_style=False, sort_keys=False)
⋮----
# Save to file
output_file = Path(__file__).parent / "generated_config.yml"
````

## File: examples/provider_example.py
````python
#!/usr/bin/env python3
⋮----
# Add the project root to the Python path
project_root = Path(__file__).parent.parent
⋮----
"""
Example script demonstrating git provider plugins for repository analysis.

This script shows how to:
1. Configure providers using AppConfig
2. Configure providers using environment variables
3. Analyze repositories with real metrics from APIs
4. Compare different configuration methods
"""
⋮----
def setup_providers_from_appconfig()
⋮----
"""Setup git provider plugins using AppConfig."""
⋮----
app_config = AppConfig.load()
⋮----
providers = registry.get_all_providers()
⋮----
provider_names = [p.get_name() for p in providers]
⋮----
def setup_providers_from_environment()
⋮----
"""Setup git provider plugins using environment variables."""
⋮----
def setup_providers_manually()
⋮----
"""Setup git provider plugins manually."""
⋮----
# GitHub provider
github_token = os.getenv("GITHUB_TOKEN")
⋮----
github_provider = GitHubProvider(api_token=github_token)
⋮----
# GitLab provider
gitlab_token = os.getenv("GITLAB_TOKEN")
⋮----
gitlab_provider = GitLabProvider(api_token=gitlab_token)
⋮----
def analyze_local_repo(repo_path: str)
⋮----
"""Analyze a local repository."""
⋮----
analysis = DetectionManager.from_path(repo_path)
⋮----
summary = analysis.summary()
⋮----
# Show metrics details
⋮----
def analyze_remote_repo(repo_url: str)
⋮----
"""Analyze a remote repository by cloning it."""
⋮----
analysis = DetectionManager.from_url(repo_url)
⋮----
# Clean up
⋮----
def demonstrate_configuration_methods()
⋮----
"""Demonstrate different configuration methods."""
⋮----
# Method 1: AppConfig
⋮----
success = setup_providers_from_appconfig()
⋮----
# Clear providers for next method
⋮----
# Method 2: Environment Variables
⋮----
success = setup_providers_from_environment()
⋮----
# Method 3: Manual Configuration
⋮----
success = setup_providers_manually()
⋮----
def main()
⋮----
"""Main example function."""
⋮----
# Demonstrate configuration methods
⋮----
# Setup providers for analysis (using AppConfig if available, otherwise environment)
⋮----
# Try AppConfig first, fall back to environment
⋮----
# Example 1: Analyze current directory
⋮----
# Example 2: Analyze a specific local repository
# Uncomment and modify the path as needed
# print("\n" + "=" * 50)
# print("Example 2: Analyzing specific local repository")
# analyze_local_repo("/path/to/your/repo")
⋮----
# Example 3: Analyze a remote repository
# Uncomment and modify the URL as needed
⋮----
# print("Example 3: Analyzing remote repository")
# analyze_remote_repo("https://github.com/username/repo")
````

## File: examples/README_fuzzyfinder_tests.md
````markdown
# FuzzyFinder Test Scripts

This directory contains test scripts that demonstrate the functionality of the `FuzzyFinder` utility from the `metagit.core.utils.fuzzyfinder` module.

## Overview

The `FuzzyFinder` is a powerful interactive search tool that provides:
- Fuzzy text matching using rapidfuzz
- Interactive navigation with arrow keys
- Preview pane for detailed information
- Customizable styling and configuration
- Support for both string lists and object lists

## Test Scripts

### 1. `fuzzyfinder_simple_test.py`
**Purpose**: Basic demonstration with simple string list
**Features**:
- Uses a list of programming language names
- Shows basic search functionality
- Demonstrates navigation and selection

**Usage**:
```bash
python examples/fuzzyfinder_simple_test.py
```

### 2. `fuzzyfinder_preview_test.py`
**Purpose**: Demonstrates preview functionality with object list
**Features**:
- Uses `ProjectFile` objects with multiple attributes
- Shows preview pane with file descriptions
- Demonstrates `display_field` and `preview_field` configuration

**Usage**:
```bash
python examples/fuzzyfinder_preview_test.py
```

### 3. `fuzzyfinder_comprehensive_test.py`
**Purpose**: Comprehensive demonstration of all features
**Features**:
- Multiple test configurations
- Different scorers (partial_ratio, token_sort_ratio)
- Case-sensitive vs case-insensitive search
- Various score thresholds
- Different styling configurations

**Usage**:
```bash
python examples/fuzzyfinder_comprehensive_test.py
```

## Key Features Demonstrated

### Object Support
The FuzzyFinder can work with both simple strings and complex objects:

```python
# Simple string list
config = FuzzyFinderConfig(
    items=["python", "javascript", "typescript"],
    prompt_text="Search: "
)

# Object list with custom fields
config = FuzzyFinderConfig(
    items=file_objects,
    display_field="name",      # Field to display and search
    preview_field="description", # Field to show in preview
    enable_preview=True
)
```

### Preview Functionality
When `enable_preview=True` is set, the FuzzyFinder shows a preview pane below the search results:

```python
config = FuzzyFinderConfig(
    items=files,
    display_field="name",
    preview_field="description",
    enable_preview=True,
    prompt_text="Search files: "
)
```

### Different Scorers
The FuzzyFinder supports multiple fuzzy matching algorithms:

- `partial_ratio`: Best for partial string matches
- `ratio`: Best for exact string similarity
- `token_sort_ratio`: Best for word order variations

```python
config = FuzzyFinderConfig(
    items=items,
    scorer="token_sort_ratio",  # or "partial_ratio", "ratio"
    score_threshold=70.0
)
```

### Custom Styling
You can customize the appearance with different colors and styles:

```python
config = FuzzyFinderConfig(
    items=items,
    highlight_color="bold white bg:#0066cc",
    normal_color="white",
    prompt_color="bold green",
    separator_color="gray"
)
```

## Navigation Controls

- **Arrow Keys**: Navigate up/down through results
- **Type**: Search/filter results
- **Enter**: Select highlighted item
- **Ctrl+C**: Exit without selection

## Configuration Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `items` | List | Required | Items to search (strings or objects) |
| `display_field` | str | None | Field name for display/search (objects only) |
| `preview_field` | str | None | Field name for preview (objects only) |
| `enable_preview` | bool | False | Enable preview pane |
| `score_threshold` | float | 70.0 | Minimum match score (0-100) |
| `max_results` | int | 10 | Maximum results to display |
| `scorer` | str | "partial_ratio" | Fuzzy matching algorithm |
| `case_sensitive` | bool | False | Case-sensitive matching |
| `prompt_text` | str | "> " | Input prompt text |
| `highlight_color` | str | "bold white bg:#4444aa" | Highlighted item style |
| `normal_color` | str | "white" | Normal item style |
| `prompt_color` | str | "bold cyan" | Prompt text style |
| `separator_color` | str | "gray" | Separator line style |

## Example Output

When running the preview test, you'll see an interface like this:

```
Search files: py
> main.py
  api.py
  database.py
  models.py
  utils.py
  middleware.py
  cli.py
=====================================
Preview:
Main application entry point with CLI interface and core functionality
```

## Requirements

The test scripts require the following dependencies:
- `prompt_toolkit`: For the interactive terminal interface
- `rapidfuzz`: For fuzzy string matching
- `pydantic`: For configuration validation

These should be installed as part of the metagit package dependencies.

## Troubleshooting

### Common Issues

1. **Import Error**: Make sure you're running from the project root directory
2. **Display Issues**: Ensure your terminal supports the color codes used
3. **Navigation Problems**: Check that your terminal supports arrow key input

### Debug Mode

To see more detailed error information, you can modify the scripts to catch and display exceptions:

```python
try:
    result = finder.run()
    if isinstance(result, Exception):
        print(f"Error: {result}")
except Exception as e:
    print(f"Unexpected error: {e}")
    import traceback
    traceback.print_exc()
````

## File: examples/record_conversion_advanced_example.py
````python
#!/usr/bin/env python
"""
Advanced example demonstrating automatic field detection in MetagitRecord conversion.

This script shows how the new approach automatically handles field differences
without requiring manual field lists or attribute definitions.
"""
⋮----
# Add the project root to the Python path
project_root = Path(__file__).parent.parent
⋮----
def main()
⋮----
"""Demonstrate advanced MetagitRecord conversion with automatic field detection."""
⋮----
# Example 1: Show field differences automatically
⋮----
differences = MetagitRecord.get_field_differences()
⋮----
for field in differences["common_fields"][:10]:  # Show first 10
⋮----
for field in differences["record_only_fields"][:5]:  # Show first 5
⋮----
# Example 2: Show compatible fields
⋮----
compatible_fields = MetagitRecord.get_compatible_fields()
⋮----
# Example 3: Create a complex record
⋮----
record = MetagitRecord(
⋮----
# Detection-specific fields (will be automatically excluded)
⋮----
# Example 4: Automatic conversion without manual field lists
⋮----
# This conversion happens automatically without any manual field definitions
config = record.to_metagit_config()
⋮----
# Verify that detection fields were automatically excluded
detection_fields_excluded = all(
⋮----
# Example 5: Show what was preserved
⋮----
for field in differences["common_fields"][:8]:  # Show first 8
record_value = getattr(record, field, None)
config_value = getattr(config, field, None)
status = "✓" if record_value == config_value else "⚠"
⋮----
has_config_field = hasattr(config, field)
⋮----
# Example 6: Performance comparison
⋮----
# Test conversion performance
start_time = time.time()
⋮----
test_record = MetagitRecord(
test_config = test_record.to_metagit_config()
end_time = time.time()
⋮----
conversion_time = end_time - start_time
⋮----
# Example 7: Advanced conversion with kwargs
⋮----
base_config = MetagitConfig(
⋮----
# Use the advanced method with flexible kwargs
advanced_record = MetagitRecord.from_metagit_config_advanced(
⋮----
# Example 8: Demonstrate the benefits
````

## File: examples/record_conversion_example.py
````python
#!/usr/bin/env python
"""
Example script demonstrating fast conversion between MetagitRecord and MetagitConfig.

This script shows how to use the latest Pydantic best practices for efficient
conversion between MetagitRecord and MetagitConfig data structures.
"""
⋮----
# Add the project root to the Python path
project_root = Path(__file__).parent.parent
⋮----
def main()
⋮----
"""Demonstrate MetagitRecord conversion methods."""
⋮----
# Example 1: Create a MetagitConfig
⋮----
config = MetagitConfig(
⋮----
# Example 2: Convert MetagitConfig to MetagitRecord
⋮----
record = MetagitRecord.from_metagit_config(
⋮----
# Example 3: Show detection summary
⋮----
summary = record.get_detection_summary()
⋮----
# Example 4: Convert MetagitRecord back to MetagitConfig
⋮----
converted_config = record.to_metagit_config()
⋮----
# Example 5: Verify round-trip conversion
⋮----
# Example 6: Performance test
⋮----
start_time = time.time()
⋮----
test_config = MetagitConfig(name=f"perf-test-{i}")
test_record = MetagitRecord.from_metagit_config(test_config)
back_to_config = test_record.to_metagit_config()
end_time = time.time()
⋮----
conversion_time = end_time - start_time
⋮----
# Example 7: Complex nested object conversion
⋮----
complex_config = MetagitConfig(
⋮----
complex_record = MetagitRecord.from_metagit_config(
⋮----
back_to_complex_config = complex_record.to_metagit_config()
⋮----
# Example 8: Error handling demonstration
⋮----
# This should work fine
minimal_config = MetagitConfig(name="minimal")
minimal_record = MetagitRecord.from_metagit_config(minimal_config)
back_to_minimal = minimal_record.to_metagit_config()
````

## File: examples/record_manager_example.py
````python
#!/usr/bin/env python
"""
Example script demonstrating the updated MetagitRecordManager.

This script shows how to:
1. Create records from existing metagit config data
2. Store records using local file storage backend
3. Store records using OpenSearch storage backend
4. Search and retrieve records
"""
⋮----
async def example_local_file_storage()
⋮----
"""Example using local file storage backend."""
⋮----
# Initialize logger
logger = UnifiedLogger(LoggerConfig(log_level="INFO", minimal_console=True))
⋮----
# Create local file storage backend
storage_dir = Path("./records")
local_backend = LocalFileStorageBackend(storage_dir)
⋮----
# Initialize record manager with local backend
record_manager = MetagitRecordManager(
⋮----
# Create a sample config manager
config_manager = MetagitConfigManager()
⋮----
# Create a sample config
sample_config = config_manager.create_config(
⋮----
# Create record from config
record = record_manager.create_record_from_config(
⋮----
# Store the record
record_id = await record_manager.store_record(record)
⋮----
# Retrieve the record
retrieved_record = await record_manager.get_record(record_id)
⋮----
# Search records
search_results = await record_manager.search_records("example")
⋮----
# List all records
all_records = await record_manager.list_records()
⋮----
async def example_opensearch_storage()
⋮----
"""Example using OpenSearch storage backend."""
⋮----
# Note: This example requires a running OpenSearch instance
# You would need to configure the OpenSearchService first
⋮----
# Import OpenSearchService (this would fail if opensearchpy is not installed)
⋮----
# Initialize OpenSearch service
opensearch_service = OpenSearchService(
⋮----
# Create OpenSearch storage backend
opensearch_backend = OpenSearchStorageBackend(opensearch_service)
⋮----
# Initialize record manager with OpenSearch backend
⋮----
search_results = await record_manager.search_records("opensearch")
⋮----
def example_file_operations()
⋮----
"""Example of direct file operations."""
⋮----
# Initialize record manager without storage backend
record_manager = MetagitRecordManager()
⋮----
# Save record to file
file_path = Path("./example-record.yml")
save_result = record_manager.save_record_to_file(record, file_path)
⋮----
# Load record from file
loaded_record = record_manager.load_record_from_file(file_path)
⋮----
# Clean up
⋮----
async def main()
⋮----
"""Run all examples."""
⋮----
# Run local file storage example
⋮----
# Run OpenSearch storage example
⋮----
# Run file operations example
````

## File: examples/repository_analysis_example.py
````python
#!/usr/bin/env python3
⋮----
"""
Example demonstrating the updated DetectionManager with all detection analysis results.

This example shows how DetectionManager now contains all the analysis results that were
previously in RepositoryAnalysis, including branch analysis, CI/CD analysis, and directory analysis.
"""
⋮----
# Add the metagit package to the path
⋮----
def example_local_repository_analysis()
⋮----
"""Demonstrate analyzing a local repository with all analysis results."""
⋮----
# Create DetectionManager for local path
analysis = DetectionManager.from_path("./")
⋮----
# Run analysis
result = analysis.run_all()
⋮----
# Display all analysis results
⋮----
# Language detection
⋮----
# Project type detection
⋮----
# Branch analysis
⋮----
# CI/CD analysis
⋮----
# Directory analysis
⋮----
# File analysis
⋮----
# Metrics
⋮----
# Metadata
⋮----
def example_remote_repository_analysis()
⋮----
"""Demonstrate analyzing a remote repository."""
⋮----
# Example repository URL
repo_url = "https://github.com/octocat/Hello-World.git"
⋮----
# Create DetectionManager for remote URL
analysis = DetectionManager.from_url(repo_url)
⋮----
# Display key analysis results
⋮----
# Clean up cloned repository
⋮----
def example_specific_analysis()
⋮----
"""Demonstrate running specific analysis methods."""
⋮----
# Create DetectionManager
⋮----
# Run specific analysis methods
methods = ["language_detection", "project_type_detection", "branch_analysis"]
⋮----
result = analysis.run_specific(method)
⋮----
def example_configuration()
⋮----
"""Demonstrate using different detection configurations."""
⋮----
# Create minimal configuration
minimal_config = DetectionManagerConfig.minimal()
⋮----
# Create full configuration
full_config = DetectionManagerConfig.all_enabled()
⋮----
# Create custom configuration
custom_config = DetectionManagerConfig(
⋮----
# Use custom configuration
analysis = DetectionManager.from_path("./", config=custom_config)
⋮----
def main()
⋮----
"""Run all examples."""
````

## File: examples/repository_detection.py
````python
#!/usr/bin/env python3
"""
Example script demonstrating the repository detection module.

This script shows how to use the RepositoryAnalysis class to analyze
both local repositories and remote git repositories.
"""
⋮----
# Add the metagit package to the path
⋮----
def analyze_local_repository(path: str) -> None
⋮----
"""Analyze a local repository path."""
⋮----
# Create logger
logger = UnifiedLogger(LoggerConfig(log_level="INFO", minimal_console=True))
⋮----
# Analyze the repository
analysis = DetectionManager.from_path(path, logger)
⋮----
# Print summary
summary = analysis.summary()
⋮----
# Convert to MetagitConfig
config = analysis.to_metagit_config()
⋮----
def analyze_remote_repository(url: str) -> None
⋮----
"""Analyze a remote git repository by cloning it."""
⋮----
# Create temporary directory for cloning
temp_dir = tempfile.mkdtemp(prefix="metagit_example_")
⋮----
analysis = DetectionManager.from_url(url, logger, temp_dir)
⋮----
# Clean up
⋮----
# analysis.cleanup()
⋮----
# Clean up temp directory if analysis failed
⋮----
def main()
⋮----
"""Main function demonstrating repository detection."""
⋮----
# Example 1: Analyze current directory (if it's a git repo)
current_dir = os.getcwd()
⋮----
# Example 2: Analyze a well-known open source repository
# Using a small, well-known repository for demonstration
remote_url = "https://github.com/octocat/Hello-World.git"
⋮----
# Example 3: Analyze another repository
# You can uncomment and modify these lines to test with other repositories
# analyze_remote_repository("https://github.com/python/cpython.git")
# analyze_remote_repository("https://github.com/torvalds/linux.git")
````

## File: examples/test_appconfig_env.py
````python
#!/usr/bin/env python
⋮----
"""
Test script to verify AppConfig environment variable loading.
"""
⋮----
def test_appconfig_env_loading()
⋮----
"""Test that AppConfig loads environment variables correctly."""
⋮----
# Create a temporary .env file
⋮----
env_file = f.name
⋮----
# Set environment variables manually for testing
⋮----
# Load AppConfig
config = AppConfig.load()
⋮----
# Verify environment variables were loaded
⋮----
# Test LLM configuration
⋮----
# Test GitHub provider configuration
⋮----
# Test GitLab provider configuration
⋮----
# Test main API configuration
⋮----
# Clean up
⋮----
# Clean up environment variables
⋮----
success = test_appconfig_env_loading()
````

## File: examples/test_record_manager_simple.py
````python
#!/usr/bin/env python
"""
Simple test script for the updated MetagitRecordManager.

This script tests the basic functionality without requiring pytest.
"""
⋮----
def test_basic_functionality()
⋮----
"""Test basic functionality of the updated MetagitRecordManager."""
⋮----
# Create a temporary directory for testing
⋮----
temp_path = Path(temp_dir)
⋮----
# Test 1: Create storage backend
⋮----
backend = LocalFileStorageBackend(temp_path)
⋮----
# Test 2: Create record manager
⋮----
record_manager = MetagitRecordManager(storage_backend=backend)
⋮----
# Test 3: Create sample config
⋮----
config_manager = MetagitConfigManager()
sample_config = config_manager.create_config(
⋮----
# Test 4: Create record from config
⋮----
record = record_manager.create_record_from_config(
⋮----
# Test 5: Store record
⋮----
async def store_record()
⋮----
record_id = await record_manager.store_record(record)
⋮----
record_id = asyncio.run(store_record())
⋮----
# Test 6: Retrieve record
⋮----
async def get_record()
⋮----
retrieved_record = await record_manager.get_record(record_id)
⋮----
retrieved_record = asyncio.run(get_record())
⋮----
# Test 7: Search records
⋮----
async def search_records()
⋮----
search_results = await record_manager.search_records("test")
⋮----
search_results = asyncio.run(search_records())
⋮----
# Test 8: List records
⋮----
async def list_records()
⋮----
records = await record_manager.list_records()
⋮----
records = asyncio.run(list_records())
⋮----
# Test 9: File operations
⋮----
file_path = temp_path / "test-record.yml"
save_result = record_manager.save_record_to_file(record, file_path)
⋮----
loaded_record = record_manager.load_record_from_file(file_path)
⋮----
def test_error_handling()
⋮----
"""Test error handling scenarios."""
⋮----
# Test 1: Record manager without storage backend
⋮----
manager = MetagitRecordManager()
⋮----
async def test_no_backend()
⋮----
record = MetagitRecord(
result = await manager.store_record(record)
⋮----
result = asyncio.run(test_no_backend())
⋮----
# Test 2: Loading non-existent file
⋮----
result = manager.load_record_from_file(Path("nonexistent.yml"))
````

## File: schemas/backend_metadata.yml
````yaml
project:
  name: example-service
  slug: example-service
  repo_url: https://github.com/org/example-service
  provider: github
  project_type: application  # [application, library, cli, api, infra, etc.]
  languages:
    - python
    - javascript
  frameworks:
    - fastapi
    - react
  runtime_environments:
    - python3.11
    - node18
  build_tools:
    - poetry
    - npm
  deployment_targets:
    - aws-lambda
    - ecs

metrics:
  sloc: 8124
  file_count: 152
  dependency_count: 42

artifacts:
  containers:
    - name: example-service
      image: ghcr.io/org/example-service:latest
      dockerfile_path: ./Dockerfile
      base_image: python:3.11-slim
      exposed_ports: [8000]
      env_vars:
        - ENV
        - DEBUG
  libraries:
    - name: example-lib
      version: 1.2.0
      output_type: wheel
      language: python
      exported_modules:
        - example.auth
        - example.utils
      compatible_with:
        - python3.8
        - python3.11
  cli_tools:
    - name: example-cli
      entrypoint: cli.py
      flags:
        - --config
        - --dry-run

components:
  - name: auth_utils
    type: module
    path: src/example/auth/utils.py
    language: python
    functions:
      - name: hash_password
        parameters: [password: str]
        returns: str
        docstring: "Hash a plaintext password using bcrypt."
        examples:
          - "hash_password('mypassword')"
      - name: verify_password
        parameters: [plain: str, hashed: str]
        returns: bool
  - name: HealthCheckRoute
    type: class
    path: src/example/routes/health.py
    description: "Provides /health endpoint."

semantics:
  inferred_tags:
    - auth
    - api
    - observability
  tech_stack:
    - fastapi
    - pydantic
    - postgresql
  role: backend
  domain_tags:
    - account-management
    - user-authentication

dependencies:
  internal:
    - repo: shared-utils
      path: ./libs/shared-utils
      version: latest
  external:
    - name: pydantic
      version: 2.1.0
      license: MIT
    - name: requests
      version: 2.31.0
      license: Apache-2.0

ownership:
  maintainers:
    - name: Jane Doe
      email: jane@company.com
  last_commit: 2025-06-17T14:20:00Z
  recent_contributors:
    - alice
    - bob
  open_issues:
    - "Fix flaky auth tests"
    - "Add rate limiting to login route"

quality:
  test_coverage: 88
  lint_score: 9.2
  security_scan:
    last_scan: 2025-06-15
    high_risk_issues: 0
  build_status: passing
  ci_pipeline: github-actions

agent_assist:
  actionable_exports:
    - hash_password
    - verify_password
  openapi_endpoints:
    - GET /health
    - POST /login
  test_coverage_map:
    - function: hash_password
      covered: true
    - function: send_email
      covered: false
  embeddings_index: embeddings/example-service.index

navigation:
  ast_available: true
  call_graph_available: true
  semantic_search_index: search/example-service.vecdb
````

## File: schemas/repo_metadata.yml
````yaml
repo:
  name: my-repo-name
  description: Brief summary of the project
  url: https://github.com/org/my-repo
  visibility: public | private | internal
  owner:
    org: org-name
    team: platform-team
    contact: dev-team@example.com

project:
  type: application | library | microservice | cli | iac | config | data-science | plugin | template | docs | test | other
  domain: web | mobile | devops | ml | database | security | finance | gaming | iot | agent | other
  language:
    primary: python
    secondary: [bash, terraform]
  framework: [django, fastapi]
  package_managers: [pip, poetry]
  build_tool: make | cmake | bazel | none
  deploy_targets: [ecs, lambda, kubernetes]

metadata:
  tags: [ml, nlp, internal-tool, devops, infra, agentic, data-science, cli, website, other]
  created_at: 2021-08-15
  last_commit_at: 2025-06-18
  default_branch: main
  license: MIT | Apache-2.0 | proprietary
  topics: [nlp, fastapi, containerized]
  forked_from: null | https://github.com/upstream/original
  archived: false
  template: false
  has_ci: true
  has_tests: true
  has_docs: true
  has_docker: true
  has_iac: true

metrics:
  stars: 15
  forks: 3
  open_issues: 2
  pull_requests:
    open: 1
    merged_last_30d: 4
  contributors: 6
  commit_frequency: weekly | daily | monthly

ci_cd:
  platform: github-actions | gitlab | jenkins | circleci | none
  pipelines: [test, lint, build, deploy]
  status_badges: true

dependencies:
  external_services: [s3, postgres, stripe-api]
  critical_libs: [torch, pandas]

iac:
  type: terraform | cloudformation | pulumi | none
  environments:
    - name: dev
      cloud: aws
      region: us-west-2
      config_path: infra/dev
    - name: prod
      cloud: aws
      region: us-east-1
      config_path: infra/prod

agentic:
  reusable_components:
    - name: embed_text
      path: lib/embedding.py
      description: Generates sentence embeddings using SentenceTransformers
  entrypoints:
    - name: main_app
      file: app/main.py
      function: run_app()
  test_interface:
    test_framework: pytest
    test_coverage: 85%
````

## File: src/metagit/cli/commands/__init__.py
````python

````

## File: src/metagit/cli/commands/gitcache.py
````python
#!/usr/bin/env python
"""
CLI commands for git cache management.

This module provides command-line interface for managing git cache operations.
"""
⋮----
@click.group()
def gitcache()
⋮----
"""Git cache management commands."""
⋮----
"""Cache a repository or local directory."""
⋮----
config = GitCacheConfig(
⋮----
manager = GitCacheManager(config)
⋮----
entry = asyncio.run(manager.cache_repository_async(source, name))
⋮----
entry = manager.cache_repository(source, name)
⋮----
def list(cache_root: str)
⋮----
"""List all cache entries."""
⋮----
config = GitCacheConfig(cache_root=Path(cache_root))
⋮----
entries = manager.list_cache_entries()
⋮----
# Show git information for git repositories
⋮----
def stats(cache_root: str)
⋮----
"""Show cache statistics."""
⋮----
stats = manager.get_cache_stats()
⋮----
@click.option("--async", "use_async", is_flag=True, help="Use async operations")
def refresh(name: str, cache_root: str, use_async: bool)
⋮----
"""Refresh a cache entry."""
⋮----
entry = asyncio.run(manager.refresh_cache_entry_async(name))
⋮----
entry = manager.refresh_cache_entry(name)
⋮----
def remove(name: str, cache_root: str)
⋮----
"""Remove a cache entry."""
⋮----
result = manager.remove_cache_entry(name)
⋮----
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
def clear(cache_root: str, yes: bool)
⋮----
"""Clear all cache entries."""
⋮----
result = manager.clear_cache()
⋮----
def path(name: str, cache_root: str)
⋮----
"""Get the path to a cached repository."""
⋮----
cache_path = manager.get_cached_repository(name)
⋮----
contents = list(cache_path.iterdir())
⋮----
def details(name: str, cache_root: str)
⋮----
"""Get detailed information about a cache entry."""
⋮----
details = manager.get_cache_entry_details(name)
⋮----
# Basic information
⋮----
# Size information
⋮----
# Timestamps
⋮----
# Git-specific information
⋮----
# Current information (if different from stored)
````

## File: src/metagit/cli/commands/record.py
````python
#!/usr/bin/env python
"""
Record management subcommands for metagit.

This module provides CLI commands for managing metagit records using the
MetagitRecordManager with support for multiple storage backends.
"""
⋮----
class DateTimeEncoder(json.JSONEncoder)
⋮----
"""Custom JSON encoder that handles datetime objects."""
⋮----
def default(self, obj)
⋮----
"""Record management subcommands"""
⋮----
# If no subcommand is provided, show help
⋮----
# Store storage configuration in context
⋮----
logger = ctx.obj.get("logger")
⋮----
def _get_record_manager(ctx: click.Context) -> MetagitRecordManager
⋮----
"""Get a configured MetagitRecordManager instance."""
logger = ctx.obj["logger"]
storage_type = ctx.obj["storage_type"]
⋮----
storage_path = Path(ctx.obj["storage_path"])
backend = LocalFileStorageBackend(storage_path)
⋮----
# Import OpenSearchService here to avoid import issues if not installed
⋮----
# Parse hosts
hosts = []
⋮----
opensearch_service = OpenSearchService(
backend = OpenSearchStorageBackend(opensearch_service)
⋮----
"""Create a record from metagit configuration"""
⋮----
# Load configuration
config_manager = MetagitConfigManager(config_path=Path(config_path))
config_result = config_manager.load_config()
⋮----
# Create record manager
record_manager = _get_record_manager(ctx)
⋮----
# Create record from config
record = record_manager.create_record_from_config(
⋮----
# Save to file if requested
⋮----
file_path = Path(output_file)
save_result = record_manager.save_record_to_file(record, file_path)
⋮----
# Store in backend
async def store_record()
⋮----
record_id = asyncio.run(store_record())
⋮----
@click.pass_context
def record_show(ctx: click.Context, record_id: Optional[str], format: str) -> None
⋮----
"""Show record(s)"""
⋮----
# Show specific record
async def get_record()
⋮----
record = asyncio.run(get_record())
⋮----
yaml.Dumper.ignore_aliases = lambda *args: True  # noqa: ARG005
output = yaml.dump(
⋮----
else:  # json
⋮----
# List all records
async def list_records()
⋮----
records = asyncio.run(list_records())
⋮----
"""Search records"""
⋮----
async def search_records()
⋮----
results = asyncio.run(search_records())
⋮----
records = results.get("records", [])
total = results.get("total", 0)
current_page = results.get("page", 1)
total_pages = results.get("pages", 1)
⋮----
# Simple table format
⋮----
record_id = getattr(record, "record_id", "N/A")
description = record.description or "N/A"
⋮----
description = description[:27] + "..."
⋮----
output = json.dumps(
⋮----
"""Update an existing record"""
⋮----
# Get existing record
⋮----
existing_record = asyncio.run(get_record())
⋮----
# Load updated config if provided
⋮----
# Create updated record
updated_record = record_manager.create_record_from_config(
⋮----
# Update only specific fields
updated_record = existing_record
⋮----
None  # Will be set by create_record_from_config
⋮----
# Update the record
async def update_record()
⋮----
result = asyncio.run(update_record())
⋮----
@click.pass_context
def record_delete(ctx: click.Context, record_id: str, force: bool) -> None
⋮----
"""Delete a record"""
⋮----
# Show record info before deletion
⋮----
# Delete the record
⋮----
async def delete_record()
⋮----
result = asyncio.run(delete_record())
⋮----
"""Export a record to file"""
⋮----
# Get the record
⋮----
# Export to file
⋮----
"""Import a record from file"""
⋮----
# Load record from file
file_path = Path(input_file)
record = record_manager.load_record_from_file(file_path)
⋮----
# Override fields if specified
⋮----
# Store the record
⋮----
@record.command("stats")
@click.pass_context
def record_stats(ctx: click.Context) -> None
⋮----
"""Show record storage statistics"""
⋮----
# Get all records for statistics
⋮----
total_records = len(records)
⋮----
# Calculate statistics
sources = {}
kinds = {}
languages = {}
⋮----
# Count by detection source
source = record.detection_source or "unknown"
⋮----
# Count by project kind
kind = record.kind or "unknown"
⋮----
# Count by primary language
⋮----
lang = record.language.primary
⋮----
# Display statistics
````

## File: src/metagit/cli/__init__.py
````python

````

## File: src/metagit/core/config/manager.py
````python
#!/usr/bin/env python
"""
Class for managing .metagit.yml configuration files.

This package provides a class for managing .metagit.yml configuration files.
"""
⋮----
class MetagitConfigManager
⋮----
"""
    Manager class for handling .metagit.yml configuration files.

    This class provides methods for loading, validating, and creating
    .metagit.yml configuration files with proper error handling and validation.
    """
⋮----
"""
        Initialize the MetagitConfigManager.

        Args:
            config_path: Path to the .metagit.yml file. If None, defaults to .metagit.yml in current directory.
        """
⋮----
@property
    def config(self) -> Union[MetagitConfig, None, Exception]
⋮----
"""
        Get the loaded configuration.

        Returns:
            MetagitConfig: The loaded configuration, or None if not loaded
        """
⋮----
def load_config(self) -> Union[MetagitConfig, Exception]
⋮----
"""
        Load and validate a .metagit.yml configuration file.

        Returns:
            MetagitConfig: Validated configuration object

        Raises:
            FileNotFoundError: If the configuration file is not found
            yaml.YAMLError: If the YAML file is malformed
            ValidationError: If the configuration doesn't match the expected schema
        """
⋮----
yaml_data = yaml.safe_load(f)
⋮----
def validate_config(self) -> Union[bool, Exception]
⋮----
"""
        Validate a .metagit.yml configuration file without loading it into memory.

        Returns:
            bool: True if the configuration is valid, False otherwise
        """
⋮----
load_result = self.load_config()
⋮----
"""
        Create a .metagit.yml project configuration file.

        Args:
            output_path: Path where to save the configuration. If None, returns the config as string.

        Returns:
            MetagitConfig or str: The created configuration object or YAML string
        """
⋮----
workspace = None
⋮----
workspace = Workspace(
project_config = MetagitConfig(
⋮----
def reload_config(self) -> Union[MetagitConfig, Exception]
⋮----
"""
        Reload the configuration from disk.

        Returns:
            MetagitConfig: The reloaded configuration object
        """
⋮----
"""
        Save a configuration to a YAML file.

        Args:
            config: Configuration to save. If None, uses the loaded config.
            output_path: Path where to save the configuration. If None, uses the instance config_path.
        """
⋮----
config_to_save = config or self._config
⋮----
save_path = output_path or self.config_path
⋮----
"""
    Create a top level .metagit.yml configuration file.
    """
logger = logger or UnifiedLogger(
⋮----
git_repo = Repo(Path.cwd())
name = Path(git_repo.working_dir).name
⋮----
name = Path.cwd().name
⋮----
description = git_repo.description or "No description"
⋮----
url = git_repo.remote().url or None
⋮----
kind = "application"
⋮----
config_manager = MetagitConfigManager()
config_result = config_manager.create_config(
⋮----
yaml.Dumper.ignore_aliases = lambda *args: True  # noqa: ARG005
output = yaml.dump(
````

## File: src/metagit/core/flows/detect_flow/crews/project_understanding_crew/config/agents.yaml
````yaml
code_structure_analyst:
  role: Codebase Structure Mapper
  goal: Analyze and document the structure and key files of a codebase
  backstory: >
    A veteran software architect known for deconstructing complex projects by examining
    file hierarchies and naming patterns.

dependency_reviewer:
  role: Dependency Inspector
  goal: Extract and analyze dependencies and configurations
  backstory: >
    A DevOps veteran with deep knowledge of Python packaging, Docker, and environment files.

logic_summarizer:
  role: Code Logic Interpreter
  goal: Summarize key classes and functions
  backstory: >
    A code reader AI trained on thousands of GitHub projects to recognize core logic patterns.

summary_formatter:
  role: Summary Structurer
  goal: Format the collected analysis into a pydantic structure
  backstory: >
    A schema-obsessed engineer AI that formats unstructured output into structured insights.
````

## File: src/metagit/core/flows/detect_flow/crews/project_understanding_crew/config/tasks.yaml
````yaml
structure_task:
  description: >
    Traverse the codebase at {path}. Count files, extract file types, and identify entry points.
    Your final answer MUST include `num_files`, `file_types`, and `entry_points`.
  expected_output: >
    {
      "num_files": int,
      "file_types": List[str],
      "entry_points": List[str]
    }

dependency_task:
  description: >
    Parse pyproject.toml, requirements.txt, and Dockerfile at {path}.
    Identify dependencies, Docker usage, and .env or config presence.
    Output must include: `main_dependencies`, `uses_docker`, `uses_env_files`.
  expected_output: >
    {
      "main_dependencies": List[str],
      "uses_docker": bool,
      "uses_env_files": bool
    }

logic_task:
  description: >
    Analyze main modules at {path} and summarize key classes and functions.
    Output must include `modules`, `key_classes`, and `key_functions`.
  expected_output: >
    {
      "modules": List[str],
      "key_classes": List[str],
      "key_functions": List[str]
    }

formatting_task:
  description: >
    Using structure_task, dependency_task, and logic_task outputs, build a MetagitConfig object.
    Your final output MUST match the full MetagitConfig pydantic class structure.
  expected_output: >
    A dict that can be used to instantiate a MetagitConfig pydantic object.
````

## File: src/metagit/core/flows/detect_flow/crews/project_understanding_crew/project_understanding_crew.py
````python
# If you want to run a snippet of code before or after the crew starts,
# you can use the @before_kickoff and @after_kickoff decorators
# https://docs.crewai.com/concepts/crews#example-crew-class-with-decorators
⋮----
@CrewBase
class ProjectUnderstandingCrew
⋮----
"""ProjectUnderstandingCrew crew"""
⋮----
agents: list[BaseAgent]
tasks: list[Task]
⋮----
# Learn more about YAML configuration files here:
# Agents: https://docs.crewai.com/concepts/agents#yaml-configuration-recommended
# Tasks: https://docs.crewai.com/concepts/tasks#yaml-configuration-recommended
⋮----
# If you would like to add tools to your agents, you can learn more about it here:
# https://docs.crewai.com/concepts/agents#agent-tools
⋮----
@agent
    def researcher(self) -> Union[Agent, Exception]
⋮----
config=self.agents_config["researcher"],  # type: ignore[index]
⋮----
@agent
    def reporting_analyst(self) -> Union[Agent, Exception]
⋮----
config=self.agents_config["reporting_analyst"],  # type: ignore[index]
⋮----
# To learn more about structured task outputs,
# task dependencies, and task callbacks, check out the documentation:
# https://docs.crewai.com/concepts/tasks#overview-of-a-task
⋮----
@task
    def research_task(self) -> Union[Task, Exception]
⋮----
config=self.tasks_config["research_task"],  # type: ignore[index]
⋮----
@task
    def reporting_task(self) -> Union[Task, Exception]
⋮----
config=self.tasks_config["reporting_task"],  # type: ignore[index]
⋮----
@crew
    def crew(self) -> Union[Crew, Exception]
⋮----
"""Creates the ProjectUnderstandingCrew crew"""
⋮----
# To learn how to add knowledge sources to your crew, check out the documentation:
# https://docs.crewai.com/concepts/knowledge#what-is-knowledge
⋮----
agents=self.agents,  # Automatically created by the @agent decorator
tasks=self.tasks,  # Automatically created by the @task decorator
⋮----
# process=Process.hierarchical, # In case you wanna use that instead https://docs.crewai.com/how-to/Hierarchical/
````

## File: src/metagit/core/flows/detect_flow/tools/__init__.py
````python

````

## File: src/metagit/core/flows/detect_flow/tools/human.py
````python
"""Tool for asking human input."""
⋮----
def _print_func(text: str) -> Union[None, Exception]
⋮----
def input_func() -> Union[str, Exception]
⋮----
contents = []
⋮----
line = input()
⋮----
class MyToolInput(BaseModel)
⋮----
"""Input schema for MyCustomTool."""
⋮----
query: str = Field(..., description="Query to the human.")
⋮----
class HumanTool(BaseTool)
⋮----
name: str = "HumanTool"
description: str = (
args_schema: type[BaseModel] = MyToolInput
prompt_func: Callable[[str], None] = _print_func
input_func: Callable[[], str] = input_func
⋮----
def _run(self, query: str) -> Union[str, Exception]
⋮----
"""Use the Multi Line Human input tool."""
⋮----
prompt_result = self.prompt_func(query)
````

## File: src/metagit/core/flows/detect_flow/tools/index_tools.py
````python
#! /usr/bin/env python3
"""
Index tools for the detect flow
"""
⋮----
"""
    Walk the repo, split files, embed, and store in FAISS.
    Returns index metadata location.
    """
⋮----
embeddings = OpenAIEmbeddings()
text_splitter = RecursiveCharacterTextSplitter(
docs = []
⋮----
full = Path(root) / fn
text = full.read_text(encoding="utf-8")
⋮----
vectorstore = FAISS.from_documents(
````

## File: src/metagit/core/flows/detect_flow/__init__.py
````python

````

## File: src/metagit/core/flows/detect_flow/main.py
````python
#!/usr/bin/env python
⋮----
# class PoemState(BaseModel):
#     sentence_count: int = 1
#     poem: str = ""
⋮----
# class PoemFlow(Flow[PoemState]):
⋮----
#     @start()
#     def generate_sentence_count(self):
#         print("Generating sentence count")
#         self.state.sentence_count = randint(1, 5)
⋮----
#     @listen(generate_sentence_count)
#     def generate_poem(self):
#         print("Generating poem")
#         result = (
#             PoemCrew()
#             .crew()
#             .kickoff(inputs={"sentence_count": self.state.sentence_count})
#         )
⋮----
#         print("Poem generated", result.raw)
#         self.state.poem = result.raw
⋮----
#     @listen(generate_poem)
#     def save_poem(self):
#         print("Saving poem")
#         with open("poem.txt", "w") as f:
#             f.write(self.state.poem)
⋮----
# def kickoff():
#     poem_flow = PoemFlow()
#     poem_flow.kickoff()
⋮----
# def plot():
⋮----
#     poem_flow.plot()
⋮----
# kickoff()
````

## File: src/metagit/core/gitcache/__init__.py
````python
#!/usr/bin/env python
"""
Git cache management module.

This module provides functionality for caching git repositories locally,
supporting both remote cloning and local file copying with async and sync operations.
"""
⋮----
__all__ = ["GitCacheConfig", "GitCacheManager"]
````

## File: src/metagit/core/gitcache/config.py
````python
#!/usr/bin/env python
"""
Git cache configuration models.

This module defines the Pydantic models used for configuring
the git cache management system.
"""
⋮----
class CacheType(str, Enum)
⋮----
"""Enumeration of cache types."""
⋮----
GIT = "git"
LOCAL = "local"
⋮----
class CacheStatus(str, Enum)
⋮----
"""Enumeration of cache statuses."""
⋮----
FRESH = "fresh"
STALE = "stale"
MISSING = "missing"
ERROR = "error"
⋮----
class GitCacheEntry(BaseModel)
⋮----
"""Model for a single git cache entry."""
⋮----
name: str = Field(..., description="Cache entry name/identifier")
source_url: str = Field(..., description="Source URL or local path")
cache_type: CacheType = Field(..., description="Type of cache entry")
cache_path: Path = Field(..., description="Local cache path")
created_at: datetime = Field(
last_updated: datetime = Field(
last_accessed: datetime = Field(
size_bytes: Optional[int] = Field(None, description="Cache size in bytes")
status: CacheStatus = Field(
error_message: Optional[str] = Field(
metadata: Dict[str, Any] = Field(
⋮----
# Git-specific tracking fields
local_commit_hash: Optional[str] = Field(
local_branch: Optional[str] = Field(None, description="Current local branch name")
remote_commit_hash: Optional[str] = Field(
remote_branch: Optional[str] = Field(None, description="Default remote branch name")
has_upstream_changes: Optional[bool] = Field(
upstream_changes_summary: Optional[str] = Field(
last_diff_check: Optional[datetime] = Field(
⋮----
@field_validator("cache_path", mode="before")
@classmethod
    def validate_cache_path(cls, v: Any) -> Path
⋮----
"""Convert string to Path object."""
⋮----
class Config
⋮----
"""Pydantic configuration."""
⋮----
use_enum_values = True
extra = "forbid"
⋮----
class GitCacheConfig(BaseModel)
⋮----
"""Configuration model for git cache management."""
⋮----
cache_root: Path = Field(
default_timeout_minutes: int = Field(
max_cache_size_gb: float = Field(
enable_async: bool = Field(default=True, description="Enable async operations")
git_config: Dict[str, Any] = Field(
provider_config: Optional[Dict[str, Any]] = Field(
entries: Dict[str, GitCacheEntry] = Field(
⋮----
@field_validator("cache_root", mode="before")
@classmethod
    def validate_cache_root(cls, v: Any) -> Path
⋮----
"""Convert string to Path object and ensure it exists."""
⋮----
cache_path = Path(v)
⋮----
cache_path = v
⋮----
# Create cache directory if it doesn't exist
⋮----
@field_validator("default_timeout_minutes")
@classmethod
    def validate_timeout(cls, v: int) -> int
⋮----
"""Validate timeout is positive."""
⋮----
@field_validator("max_cache_size_gb")
@classmethod
    def validate_max_size(cls, v: float) -> float
⋮----
"""Validate max cache size is positive."""
⋮----
def get_cache_path(self, name: str) -> Path
⋮----
"""Get the cache path for a specific entry."""
⋮----
def is_entry_stale(self, entry: GitCacheEntry) -> bool
⋮----
"""Check if a cache entry is stale based on timeout."""
timeout_delta = timedelta(minutes=self.default_timeout_minutes)
⋮----
def get_cache_size_bytes(self) -> int
⋮----
"""Get total cache size in bytes."""
total_size = 0
⋮----
def get_cache_size_gb(self) -> float
⋮----
"""Get total cache size in GB."""
⋮----
def is_cache_full(self) -> bool
⋮----
"""Check if cache is at maximum size."""
⋮----
def add_entry(self, entry: GitCacheEntry) -> None
⋮----
"""Add a cache entry."""
⋮----
def remove_entry(self, name: str) -> bool
⋮----
"""Remove a cache entry."""
⋮----
def get_entry(self, name: str) -> Optional[GitCacheEntry]
⋮----
"""Get a cache entry by name."""
⋮----
def list_entries(self) -> List[GitCacheEntry]
⋮----
"""List all cache entries."""
⋮----
def clear_entries(self) -> None
⋮----
"""Clear all cache entries."""
````

## File: src/metagit/core/gitcache/manager.py
````python
#!/usr/bin/env python
"""
Git cache manager for handling repository caching operations.

This module provides both synchronous and asynchronous operations
for caching git repositories and local directories.
"""
⋮----
import git  # Add this at the top with other imports
⋮----
logger = logging.getLogger(__name__)
⋮----
class GitCacheManager
⋮----
"""Manager for git cache operations."""
⋮----
def __init__(self, config: GitCacheConfig)
⋮----
"""
        Initialize the git cache manager.

        Args:
            config: Git cache configuration
        """
⋮----
def register_provider(self, provider: GitProvider) -> None
⋮----
"""
        Register a git provider for handling specific URLs.

        Args:
            provider: Git provider instance
        """
⋮----
def _get_provider_for_url(self, url: str) -> Optional[GitProvider]
⋮----
"""
        Get the appropriate provider for a given URL.

        Args:
            url: Repository URL

        Returns:
            Git provider or None if no provider supports the URL
        """
normalized_url = normalize_git_url(url)
⋮----
def _generate_cache_name(self, source: str) -> str
⋮----
"""
        Generate a cache name from source URL or path.

        Args:
            source: Source URL or local path

        Returns:
            Generated cache name
        """
# For git URLs, extract repo name
⋮----
normalized_url = normalize_git_url(source)
# Extract repo name from URL
⋮----
repo_name = normalized_url.split("/")[-1]
⋮----
repo_name = repo_name[:-4]
⋮----
# For local paths, use the directory name
path = Path(source)
⋮----
def _is_git_url(self, source: str) -> bool
⋮----
"""
        Check if source is a git URL.

        Args:
            source: Source URL or path

        Returns:
            True if source is a git URL
        """
⋮----
def _is_local_path(self, source: str) -> bool
⋮----
"""
        Check if source is a local path.

        Args:
            source: Source URL or path

        Returns:
            True if source is a local path
        """
⋮----
def _is_git_repository(self, path: Path) -> bool
⋮----
"""
        Check if a local path is a git repository using gitpython.

        Args:
            path: Local path to check

        Returns:
            True if path is a git repository
        """
⋮----
_ = git.Repo(path)
⋮----
def _is_local_git_repository(self, source: str) -> bool
⋮----
"""
        Check if source is a local git repository.

        Args:
            source: Source URL or path

        Returns:
            True if source is a local git repository
        """
⋮----
def _clone_repository(self, url: str, cache_path: Path) -> Union[bool, Exception]
⋮----
"""
        Clone a git repository using gitpython.

        Args:
            url: Repository URL
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
⋮----
# Remove existing directory if it exists
⋮----
"""
        Clone a git repository asynchronously using gitpython in a thread.

        Args:
            url: Repository URL
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
⋮----
result = await asyncio.to_thread(self._clone_repository, url, cache_path)
⋮----
"""
        Copy a local directory to cache.

        Args:
            source_path: Source directory path
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
⋮----
# Copy directory
⋮----
"""
        Copy a local directory to cache asynchronously.

        Args:
            source_path: Source directory path
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
⋮----
# Run copy operation in thread pool to avoid blocking
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
⋮----
def _pull_updates(self, cache_path: Path) -> Union[bool, Exception]
⋮----
"""
        Pull updates for an existing git repository using gitpython.

        Args:
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
⋮----
repo = git.Repo(cache_path)
origin = repo.remotes.origin
⋮----
async def _pull_updates_async(self, cache_path: Path) -> Union[bool, Exception]
⋮----
"""
        Pull updates for an existing git repository asynchronously using gitpython in a thread.

        Args:
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
⋮----
result = await asyncio.to_thread(self._pull_updates, cache_path)
⋮----
def _calculate_directory_size(self, path: Path) -> int
⋮----
"""
        Calculate directory size in bytes.

        Args:
            path: Directory path

        Returns:
            Size in bytes
        """
total_size = 0
⋮----
"""
        Cache a repository or local directory synchronously.

        Args:
            source: Source URL or local path
            name: Optional cache name (auto-generated if not provided)

        Returns:
            GitCacheEntry if successful, Exception if failed
        """
⋮----
# Generate cache name if not provided
⋮----
name = self._generate_cache_name(source)
⋮----
# Check if entry already exists
existing_entry = self.config.get_entry(name)
cache_path = self.config.get_cache_path(name)
⋮----
# Determine cache type
⋮----
cache_type = CacheType.GIT
⋮----
cache_type = CacheType.LOCAL
⋮----
# Handle existing cache
⋮----
# Check for differences before pulling updates
diff_info = self._check_repository_differences(cache_path)
⋮----
# Update entry with difference information
⋮----
# Only pull if there are upstream changes
⋮----
result = self._pull_updates(cache_path)
⋮----
# For local directories, recopy
result = self._copy_local_directory(Path(source), cache_path)
⋮----
# Update entry
⋮----
# Create new cache entry
entry = GitCacheEntry(
⋮----
# Perform caching operation
⋮----
result = self._clone_repository(source, cache_path)
⋮----
# Calculate size and add entry
⋮----
# For git repositories, populate git information
⋮----
"""
        Cache a repository or local directory asynchronously.

        Args:
            source: Source URL or local path
            name: Optional cache name (auto-generated if not provided)

        Returns:
            GitCacheEntry if successful, Exception if failed
        """
⋮----
result = await self._pull_updates_async(cache_path)
⋮----
result = await self._copy_local_directory_async(
⋮----
result = await self._clone_repository_async(source, cache_path)
⋮----
def get_cached_repository(self, name: str) -> Union[Path, Exception]
⋮----
"""
        Get the path to a cached repository.

        Args:
            name: Cache entry name

        Returns:
            Path to cached repository or Exception if not found
        """
entry = self.config.get_entry(name)
⋮----
# Update last accessed time
⋮----
# Check if cache is stale
⋮----
def list_cache_entries(self) -> List[GitCacheEntry]
⋮----
"""
        List all cache entries.

        Returns:
            List of cache entries
        """
entries = self.config.list_entries()
⋮----
# Update status for each entry
⋮----
def remove_cache_entry(self, name: str) -> Union[bool, Exception]
⋮----
"""
        Remove a cache entry and its files.

        Args:
            name: Cache entry name

        Returns:
            True if successful, Exception if failed
        """
⋮----
# Remove cache directory
⋮----
# Remove entry from config
⋮----
def refresh_cache_entry(self, name: str) -> Union[GitCacheEntry, Exception]
⋮----
"""
        Refresh a cache entry by re-caching the source.

        Args:
            name: Cache entry name

        Returns:
            Updated GitCacheEntry or Exception if failed
        """
⋮----
# Re-cache the source
⋮----
"""
        Refresh a cache entry by re-caching the source asynchronously.

        Args:
            name: Cache entry name

        Returns:
            Updated GitCacheEntry or Exception if failed
        """
⋮----
def clear_cache(self) -> Union[bool, Exception]
⋮----
"""
        Clear all cache entries and files.

        Returns:
            True if successful, Exception if failed
        """
⋮----
def get_cache_stats(self) -> Dict[str, Any]
⋮----
"""
        Get cache statistics.

        Returns:
            Dictionary with cache statistics
        """
⋮----
total_entries = len(entries)
git_entries = sum(1 for e in entries if e.cache_type == CacheType.GIT)
local_entries = sum(1 for e in entries if e.cache_type == CacheType.LOCAL)
⋮----
fresh_entries = sum(1 for e in entries if e.status == CacheStatus.FRESH)
stale_entries = sum(1 for e in entries if e.status == CacheStatus.STALE)
missing_entries = sum(1 for e in entries if e.status == CacheStatus.MISSING)
error_entries = sum(1 for e in entries if e.status == CacheStatus.ERROR)
⋮----
total_size_bytes = self.config.get_cache_size_bytes()
total_size_gb = self.config.get_cache_size_gb()
⋮----
def _get_repository_info(self, repo_path: Path) -> Dict[str, Any]
⋮----
"""
        Get repository information including commit hash and branch.

        Args:
            repo_path: Path to the git repository

        Returns:
            Dictionary with repository information
        """
⋮----
repo = git.Repo(repo_path)
head = repo.head
⋮----
info = {
⋮----
# Try to get branch name if detached
⋮----
# Get the branch that HEAD was pointing to
⋮----
"""
        Get remote repository information.

        Args:
            repo_path: Path to the git repository
            remote_name: Name of the remote (default: origin)

        Returns:
            Dictionary with remote information
        """
⋮----
remote = repo.remotes[remote_name]
⋮----
# Fetch latest info from remote
⋮----
# Get default branch
default_branch = None
⋮----
# Try to get default branch from remote
default_branch = (
⋮----
# Fallback to common default branches
⋮----
default_branch = branch
⋮----
remote_ref = remote.refs[default_branch]
⋮----
def _check_repository_differences(self, repo_path: Path) -> Dict[str, Any]
⋮----
"""
        Check for differences between local and remote repositories.

        Args:
            repo_path: Path to the git repository

        Returns:
            Dictionary with difference information
        """
⋮----
# Get local info
local_info = self._get_repository_info(repo_path)
⋮----
# Get remote info
remote_info = self._get_remote_info(repo_path)
⋮----
# Check if there are differences
has_changes = False
changes_summary = ""
⋮----
has_changes = True
⋮----
# Get commit difference summary
⋮----
# Get commits that are in remote but not in local
local_commit = repo.commit(local_info["commit_hash"])
remote_commit = repo.commit(remote_info["commit_hash"])
⋮----
# Get commits ahead and behind
ahead_commits = list(
behind_commits = list(
⋮----
changes_summary = f"Remote is {len(ahead_commits)} commits ahead, local is {len(behind_commits)} commits ahead"
⋮----
changes_summary = f"Commit hashes differ: local={local_info['commit_hash'][:8]}, remote={remote_info['commit_hash'][:8]}"
⋮----
def get_cache_entry_details(self, name: str) -> Union[Dict[str, Any], Exception]
⋮----
"""
        Get detailed information about a cache entry including git differences.

        Args:
            name: Cache entry name

        Returns:
            Dictionary with detailed entry information or Exception if not found
        """
⋮----
details = {
⋮----
# Add git-specific information for git repositories
⋮----
# Check for fresh differences if last check was more than 5 minutes ago
⋮----
diff_info = self._check_repository_differences(entry.cache_path)
````

## File: src/metagit/core/gitcache/README.md
````markdown
# Git Cache Management System

The Git Cache Management System provides a comprehensive solution for caching git repositories and local directories with support for both synchronous and asynchronous operations.

## Features

- **Dual Operation Modes**: Support for both synchronous and asynchronous operations
- **Multiple Source Types**: Cache both git repositories and local directories
- **Provider Integration**: Use existing git provider configurations for authentication
- **Smart Caching**: Check for existing cache and pull updates for git repositories
- **Local Directory Support**: Full directory copying for local sources
- **Cache Management**: List, remove, and refresh cache entries
- **Timeout Management**: Configurable cache timeout with automatic stale detection
- **Size Management**: Track and limit cache size
- **Error Handling**: Comprehensive error handling with detailed status tracking

## Architecture

### Core Components

1. **GitCacheConfig**: Central configuration management using Pydantic models
2. **GitCacheEntry**: Individual cache entry representation
3. **GitCacheManager**: Main manager class handling all cache operations

### File Structure

```
metagit/core/gitcache/
├── __init__.py          # Module exports
├── config.py           # Configuration models
├── manager.py          # Main cache manager
└── README.md           # This file
```

## Usage

### Basic Setup

```python
from metagit.core.gitcache import GitCacheConfig, GitCacheManager
from pathlib import Path

# Create configuration
config = GitCacheConfig(
    cache_root=Path("./.metagit/.cache"),
    default_timeout_minutes=60,
    max_cache_size_gb=10.0
)

# Create manager
manager = GitCacheManager(config)
```

### Caching Git Repositories

```python
# Cache a git repository
entry = manager.cache_repository("https://github.com/octocat/Hello-World.git")

if isinstance(entry, Exception):
    print(f"Error: {entry}")
else:
    print(f"Cached: {entry.name}")
    print(f"Path: {entry.cache_path}")
    print(f"Status: {entry.status}")
```

### Caching Local Directories

```python
# Cache a local directory
entry = manager.cache_repository("/path/to/local/project", name="my-project")

if isinstance(entry, Exception):
    print(f"Error: {entry}")
else:
    print(f"Cached: {entry.name}")
    print(f"Type: {entry.cache_type}")
```

### Asynchronous Operations

```python
import asyncio

async def cache_multiple_repos():
    # Cache multiple repositories concurrently
    tasks = [
        manager.cache_repository_async("https://github.com/user/repo1.git"),
        manager.cache_repository_async("https://github.com/user/repo2.git"),
        manager.cache_repository_async("/path/to/local/project")
    ]
    
    results = await asyncio.gather(*tasks, return_exceptions=True)
    
    for result in results:
        if isinstance(result, Exception):
            print(f"Error: {result}")
        else:
            print(f"Successfully cached: {result.name}")

# Run async function
asyncio.run(cache_multiple_repos())
```

### Cache Management

```python
# List all cache entries
entries = manager.list_cache_entries()
for entry in entries:
    print(f"{entry.name}: {entry.cache_type} ({entry.status})")

# Get cache statistics
stats = manager.get_cache_stats()
print(f"Total entries: {stats['total_entries']}")
print(f"Total size: {stats['total_size_gb']:.2f} GB")

# Get cached repository path
cache_path = manager.get_cached_repository("repo-name")
if isinstance(cache_path, Exception):
    print(f"Error: {cache_path}")
else:
    print(f"Cache path: {cache_path}")

# Refresh cache entry
entry = manager.refresh_cache_entry("repo-name")

# Remove cache entry
result = manager.remove_cache_entry("repo-name")

# Clear all cache
result = manager.clear_cache()
```

### Provider Integration

```python
from metagit.core.providers.github import GitHubProvider

# Register a git provider
provider = GitHubProvider(api_token="your-token")
manager.register_provider(provider)

# The manager will use the provider for authentication when cloning
entry = manager.cache_repository("https://github.com/private/repo.git")
```

## Configuration

### GitCacheConfig Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `cache_root` | Path | `./.metagit/.cache` | Root directory for cache storage |
| `default_timeout_minutes` | int | 60 | Default cache timeout in minutes |
| `max_cache_size_gb` | float | 10.0 | Maximum cache size in GB |
| `enable_async` | bool | True | Enable async operations |
| `git_config` | Dict | {} | Git configuration options |
| `provider_config` | Dict | None | Provider-specific configuration |

### Git Configuration

```python
config = GitCacheConfig(
    git_config={
        "user.name": "Your Name",
        "user.email": "your.email@example.com",
        "http.extraheader": "AUTHORIZATION: basic <base64-encoded-token>"
    }
)
```

## Cache Entry Status

Cache entries have the following statuses:

- **FRESH**: Cache is up-to-date and within timeout
- **STALE**: Cache exists but is older than timeout
- **MISSING**: Cache entry exists but files are missing
- **ERROR**: Cache operation failed

## Error Handling

All operations return either the expected result or an Exception. This allows for comprehensive error handling:

```python
entry = manager.cache_repository("https://github.com/user/repo.git")

if isinstance(entry, Exception):
    # Handle error
    print(f"Cache failed: {entry}")
    # Check if it's a specific type of error
    if "Authentication failed" in str(entry):
        print("Please check your credentials")
    elif "Repository not found" in str(entry):
        print("Repository does not exist or is private")
else:
    # Handle success
    print(f"Successfully cached: {entry.name}")
```

## Best Practices

1. **Use Provider Configuration**: Register git providers for authentication
2. **Handle Errors Gracefully**: Always check return types for exceptions
3. **Monitor Cache Size**: Use `get_cache_stats()` to monitor cache usage
4. **Refresh Stale Cache**: Use `refresh_cache_entry()` for important repositories
5. **Clean Up Regularly**: Remove unused cache entries to save space
6. **Use Async for Multiple Operations**: Use async methods when caching multiple repositories

## Examples

See `examples/gitcache_example.py` for comprehensive usage examples including:

- Synchronous operations
- Asynchronous operations
- Cache management
- Error handling
- Provider integration

## Testing

Run the test suite:

```bash
python -m pytest tests/test_gitcache.py -v
```

The test suite covers:

- Configuration validation
- Cache entry management
- Repository cloning
- Local directory copying
- Async operations
- Error scenarios
- Cache statistics

## Integration

The Git Cache Management System integrates with:

- **Git Providers**: Use existing provider configurations for authentication
- **Project Management**: Cache repositories for analysis
- **CI/CD Pipelines**: Cache dependencies and tools
- **Development Workflows**: Local development with cached repositories
````

## File: src/metagit/core/providers/__init__.py
````python
#!/usr/bin/env python
"""
Provider registry for Git hosting platforms.
"""
⋮----
logger = logging.getLogger(__name__)
⋮----
class ProviderRegistry
⋮----
"""Registry for git provider plugins."""
⋮----
def __init__(self)
⋮----
def register(self, provider: GitProvider) -> None
⋮----
"""Register a git provider plugin."""
⋮----
def unregister(self, provider_name: str) -> None
⋮----
"""Unregister a provider by name."""
⋮----
def clear(self) -> None
⋮----
"""Clear all registered providers."""
⋮----
def get_provider_for_url(self, url: str) -> Optional[GitProvider]
⋮----
"""Get the appropriate provider for a given URL."""
normalized_url = normalize_git_url(url)
⋮----
def get_all_providers(self) -> list[GitProvider]
⋮----
"""Get all registered providers."""
⋮----
def get_provider_by_name(self, name: str) -> Optional[GitProvider]
⋮----
"""Get a provider by name."""
⋮----
def configure_from_app_config(self, app_config) -> None
⋮----
"""
        Configure providers from AppConfig settings.

        Args:
            app_config: AppConfig instance with provider settings
        """
⋮----
# Clear existing providers
⋮----
# Configure GitHub provider
⋮----
github_provider = GitHubProvider(
⋮----
pass  # GitHub provider not available
⋮----
# Configure GitLab provider
⋮----
gitlab_provider = GitLabProvider(
⋮----
pass  # GitLab provider not available
⋮----
def configure_from_environment(self) -> None
⋮----
"""Configure providers from environment variables (legacy method)."""
# GitHub provider
github_token = os.getenv("GITHUB_TOKEN")
⋮----
github_provider = GitHubProvider(api_token=github_token)
⋮----
# GitLab provider
gitlab_token = os.getenv("GITLAB_TOKEN")
⋮----
gitlab_provider = GitLabProvider(api_token=gitlab_token)
⋮----
# Global registry instance
registry = ProviderRegistry()
⋮----
# Export the registry for backward compatibility
__all__ = ["GitProvider", "ProviderRegistry", "registry"]
````

## File: src/metagit/core/providers/base.py
````python
#!/usr/bin/env python
"""
Base provider for Git hosting platforms.
"""
⋮----
logger = logging.getLogger(__name__)
⋮----
class GitProvider(ABC)
⋮----
"""Base class for git provider plugins."""
⋮----
def __init__(self, api_token: Optional[str] = None, base_url: Optional[str] = None)
⋮----
"""
        Initialize the git provider.

        Args:
            api_token: API token for authentication
            base_url: Base URL for the API (for self-hosted instances)
        """
⋮----
@abstractmethod
    def get_name(self) -> str
⋮----
"""Get the provider name."""
⋮----
@abstractmethod
    def can_handle_url(self, url: str) -> bool
⋮----
"""Check if this provider can handle the given repository URL."""
⋮----
@abstractmethod
    def extract_repo_info(self, url: str) -> Dict[str, str]
⋮----
"""
        Extract repository information from URL.

        Returns:
            Dict with keys: owner, repo, api_url
        """
⋮----
"""
        Get repository metrics from the provider.

        Args:
            owner: Repository owner/organization
            repo: Repository name

        Returns:
            Metrics object or Exception
        """
⋮----
"""
        Get additional repository metadata.

        Args:
            owner: Repository owner/organization
            repo: Repository name

        Returns:
            Dict with metadata or Exception
        """
⋮----
def supports_url(self, url: str) -> bool
⋮----
"""Check if this provider supports the given URL."""
normalized_url = normalize_git_url(url)
⋮----
def is_available(self) -> bool
⋮----
"""Check if the provider is available (has API token, etc.)."""
````

## File: src/metagit/core/providers/github.py
````python
#!/usr/bin/env python3
"""
GitHub provider for repository metadata and metrics.
"""
⋮----
logger = logging.getLogger(__name__)
⋮----
class GitHubProvider(GitProvider)
⋮----
"""GitHub provider plugin."""
⋮----
def __init__(self, api_token: Optional[str] = None, base_url: Optional[str] = None)
⋮----
"""
        Initialize GitHub provider.

        Args:
            api_token: GitHub personal access token
            base_url: Base URL for GitHub API (for GitHub Enterprise)
        """
⋮----
def get_name(self) -> str
⋮----
"""Get the provider name."""
⋮----
def can_handle_url(self, url: str) -> bool
⋮----
"""Check if this provider can handle the given repository URL."""
parsed = urlparse(url)
⋮----
def extract_repo_info(self, url: str) -> Dict[str, str]
⋮----
"""Extract owner and repo from GitHub URL."""
normalized_url = normalize_git_url(url)
⋮----
# GitHub URL patterns
patterns = [
⋮----
match = re.match(pattern, normalized_url)
⋮----
"""Get repository metrics from GitHub API."""
⋮----
# Get repository data
repo_url = f"{self.api_base}/repos/{owner}/{repo}"
repo_response = self.session.get(repo_url)
⋮----
repo_data = repo_response.json()
⋮----
# Get issues data
issues_url = f"{self.api_base}/repos/{owner}/{repo}/issues"
issues_params = {"state": "open", "per_page": 1}
issues_response = self.session.get(issues_url, params=issues_params)
⋮----
# Get pull requests data
prs_url = f"{self.api_base}/repos/{owner}/{repo}/pulls"
prs_params = {"state": "open", "per_page": 1}
prs_response = self.session.get(prs_url, params=prs_params)
⋮----
# Get contributors data
contributors_url = f"{self.api_base}/repos/{owner}/{repo}/contributors"
contributors_response = self.session.get(contributors_url)
⋮----
contributors_data = contributors_response.json()
⋮----
# Get recent commits for commit frequency
commits_url = f"{self.api_base}/repos/{owner}/{repo}/commits"
commits_params = {"per_page": 100}
commits_response = self.session.get(commits_url, params=commits_params)
⋮----
commits_data = commits_response.json()
⋮----
# Calculate commit frequency
commit_frequency = self._calculate_commit_frequency(commits_data)
⋮----
# Create pull requests object
pull_requests = PullRequests(
⋮----
merged_last_30d=0,  # Would need additional API call for this
⋮----
# Create metrics object
metrics = Metrics(
⋮----
"""Get additional repository metadata from GitHub API."""
⋮----
response = self.session.get(repo_url)
⋮----
repo_data = response.json()
⋮----
# Get topics
topics_url = f"{self.api_base}/repos/{owner}/{repo}/topics"
topics_response = self.session.get(topics_url)
topics_data = (
⋮----
metadata = {
⋮----
"""Calculate commit frequency from recent commits."""
⋮----
# Get commit dates
commit_dates = []
⋮----
date_str = commit["commit"]["author"]["date"]
⋮----
# Calculate frequency based on recent activity
⋮----
recent_commits = 0
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
⋮----
for date_str in commit_dates[:10]:  # Check last 10 commits
⋮----
commit_date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
````

## File: src/metagit/core/providers/gitlab.py
````python
#!/usr/bin/env python3
"""
GitLab provider for repository metadata and metrics.
"""
⋮----
logger = logging.getLogger(__name__)
⋮----
class GitLabProvider(GitProvider)
⋮----
"""GitLab provider plugin."""
⋮----
def __init__(self, api_token: Optional[str] = None, base_url: Optional[str] = None)
⋮----
"""
        Initialize GitLab provider.

        Args:
            api_token: GitLab personal access token
            base_url: Base URL for GitLab API (for self-hosted instances)
        """
⋮----
def get_name(self) -> str
⋮----
"""Get the provider name."""
⋮----
def can_handle_url(self, url: str) -> bool
⋮----
"""Check if this provider can handle the given repository URL."""
parsed = urlparse(url)
⋮----
def extract_repo_info(self, url: str) -> Dict[str, str]
⋮----
"""Extract owner and repo from GitLab URL."""
normalized_url = normalize_git_url(url)
⋮----
# GitLab URL patterns
patterns = [
⋮----
match = re.match(pattern, normalized_url)
⋮----
"""Get repository metrics from GitLab API."""
⋮----
project_path = f"{owner}/{repo}"
project_id = project_path.replace("/", "%2F")
⋮----
# Get project data
project_url = f"{self.api_base}/projects/{project_id}"
project_response = self.session.get(project_url)
⋮----
project_data = project_response.json()
⋮----
# Get issues data
issues_url = f"{self.api_base}/projects/{project_id}/issues"
issues_params = {"state": "opened", "per_page": 1}
issues_response = self.session.get(issues_url, params=issues_params)
⋮----
# Get merge requests data
mr_url = f"{self.api_base}/projects/{project_id}/merge_requests"
mr_params = {"state": "opened", "per_page": 1}
mr_response = self.session.get(mr_url, params=mr_params)
⋮----
# Get contributors data (approximation using project members)
members_url = f"{self.api_base}/projects/{project_id}/members"
members_response = self.session.get(members_url)
members_data = (
⋮----
# Get recent commits for commit frequency
commits_url = f"{self.api_base}/projects/{project_id}/repository/commits"
commits_params = {"per_page": 100}
commits_response = self.session.get(commits_url, params=commits_params)
⋮----
commits_data = commits_response.json()
⋮----
# Calculate commit frequency
commit_frequency = self._calculate_commit_frequency(commits_data)
⋮----
# Create pull requests object (GitLab calls them merge requests)
pull_requests = PullRequests(
⋮----
merged_last_30d=0,  # Would need additional API call for this
⋮----
# Create metrics object
metrics = Metrics(
⋮----
"""Get additional repository metadata from GitLab API."""
⋮----
response = self.session.get(project_url)
⋮----
project_data = response.json()
⋮----
metadata = {
⋮----
"""Calculate commit frequency from recent commits."""
⋮----
# Get commit dates
commit_dates = []
⋮----
# Calculate frequency based on recent activity
⋮----
recent_commits = 0
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
⋮----
for date_str in commit_dates[:10]:  # Check last 10 commits
⋮----
commit_date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
````

## File: src/metagit/core/record/__init__.py
````python
#!/usr/bin/env python
"""
Record management module for metagit.

This module provides classes for managing metagit records with support for
multiple storage backends (OpenSearch and local files) and the ability to
create records from existing metagit configuration data.
"""
⋮----
__all__ = [
````

## File: src/metagit/core/record/manager.py
````python
#!/usr/bin/env python
"""
Class for managing metagit records.

This package provides a class for managing metagit records with support for
multiple storage backends (OpenSearch and local files) and the ability to
create records from existing metagit configuration data.
"""
⋮----
class DateTimeEncoder(json.JSONEncoder)
⋮----
"""Custom JSON encoder that handles datetime objects."""
⋮----
def default(self, obj)
⋮----
class RecordStorageBackend(ABC)
⋮----
"""Abstract base class for record storage backends."""
⋮----
@abstractmethod
    async def store_record(self, record: MetagitRecord) -> Union[str, Exception]
⋮----
"""Store a record and return the record ID."""
⋮----
@abstractmethod
    async def get_record(self, record_id: str) -> Union[MetagitRecord, Exception]
⋮----
"""Retrieve a record by ID."""
⋮----
"""Update an existing record."""
⋮----
@abstractmethod
    async def delete_record(self, record_id: str) -> Union[bool, Exception]
⋮----
"""Delete a record by ID."""
⋮----
"""Search records with optional filters."""
⋮----
"""List all records with pagination."""
⋮----
class LocalFileStorageBackend(RecordStorageBackend)
⋮----
"""Local file-based storage backend for records."""
⋮----
def __init__(self, storage_dir: Path)
⋮----
"""
        Initialize local file storage backend.

        Args:
            storage_dir: Directory to store record files
        """
⋮----
def _ensure_index_exists(self) -> None
⋮----
"""Ensure the index file exists."""
⋮----
def _load_index(self) -> Dict[str, Any]
⋮----
"""Load the index file."""
⋮----
def _save_index(self, index_data: Dict[str, Any]) -> None
⋮----
"""Save the index file."""
⋮----
def _get_next_id(self) -> str
⋮----
"""Get the next available record ID."""
index_data = self._load_index()
next_id = index_data["next_id"]
⋮----
async def store_record(self, record: MetagitRecord) -> Union[str, Exception]
⋮----
"""Store a record to local file."""
⋮----
record_id = self._get_next_id()
record_file = self.storage_dir / f"{record_id}.json"
⋮----
# Add metadata
record_data = record.model_dump(exclude_none=True, exclude_defaults=True)
⋮----
# Update index
⋮----
async def get_record(self, record_id: str) -> Union[MetagitRecord, Exception]
⋮----
record_data = json.load(f)
⋮----
# Remove metadata fields before creating record
⋮----
# Load existing data to preserve metadata
⋮----
existing_data = json.load(f)
⋮----
# Update record data
⋮----
async def delete_record(self, record_id: str) -> Union[bool, Exception]
⋮----
all_records = await self.list_records(
⋮----
)  # Get all for search
⋮----
# Simple text search
filtered_records = []
⋮----
# Apply additional filters
⋮----
filtered_records = [
⋮----
# Pagination
start_idx = (page - 1) * size
end_idx = start_idx + size
paginated_records = filtered_records[start_idx:end_idx]
⋮----
record_ids = list(index_data["records"].keys())
⋮----
paginated_ids = record_ids[start_idx:end_idx]
⋮----
records = []
⋮----
record_result = await self.get_record(record_id)
⋮----
continue  # Skip failed records
⋮----
class OpenSearchStorageBackend(RecordStorageBackend)
⋮----
"""OpenSearch-based storage backend for records."""
⋮----
def __init__(self, opensearch_service)
⋮----
"""
        Initialize OpenSearch storage backend.

        Args:
            opensearch_service: Configured OpenSearchService instance
        """
⋮----
"""Store a record to OpenSearch."""
⋮----
search_result = await self.opensearch_service.search_records(
⋮----
class MetagitRecordManager
⋮----
"""
    Manager class for handling metagit records.

    This class provides methods for loading, validating, and creating
    metagit records with proper error handling and validation.
    Supports multiple storage backends (OpenSearch and local files).
    """
⋮----
"""
        Initialize the MetagitRecordManager.

        Args:
            storage_backend: Storage backend for records (OpenSearch or local file)
            metagit_config_manager: Optional MetagitConfigManager instance
            logger: Optional logger instance
        """
⋮----
"""
        Create a MetagitRecord from existing MetagitConfig data.

        Args:
            config: MetagitConfig to convert. If None, uses config from config_manager.
            detection_source: Source of the detection (e.g., 'github', 'gitlab', 'local')
            detection_version: Version of the detection system used
            additional_data: Additional data to include in the record

        Returns:
            MetagitRecord: The created record
        """
⋮----
# Get config from parameter or config manager
⋮----
config_result = self.config_manager.load_config()
⋮----
config = config_result
⋮----
# Get current git information
git_info = self._get_git_info()
⋮----
# Create record data
record_data = config.model_dump(exclude_none=True, exclude_defaults=True)
⋮----
# Add detection-specific fields
⋮----
# Add additional data if provided
⋮----
# Create and validate record
record = MetagitRecord(**record_data)
⋮----
def _get_git_info(self) -> Dict[str, Optional[str]]
⋮----
"""Get current git repository information."""
⋮----
repo = Repo(Path.cwd())
⋮----
"""
        Store a record using the configured storage backend.

        Args:
            record: MetagitRecord to store

        Returns:
            str: Record ID if successful, Exception otherwise
        """
⋮----
"""
        Retrieve a record by ID.

        Args:
            record_id: ID of the record to retrieve

        Returns:
            MetagitRecord: The retrieved record, or Exception if failed
        """
⋮----
"""
        Update an existing record.

        Args:
            record_id: ID of the record to update
            record: Updated MetagitRecord

        Returns:
            bool: True if successful, Exception otherwise
        """
⋮----
"""
        Delete a record by ID.

        Args:
            record_id: ID of the record to delete

        Returns:
            bool: True if successful, Exception otherwise
        """
⋮----
"""
        Search records with optional filters.

        Args:
            query: Search query string
            filters: Optional filters to apply
            page: Page number for pagination
            size: Number of records per page

        Returns:
            Dict: Search results with pagination info
        """
⋮----
"""
        List all records with pagination.

        Args:
            page: Page number for pagination
            size: Number of records per page

        Returns:
            List[MetagitRecord]: List of records
        """
⋮----
"""
        Save a record to a local YAML file.

        Args:
            record: MetagitRecord to save
            file_path: Path where to save the record

        Returns:
            None if successful, Exception otherwise
        """
⋮----
def load_record_from_file(self, file_path: Path) -> Union[MetagitRecord, Exception]
⋮----
"""
        Load a record from a local YAML file.

        Args:
            file_path: Path to the record file

        Returns:
            MetagitRecord: The loaded record, or Exception if failed
        """
⋮----
yaml_data = yaml.safe_load(f)
````

## File: src/metagit/core/record/models.py
````python
#!/usr/bin/env python
"""
Pydantic models for metagit records.
"""
⋮----
# Import models from detect module for forward references
⋮----
# Forward references for type hints
LanguageDetection = "LanguageDetection"
ProjectTypeDetection = "ProjectTypeDetection"
GitBranchAnalysis = "GitBranchAnalysis"
CIConfigAnalysis = "CIConfigAnalysis"
DirectoryDetails = "DirectoryDetails"
DirectorySummary = "DirectorySummary"
⋮----
T = TypeVar("T", bound=BaseModel)
⋮----
"""
    Automatically detect common fields between two Pydantic models.

    This utility function uses Pydantic's field introspection to find
    fields that exist in both models, making conversion more maintainable.

    Args:
        source_model: The source model class
        target_model: The target model class

    Returns:
        Set of field names that exist in both models
    """
source_fields = set(source_model.model_fields.keys())
target_fields = set(target_model.model_fields.keys())
⋮----
"""
    Convert data between Pydantic models with automatic field mapping.

    This function provides a generic way to convert data between any two
    Pydantic models by automatically detecting compatible fields.

    Args:
        source_data: Dictionary of source model data
        target_model: Target model class
        field_mapping: Optional mapping of source field names to target field names

    Returns:
        Instance of target model

    Raises:
        ValueError: If conversion fails
    """
⋮----
# Apply field mapping if provided
⋮----
mapped_data = {}
⋮----
source_data = mapped_data
⋮----
# Filter to only include fields that exist in target model
⋮----
filtered_data = {k: v for k, v in source_data.items() if k in target_fields}
⋮----
# Use model_validate for fast, validated conversion
⋮----
class MetagitRecord(MetagitConfig)
⋮----
"""
    Extended model for metagit records that includes detection-specific data suitable for OpenSearch.

    This class inherits from MetagitConfig and adds detection-specific attributes.
    Now includes all RepositoryAnalysis attributes for comprehensive repository information.
    """
⋮----
# Detection-specific attributes
branch: Optional[str] = Field(None, description="Current branch")
checksum: Optional[str] = Field(None, description="Branch checksum")
last_updated: Optional[datetime] = Field(None, description="Last updated timestamp")
branches: Optional[List[Branch]] = Field(None, description="Release branches")
metrics: Optional[Metrics] = Field(None, description="Repository metrics")
metadata: Optional[RepoMetadata] = Field(None, description="Repository metadata")
⋮----
# Language and project type detection
language: Optional[Language] = Field(
language_version: Optional[str] = Field(
domain: Optional[ProjectDomain] = Field(None, description="Project domain")
⋮----
# Additional detection fields
detection_timestamp: Optional[datetime] = Field(
detection_source: Optional[str] = Field(
detection_version: Optional[str] = Field(
⋮----
# RepositoryAnalysis attributes merged from repository.py
# Repository path and URL information
path: Optional[str] = Field(None, description="Repository path")
url: Optional[str] = Field(None, description="Repository URL")
is_git_repo: bool = Field(
is_cloned: bool = Field(
temp_dir: Optional[str] = Field(None, description="Temporary directory if cloned")
⋮----
# Detection results from RepositoryAnalysis
language_detection: Optional[LanguageDetection] = Field(
project_type_detection: Optional[ProjectTypeDetection] = Field(
⋮----
# Analysis results from RepositoryAnalysis
branch_analysis: Optional[GitBranchAnalysis] = Field(
ci_config_analysis: Optional[CIConfigAnalysis] = Field(
directory_summary: Optional[DirectorySummary] = Field(
directory_details: Optional[DirectoryDetails] = Field(
⋮----
# Repository metadata from RepositoryAnalysis
license_info: Optional[License] = Field(None, description="License information")
maintainers: List[Maintainer] = Field(
existing_workspace: Optional[Workspace] = Field(
⋮----
# Additional metadata from RepositoryAnalysis
artifacts: Optional[List[Artifact]] = Field(
secrets_management: Optional[List[str]] = Field(
secrets: Optional[List[Secret]] = Field(None, description="Repository secrets")
documentation: Optional[List[str]] = Field(
alerts: Optional[List[AlertingChannel]] = Field(
dashboards: Optional[List[Dashboard]] = Field(None, description="Dashboards")
environments: Optional[List[Environment]] = Field(None, description="Environments")
⋮----
# File analysis from RepositoryAnalysis
detected_files: Dict[str, List[str]] = Field(
has_docker: bool = Field(
has_tests: bool = Field(
has_docs: bool = Field(
has_iac: bool = Field(
⋮----
class Config
⋮----
"""Pydantic configuration."""
⋮----
use_enum_values = True
validate_assignment = True
extra = "forbid"
⋮----
@classmethod
    def from_yaml(cls, yaml_str: str) -> "MetagitRecord"
⋮----
"""Create a MetagitRecord from a YAML string."""
⋮----
@classmethod
    def from_json(cls, json_str: str) -> "MetagitRecord"
⋮----
"""Create a MetagitRecord from a JSON string."""
⋮----
@classmethod
    def from_dict(cls, data: dict) -> "MetagitRecord"
⋮----
"""Create a MetagitRecord from a dictionary."""
⋮----
def to_yaml(self) -> str
⋮----
"""Convert a MetagitRecord to a YAML string."""
⋮----
@classmethod
    def to_json(self) -> str
⋮----
"""Convert a MetagitRecord to a JSON string."""
⋮----
def to_metagit_config(self, exclude_detection_fields: bool = True) -> MetagitConfig
⋮----
"""
        Fast conversion from MetagitRecord to MetagitConfig using automatic field detection.

        This method efficiently converts a MetagitRecord to a MetagitConfig by:
        1. Using Pydantic's field introspection to automatically detect compatible fields
        2. Leveraging model_dump() with field filtering for optimal performance
        3. Using model_validate() for fast, validated conversion
        4. Automatically handling field differences between models

        Args:
            exclude_detection_fields: Whether to exclude detection-specific fields
                                     (currently always True as MetagitConfig doesn't support them)

        Returns:
            MetagitConfig: A new MetagitConfig instance with the shared fields

        Example:
            record = MetagitRecord(name="my-project", description="A project")
            config = record.to_metagit_config()

        Performance Notes:
            - Uses Pydantic's field introspection for automatic field detection
            - Leverages Pydantic's C-optimized validation
            - Minimal memory allocation through direct field copying
            - No deep copying of nested objects (uses references)
        """
# Get the model data, excluding None values and defaults for performance
model_data = self.model_dump(
⋮----
# Use the generic conversion utility for automatic field mapping
⋮----
def to_metagit_config_advanced(self, **kwargs) -> MetagitConfig
⋮----
"""
        Advanced conversion method with automatic field mapping and validation.

        This method provides more sophisticated conversion capabilities:
        1. Automatic field compatibility detection
        2. Type conversion and validation
        3. Support for custom field mappings
        4. Better error handling and reporting

        Args:
            **kwargs: Additional options for conversion behavior

        Returns:
            MetagitConfig: A new MetagitConfig instance

        Example:
            record = MetagitRecord(name="my-project", description="A project")
            config = record.to_metagit_config_advanced()
        """
⋮----
# Use the standard method for now, but this could be extended
# with more sophisticated field mapping logic
⋮----
# Could add more sophisticated error handling here
⋮----
"""
        Fast conversion from MetagitConfig to MetagitRecord using latest Pydantic best practices.

        This method efficiently converts a MetagitConfig to a MetagitRecord by:
        1. Using model_dump() for optimal serialization performance
        2. Adding detection-specific fields with minimal overhead
        3. Leveraging Pydantic's built-in validation
        4. Supporting additional detection data injection

        Args:
            config: MetagitConfig instance to convert
            detection_source: Source of the detection (e.g., 'github', 'gitlab', 'local')
            detection_version: Version of the detection system used
            additional_detection_data: Additional detection-specific data to include

        Returns:
            MetagitRecord: A new MetagitRecord instance

        Example:
            config = MetagitConfig(name="my-project", description="A project")
            record = MetagitRecord.from_metagit_config(
                config,
                detection_source="github",
                detection_version="2.0.0"
            )

        Performance Notes:
            - Uses model_dump() with exclude_none=True for optimal serialization
            - Leverages Pydantic's C-optimized validation
            - Minimal memory allocation through direct field copying
            - No deep copying of nested objects (uses references)
        """
# Get the base config data
record_data = config.model_dump(exclude_none=True, exclude_defaults=True)
⋮----
# Add detection-specific fields
⋮----
# Add additional detection data if provided
⋮----
"""
        Advanced conversion from MetagitConfig to MetagitRecord with automatic field handling.

        This method provides more sophisticated conversion capabilities:
        1. Automatic field mapping and validation
        2. Support for complex detection data structures
        3. Better error handling and reporting
        4. Extensible for future enhancements

        Args:
            config: MetagitConfig instance to convert
            **detection_kwargs: Detection-specific parameters and data

        Returns:
            MetagitRecord: A new MetagitRecord instance

        Example:
            config = MetagitConfig(name="my-project", description="A project")
            record = MetagitRecord.from_metagit_config_advanced(
                config,
                detection_source="github",
                detection_version="2.0.0",
                branch="main",
                checksum="abc123"
            )
        """
# Extract standard detection parameters
detection_source = detection_kwargs.pop("detection_source", "local")
detection_version = detection_kwargs.pop("detection_version", "1.0.0")
⋮----
def get_detection_summary(self) -> dict
⋮----
"""
        Get a summary of detection-specific data for quick analysis.

        Returns:
            dict: Summary of detection data including source, version, and key metrics
        """
summary = {
⋮----
# Add metrics summary if available
⋮----
# Add metadata summary if available
⋮----
@classmethod
    def get_field_differences(cls) -> dict
⋮----
"""
        Get the field differences between MetagitRecord and MetagitConfig.

        This method helps understand what fields are unique to each model,
        making it easier to understand the conversion behavior.

        Returns:
            dict: Field differences between the models
        """
record_fields = set(cls.model_fields.keys())
config_fields = set(MetagitConfig.model_fields.keys())
⋮----
@classmethod
    def get_compatible_fields(cls) -> set[str]
⋮----
"""
        Get the fields that are compatible between MetagitRecord and MetagitConfig.

        Returns:
            set: Field names that exist in both models
        """
⋮----
# from metagit.core.detect.models import (
#     CIConfigAnalysis,
#     GitBranchAnalysis,
#     LanguageDetection,
#     ProjectTypeDetection,
# )
# from metagit.core.utils.files import DirectoryDetails, DirectorySummary
⋮----
# MetagitRecord.model_rebuild()
````

## File: src/metagit/core/utils/click.py
````python
"""
Click utility functions
"""
⋮----
def call_click_command(cmd, *args, **kwargs)
⋮----
"""Wrapper to call a click command

    :param cmd: click cli command function to call
    :param args: arguments to pass to the function
    :param kwargs: keywrod arguments to pass to the function
    :return: None
    """
⋮----
# Get positional arguments from args
arg_values = {c.name: a for a, c in zip(args, cmd.params, strict=False)}
args_needed = {c.name: c for c in cmd.params if c.name not in arg_values}
⋮----
# build and check opts list from kwargs
opts = {a.name: a for a in cmd.params if isinstance(a, click.Option)}
⋮----
# check positional arguments list
⋮----
# build parameter lists
opts_list = sum([[o.opts[0], str(arg_values[n])] for n, o in opts.items()], [])
args_list = [str(v) for n, v in arg_values.items() if n not in opts]
⋮----
# call the command
⋮----
def call_click_command_with_ctx(cmd, ctx, *args, **kwargs)
⋮----
"""Wrapper to call a click command with a Context object

    :param cmd: click cli command function to call
    :param ctx: click context
    :param args: arguments to pass to the function
    :param kwargs: keyword arguments to pass to the function
    :return: None
    """
⋮----
# monkey patch make_context
def make_context(*some_args, **some_kwargs):  # noqa: ARG001 ARG002
⋮----
child_ctx = click.Context(cmd, parent=ctx)
⋮----
prev_make_context = cmd.make_context
⋮----
# restore make_context
````

## File: src/metagit/core/utils/common.py
````python
"""
common functions
"""
⋮----
import yaml  # Use standard PyYAML for dumping
⋮----
__all__ = [
⋮----
def create_vscode_workspace(_: str, repo_paths: List[str]) -> Union[str, Exception]
⋮----
"""
    Create VS Code workspace file content.

    Args:
        project_name: The name of the project
        repo_paths: List of repository paths to include in the workspace

    Returns:
        JSON string representing the VS Code workspace file content
    """
⋮----
workspace_data = {
⋮----
# Add each repository as a folder in the workspace
⋮----
# Convert to JSON string
⋮----
def open_editor(editor: str, path: str) -> Union[None, Exception]
⋮----
"""
    Open a path in the specified editor in an OS-agnostic way.

    Args:
        editor: The editor command to use (e.g., 'code', 'vim', 'nano')
        path: The path to open in the editor

    Returns:
        None on success, Exception on failure
    """
⋮----
# Ensure the path exists
⋮----
# Use subprocess to open the editor
# This works cross-platform as subprocess handles the differences
result = subprocess.run([editor, path], capture_output=True, text=True)
⋮----
new_key = parent_key + sep + k if parent_key else k
⋮----
def regex_replace(s: str, find: str, replace: str) -> Union[str, Exception]
⋮----
"""A non-optimal implementation of a regex filter for use in our Jinja2 template processing"""
⋮----
def env_override(value: str, key: str) -> Union[str, None, Exception]
⋮----
"""Can be used to pull env vars into templates"""
⋮----
def to_yaml(value: Any) -> Union[str, Any, Exception]
⋮----
"""convert dicts to yaml"""
⋮----
"""Pretty up output in Jinja template"""
⋮----
pretty_result = pretty(value, indent + 2, result + "\n")
⋮----
result = pretty_result
⋮----
def merge_dicts(a: Dict, b: Dict, path: List = None) -> Union[Dict, Exception]
⋮----
""" "merges b into a"""
⋮----
path = []
⋮----
merge_result = merge_dicts(a[key], b[key], path + [str(key)])
⋮----
pass  # same leaf value
⋮----
def parse_checksum_file(file_path: str) -> Union[Dict[str, str], Exception]
⋮----
checksums = {}
⋮----
differences = []
⋮----
base_filename = filepath.split("/")[-1]
⋮----
checksum2 = checksums2[filepath]
⋮----
def normalize_git_url(url: Optional[str]) -> Optional[str]
⋮----
"""
    Normalize a git URL by removing trailing forward slashes.

    Args:
        url: Git URL to normalize

    Returns:
        Normalized URL without trailing forward slash, or None if input is None
    """
⋮----
# Convert to string if it's an HttpUrl or other object
url_str = str(url).strip()
⋮----
# Remove trailing forward slash
⋮----
url_str = url_str.rstrip("/")
⋮----
def get_project_root() -> Path
⋮----
"""Get the project root directory."""
⋮----
def ensure_directory(path: Union[str, Path]) -> Path
⋮----
"""Ensure a directory exists, creating it if necessary."""
path_obj = Path(path)
⋮----
def safe_get(dictionary: Dict[str, Any], key: str, default: Any = None) -> Any
⋮----
"""Safely get a value from a dictionary."""
⋮----
def flatten_list(nested_list: List[Any]) -> List[Any]
⋮----
"""Flatten a nested list."""
flattened = []
⋮----
def is_git_repository(path: Union[str, Path]) -> bool
⋮----
"""Check if a path is a git repository."""
⋮----
"""Get the relative path from base_path to target_path."""
base = Path(base_path).resolve()
target = Path(target_path).resolve()
⋮----
def sanitize_filename(filename: str) -> str
⋮----
"""Sanitize a filename by removing or replacing invalid characters."""
# Remove or replace invalid characters
sanitized = re.sub(r'[<>:"/\\|?*]', "_", filename)
# Remove leading/trailing spaces and dots
sanitized = sanitized.strip(" .")
# Ensure it's not empty
⋮----
sanitized = "unnamed"
⋮----
def format_bytes(bytes_value: int) -> str
⋮----
"""Format bytes into a human-readable string."""
⋮----
def parse_env_list(env_value: Optional[str], separator: str = ",") -> List[str]
⋮----
"""Parse a comma-separated environment variable into a list."""
⋮----
def filter_none_values(data: Dict[str, Any]) -> Dict[str, Any]
⋮----
"""Remove None values from a dictionary."""
````

## File: src/metagit/core/workspace/__init__.py
````python

````

## File: src/metagit/core/workspace/manager.py
````python
#!/usr/bin/env python
"""
Class for managing workspaces.

This package provides a class for managing workspaces.
"""
⋮----
def get_workspace_path(config: AppConfig) -> Union[str, Exception]
⋮----
"""
    Get the workspace path from the config.
    """
⋮----
def get_synced_projects(config: AppConfig) -> Union[List[WorkspaceProject], Exception]
⋮----
"""
    Get the synced projects from the config.
    """
⋮----
class WorkspaceManager
⋮----
"""
    Manager class for handling workspaces.

    This class provides methods for loading, validating, and creating
    workspaces with proper error handling and validation.
    """
⋮----
def __init__(self, workspace_path: str) -> None
⋮----
"""
        Initialize the MetagitWorkspaceManager.

        Args:
            workspace_path: Path to the workspace.
        """
````

## File: src/metagit/data/prompts/gemini_prompt_filled.md
````markdown
You are an AI agent specializing in project analysis and configuration. Your task is to generate a new YAML configuration file, `./.metagit.new.yaml`, by analyzing the project located at `./`.

The structure, fields, and data for this new YAML file must be determined by following the JSON schema located at `./.metagit/metagit_config.schema.json`.

**Your process must be as follows:**

1.  **Read and Understand the Schema:** First, read the JSON schema file at `./.metagit/metagit_config.schema.json`.
2.  **Read Existing YAML manifest:** If an existing YAML file exists at `./.metagit.yml` it should be used as the basis for your efforts. The `workspace` attribute in this file **must** be preserved in your generated output file later on.
3.  **Read Existing Project File Data:** Read in the contents of `./.metagit/local_summary.yml` and use it to determine which subfolders to target in your analysis efforts. You **must** only traverse and analyse folders in the paths defined in this folder.
3.  **Follow Schema Descriptions:** For each property in the schema, you **must** use its corresponding `description` field as additional instruction for to help best determine the values for each property.
4.  **Analyze the Project:** Systematically search and read the files within the project directory (`.`) to gather the information needed to populate the YAML fields, as guided by the schemas descriptions. Skip all folders and files if they match any of the patterns defined in `./.gitignore` if the file exists.
5.  **Generate the YAML File:** Construct the `./.metagit.new.yaml` file. The file must be valid YAML and strictly conform to the schema.
6.  **Handle Missing Information:**
    *   If you cannot determine a value for a **required** field after a thorough analysis, state this clearly.
    *   For **optional** fields, omit them if the relevant information cannot be found.

Your final output should be the complete content for the new `./.metagit.new.yaml` file.
````

## File: src/metagit/data/prompts/gemini_prompt.md
````markdown
You are an AI agent specializing in project analysis and configuration. Your task is to generate a new YAML configuration file, `{{project_path}}/.metagit.new.yaml`, by analyzing the project located at `{{project_path}}/`.

The structure, fields, and data for this new YAML file must be determined by following the JSON schema located at `{{project_path}}/.metagit/metagit_config.schema.json`.

**Your process must be as follows:**

1.  **Read and Understand the Schema:** First, read the JSON schema file at `{{project_path}}/.metagit/metagit_config.schema.json`.
2.  **Read Existing YAML manifest:** If an existing YAML file exists at `{{project_path}}/.metagit.yml` it should be used as the basis for your efforts. The `workspace` attribute in this file **must** be preserved in your generated output file later on.
3.  **Follow Schema Descriptions:** For each property in the schema, you **must** use its corresponding `description` field as a direct instruction for how to find or infer the correct value from the project's files and structure.
4.  **Analyze the Project:** Systematically search and read the files within the project directory (`{{project_path}}`) to gather the information needed to populate the YAML fields, as guided by the schema's descriptions. Skip all folders and files if they match any of the patterns defined in `{{project_path}}/.gitignore` if the file exists.
5.  **Generate the YAML File:** Construct the `{{project_path}}/.metagit.new.yaml` file. The file must be valid YAML and strictly conform to the schema.
6.  **Handle Missing Information:**
    *   If you cannot determine a value for a **required** field after a thorough analysis, state this clearly.
    *   For **optional** fields, omit them if the relevant information cannot be found.

Your final output should be the complete content for the new `{{project_path}}/.metagit.new.yaml` file.
````

## File: src/metagit/data/build-files.yaml
````yaml
build_files:
  general:
    - Makefile
    - CMakeLists.txt
    - build.gradle
    - build.gradle.kts
    - setup.py
    - setup.cfg
    - requirements.txt
    - Taskfile.yaml
    - tasks.json
    - BUCK
  javascript:
    - package.json
    - webpack.config.js
    - vite.config.js
    - rollup.config.js
    - gulpfile.js
    - Gruntfile.js
  important:
    - build.xml
    - package.json
    - pom.xml
    - build.gradle
    - build.sbt
    - .github/workflows/*.yml
    - .gitlab-ci.yml
    - .travis.yml
    - Jenkinsfile
    - circleci/config.yml
    - bitrise.yml
    - appveyor.yml
    - circle.yml
    - .circleci/config.yml
    - codeship-steps.yml
    - wercker.yml
    - docker-compose.yml
    - docker-cloud.yml
    - docker-stack.yml
    - azure-pipelines.yml
    - bitbucket-pipelines.yml
    - Dockerfile
    - docker-compose.yml
    - skaffold.yaml
    - pyproject.toml
    - requirements.txt
    - setup.py
    - setup.cfg
    - '*.tf'
    - Pulumi.yaml
  testing:
    - Jestfile.js
    - jest.config.js
    - karma.conf.js
    - tox.ini
    - .eslintrc.js
    - .eslintrc.json
    - .prettierrc
  containerization:
    - Dockerfile
    - docker-compose.yml
    - skaffold.yaml
  infrastructure:
    - '*.tf'
    - Pulumi.yaml
  python:
    - pyproject.toml
    - requirements.txt
    - setup.py
    - setup.cfg
    - tox.ini
````

## File: src/metagit/data/cd-files.json
````json
{
  "**/*.tf": "terraform",
  "skaffold.yaml": "skaffold",
  "Pulumi.yaml": "pulumi"
}
````

## File: src/metagit/data/ci-files.json
````json
{
  "appveyor.yml": "Appveyor",
  "circle.yml": "CircleCI",
  ".circleci/config.yml": "CircleCI",
  "codeship-steps.yml": "Codeship",
  "wercker.yml": "Wercker",
  "docker-compose.yml": "Docker",
  "docker-cloud.yml": "Docker Cloud",
  "docker-stack.yml": "Docker Stack",
  ".github/workflows/*.yml": "GitHub Actions",
  ".gitlab-ci.yml": "GitLab CI",
  "Jenkinsfile": "Jenkins",
  ".travis.yml": "Travis CI",
  "azure-pipelines.yml": "Azure DevOps",
  "bitbucket-pipelines.yml": "Bitbucket Pipelines"
}
````

## File: src/metagit/data/file-types.json
````json
{
  "extensions": [
    {
      "kind": "ABAP",
      "type": "programming",
      "extensions": [".abap"]
    },
    {
      "kind": "AGS Script",
      "type": "programming",
      "extensions": [".asc", ".ash"]
    },
    {
      "kind": "AMPL",
      "type": "programming",
      "extensions": [".ampl", ".mod"]
    },
    {
      "kind": "ANTLR",
      "type": "programming",
      "extensions": [".g4"]
    },
    {
      "kind": "API Blueprint",
      "type": "markup",
      "extensions": [".apib"]
    },
    {
      "kind": "APL",
      "type": "programming",
      "extensions": [".apl", ".dyalog"]
    },
    {
      "kind": "ASP",
      "type": "programming",
      "extensions": [
        ".asp",
        ".asax",
        ".ascx",
        ".ashx",
        ".asmx",
        ".aspx",
        ".axd"
      ]
    },
    {
      "kind": "ATS",
      "type": "programming",
      "extensions": [".dats", ".hats", ".sats"]
    },
    {
      "kind": "ActionScript",
      "type": "programming",
      "extensions": [".as"]
    },
    {
      "kind": "Ada",
      "type": "programming",
      "extensions": [".adb", ".ada", ".ads"]
    },
    {
      "kind": "Agda",
      "type": "programming",
      "extensions": [".agda"]
    },
    {
      "kind": "Alloy",
      "type": "programming",
      "extensions": [".als"]
    },
    {
      "kind": "Ant Build System",
      "type": "data"
    },
    {
      "kind": "ApacheConf",
      "type": "markup",
      "extensions": [".apacheconf", ".vhost"]
    },
    {
      "kind": "Apex",
      "type": "programming",
      "extensions": [".cls"]
    },
    {
      "kind": "AppleScript",
      "type": "programming",
      "extensions": [".applescript", ".scpt"]
    },
    {
      "kind": "Arc",
      "type": "programming",
      "extensions": [".arc"]
    },
    {
      "kind": "Arduino",
      "type": "programming",
      "extensions": [".ino"]
    },
    {
      "kind": "AsciiDoc",
      "type": "prose",
      "extensions": [".asciidoc", ".adoc", ".asc"]
    },
    {
      "kind": "AspectJ",
      "type": "programming",
      "extensions": [".aj"]
    },
    {
      "kind": "Assembly",
      "type": "programming",
      "extensions": [".asm", ".a51", ".inc", ".nasm"]
    },
    {
      "kind": "Augeas",
      "type": "programming",
      "extensions": [".aug"]
    },
    {
      "kind": "AutoHotkey",
      "type": "programming",
      "extensions": [".ahk", ".ahkl"]
    },
    {
      "kind": "AutoIt",
      "type": "programming",
      "extensions": [".au3"]
    },
    {
      "kind": "Awk",
      "type": "programming",
      "extensions": [".awk", ".auk", ".gawk", ".mawk", ".nawk"]
    },
    {
      "kind": "Batchfile",
      "type": "programming",
      "extensions": [".bat", ".cmd"]
    },
    {
      "kind": "Befunge",
      "type": "programming",
      "extensions": [".befunge"]
    },
    {
      "kind": "Bison",
      "type": "programming",
      "extensions": [".bison"]
    },
    {
      "kind": "BitBake",
      "type": "programming",
      "extensions": [".bb"]
    },
    {
      "kind": "BlitzBasic",
      "type": "programming",
      "extensions": [".bb", ".decls"]
    },
    {
      "kind": "BlitzMax",
      "type": "programming",
      "extensions": [".bmx"]
    },
    {
      "kind": "Bluespec",
      "type": "programming",
      "extensions": [".bsv"]
    },
    {
      "kind": "Boo",
      "type": "programming",
      "extensions": [".boo"]
    },
    {
      "kind": "Brainfuck",
      "type": "programming",
      "extensions": [".b", ".bf"]
    },
    {
      "kind": "Brightscript",
      "type": "programming",
      "extensions": [".brs"]
    },
    {
      "kind": "Bro",
      "type": "programming",
      "extensions": [".bro"]
    },
    {
      "kind": "C",
      "type": "programming",
      "extensions": [".c", ".cats", ".h", ".idc", ".w"]
    },
    {
      "kind": "C#",
      "type": "programming",
      "extensions": [".cs", ".cake", ".cshtml", ".csx"]
    },
    {
      "kind": "C++",
      "type": "programming",
      "extensions": [
        ".cpp",
        ".c++",
        ".cc",
        ".cp",
        ".cxx",
        ".h",
        ".h++",
        ".hh",
        ".hpp",
        ".hxx",
        ".inc",
        ".inl",
        ".ipp",
        ".tcc",
        ".tpp"
      ]
    },
    {
      "kind": "C-ObjDump",
      "type": "data",
      "extensions": [".c-objdump"]
    },
    {
      "kind": "C2hs Haskell",
      "type": "programming",
      "extensions": [".chs"]
    },
    {
      "kind": "CLIPS",
      "type": "programming",
      "extensions": [".clp"]
    },
    {
      "kind": "CMake",
      "type": "programming",
      "extensions": [".cmake", ".cmake.in"]
    },
    {
      "kind": "COBOL",
      "type": "programming",
      "extensions": [".cob", ".cbl", ".ccp", ".cobol", ".cpy"]
    },
    {
      "kind": "CSS",
      "type": "markup",
      "extensions": [".css"]
    },
    {
      "kind": "CSV",
      "type": "data",
      "extensions": [".csv"]
    },
    {
      "kind": "Cap'n Proto",
      "type": "programming",
      "extensions": [".capnp"]
    },
    {
      "kind": "CartoCSS",
      "type": "programming",
      "extensions": [".mss"]
    },
    {
      "kind": "Ceylon",
      "type": "programming",
      "extensions": [".ceylon"]
    },
    {
      "kind": "Chapel",
      "type": "programming",
      "extensions": [".chpl"]
    },
    {
      "kind": "Charity",
      "type": "programming",
      "extensions": [".ch"]
    },
    {
      "kind": "ChucK",
      "type": "programming",
      "extensions": [".ck"]
    },
    {
      "kind": "Cirru",
      "type": "programming",
      "extensions": [".cirru"]
    },
    {
      "kind": "Clarion",
      "type": "programming",
      "extensions": [".clw"]
    },
    {
      "kind": "Clean",
      "type": "programming",
      "extensions": [".icl", ".dcl"]
    },
    {
      "kind": "Click",
      "type": "programming",
      "extensions": [".click"]
    },
    {
      "kind": "Clojure",
      "type": "programming",
      "extensions": [
        ".clj",
        ".boot",
        ".cl2",
        ".cljc",
        ".cljs",
        ".cljs.hl",
        ".cljscm",
        ".cljx",
        ".hic"
      ]
    },
    {
      "kind": "CoffeeScript",
      "type": "programming",
      "extensions": [".coffee", "._coffee", ".cake", ".cjsx", ".cson", ".iced"]
    },
    {
      "kind": "ColdFusion",
      "type": "programming",
      "extensions": [".cfm", ".cfml"]
    },
    {
      "kind": "ColdFusion CFC",
      "type": "programming",
      "extensions": [".cfc"]
    },
    {
      "kind": "Common Lisp",
      "type": "programming",
      "extensions": [
        ".lisp",
        ".asd",
        ".cl",
        ".l",
        ".lsp",
        ".ny",
        ".podsl",
        ".sexp"
      ]
    },
    {
      "kind": "Component Pascal",
      "type": "programming",
      "extensions": [".cp", ".cps"]
    },
    {
      "kind": "Cool",
      "type": "programming",
      "extensions": [".cl"]
    },
    {
      "kind": "Coq",
      "type": "programming",
      "extensions": [".coq", ".v"]
    },
    {
      "kind": "Cpp-ObjDump",
      "type": "data",
      "extensions": [
        ".cppobjdump",
        ".c++-objdump",
        ".c++objdump",
        ".cpp-objdump",
        ".cxx-objdump"
      ]
    },
    {
      "kind": "Creole",
      "type": "prose",
      "extensions": [".creole"]
    },
    {
      "kind": "Crystal",
      "type": "programming",
      "extensions": [".cr"]
    },
    {
      "kind": "Cucumber",
      "type": "programming",
      "extensions": [".feature"]
    },
    {
      "kind": "Cuda",
      "type": "programming",
      "extensions": [".cu", ".cuh"]
    },
    {
      "kind": "Cycript",
      "type": "programming",
      "extensions": [".cy"]
    },
    {
      "kind": "Cython",
      "type": "programming",
      "extensions": [".pyx", ".pxd", ".pxi"]
    },
    {
      "kind": "D",
      "type": "programming",
      "extensions": [".d", ".di"]
    },
    {
      "kind": "D-ObjDump",
      "type": "data",
      "extensions": [".d-objdump"]
    },
    {
      "kind": "DIGITAL Command Language",
      "type": "programming",
      "extensions": [".com"]
    },
    {
      "kind": "DM",
      "type": "programming",
      "extensions": [".dm"]
    },
    {
      "kind": "DNS Zone",
      "type": "data",
      "extensions": [".zone", ".arpa"]
    },
    {
      "kind": "DTrace",
      "type": "programming",
      "extensions": [".d"]
    },
    {
      "kind": "Darcs Patch",
      "type": "data",
      "extensions": [".darcspatch", ".dpatch"]
    },
    {
      "kind": "Dart",
      "type": "programming",
      "extensions": [".dart"]
    },
    {
      "kind": "Diff",
      "type": "data",
      "extensions": [".diff", ".patch"]
    },
    {
      "kind": "Dockerfile",
      "type": "data",
      "extensions": [".dockerfile", ".dockerignore"]
    },
    {
      "kind": "Docker",
      "type": "programming",
      "extensions": ["Dockerfile", "docker-compose.yml", "docker-compose.yaml", "docker-compose.*.yml", "docker-compose.*.yaml"]
    },
    {
      "kind": "Dogescript",
      "type": "programming",
      "extensions": [".djs"]
    },
    {
      "kind": "Dylan",
      "type": "programming",
      "extensions": [".dylan", ".dyl", ".intr", ".lid"]
    },
    {
      "kind": "E",
      "type": "programming",
      "extensions": [".E"]
    },
    {
      "kind": "ECL",
      "type": "programming",
      "extensions": [".ecl", ".eclxml"]
    },
    {
      "kind": "ECLiPSe",
      "type": "programming",
      "extensions": [".ecl"]
    },
    {
      "kind": "Eagle",
      "type": "markup",
      "extensions": [".sch", ".brd"]
    },
    {
      "kind": "Ecere Projects",
      "type": "data",
      "extensions": [".epj"]
    },
    {
      "kind": "Eiffel",
      "type": "programming",
      "extensions": [".e"]
    },
    {
      "kind": "Elixir",
      "type": "programming",
      "extensions": [".ex", ".exs"]
    },
    {
      "kind": "Elm",
      "type": "programming",
      "extensions": [".elm"]
    },
    {
      "kind": "Emacs Lisp",
      "type": "programming",
      "extensions": [".el", ".emacs", ".emacs.desktop"]
    },
    {
      "kind": "EmberScript",
      "type": "programming",
      "extensions": [".em", ".emberscript"]
    },
    {
      "kind": "Erlang",
      "type": "programming",
      "extensions": [".erl", ".es", ".escript", ".hrl", ".xrl", ".yrl"]
    },
    {
      "kind": "F#",
      "type": "programming",
      "extensions": [".fs", ".fsi", ".fsx"]
    },
    {
      "kind": "FLUX",
      "type": "programming",
      "extensions": [".fx", ".flux"]
    },
    {
      "kind": "FORTRAN",
      "type": "programming",
      "extensions": [
        ".f90",
        ".f",
        ".f03",
        ".f08",
        ".f77",
        ".f95",
        ".for",
        ".fpp"
      ]
    },
    {
      "kind": "Factor",
      "type": "programming",
      "extensions": [".factor"]
    },
    {
      "kind": "Fancy",
      "type": "programming",
      "extensions": [".fy", ".fancypack"]
    },
    {
      "kind": "Fantom",
      "type": "programming",
      "extensions": [".fan"]
    },
    {
      "kind": "Filterscript",
      "type": "programming",
      "extensions": [".fs"]
    },
    {
      "kind": "Formatted",
      "type": "data",
      "extensions": [".for", ".eam.fs"]
    },
    {
      "kind": "Forth",
      "type": "programming",
      "extensions": [
        ".fth",
        ".4th",
        ".f",
        ".for",
        ".forth",
        ".fr",
        ".frt",
        ".fs"
      ]
    },
    {
      "kind": "FreeMarker",
      "type": "programming",
      "extensions": [".ftl"]
    },
    {
      "kind": "Frege",
      "type": "programming",
      "extensions": [".fr"]
    },
    {
      "kind": "G-code",
      "type": "data",
      "extensions": [".g", ".gco", ".gcode"]
    },
    {
      "kind": "GAMS",
      "type": "programming",
      "extensions": [".gms"]
    },
    {
      "kind": "GAP",
      "type": "programming",
      "extensions": [".g", ".gap", ".gd", ".gi", ".tst"]
    },
    {
      "kind": "GAS",
      "type": "programming",
      "extensions": [".s", ".ms"]
    },
    {
      "kind": "GDScript",
      "type": "programming",
      "extensions": [".gd"]
    },
    {
      "kind": "GLSL",
      "type": "programming",
      "extensions": [
        ".glsl",
        ".fp",
        ".frag",
        ".frg",
        ".fs",
        ".fsh",
        ".fshader",
        ".geo",
        ".geom",
        ".glslv",
        ".gshader",
        ".shader",
        ".vert",
        ".vrx",
        ".vsh",
        ".vshader"
      ]
    },
    {
      "kind": "Game Maker Language",
      "type": "programming",
      "extensions": [".gml"]
    },
    {
      "kind": "Genshi",
      "type": "programming",
      "extensions": [".kid"]
    },
    {
      "kind": "Gentoo Ebuild",
      "type": "programming",
      "extensions": [".ebuild"]
    },
    {
      "kind": "Gentoo Eclass",
      "type": "programming",
      "extensions": [".eclass"]
    },
    {
      "kind": "Gettext Catalog",
      "type": "prose",
      "extensions": [".po", ".pot"]
    },
    {
      "kind": "Glyph",
      "type": "programming",
      "extensions": [".glf"]
    },
    {
      "kind": "Gnuplot",
      "type": "programming",
      "extensions": [".gp", ".gnu", ".gnuplot", ".plot", ".plt"]
    },
    {
      "kind": "Go",
      "type": "programming",
      "extensions": [".go"]
    },
    {
      "kind": "Golo",
      "type": "programming",
      "extensions": [".golo"]
    },
    {
      "kind": "Gosu",
      "type": "programming",
      "extensions": [".gs", ".gst", ".gsx", ".vark"]
    },
    {
      "kind": "Grace",
      "type": "programming",
      "extensions": [".grace"]
    },
    {
      "kind": "Gradle",
      "type": "data",
      "extensions": [".gradle"]
    },
    {
      "kind": "Grammatical Framework",
      "type": "programming",
      "extensions": [".gf"]
    },
    {
      "kind": "Graph Modeling Language",
      "type": "data",
      "extensions": [".gml"]
    },
    {
      "kind": "GraphQL",
      "type": "data",
      "extensions": [".graphql"]
    },
    {
      "kind": "Graphviz (DOT)",
      "type": "data",
      "extensions": [".dot", ".gv"]
    },
    {
      "kind": "Groff",
      "type": "markup",
      "extensions": [
        ".man",
        ".1",
        ".1in",
        ".1m",
        ".1x",
        ".2",
        ".3",
        ".3in",
        ".3m",
        ".3qt",
        ".3x",
        ".4",
        ".5",
        ".6",
        ".7",
        ".8",
        ".9",
        ".l",
        ".me",
        ".ms",
        ".n",
        ".rno",
        ".roff"
      ]
    },
    {
      "kind": "Groovy",
      "type": "programming",
      "extensions": [".groovy", ".grt", ".gtpl", ".gvy"]
    },
    {
      "kind": "Groovy Server Pages",
      "type": "programming",
      "extensions": [".gsp"]
    },
    {
      "kind": "HCL",
      "type": "programming",
      "extensions": [".hcl", ".tf"]
    },
    {
      "kind": "HLSL",
      "type": "programming",
      "extensions": [".hlsl", ".fx", ".fxh", ".hlsli"]
    },
    {
      "kind": "HTML",
      "type": "markup",
      "extensions": [
        ".html",
        ".htm",
        ".html.hl",
        ".inc",
        ".st",
        ".xht",
        ".xhtml"
      ]
    },
    {
      "kind": "HTML+Django",
      "type": "markup",
      "extensions": [".mustache", ".jinja"]
    },
    {
      "kind": "HTML+EEX",
      "type": "markup",
      "extensions": [".eex"]
    },
    {
      "kind": "HTML+ERB",
      "type": "markup",
      "extensions": [".erb", ".erb.deface"]
    },
    {
      "kind": "HTML+PHP",
      "type": "markup",
      "extensions": [".phtml"]
    },
    {
      "kind": "HTTP",
      "type": "data",
      "extensions": [".http"]
    },
    {
      "kind": "Hack",
      "type": "programming",
      "extensions": [".hh", ".php"]
    },
    {
      "kind": "Haml",
      "type": "markup",
      "extensions": [".haml", ".haml.deface"]
    },
    {
      "kind": "Handlebars",
      "type": "markup",
      "extensions": [".handlebars", ".hbs"]
    },
    {
      "kind": "Harbour",
      "type": "programming",
      "extensions": [".hb"]
    },
    {
      "kind": "Haskell",
      "type": "programming",
      "extensions": [".hs", ".hsc"]
    },
    {
      "kind": "Haxe",
      "type": "programming",
      "extensions": [".hx", ".hxsl"]
    },
    {
      "kind": "Hy",
      "type": "programming",
      "extensions": [".hy"]
    },
    {
      "kind": "HyPhy",
      "type": "programming",
      "extensions": [".bf"]
    },
    {
      "kind": "IDL",
      "type": "programming",
      "extensions": [".pro", ".dlm"]
    },
    {
      "kind": "IGOR Pro",
      "type": "programming",
      "extensions": [".ipf"]
    },
    {
      "kind": "INI",
      "type": "data",
      "extensions": [".ini", ".cfg", ".prefs", ".pro", ".properties"]
    },
    {
      "kind": "IRC log",
      "type": "data",
      "extensions": [".irclog", ".weechatlog"]
    },
    {
      "kind": "Idris",
      "type": "programming",
      "extensions": [".idr", ".lidr"]
    },
    {
      "kind": "Inform 7",
      "type": "programming",
      "extensions": [".ni", ".i7x"]
    },
    {
      "kind": "Inno Setup",
      "type": "programming",
      "extensions": [".iss"]
    },
    {
      "kind": "Io",
      "type": "programming",
      "extensions": [".io"]
    },
    {
      "kind": "Ioke",
      "type": "programming",
      "extensions": [".ik"]
    },
    {
      "kind": "Isabelle",
      "type": "programming",
      "extensions": [".thy"]
    },
    {
      "kind": "Isabelle ROOT",
      "type": "programming"
    },
    {
      "kind": "J",
      "type": "programming",
      "extensions": [".ijs"]
    },
    {
      "kind": "JFlex",
      "type": "programming",
      "extensions": [".flex", ".jflex"]
    },
    {
      "kind": "JSON",
      "type": "data",
      "extensions": [".json", ".geojson", ".lock", ".topojson"]
    },
    {
      "kind": "JSON5",
      "type": "data",
      "extensions": [".json5"]
    },
    {
      "kind": "JSONLD",
      "type": "data",
      "extensions": [".jsonld"]
    },
    {
      "kind": "JSONiq",
      "type": "programming",
      "extensions": [".jq"]
    },
    {
      "kind": "JSX",
      "type": "programming",
      "extensions": [".jsx"]
    },
    {
      "kind": "Jade",
      "type": "markup",
      "extensions": [".jade"]
    },
    {
      "kind": "Jasmin",
      "type": "programming",
      "extensions": [".j"]
    },
    {
      "kind": "Java",
      "type": "programming",
      "extensions": [".java"]
    },
    {
      "kind": "Java Server Pages",
      "type": "programming",
      "extensions": [".jsp"]
    },
    {
      "kind": "JavaScript",
      "type": "programming",
      "extensions": [
        ".js",
        "._js",
        ".bones",
        ".es",
        ".es6",
        ".frag",
        ".gs",
        ".jake",
        ".jsb",
        ".jscad",
        ".jsfl",
        ".jsm",
        ".jss",
        ".njs",
        ".pac",
        ".sjs",
        ".ssjs",
        ".sublime-build",
        ".sublime-commands",
        ".sublime-completions",
        ".sublime-keymap",
        ".sublime-macro",
        ".sublime-menu",
        ".sublime-mousemap",
        ".sublime-project",
        ".sublime-settings",
        ".sublime-theme",
        ".sublime-workspace",
        ".sublime_metrics",
        ".sublime_session",
        ".xsjs",
        ".xsjslib"
      ]
    },
    {
      "kind": "Julia",
      "type": "programming",
      "extensions": [".jl"]
    },
    {
      "kind": "Jupyter Notebook",
      "type": "markup",
      "extensions": [".ipynb"]
    },
    {
      "kind": "KRL",
      "type": "programming",
      "extensions": [".krl"]
    },
    {
      "kind": "KiCad",
      "type": "programming",
      "extensions": [".sch", ".brd", ".kicad_pcb"]
    },
    {
      "kind": "Kit",
      "type": "markup",
      "extensions": [".kit"]
    },
    {
      "kind": "Kotlin",
      "type": "programming",
      "extensions": [".kt", ".ktm", ".kts"]
    },
    {
      "kind": "LFE",
      "type": "programming",
      "extensions": [".lfe"]
    },
    {
      "kind": "LLVM",
      "type": "programming",
      "extensions": [".ll"]
    },
    {
      "kind": "LOLCODE",
      "type": "programming",
      "extensions": [".lol"]
    },
    {
      "kind": "LSL",
      "type": "programming",
      "extensions": [".lsl", ".lslp"]
    },
    {
      "kind": "LabVIEW",
      "type": "programming",
      "extensions": [".lvproj"]
    },
    {
      "kind": "Lasso",
      "type": "programming",
      "extensions": [".lasso", ".las", ".lasso8", ".lasso9", ".ldml"]
    },
    {
      "kind": "Latte",
      "type": "markup",
      "extensions": [".latte"]
    },
    {
      "kind": "Lean",
      "type": "programming",
      "extensions": [".lean", ".hlean"]
    },
    {
      "kind": "Less",
      "type": "markup",
      "extensions": [".less"]
    },
    {
      "kind": "Lex",
      "type": "programming",
      "extensions": [".l", ".lex"]
    },
    {
      "kind": "LilyPond",
      "type": "programming",
      "extensions": [".ly", ".ily"]
    },
    {
      "kind": "Limbo",
      "type": "programming",
      "extensions": [".b", ".m"]
    },
    {
      "kind": "Linker Script",
      "type": "data",
      "extensions": [".ld", ".lds"]
    },
    {
      "kind": "Linux Kernel Module",
      "type": "data",
      "extensions": [".mod"]
    },
    {
      "kind": "Liquid",
      "type": "markup",
      "extensions": [".liquid"]
    },
    {
      "kind": "Literate Agda",
      "type": "programming",
      "extensions": [".lagda"]
    },
    {
      "kind": "Literate CoffeeScript",
      "type": "programming",
      "extensions": [".litcoffee"]
    },
    {
      "kind": "Literate Haskell",
      "type": "programming",
      "extensions": [".lhs"]
    },
    {
      "kind": "LiveScript",
      "type": "programming",
      "extensions": [".ls", "._ls"]
    },
    {
      "kind": "Logos",
      "type": "programming",
      "extensions": [".xm", ".x", ".xi"]
    },
    {
      "kind": "Logtalk",
      "type": "programming",
      "extensions": [".lgt", ".logtalk"]
    },
    {
      "kind": "LookML",
      "type": "programming",
      "extensions": [".lookml"]
    },
    {
      "kind": "LoomScript",
      "type": "programming",
      "extensions": [".ls"]
    },
    {
      "kind": "Lua",
      "type": "programming",
      "extensions": [".lua", ".fcgi", ".nse", ".pd_lua", ".rbxs", ".wlua"]
    },
    {
      "kind": "M",
      "type": "programming",
      "extensions": [".mumps", ".m"]
    },
    {
      "kind": "M4",
      "type": "programming",
      "extensions": [".m4"]
    },
    {
      "kind": "M4Sugar",
      "type": "programming",
      "extensions": [".m4"]
    },
    {
      "kind": "MAXScript",
      "type": "programming",
      "extensions": [".ms", ".mcr"]
    },
    {
      "kind": "MTML",
      "type": "markup",
      "extensions": [".mtml"]
    },
    {
      "kind": "MUF",
      "type": "programming",
      "extensions": [".muf", ".m"]
    },
    {
      "kind": "Makefile",
      "type": "programming",
      "extensions": [".mak", ".d", ".mk", ".mkfile", "Makefile"]
    },
    {
      "kind": "Mako",
      "type": "programming",
      "extensions": [".mako", ".mao"]
    },
    {
      "kind": "Markdown",
      "type": "prose",
      "extensions": [".md", ".markdown", ".mkd", ".mkdn", ".mkdown", ".ron"]
    },
    {
      "kind": "Mask",
      "type": "markup",
      "extensions": [".mask"]
    },
    {
      "kind": "Mathematica",
      "type": "programming",
      "extensions": [
        ".mathematica",
        ".cdf",
        ".m",
        ".ma",
        ".mt",
        ".nb",
        ".nbp",
        ".wl",
        ".wlt"
      ]
    },
    {
      "kind": "Matlab",
      "type": "programming",
      "extensions": [".matlab", ".m"]
    },
    {
      "kind": "Maven POM",
      "type": "data"
    },
    {
      "kind": "Max",
      "type": "programming",
      "extensions": [".maxpat", ".maxhelp", ".maxproj", ".mxt", ".pat"]
    },
    {
      "kind": "MediaWiki",
      "type": "prose",
      "extensions": [".mediawiki", ".wiki"]
    },
    {
      "kind": "Mercury",
      "type": "programming",
      "extensions": [".m", ".moo"]
    },
    {
      "kind": "Metal",
      "type": "programming",
      "extensions": [".metal"]
    },
    {
      "kind": "MiniD",
      "type": "programming",
      "extensions": [".minid"]
    },
    {
      "kind": "Mirah",
      "type": "programming",
      "extensions": [".druby", ".duby", ".mir", ".mirah"]
    },
    {
      "kind": "Modelica",
      "type": "programming",
      "extensions": [".mo"]
    },
    {
      "kind": "Modula-2",
      "type": "programming",
      "extensions": [".mod"]
    },
    {
      "kind": "Module Management System",
      "type": "programming",
      "extensions": [".mms", ".mmk"]
    },
    {
      "kind": "Monkey",
      "type": "programming",
      "extensions": [".monkey"]
    },
    {
      "kind": "Moocode",
      "type": "programming",
      "extensions": [".moo"]
    },
    {
      "kind": "MoonScript",
      "type": "programming",
      "extensions": [".moon"]
    },
    {
      "kind": "Myghty",
      "type": "programming",
      "extensions": [".myt"]
    },
    {
      "kind": "NCL",
      "type": "programming",
      "extensions": [".ncl"]
    },
    {
      "kind": "NL",
      "type": "data",
      "extensions": [".nl"]
    },
    {
      "kind": "NSIS",
      "type": "programming",
      "extensions": [".nsi", ".nsh"]
    },
    {
      "kind": "Nemerle",
      "type": "programming",
      "extensions": [".n"]
    },
    {
      "kind": "NetLinx",
      "type": "programming",
      "extensions": [".axs", ".axi"]
    },
    {
      "kind": "NetLinx+ERB",
      "type": "programming",
      "extensions": [".axs.erb", ".axi.erb"]
    },
    {
      "kind": "NetLogo",
      "type": "programming",
      "extensions": [".nlogo"]
    },
    {
      "kind": "NewLisp",
      "type": "programming",
      "extensions": [".nl", ".lisp", ".lsp"]
    },
    {
      "kind": "Nginx",
      "type": "markup",
      "extensions": [".nginxconf", ".vhost"]
    },
    {
      "kind": "Nimrod",
      "type": "programming",
      "extensions": [".nim", ".nimrod"]
    },
    {
      "kind": "Ninja",
      "type": "data",
      "extensions": [".ninja"]
    },
    {
      "kind": "Nit",
      "type": "programming",
      "extensions": [".nit"]
    },
    {
      "kind": "Nix",
      "type": "programming",
      "extensions": [".nix"]
    },
    {
      "kind": "Nu",
      "type": "programming",
      "extensions": [".nu"]
    },
    {
      "kind": "NumPy",
      "type": "programming",
      "extensions": [".numpy", ".numpyw", ".numsc"]
    },
    {
      "kind": "OCaml",
      "type": "programming",
      "extensions": [".ml", ".eliom", ".eliomi", ".ml4", ".mli", ".mll", ".mly"]
    },
    {
      "kind": "ObjDump",
      "type": "data",
      "extensions": [".objdump"]
    },
    {
      "kind": "Objective-C",
      "type": "programming",
      "extensions": [".m", ".h"]
    },
    {
      "kind": "Objective-C++",
      "type": "programming",
      "extensions": [".mm"]
    },
    {
      "kind": "Objective-J",
      "type": "programming",
      "extensions": [".j", ".sj"]
    },
    {
      "kind": "Omgrofl",
      "type": "programming",
      "extensions": [".omgrofl"]
    },
    {
      "kind": "Opa",
      "type": "programming",
      "extensions": [".opa"]
    },
    {
      "kind": "Opal",
      "type": "programming",
      "extensions": [".opal"]
    },
    {
      "kind": "OpenCL",
      "type": "programming",
      "extensions": [".cl", ".opencl"]
    },
    {
      "kind": "OpenEdge ABL",
      "type": "programming",
      "extensions": [".p", ".cls"]
    },
    {
      "kind": "OpenSCAD",
      "type": "programming",
      "extensions": [".scad"]
    },
    {
      "kind": "Org",
      "type": "prose",
      "extensions": [".org"]
    },
    {
      "kind": "Ox",
      "type": "programming",
      "extensions": [".ox", ".oxh", ".oxo"]
    },
    {
      "kind": "Oxygene",
      "type": "programming",
      "extensions": [".oxygene"]
    },
    {
      "kind": "Oz",
      "type": "programming",
      "extensions": [".oz"]
    },
    {
      "kind": "PAWN",
      "type": "programming",
      "extensions": [".pwn", ".inc"]
    },
    {
      "kind": "PHP",
      "type": "programming",
      "extensions": [
        ".php",
        ".aw",
        ".ctp",
        ".fcgi",
        ".inc",
        ".php3",
        ".php4",
        ".php5",
        ".phps",
        ".phpt"
      ]
    },
    {
      "kind": "PLSQL",
      "type": "programming",
      "extensions": [".pls", ".pck", ".pkb", ".pks", ".plb", ".plsql", ".sql"]
    },
    {
      "kind": "PLpgSQL",
      "type": "programming",
      "extensions": [".sql"]
    },
    {
      "kind": "POV-Ray SDL",
      "type": "programming",
      "extensions": [".pov", ".inc"]
    },
    {
      "kind": "Pan",
      "type": "programming",
      "extensions": [".pan"]
    },
    {
      "kind": "Papyrus",
      "type": "programming",
      "extensions": [".psc"]
    },
    {
      "kind": "Parrot",
      "type": "programming",
      "extensions": [".parrot"]
    },
    {
      "kind": "Parrot Assembly",
      "type": "programming",
      "extensions": [".pasm"]
    },
    {
      "kind": "Parrot Internal Representation",
      "type": "programming",
      "extensions": [".pir"]
    },
    {
      "kind": "Pascal",
      "type": "programming",
      "extensions": [".pas", ".dfm", ".dpr", ".inc", ".lpr", ".pp"]
    },
    {
      "kind": "Perl",
      "type": "programming",
      "extensions": [
        ".pl",
        ".al",
        ".cgi",
        ".fcgi",
        ".perl",
        ".ph",
        ".plx",
        ".pm",
        ".pod",
        ".psgi",
        ".t"
      ]
    },
    {
      "kind": "Perl6",
      "type": "programming",
      "extensions": [
        ".6pl",
        ".6pm",
        ".nqp",
        ".p6",
        ".p6l",
        ".p6m",
        ".pl",
        ".pl6",
        ".pm",
        ".pm6",
        ".t"
      ]
    },
    {
      "kind": "Pickle",
      "type": "data",
      "extensions": [".pkl"]
    },
    {
      "kind": "PicoLisp",
      "type": "programming",
      "extensions": [".l"]
    },
    {
      "kind": "PigLatin",
      "type": "programming",
      "extensions": [".pig"]
    },
    {
      "kind": "Pike",
      "type": "programming",
      "extensions": [".pike", ".pmod"]
    },
    {
      "kind": "Pod",
      "type": "prose",
      "extensions": [".pod"]
    },
    {
      "kind": "PogoScript",
      "type": "programming",
      "extensions": [".pogo"]
    },
    {
      "kind": "Pony",
      "type": "programming",
      "extensions": [".pony"]
    },
    {
      "kind": "PostScript",
      "type": "markup",
      "extensions": [".ps", ".eps"]
    },
    {
      "kind": "PowerShell",
      "type": "programming",
      "extensions": [".ps1", ".psd1", ".psm1"]
    },
    {
      "kind": "Processing",
      "type": "programming",
      "extensions": [".pde"]
    },
    {
      "kind": "Prolog",
      "type": "programming",
      "extensions": [".pl", ".pro", ".prolog", ".yap"]
    },
    {
      "kind": "Propeller Spin",
      "type": "programming",
      "extensions": [".spin"]
    },
    {
      "kind": "Protocol Buffer",
      "type": "markup",
      "extensions": [".proto"]
    },
    {
      "kind": "Public Key",
      "type": "data",
      "extensions": [".asc", ".pub"]
    },
    {
      "kind": "Puppet",
      "type": "programming",
      "extensions": [".pp"]
    },
    {
      "kind": "Pure Data",
      "type": "programming",
      "extensions": [".pd"]
    },
    {
      "kind": "PureBasic",
      "type": "programming",
      "extensions": [".pb", ".pbi"]
    },
    {
      "kind": "PureScript",
      "type": "programming",
      "extensions": [".purs"]
    },
    {
      "kind": "Python",
      "type": "programming",
      "extensions": [
        ".py",
        ".bzl",
        ".cgi",
        ".fcgi",
        ".gyp",
        ".lmi",
        ".pyde",
        ".pyp",
        ".pyt",
        ".pyw",
        ".rpy",
        ".tac",
        ".wsgi",
        ".xpy"
      ]
    },
    {
      "kind": "Python traceback",
      "type": "data",
      "extensions": [".pytb"]
    },
    {
      "kind": "QML",
      "type": "programming",
      "extensions": [".qml", ".qbs"]
    },
    {
      "kind": "QMake",
      "type": "programming",
      "extensions": [".pro", ".pri"]
    },
    {
      "kind": "R",
      "type": "programming",
      "extensions": [".r", ".rd", ".rsx"]
    },
    {
      "kind": "RAML",
      "type": "markup",
      "extensions": [".raml"]
    },
    {
      "kind": "RDoc",
      "type": "prose",
      "extensions": [".rdoc"]
    },
    {
      "kind": "REALbasic",
      "type": "programming",
      "extensions": [
        ".rbbas",
        ".rbfrm",
        ".rbmnu",
        ".rbres",
        ".rbtbar",
        ".rbuistate"
      ]
    },
    {
      "kind": "RHTML",
      "type": "markup",
      "extensions": [".rhtml"]
    },
    {
      "kind": "RMarkdown",
      "type": "prose",
      "extensions": [".rmd"]
    },
    {
      "kind": "Racket",
      "type": "programming",
      "extensions": [".rkt", ".rktd", ".rktl", ".scrbl"]
    },
    {
      "kind": "Ragel in Ruby Host",
      "type": "programming",
      "extensions": [".rl"]
    },
    {
      "kind": "Raw token data",
      "type": "data",
      "extensions": [".raw"]
    },
    {
      "kind": "Rebol",
      "type": "programming",
      "extensions": [".reb", ".r", ".r2", ".r3", ".rebol"]
    },
    {
      "kind": "Red",
      "type": "programming",
      "extensions": [".red", ".reds"]
    },
    {
      "kind": "Redcode",
      "type": "programming",
      "extensions": [".cw"]
    },
    {
      "kind": "Ren'Py",
      "type": "programming",
      "extensions": [".rpy"]
    },
    {
      "kind": "RenderScript",
      "type": "programming",
      "extensions": [".rs", ".rsh"]
    },
    {
      "kind": "RobotFramework",
      "type": "programming",
      "extensions": [".robot"]
    },
    {
      "kind": "Rouge",
      "type": "programming",
      "extensions": [".rg"]
    },
    {
      "kind": "Ruby",
      "type": "programming",
      "extensions": [
        ".rb",
        ".builder",
        ".fcgi",
        ".gemspec",
        ".god",
        ".irbrc",
        ".jbuilder",
        ".mspec",
        ".pluginspec",
        ".podspec",
        ".rabl",
        ".rake",
        ".rbuild",
        ".rbw",
        ".rbx",
        ".ru",
        ".ruby",
        ".thor",
        ".watchr"
      ]
    },
    {
      "kind": "Rust",
      "type": "programming",
      "extensions": [".rs", ".rs.in"]
    },
    {
      "kind": "SAS",
      "type": "programming",
      "extensions": [".sas"]
    },
    {
      "kind": "SCSS",
      "type": "markup",
      "extensions": [".scss"]
    },
    {
      "kind": "SMT",
      "type": "programming",
      "extensions": [".smt2", ".smt"]
    },
    {
      "kind": "SPARQL",
      "type": "data",
      "extensions": [".sparql", ".rq"]
    },
    {
      "kind": "SQF",
      "type": "programming",
      "extensions": [".sqf", ".hqf"]
    },
    {
      "kind": "SQL",
      "type": "data",
      "extensions": [
        ".sql",
        ".cql",
        ".ddl",
        ".inc",
        ".prc",
        ".tab",
        ".udf",
        ".viw"
      ]
    },
    {
      "kind": "SQLPL",
      "type": "programming",
      "extensions": [".sql", ".db2"]
    },
    {
      "kind": "STON",
      "type": "data",
      "extensions": [".ston"]
    },
    {
      "kind": "SVG",
      "type": "data",
      "extensions": [".svg"]
    },
    {
      "kind": "Sage",
      "type": "programming",
      "extensions": [".sage", ".sagews"]
    },
    {
      "kind": "SaltStack",
      "type": "programming",
      "extensions": [".sls"]
    },
    {
      "kind": "Sass",
      "type": "markup",
      "extensions": [".sass"]
    },
    {
      "kind": "Scala",
      "type": "programming",
      "extensions": [".scala", ".sbt", ".sc"]
    },
    {
      "kind": "Scaml",
      "type": "markup",
      "extensions": [".scaml"]
    },
    {
      "kind": "Scheme",
      "type": "programming",
      "extensions": [".scm", ".sld", ".sls", ".sps", ".ss"]
    },
    {
      "kind": "Scilab",
      "type": "programming",
      "extensions": [".sci", ".sce", ".tst"]
    },
    {
      "kind": "Self",
      "type": "programming",
      "extensions": [".self"]
    },
    {
      "kind": "Shell",
      "type": "programming",
      "extensions": [
        ".sh",
        ".bash",
        ".bats",
        ".cgi",
        ".command",
        ".fcgi",
        ".ksh",
        ".sh.in",
        ".tmux",
        ".tool",
        ".zsh"
      ]
    },
    {
      "kind": "ShellSession",
      "type": "programming",
      "extensions": [".sh-session"]
    },
    {
      "kind": "Shen",
      "type": "programming",
      "extensions": [".shen"]
    },
    {
      "kind": "Slash",
      "type": "programming",
      "extensions": [".sl"]
    },
    {
      "kind": "Slim",
      "type": "markup",
      "extensions": [".slim"]
    },
    {
      "kind": "Smali",
      "type": "programming",
      "extensions": [".smali"]
    },
    {
      "kind": "Smalltalk",
      "type": "programming",
      "extensions": [".st", ".cs"]
    },
    {
      "kind": "Smarty",
      "type": "programming",
      "extensions": [".tpl"]
    },
    {
      "kind": "SourcePawn",
      "type": "programming",
      "extensions": [".sp", ".inc", ".sma"]
    },
    {
      "kind": "Squirrel",
      "type": "programming",
      "extensions": [".nut"]
    },
    {
      "kind": "Stan",
      "type": "programming",
      "extensions": [".stan"]
    },
    {
      "kind": "Standard ML",
      "type": "programming",
      "extensions": [".ML", ".fun", ".sig", ".sml"]
    },
    {
      "kind": "Stata",
      "type": "programming",
      "extensions": [
        ".do",
        ".ado",
        ".doh",
        ".ihlp",
        ".mata",
        ".matah",
        ".sthlp"
      ]
    },
    {
      "kind": "Stylus",
      "type": "markup",
      "extensions": [".styl"]
    },
    {
      "kind": "SuperCollider",
      "type": "programming",
      "extensions": [".sc", ".scd"]
    },
    {
      "kind": "Swift",
      "type": "programming",
      "extensions": [".swift"]
    },
    {
      "kind": "SystemVerilog",
      "type": "programming",
      "extensions": [".sv", ".svh", ".vh"]
    },
    {
      "kind": "TOML",
      "type": "data",
      "extensions": [".toml"]
    },
    {
      "kind": "TXL",
      "type": "programming",
      "extensions": [".txl"]
    },
    {
      "kind": "Tcl",
      "type": "programming",
      "extensions": [".tcl", ".adp", ".tm"]
    },
    {
      "kind": "Tcsh",
      "type": "programming",
      "extensions": [".tcsh", ".csh"]
    },
    {
      "kind": "TeX",
      "type": "markup",
      "extensions": [
        ".tex",
        ".aux",
        ".bbx",
        ".bib",
        ".cbx",
        ".cls",
        ".dtx",
        ".ins",
        ".lbx",
        ".ltx",
        ".mkii",
        ".mkiv",
        ".mkvi",
        ".sty",
        ".toc"
      ]
    },
    {
      "kind": "Tea",
      "type": "markup",
      "extensions": [".tea"]
    },
    {
      "kind": "Terra",
      "type": "programming",
      "extensions": [".t"]
    },
    {
      "kind": "Text",
      "type": "prose",
      "extensions": [".txt", ".fr", ".nb", ".ncl", ".no"]
    },
    {
      "kind": "Textile",
      "type": "prose",
      "extensions": [".textile"]
    },
    {
      "kind": "Thrift",
      "type": "programming",
      "extensions": [".thrift"]
    },
    {
      "kind": "Turing",
      "type": "programming",
      "extensions": [".t", ".tu"]
    },
    {
      "kind": "Turtle",
      "type": "data",
      "extensions": [".ttl"]
    },
    {
      "kind": "Twig",
      "type": "markup",
      "extensions": [".twig"]
    },
    {
      "kind": "TypeScript",
      "type": "programming",
      "extensions": [".ts", ".tsx"]
    },
    {
      "kind": "Unified Parallel C",
      "type": "programming",
      "extensions": [".upc"]
    },
    {
      "kind": "Unity3D Asset",
      "type": "data",
      "extensions": [".anim", ".asset", ".mat", ".meta", ".prefab", ".unity"]
    },
    {
      "kind": "Uno",
      "type": "programming",
      "extensions": [".uno"]
    },
    {
      "kind": "UnrealScript",
      "type": "programming",
      "extensions": [".uc"]
    },
    {
      "kind": "UrWeb",
      "type": "programming",
      "extensions": [".ur", ".urs"]
    },
    {
      "kind": "VCL",
      "type": "programming",
      "extensions": [".vcl"]
    },
    {
      "kind": "VHDL",
      "type": "programming",
      "extensions": [
        ".vhdl",
        ".vhd",
        ".vhf",
        ".vhi",
        ".vho",
        ".vhs",
        ".vht",
        ".vhw"
      ]
    },
    {
      "kind": "Vala",
      "type": "programming",
      "extensions": [".vala", ".vapi"]
    },
    {
      "kind": "Verilog",
      "type": "programming",
      "extensions": [".v", ".veo"]
    },
    {
      "kind": "VimL",
      "type": "programming",
      "extensions": [".vim"]
    },
    {
      "kind": "Visual Basic",
      "type": "programming",
      "extensions": [
        ".vb",
        ".bas",
        ".cls",
        ".frm",
        ".frx",
        ".vba",
        ".vbhtml",
        ".vbs"
      ]
    },
    {
      "kind": "Volt",
      "type": "programming",
      "extensions": [".volt"]
    },
    {
      "kind": "Vue",
      "type": "markup",
      "extensions": [".vue"]
    },
    {
      "kind": "Web Ontology Language",
      "type": "markup",
      "extensions": [".owl"]
    },
    {
      "kind": "WebIDL",
      "type": "programming",
      "extensions": [".webidl"]
    },
    {
      "kind": "X10",
      "type": "programming",
      "extensions": [".x10"]
    },
    {
      "kind": "XC",
      "type": "programming",
      "extensions": [".xc"]
    },
    {
      "kind": "XML",
      "type": "data",
      "extensions": [
        ".xml",
        ".ant",
        ".axml",
        ".ccxml",
        ".clixml",
        ".cproject",
        ".csl",
        ".csproj",
        ".ct",
        ".dita",
        ".ditamap",
        ".ditaval",
        ".dll.config",
        ".dotsettings",
        ".filters",
        ".fsproj",
        ".fxml",
        ".glade",
        ".gml",
        ".grxml",
        ".iml",
        ".ivy",
        ".jelly",
        ".jsproj",
        ".kml",
        ".launch",
        ".mdpolicy",
        ".mm",
        ".mod",
        ".mxml",
        ".nproj",
        ".nuspec",
        ".odd",
        ".osm",
        ".plist",
        ".pluginspec",
        ".props",
        ".ps1xml",
        ".psc1",
        ".pt",
        ".rdf",
        ".rss",
        ".scxml",
        ".srdf",
        ".storyboard",
        ".stTheme",
        ".sublime-snippet",
        ".targets",
        ".tmCommand",
        ".tml",
        ".tmLanguage",
        ".tmPreferences",
        ".tmSnippet",
        ".tmTheme",
        ".ts",
        ".tsx",
        ".ui",
        ".urdf",
        ".ux",
        ".vbproj",
        ".vcxproj",
        ".vssettings",
        ".vxml",
        ".wsdl",
        ".wsf",
        ".wxi",
        ".wxl",
        ".wxs",
        ".x3d",
        ".xacro",
        ".xaml",
        ".xib",
        ".xlf",
        ".xliff",
        ".xmi",
        ".xml.dist",
        ".xproj",
        ".xsd",
        ".xul",
        ".zcml"
      ]
    },
    {
      "kind": "XPages",
      "type": "programming",
      "extensions": [".xsp-config", ".xsp.metadata"]
    },
    {
      "kind": "XProc",
      "type": "programming",
      "extensions": [".xpl", ".xproc"]
    },
    {
      "kind": "XQuery",
      "type": "programming",
      "extensions": [".xquery", ".xq", ".xql", ".xqm", ".xqy"]
    },
    {
      "kind": "XS",
      "type": "programming",
      "extensions": [".xs"]
    },
    {
      "kind": "XSLT",
      "type": "programming",
      "extensions": [".xslt", ".xsl"]
    },
    {
      "kind": "Xojo",
      "type": "programming",
      "extensions": [
        ".xojo_code",
        ".xojo_menu",
        ".xojo_report",
        ".xojo_script",
        ".xojo_toolbar",
        ".xojo_window"
      ]
    },
    {
      "kind": "Xtend",
      "type": "programming",
      "extensions": [".xtend"]
    },
    {
      "kind": "YAML",
      "type": "data",
      "extensions": [
        ".yml",
        ".reek",
        ".rviz",
        ".sublime-syntax",
        ".syntax",
        ".yaml",
        ".yaml-tmlanguage"
      ]
    },
    {
      "kind": "YANG",
      "type": "data",
      "extensions": [".yang"]
    },
    {
      "kind": "Yacc",
      "type": "programming",
      "extensions": [".y", ".yacc", ".yy"]
    },
    {
      "kind": "Zephir",
      "type": "programming",
      "extensions": [".zep"]
    },
    {
      "kind": "Zimpl",
      "type": "programming",
      "extensions": [".zimpl", ".zmpl", ".zpl"]
    },
    {
      "kind": "desktop",
      "type": "data",
      "extensions": [".desktop", ".desktop.in"]
    },
    {
      "kind": "eC",
      "type": "programming",
      "extensions": [".ec", ".eh"]
    },
    {
      "kind": "edn",
      "type": "data",
      "extensions": [".edn"]
    },
    {
      "kind": "fish",
      "type": "programming",
      "extensions": [".fish"]
    },
    {
      "kind": "mupad",
      "type": "programming",
      "extensions": [".mu"]
    },
    {
      "kind": "nesC",
      "type": "programming",
      "extensions": [".nc"]
    },
    {
      "kind": "ooc",
      "type": "programming",
      "extensions": [".ooc"]
    },
    {
      "kind": "reStructuredText",
      "type": "prose",
      "extensions": [".rst", ".rest", ".rest.txt", ".rst.txt"]
    },
    {
      "kind": "wisp",
      "type": "programming",
      "extensions": [".wisp"]
    },
    {
      "kind": "xBase",
      "type": "programming",
      "extensions": [".prg", ".ch", ".prw"]
    }
  ]
}
````

## File: src/metagit/data/package-managers.json
````json
{
  "requirements.txt": "Python",
  "requirements.*.txt": "Python",
  "pyproject.toml": "Python",
  "setup.py": "Python",
  "setup.cfg": "Python",
  "Pipfile": "Python",
  "poetry.lock": "Python",
  "poetry.toml": "Python",
  "uv.lock": "Python",
  "go.mod": "Go",
  "go.sum": "Go",
  "Cargo.toml": "Rust",
  "Cargo.lock": "Rust",
  "pom.xml": "Java",
  "build.gradle": "Java",
  "build.sbt": "Scala",
  "package.json": "NodeJS",
  "package-lock.json": "NodeJS",
  "yarn.lock": "NodeJS",
  "composer.json": "PHP",
  "composer.lock": "PHP",
  "Gemfile": "Ruby",
  "Gemfile.lock": "Ruby",
  "mix.exs": "Elixir",
  "shard.yml": "Crystal",
  "dub.json": "D",
  "dub.sdl": "D",
  "stack.yaml": "Haskell",
  "cabal.project": "Haskell",
  "elm.json": "Elm",
  "elm-package.json": "Elm",
  "rebar.config": "Erlang",
  "rebar.lock": "Erlang",
  "rebar3.config": "Erlang",
  "rebar3.lock": "Erlang",
  "pubspec.yaml": "Dart",
  "pubspec.lock": "Dart",
  "nuget.config": "C#",
  "project.json": "C#"
}
````

## File: src/metagit/data/README.md
````markdown
# Metagit Data

Much of this is unused currently. The only item that matters is the `metagit.config.yaml` file which represents the default configuration if none is passed in or found in the default or directory.
````

## File: tasks/Taskfile.api.yml
````yaml
version: "3"
silent: true

tasks:
  show:
    desc: Show python variables for this task
    cmds:
      - |
        echo "GIT_LATEST_TAG: {{.GIT_LATEST_TAG}}"
        echo "VERSION: {{.VERSION}}"

  test:detect:
    desc: Run the api tests
    cmds:
      - |
        curl -X POST "http://localhost:8000/detect" \
          -H "Content-Type: application/json" \
          -d '{
            "repository_url": "https://github.com/zloeber/mag-branch",
            "priority": "normal"
          }'

  dev:
    desc: Run the api in dev mode
    env:
      OPENSEARCH_HOST: localhost
      OPENSEARCH_PORT: 9200
      OPENSEARCH_INDEX: metagit-records
      OPENSEARCH_USE_SSL: false
      OPENSEARCH_VERIFY_CERTS: false
      API_HOST: 0.0.0.0
      API_PORT: 8000
      API_DEBUG: true
      MAX_CONCURRENT_JOBS: 5
    cmds:
      - |
        docker compose -f docker-compose.dev.yml up -d
        uv run -m metagit.api.main
````

## File: tasks/Taskfile.docker.yml
````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: "3"
silent: true
vars:
  local_bin_path:
    sh: if [[ "{{.LOCAL_BIN_PATH}}" == "" ]]; then echo "./.local/bin"; else echo "{{.LOCAL_BIN_PATH}}"; fi
  ROVER_VERSION: '{{default "0.2.2" .ROVER_VERSION}}'
  DOCKER_BUILDKIT: 1
  DOCKER_SERVER: '{{default "{{cookiecutter.docker_registry}}" .DOCKER_SERVER}}'
  DOCKER_FILE: '{{default "Dockerfile" .DOCKER_FILE}}'
  DOCKER_PATH: '{{default "." .DOCKER_PATH}}'
  DOCKER_EXTRACT_PATH: '{{default "." .DOCKER_EXTRACT_PATH}}'
  DOCKER_IMAGE: '{{default "." .PROJECT}}'
  docker: docker
  IS_CI: "{{default 0 .IS_CI}}"

tasks:
  show:
    desc: Show terraform variables for this task
    cmds:
      - |
        echo "DOCKER_IMAGE: {{.DOCKER_IMAGE}}"
        echo "DOCKER_FILE: {{.DOCKER_FILE}}"
        echo "DOCKER_PATH: {{.DOCKER_PATH}}"
        echo "local_bin_path: {{.local_bin_path}}"

  login:
    desc: Login to container registry
    cmds:
      - |
        {{.docker}} login {{.DOCKER_SERVER}}

  tag:
    desc: Tag container image
    cmds:
      - |
        {{.docker}} tag {{.DOCKER_IMAGE}}:local {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:{{.GIT_COMMIT}}
        {{.docker}} tag {{.DOCKER_IMAGE}}:local {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:{{.VERSION}}
        {{.docker}} tag {{.DOCKER_IMAGE}}:local {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:latest

  push:
    desc: Push tagged images to registry
    cmds:
      - |
        echo "Pushing container image to registry: latest {{.VERSION}} {{.GIT_COMMIT}}"
        {{.docker}} push {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:{{.GIT_COMMIT}}
        {{.docker}} push {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:{{.VERSION}}
        {{.docker}} push {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:latest

  run:
    desc: Run a local container image for the app
    cmds:
      - |
        {{.docker}} run -t --rm -i --name={{.DOCKER_IMAGE}} {{.DOCKER_IMAGE}}:local {{.CLI_ARGS}}

  scan:
    desc: Run a {{.docker}} snyk security scan
    cmds:
      - |
        {{.docker}} scan {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:latest

  shell:
    desc: Run a local container image for the app
    cmds:
      - |
        {{.docker}} run -t --rm -i --name={{.DOCKER_IMAGE}} {{.DOCKER_IMAGE}}:local /bin/sh

  extract:
    desc: Example of using buildkit to extract files from an image
    cmds:
      - |
        mkdir -p {{.ROOT_DIR}} /.local/artifacts
        {{.docker}} build -f {{.DOCKER_FILE}} \
          --target artifact \
            --output type=local,dest=./.local/artifacts .

  build:
    desc: Build container image
    cmds:
      - |
        {{.docker}} build {{.DOCKER_BUILD_ARGS}} -t {{.DOCKER_IMAGE}}:local -f {{.DOCKER_FILE}} {{.DOCKER_PATH}}

  build:base:
    desc: Build base container image
    cmds:
      - |
        echo "Attempting to build the base image: ./.docker/{{.DOCKER_FILE}}.base"
        {{.docker}} build {{.DOCKER_BUILD_ARGS}} -t {{.DOCKER_IMAGE}}:local-base -f ./.docker/{{.DOCKER_FILE}}.base {{.DOCKER_PATH}}

  build:clean:
    desc: Build container image (no cache)
    cmds:
      - |
        {{.docker}} build --no-cache {{.DOCKER_BUILD_ARGS}} -t {{.DOCKER_IMAGE}}:local -f {{.DOCKER_FILE}} {{.DOCKER_PATH}}

  clean:
    desc: Clean local cached {{.docker}} elements
    cmds:
      - |
        {{.docker}} system prune
````

## File: tasks/Taskfile.git.yml
````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json

version: "3"
silent: true
tasks:
  show:
    desc: Show python variables for this task
    cmds:
      - |
        echo "GIT_LATEST_TAG: {{.GIT_LATEST_TAG}}"
        echo "VERSION: {{.VERSION}}"

  lint:
    desc: Lint
    cmds:
      - |
        failedfiles=$(find . -type f \( -iname "*.tf" -o -iname "*.yml" -o -iname "checksum.*" ! -iname ".local" ! -iname "venv*" ! -iname "*inc/Taskfile.git.yml" ! -iname ".git" ! -iname "Makefile" ! -iname "Taskfile.git.yml" \) -exec grep -l "<<<<<<< HEAD" {} \;)
        if [ "$failedfiles" ]; then
          echo "Failed git/lint files: ${failedfiles} "
          exit 1
        fi
        echo "Merge request conflict detrius not found!"

  pre-commit:
    desc: Install pre-commit hooks
    cmds:
      - pre-commit install --install-hooks
      - pre-commit run

  set:upstream:
    desc: Set the current git branch upstream to a branch by the same name on the origin
    cmds:
      - |
        GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo local)
        GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo unknown)
        git branch \
          --set-upstream-to=origin/${GIT_BRANCH} ${GIT_BRANCH}

  merge:renovate:
    desc: Attempt to merge all renovate/* branches
    cmds:
      - |
        branch=$(git symbolic-ref --short -q HEAD)
        echo "Current Branch: ${branch}"
        if [ "$branch" == "master" ]; then
          echo "Cannot run this process against master branch!"
          exit
        fi
        # Ensure working directory in version branch clean
        git update-index -q --refresh
        if ! git diff-index --quiet HEAD --; then
          echo "Working directory not clean, Committing current changes."
          git add . && git commit -m 'fix: merge in renovate updates'
        fi

        echo "Attempting to merge in all renovate/* branches found."
        echo "Any merge conflicts will need to be resolved before proceeding."
        for b in $(git branch -r | grep renovate/); do
          git merge $b;
          echo "Merged ${b} succesfully!"
        done

  # init:submodule:
  #   desc: Iitialize all git submodules
  #   cmds:
  #     - |
  #       git submodule init
  #       git submodule update --recursive --init
  #       git submodule foreach 'git checkout master || git checkout main'

  # init:
  #   desc: Initializes git with author information
  #   cmds:
  #     - |
  #       git config user.name "{{.AUTHOR_NAME}}"
  #       git config user.email "{{.AUTHOR_EMAIL}}"

  security:scan:
    desc: Perform security scan on local repo
    cmds:
      - |
        docker run --rm \
          -e "WORKSPACE=${PWD}" \
          -e ENABLE_OSS_RISK=true \
          -v $PWD:/app shiftleft/sast-scan scan \
          --src /app \
          --type credscan,nodejs,python,yaml,terraform,ansible,bash,dockerfile,bash,depscan \
          --out_dir /app/.local/reports
        echo "View results in ./.local/reports"

  gitleaks:
    desc: Perform gitleaks scan
    cmds:
      - |
        docker pull ghcr.io/zricethezav/gitleaks:latest
        docker run -v ${PWD}:/path zricethezav/gitleaks:latest \
          detect -r /path/.local/reports \
          --source="/path"

  ignore:update:
    desc: Adds a line to an existing .gitignore file if not already present
    cmds:
      - |
        if [[ "{{.TO_IGNORE}}" != "" ]]; then
          FILE=.gitignore
          echo "Attempting to add {{.TO_IGNORE}} to $FILE"
          if [ ! -f "$FILE" ]; then
              echo "{{.TO_IGNORE}}" > "$FILE"
          else
              if ! grep -q '{{.TO_IGNORE}}' "$FILE"; then
                echo -e "\n{{.TO_IGNORE}}" >> "$FILE"
              fi
          fi
          sed -i '/^$/d' "$FILE"
        else
          echo "Need to define the TO_IGNORE variable"
        fi
````

## File: tasks/Taskfile.workspace.yml
````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json

version: "3"
silent: true
vars:
  workspace_profiles: "{{.ROOT_DIR}}/profiles"
  workspaces: "workspace"
  PROFILE: '{{default "default" .PROFILE}}'
  CONFIG_FILE: "{{.workspace_profiles}}/{{.PROFILE}}.yml"
  PULL_UPDATES: '{{default "false" .PULL_UPDATES}}'

tasks:
  show:
    desc: Show variables for workspace tasks
    cmds:
      - |
        REPO_COUNT=$(yq '.repos | length' {{.CONFIG_FILE}})
        WORKSPACE=${WORKSPACE:-"$(yq '.workspace' {{.CONFIG_FILE}} )"}
        WORKSPACE_PROJECT=$(yq '.project' {{.CONFIG_FILE}})
        TARGET_PATH="{{.ROOT_DIR}}/${WORKSPACE}/${WORKSPACE_PROJECT}"
        echo "ROOT_DIR: {{.ROOT_DIR}}"
        echo "workspace_profiles: {{.workspace_profiles}}"
        echo "CONFIG_FILE: {{.CONFIG_FILE}}"
        echo "WORKSPACE: ${WORKSPACE}"
        echo "PROFILE: {{.PROFILE}}"
        echo "REPO_COUNT: ${REPO_COUNT}"
        echo "TARGET_PATH: ${TARGET_PATH}"

  sync:
    desc: Initialize and/or sync workspace for profile
    cmds:
      - |
        pushd () {
          command pushd "$@" > /dev/null
        }
        popd () {
          command popd "$@" > /dev/null
        }
        WORKSPACE=${WORKSPACE:-"$(yq '.workspace' {{.CONFIG_FILE}} )"}
        WORKSPACE_PROJECT=$(yq '.project' {{.CONFIG_FILE}})
        TARGET_PATH="{{.ROOT_DIR}}/${WORKSPACE}/${WORKSPACE_PROJECT}"
        REPO_COUNT=$(yq '.repos | length' {{.CONFIG_FILE}})
        echo "WORKSPACE: ${WORKSPACE}"
        echo "CONFIG_FILE: {{.CONFIG_FILE}}"
        echo "PROFILE: {{.PROFILE}}"
        echo "REPO_COUNT: ${REPO_COUNT}"
        echo "TARGET_PATH: ${TARGET_PATH}"
        mkdir -p "${TARGET_PATH}" >/dev/null
        echo "---"
        REPO_INDEX=0
        pushd ${TARGET_PATH}
        while [ $REPO_INDEX -lt $REPO_COUNT ]; do
          cd ${TARGET_PATH}
          CURRENT_NAME=$(yq ".repos.[$REPO_INDEX].name" {{.CONFIG_FILE}})
          CURRENT_URL=$(yq ".repos.[$REPO_INDEX].url" {{.CONFIG_FILE}})
          CLONE_BRANCHES=$(yq ".repos.[$REPO_INDEX].branches | .[]" {{.CONFIG_FILE}})
          CLONE_PATH="${TARGET_PATH}/${CURRENT_NAME}"
          echo "Sync Project: ${CURRENT_NAME}"
          mkdir -p ${CLONE_PATH}
          git clone --recursive ${CURRENT_URL} ${CLONE_PATH} || true
          cd ${CLONE_PATH}
          CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "none" )
          if [[ "{{.PULL_UPDATES}}" != "false" ]]; then
              echo "Running branch updates..."
              for BRANCH in $CLONE_BRANCHES; do
                  echo "  - Update branch: $BRANCH"
                  git checkout $BRANCH
                  git pull --autostash 2>/dev/null
                  #git stash 2>/dev/null
                  #git pull
                  #git stash pop
              done
              git checkout {{.ROOT_DIR}} 2>/dev/null
          fi
          let REPO_INDEX=REPO_INDEX+1
          echo ""
        done
        popd
        echo "Workspace Created: ${TARGET_PATH}"
      - task: vscode:create

  vscode:select:
    desc: Use fzf to select a project in a workspace and open with vscode
    silent: false
    cmds:
      - |
        WORKSPACE=${WORKSPACE:-"$(yq '.workspace' {{.CONFIG_FILE}} )"}
        WORKSPACE_PROJECT=$(yq '.project' {{.CONFIG_FILE}})
        TARGET_PATH="${WORKSPACE}/${WORKSPACE_PROJECT}"
        SELECTION=$(find ${TARGET_PATH} -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort | fzf) && \
        [ "$SELECTION" != "./" ] && code "./${TARGET_PATH}/${SELECTION}" || true

  select:
    desc: Use fzf to select a workspace and open with vscode
    cmds:
      - |
        WORKSPACE=${WORKSPACE:-"$(yq '.workspace' {{.CONFIG_FILE}} )"}
        WORKSPACE_PROJECT=$(yq '.project' {{.CONFIG_FILE}})
        TARGET_PATH="${WORKSPACE}"
        SELECTION=$(find ${TARGET_PATH} -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort | fzf) && \
        [ "$SELECTION" != "./" ] && code "./${TARGET_PATH}/${SELECTION}/workspace.code-workspace" || true

  vscode:create:
    desc: Create vscode workspace file relative to this project
    cmds:
      - |
        WORKSPACE=${WORKSPACE:-"$(yq '.workspace' {{.CONFIG_FILE}} )"}
        WORKSPACE_PROJECT=$(yq '.project' {{.CONFIG_FILE}})
        CONFIG_FILE={{.CONFIG_FILE}}
        REPO_PATHS="$(yq '.repos.[].name' {{.CONFIG_FILE}})"
        VSCODEWS=$(cat ./scripts/vscode-workspace.tpl)
        for wspath in $REPO_PATHS
        do
            newpath="./$wspath"
            echo "Adding path: ${newpath}"
            VSCODEWS=$(echo $VSCODEWS | jq --arg newpath "$newpath" '.folders += [{ "path": $newpath }]')
        done
        echo $VSCODEWS | jq > {{.ROOT_DIR}}/${WORKSPACE}/${WORKSPACE_PROJECT}/workspace.code-workspace
        echo "Workspace file for VSCode created: ./${WORKSPACE}/${WORKSPACE_PROJECT}/workspace.code-workspace"
````

## File: tests/conftest.py
````python
@pytest.fixture(scope="session", autouse=True)
def load_env()
⋮----
"""Load environment variables from .env_example for all tests."""
⋮----
@pytest.fixture(scope="session", autouse=True)
def configure_logging()
⋮----
"""Configure logging for tests to prevent file I/O errors."""
# Set environment variable to disable file logging during tests
⋮----
os.environ["METAGIT_LOG_LEVEL"] = "WARNING"  # Reduce log noise during tests
⋮----
# Remove all existing loguru handlers to prevent file I/O issues
⋮----
# Add a simple console handler for tests
⋮----
lambda msg: None,  # Null sink to suppress output during tests
⋮----
@pytest.fixture(autouse=True)
def cleanup_logging()
⋮----
"""Clean up logging after each test to prevent file handle issues."""
⋮----
# Remove any handlers that might have been added during the test
⋮----
@pytest.fixture
def temp_dir()
⋮----
"""Create a temporary directory for test use."""
````

## File: tests/test_appconfig_init.disabled
````
#!/usr/bin/env python
"""
Unit tests for metagit.core.appconfig.__init__
"""

import yaml

from metagit.core.appconfig import get_config, load_config
from metagit.core.appconfig.models import AppConfig


def test_load_config_success(tmp_path):
    config_path = tmp_path / "testconfig.yaml"
    data = {"config": AppConfig().model_dump()}
    with open(config_path, "w") as f:
        yaml.dump(data, f)
    cfg = load_config(str(config_path))
    assert isinstance(cfg, AppConfig)


def test_load_config_file_not_found(tmp_path):
    result = load_config(str(tmp_path / "nope.yaml"))
    assert isinstance(result, Exception)


def test_get_config_dict(capsys):
    cfg = AppConfig()
    result = get_config(cfg, output="dict")
    assert isinstance(result, dict)


def test_get_config_json(capsys):
    cfg = AppConfig()
    get_config(cfg, output="json")
    captured = capsys.readouterr()
    assert "config" in captured.out or "config" in captured.err


def test_get_config_yaml(capsys):
    cfg = AppConfig()
    get_config(cfg, output="yaml")
    captured = capsys.readouterr()
    assert "config" in captured.out or "config" in captured.err
````

## File: tests/test_detect_manager.disabled
````
#!/usr/bin/env python3
"""
Unit tests for DetectionManager and DetectionManagerConfig.
"""

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from metagit.core.config.models import Language, ProjectDomain, ProjectType
from metagit.core.detect import (
    BranchInfo,
    CIConfigAnalysis,
    DetectionManager,
    DetectionManagerConfig,
    GitBranchAnalysis,
    LanguageDetection,
    ProjectTypeDetection,
)
from metagit.core.record.models import MetagitRecord


@pytest.fixture
def default_path(tmp_path):
    # Use a temporary directory for path
    return str(tmp_path)


def test_config_all_enabled():
    config = DetectionManagerConfig.all_enabled()
    enabled = config.get_enabled_methods()
    assert set(enabled) == {
        "branch_analysis",
        "ci_config_analysis",
        "directory_summary",
        "directory_details",
        "commit_analysis",
        "tag_analysis",
    }


def test_config_minimal():
    config = DetectionManagerConfig.minimal()
    enabled = config.get_enabled_methods()
    assert set(enabled) == {"branch_analysis", "ci_config_analysis"}


def test_config_toggle():
    config = DetectionManagerConfig(
        branch_analysis_enabled=False,
        ci_config_analysis_enabled=True,
        directory_summary_enabled=True,
        directory_details_enabled=False,
    )
    enabled = config.get_enabled_methods()
    assert set(enabled) == {"ci_config_analysis", "directory_summary"}


def test_detection_manager_from_path(default_path):
    """Test creating DetectionManager from path."""
    config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=True,
        directory_summary_enabled=True,
        directory_details_enabled=True,
    )

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)
        assert manager.path == default_path
        # Use the actual path name instead of hardcoded "tmp_path"
        expected_name = Path(default_path).name
        assert manager.name == expected_name
        assert manager.detection_config == config


def test_detection_manager_from_path_with_existing_config(default_path):
    """Test creating DetectionManager from path with existing config."""
    existing_config = MetagitRecord(
        name="test-project", description="A test project", kind="application"
    )

    config = DetectionManagerConfig.minimal()

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=existing_config,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)
        assert manager.name == "test-project"
        assert manager.description == "A test project"
        assert manager.kind == "application"


def test_detection_manager_run_all_and_summary(default_path):
    config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=True,
        directory_summary_enabled=True,
        directory_details_enabled=True,
    )

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)

        # Create a mock RepositoryAnalysis with all analysis results
        mock_repo_analysis = RepositoryAnalysis(
            path=default_path,
            name="test-project",
            description="A test project",
            url="https://github.com/test/project",
            language_detection=LanguageDetection(primary="Python"),
            project_type_detection=ProjectTypeDetection(
                type=ProjectType.APPLICATION, domain=ProjectDomain.WEB
            ),
            branch_analysis=GitBranchAnalysis(
                strategy_guess="Unknown",
                branches=[BranchInfo(name="main", is_remote=False)],
            ),
            ci_config_analysis=CIConfigAnalysis(detected_tool="TestCI"),
            metadata=None,
            metrics=None,
        )

        # Patch RepositoryAnalysis.from_path to return our mock
        with patch(
            "metagit.core.detect.manager.RepositoryAnalysis.from_path",
            return_value=mock_repo_analysis,
        ):
            result = manager.run_all()
            assert result is None
            assert manager.analysis_completed is True
            assert manager.repository_analysis is not None

            # Patch missing fields for summary
            object.__setattr__(manager, "domain", ProjectDomain.WEB)
            object.__setattr__(manager, "kind", ProjectType.APPLICATION)
            object.__setattr__(manager, "language", Language(primary="Python"))
            object.__setattr__(manager, "language_version", "3.9")

            summary = manager.summary()
            assert isinstance(summary, str)
            assert "Unknown" in summary
            assert "TestCI" in summary


def test_detection_manager_run_specific(default_path):
    config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=False,
        directory_summary_enabled=False,
        directory_details_enabled=False,
    )

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)

        # Test that disabled methods return Exception
        result = manager.run_specific("ci_config_analysis")
        assert isinstance(result, Exception)

        # Test that enabled methods delegate to run_all
        with patch.object(manager, "run_all", return_value=None) as mock_run_all:
            result = manager.run_specific("branch_analysis")
            assert result is None
            mock_run_all.assert_called_once()


def test_detection_manager_run_specific_unknown_method(default_path):
    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path)
        assert not isinstance(manager, Exception)

        result = manager.run_specific("unknown_method")
        assert isinstance(result, Exception)
        assert "Unknown analysis method" in str(result)


def test_detection_manager_summary_handles_missing(default_path):
    config = DetectionManagerConfig.minimal()

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)

        # Test summary without running analysis
        summary = manager.summary()
        assert isinstance(summary, str)
        assert "not available" in summary


def test_detection_manager_serialization(default_path):
    config = DetectionManagerConfig.minimal()

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)

        # Test YAML serialization
        yaml_output = manager.to_yaml()
        assert isinstance(yaml_output, str)
        assert "name:" in yaml_output
        assert "project_path:" in yaml_output

        # Test JSON serialization
        json_output = manager.to_json()
        assert isinstance(json_output, str)
        assert '"name"' in json_output
        assert '"project_path"' in json_output


def test_detection_manager_inherits_from_metagit_record(default_path):
    """Test that DetectionManager properly inherits from MetagitRecord."""
    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path)
        assert not isinstance(manager, Exception)

        # Should have MetagitRecord attributes
        assert hasattr(manager, "name")
        assert hasattr(manager, "path")
        assert hasattr(manager, "detection_timestamp")
        assert hasattr(manager, "detection_source")
        assert hasattr(manager, "detection_version")

        # Should also have DetectionManager-specific attributes
        assert hasattr(manager, "detection_config")
        assert hasattr(manager, "repository_analysis")
        assert hasattr(manager, "analysis_completed")
        assert hasattr(manager, "project_path")


def test_detection_manager_cleanup(default_path):
    """Test that DetectionManager cleanup delegates to RepositoryAnalysis."""
    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path)
        assert not isinstance(manager, Exception)

        # Create a mock RepositoryAnalysis
        mock_repo_analysis = MagicMock()
        manager.repository_analysis = mock_repo_analysis

        # Test cleanup
        manager.cleanup()
        mock_repo_analysis.cleanup.assert_called_once()


def test_detection_manager_update_metagit_record(default_path):
    """Test that DetectionManager properly updates MetagitRecord fields from RepositoryAnalysis."""
    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path)
        assert not isinstance(manager, Exception)

        # Create a mock RepositoryAnalysis with data
        mock_repo_analysis = RepositoryAnalysis(
            path=default_path,
            name="test-project",
            description="A test project",
            url="https://github.com/test/project",
            language_detection=LanguageDetection(primary="Python"),
            project_type_detection=ProjectTypeDetection(
                type=ProjectType.APPLICATION, domain=ProjectDomain.WEB
            ),
            branch_analysis=GitBranchAnalysis(
                strategy_guess="GitHub Flow",
                branches=[BranchInfo(name="main", is_remote=False)],
            ),
            ci_config_analysis=CIConfigAnalysis(detected_tool="GitHub Actions"),
        )

        manager.repository_analysis = mock_repo_analysis

        # Test _update_metagit_record
        manager._update_metagit_record()

        # Check that MetagitRecord fields were updated
        assert manager.name == "test-project"
        assert manager.description == "A test project"
        assert manager.url == "https://github.com/test/project"
        assert manager.language.primary == "Python"
        assert manager.kind == ProjectType.APPLICATION
        assert manager.domain == ProjectDomain.WEB
        assert manager.branch_strategy == "GitHub Flow"
        assert len(manager.branches) == 1
        assert manager.branches[0].name == "main"
        assert manager.cicd.platform == "GitHub Actions"
````

## File: tests/test_gitcache.py
````python
#!/usr/bin/env python
"""
Unit tests for git cache management system.

This module contains comprehensive tests for the GitCacheConfig,
GitCacheEntry, and GitCacheManager classes.
"""
⋮----
test_repo_url = "https://github.com/metagit-ai/metagit-detect.git"
test_repo_url_no_ext = "https://github.com/metagit-ai/metagit-detect"
test_repo_does_not_exist = "https://github.com/metagit-ai/does-not-exist.git"
repo_name = "metagit-detect"
⋮----
class TestGitCacheEntry(unittest.TestCase)
⋮----
"""Test cases for GitCacheEntry model."""
⋮----
def setUp(self)
⋮----
"""Set up test fixtures."""
⋮----
def tearDown(self)
⋮----
"""Clean up test fixtures."""
⋮----
def test_git_cache_entry_creation(self)
⋮----
"""Test creating a GitCacheEntry."""
entry = GitCacheEntry(
⋮----
def test_git_cache_entry_with_string_path(self)
⋮----
"""Test creating GitCacheEntry with string path."""
⋮----
def test_git_cache_entry_metadata(self)
⋮----
"""Test GitCacheEntry with metadata."""
metadata = {"branch": "main", "commit": "abc123"}
⋮----
class TestGitCacheConfig(unittest.TestCase)
⋮----
"""Test cases for GitCacheConfig model."""
⋮----
def test_git_cache_config_defaults(self)
⋮----
"""Test GitCacheConfig with default values."""
config = GitCacheConfig()
⋮----
def test_git_cache_config_custom_values(self)
⋮----
"""Test GitCacheConfig with custom values."""
cache_root = Path(self.temp_dir) / "custom_cache"
config = GitCacheConfig(
⋮----
def test_git_cache_config_cache_root_creation(self)
⋮----
"""Test that cache root directory is created."""
cache_root = Path(self.temp_dir) / "new_cache"
config = GitCacheConfig(cache_root=cache_root)
⋮----
def test_git_cache_config_validation(self)
⋮----
"""Test GitCacheConfig validation."""
⋮----
def test_git_cache_config_get_cache_path(self)
⋮----
"""Test getting cache path for entry."""
config = GitCacheConfig(cache_root=Path(self.temp_dir))
cache_path = config.get_cache_path("test-repo")
⋮----
def test_git_cache_config_entry_management(self)
⋮----
"""Test entry management methods."""
⋮----
# Test adding entry
⋮----
# Test getting entry
retrieved_entry = config.get_entry("test-repo")
⋮----
# Test getting non-existent entry
⋮----
# Test listing entries
entries = config.list_entries()
⋮----
# Test removing entry
⋮----
# Test removing non-existent entry
⋮----
def test_git_cache_config_stale_detection(self)
⋮----
"""Test stale entry detection."""
config = GitCacheConfig(default_timeout_minutes=60)
⋮----
# Fresh entry
fresh_entry = GitCacheEntry(
⋮----
# Stale entry
stale_entry = GitCacheEntry(
⋮----
class TestGitCacheManager(unittest.TestCase)
⋮----
"""Test cases for GitCacheManager class."""
⋮----
def test_git_cache_manager_initialization(self)
⋮----
"""Test GitCacheManager initialization."""
⋮----
def test_git_cache_manager_provider_registration(self)
⋮----
"""Test provider registration."""
mock_provider = MagicMock()
⋮----
def test_git_cache_manager_generate_cache_name(self)
⋮----
"""Test cache name generation."""
# Git URL
git_url = test_repo_url
name = self.manager._generate_cache_name(git_url)
⋮----
# Git URL without .git extension
git_url_no_ext = test_repo_url_no_ext
name = self.manager._generate_cache_name(git_url_no_ext)
⋮----
# Local path
local_path = "/path/to/directory"
name = self.manager._generate_cache_name(local_path)
⋮----
def test_git_cache_manager_url_detection(self)
⋮----
"""Test URL type detection."""
# Git URLs
⋮----
# Non-git URLs
⋮----
def test_git_cache_manager_local_path_detection(self)
⋮----
"""Test local path detection."""
# Create a temporary directory
temp_dir = Path(self.temp_dir) / "test_dir"
⋮----
@patch("subprocess.run")
    def test_git_cache_manager_clone_repository(self, mock_run)
⋮----
"""Test repository cloning."""
⋮----
cache_path = Path.joinpath(Path(self.temp_dir), "test_repo")
result = self.manager._clone_repository(test_repo_url, cache_path)
⋮----
# mock_run.assert_called_once()
⋮----
@patch("git.Repo")
    def test_git_cache_manager_clone_repository_failure(self, mock_repo)
⋮----
"""Test repository cloning failure."""
⋮----
result = self.manager._clone_repository(test_repo_does_not_exist, cache_path)
⋮----
def test_git_cache_manager_copy_local_directory(self)
⋮----
"""Test local directory copying."""
# Create source directory with some files
source_dir = Path.joinpath(Path(self.temp_dir), "source")
⋮----
cache_path = Path.joinpath(Path(self.temp_dir), "cache_copy")
result = self.manager._copy_local_directory(source_dir, cache_path)
⋮----
def test_git_cache_manager_copy_local_directory_failure(self)
⋮----
"""Test local directory copying failure."""
non_existent_path = Path("/non/existent/path")
⋮----
result = self.manager._copy_local_directory(non_existent_path, cache_path)
⋮----
def test_git_cache_manager_calculate_directory_size(self)
⋮----
"""Test directory size calculation."""
# Create test directory with files
test_dir = Path.joinpath(Path(self.temp_dir), "size_test")
⋮----
size = self.manager._calculate_directory_size(test_dir)
⋮----
def test_git_cache_manager_cache_stats(self)
⋮----
"""Test cache statistics."""
# Add some test entries
entry1 = GitCacheEntry(
entry2 = GitCacheEntry(
⋮----
stats = self.manager.get_cache_stats()
⋮----
def test_git_cache_manager_remove_cache_entry(self)
⋮----
"""Test removing cache entry."""
# Create a test cache directory
cache_path = Path.joinpath(Path(self.temp_dir), "test_cache")
⋮----
result = self.manager.remove_cache_entry("test-repo")
⋮----
def test_git_cache_manager_remove_nonexistent_entry(self)
⋮----
"""Test removing non-existent cache entry."""
result = self.manager.remove_cache_entry("non-existent")
⋮----
def test_git_cache_manager_rejects_non_git_directory(self)
⋮----
"""Test that non-git directories cannot be cached as local repositories."""
# Create a plain directory (not a git repo)
non_git_dir = Path.joinpath(Path(self.temp_dir), "plain_dir")
⋮----
result = self.manager.cache_repository(str(non_git_dir))
⋮----
def test_git_cache_manager_accepts_local_git_repository(self)
⋮----
"""Test that a valid local git repository can be cached."""
# Create a git repo
git_dir = Path.joinpath(Path(self.temp_dir), "git_repo")
⋮----
# Initialize git
⋮----
result = self.manager.cache_repository(str(git_dir))
⋮----
class TestGitCacheManagerAsync(unittest.IsolatedAsyncioTestCase)
⋮----
"""Test cases for GitCacheManager async operations."""
⋮----
"""Test async repository cloning."""
# Mock subprocess
mock_process = AsyncMock()
⋮----
result = await self.manager._clone_repository_async(test_repo_url, cache_path)
⋮----
# mock_create_subprocess.assert_called_once()
⋮----
"""Test async repository cloning failure."""
# Mock git clone to raise an exception
⋮----
async def test_git_cache_manager_copy_local_directory_async(self)
⋮----
"""Test async local directory copying."""
⋮----
result = await self.manager._copy_local_directory_async(source_dir, cache_path)
⋮----
async def test_git_cache_manager_copy_local_directory_async_failure(self)
⋮----
"""Test async local directory copying failure."""
⋮----
result = await self.manager._copy_local_directory_async(
⋮----
@patch("metagit.core.gitcache.manager.GitCacheManager._clone_repository_async")
    async def test_git_cache_manager_cache_repository_async(self, mock_clone)
⋮----
"""Test async repository caching."""
⋮----
result = await self.manager.cache_repository_async(test_repo_url)
⋮----
@patch("metagit.core.gitcache.manager.GitCacheManager._copy_local_directory_async")
    async def test_git_cache_manager_cache_local_directory_async(self, mock_copy)
⋮----
"""Test async local directory caching."""
⋮----
temp_dir = Path.joinpath(Path(self.temp_dir), "test_dir")
⋮----
result = await self.manager.cache_repository_async(str(temp_dir))
⋮----
@patch("metagit.core.gitcache.manager.GitCacheManager.cache_repository_async")
    async def test_git_cache_manager_refresh_cache_entry_async(self, mock_cache)
⋮----
"""Test async cache entry refresh."""
# Create test entry
⋮----
result = await self.manager.refresh_cache_entry_async("test-repo")
````

## File: tests/test_record_conversion.py
````python
#!/usr/bin/env python
"""
Unit tests for MetagitRecord conversion methods.

This module tests the fast conversion methods between MetagitRecord and MetagitConfig
using the latest Pydantic best practices.
"""
⋮----
class TestMetagitRecordConversion(unittest.TestCase)
⋮----
"""Test cases for MetagitRecord conversion methods."""
⋮----
def setUp(self)
⋮----
"""Set up test fixtures."""
⋮----
# Detection-specific fields
⋮----
def test_to_metagit_config_basic(self)
⋮----
"""Test basic conversion from MetagitRecord to MetagitConfig."""
config = self.sample_record.to_metagit_config()
⋮----
# Should have all MetagitConfig fields
⋮----
# Should not have detection-specific fields
⋮----
def test_to_metagit_config_with_detection_fields(self)
⋮----
"""Test conversion keeping detection fields."""
# This test is removed because MetagitConfig doesn't support detection fields
# The exclude_detection_fields parameter is for future extensibility
⋮----
def test_field_differences(self)
⋮----
"""Test field difference detection."""
differences = MetagitRecord.get_field_differences()
⋮----
# Should have field difference information
⋮----
# Should have some common fields
⋮----
# Should have some record-only fields
⋮----
def test_compatible_fields(self)
⋮----
"""Test compatible field detection."""
compatible_fields = MetagitRecord.get_compatible_fields()
⋮----
# Should have some compatible fields
⋮----
# Should not include detection-specific fields
⋮----
def test_from_metagit_config_basic(self)
⋮----
"""Test basic conversion from MetagitConfig to MetagitRecord."""
record = MetagitRecord.from_metagit_config(self.sample_config)
⋮----
# Should have detection-specific fields with defaults
⋮----
def test_from_metagit_config_with_custom_detection_data(self)
⋮----
"""Test conversion with custom detection data."""
additional_data = {
⋮----
record = MetagitRecord.from_metagit_config(
⋮----
# Should have custom detection data
⋮----
def test_conversion_round_trip(self)
⋮----
"""Test round-trip conversion: Config -> Record -> Config."""
# Config -> Record
⋮----
# Record -> Config
config = record.to_metagit_config()
⋮----
# Should be equivalent to original config
⋮----
def test_conversion_round_trip_with_detection_fields(self)
⋮----
"""Test round-trip conversion keeping detection fields."""
⋮----
def test_get_detection_summary(self)
⋮----
"""Test getting detection summary."""
summary = self.sample_record.get_detection_summary()
⋮----
# Should have basic detection info
⋮----
# Should have metrics summary
⋮----
# Should have metadata summary
⋮----
def test_get_detection_summary_without_optional_fields(self)
⋮----
"""Test detection summary when optional fields are None."""
record = MetagitRecord(
⋮----
summary = record.get_detection_summary()
⋮----
# Should not have metrics or metadata
⋮----
def test_conversion_performance(self)
⋮----
"""Test that conversion is fast and efficient."""
⋮----
# Create a complex record
⋮----
# Measure conversion time
start_time = time.time()
⋮----
end_time = time.time()
⋮----
# Should complete 1000 conversions in under 1 second
conversion_time = end_time - start_time
⋮----
def test_conversion_with_complex_nested_objects(self)
⋮----
"""Test conversion with complex nested objects."""
# Create config with complex nested objects
config = MetagitConfig(
⋮----
# Convert to record
⋮----
# Convert back to config
result_config = record.to_metagit_config()
⋮----
# Should preserve complex nested objects
⋮----
def test_conversion_with_minimal_data(self)
⋮----
"""Test conversion with minimal required data."""
# Minimal config
minimal_config = MetagitConfig(name="minimal-project")
⋮----
record = MetagitRecord.from_metagit_config(minimal_config)
⋮----
# Should have required fields
⋮----
# Should preserve required fields
⋮----
def test_conversion_validation(self)
⋮----
"""Test that conversion maintains data validation."""
# Create a valid record
⋮----
# Convert to config
⋮----
# Should be a valid MetagitConfig
⋮----
# Convert back to record
new_record = MetagitRecord.from_metagit_config(config)
⋮----
# Should be a valid MetagitRecord
````

## File: tests/test_record_manager.py
````python
#!/usr/bin/env python
"""
Tests for the updated MetagitRecordManager.

This module tests the new functionality including:
- Storage backend abstraction
- Local file storage backend
- OpenSearch storage backend
- Record creation from config
- File operations
"""
⋮----
class TestRecordStorageBackend
⋮----
"""Test the abstract RecordStorageBackend class."""
⋮----
def test_abstract_methods(self)
⋮----
"""Test that RecordStorageBackend is abstract."""
⋮----
class TestLocalFileStorageBackend
⋮----
"""Test the LocalFileStorageBackend class."""
⋮----
@pytest.fixture
    def temp_dir(self)
⋮----
"""Create a temporary directory for testing."""
⋮----
@pytest.fixture
    def backend(self, temp_dir)
⋮----
"""Create a LocalFileStorageBackend instance."""
⋮----
@pytest.fixture
    def sample_record(self)
⋮----
"""Create a sample MetagitRecord for testing."""
⋮----
def test_init_creates_directory(self, temp_dir)
⋮----
"""Test that initialization creates the storage directory."""
backend = LocalFileStorageBackend(temp_dir)
⋮----
def test_ensure_index_exists(self, backend)
⋮----
"""Test that index file is created if it doesn't exist."""
index_file = backend.storage_dir / "index.json"
⋮----
index_data = json.load(f)
⋮----
def test_get_next_id(self, backend)
⋮----
"""Test getting the next available ID."""
first_id = backend._get_next_id()
⋮----
second_id = backend._get_next_id()
⋮----
@pytest.mark.asyncio
    async def test_store_record(self, backend, sample_record)
⋮----
"""Test storing a record."""
record_id = await backend.store_record(sample_record)
⋮----
# Check that record file was created
record_file = backend.storage_dir / f"{record_id}.json"
⋮----
# Check index was updated
index_data = backend._load_index()
⋮----
@pytest.mark.asyncio
    async def test_get_record(self, backend, sample_record)
⋮----
"""Test retrieving a record."""
# Store a record first
⋮----
# Retrieve the record
retrieved_record = await backend.get_record(record_id)
⋮----
@pytest.mark.asyncio
    async def test_get_record_not_found(self, backend)
⋮----
"""Test retrieving a non-existent record."""
result = await backend.get_record("nonexistent")
⋮----
@pytest.mark.asyncio
    async def test_update_record(self, backend, sample_record)
⋮----
"""Test updating a record."""
⋮----
# Update the record
updated_record = MetagitRecord(
⋮----
result = await backend.update_record(record_id, updated_record)
⋮----
# Verify the record was updated
⋮----
@pytest.mark.asyncio
    async def test_delete_record(self, backend, sample_record)
⋮----
"""Test deleting a record."""
⋮----
# Delete the record
result = await backend.delete_record(record_id)
⋮----
# Verify the record was deleted
get_result = await backend.get_record(record_id)
⋮----
@pytest.mark.asyncio
    async def test_search_records(self, backend, sample_record)
⋮----
"""Test searching records."""
⋮----
# Search for the record
search_results = await backend.search_records("test")
⋮----
@pytest.mark.asyncio
    async def test_list_records(self, backend, sample_record)
⋮----
"""Test listing records."""
⋮----
# List all records
records = await backend.list_records()
⋮----
class TestOpenSearchStorageBackend
⋮----
"""Test the OpenSearchStorageBackend class."""
⋮----
@pytest.fixture
    def mock_opensearch_service(self)
⋮----
"""Create a mock OpenSearchService."""
service = MagicMock()
⋮----
@pytest.fixture
    def backend(self, mock_opensearch_service)
⋮----
"""Create an OpenSearchStorageBackend instance."""
⋮----
@pytest.mark.asyncio
    async def test_get_record(self, backend)
⋮----
record = await backend.get_record("test-id")
⋮----
result = await backend.update_record("test-id", sample_record)
⋮----
@pytest.mark.asyncio
    async def test_delete_record(self, backend)
⋮----
result = await backend.delete_record("test-id")
⋮----
@pytest.mark.asyncio
    async def test_search_records(self, backend)
⋮----
results = await backend.search_records("test")
⋮----
@pytest.mark.asyncio
    async def test_list_records(self, backend)
⋮----
class TestMetagitRecordManager
⋮----
"""Test the MetagitRecordManager class."""
⋮----
@pytest.fixture
    def local_backend(self, temp_dir)
⋮----
@pytest.fixture
    def record_manager(self, local_backend)
⋮----
"""Create a MetagitRecordManager instance."""
⋮----
@pytest.fixture
    def sample_config(self)
⋮----
"""Create a sample MetagitConfig for testing."""
⋮----
def test_init_without_backend(self)
⋮----
"""Test initialization without storage backend."""
manager = MetagitRecordManager()
⋮----
def test_init_with_backend(self, local_backend)
⋮----
"""Test initialization with storage backend."""
manager = MetagitRecordManager(storage_backend=local_backend)
⋮----
def test_init_with_config_manager(self, local_backend)
⋮----
"""Test initialization with config manager."""
config_manager = MetagitConfigManager()
manager = MetagitRecordManager(
⋮----
def test_create_record_from_config(self, record_manager, sample_config)
⋮----
"""Test creating a record from config."""
record = record_manager.create_record_from_config(
⋮----
def test_create_record_from_config_manager(self, local_backend)
⋮----
"""Test creating a record using config manager."""
# config_manager = MetagitConfigManager()
⋮----
# This should fail because no config file exists
result = manager.create_record_from_config()
⋮----
def test_create_record_with_additional_data(self, record_manager, sample_config)
⋮----
"""Test creating a record with additional data."""
additional_data = {
⋮----
def test_get_git_info(self, record_manager)
⋮----
"""Test getting git information."""
git_info = record_manager._get_git_info()
⋮----
@pytest.mark.asyncio
    async def test_store_record_with_backend(self, record_manager, sample_config)
⋮----
"""Test storing a record with storage backend."""
⋮----
record_id = await record_manager.store_record(record)
⋮----
@pytest.mark.asyncio
    async def test_store_record_without_backend(self)
⋮----
"""Test storing a record without storage backend."""
⋮----
record = MetagitRecord(
⋮----
result = await manager.store_record(record)
⋮----
@pytest.mark.asyncio
    async def test_get_record_with_backend(self, record_manager, sample_config)
⋮----
"""Test getting a record with storage backend."""
⋮----
retrieved_record = await record_manager.get_record(record_id)
⋮----
@pytest.mark.asyncio
    async def test_get_record_without_backend(self)
⋮----
"""Test getting a record without storage backend."""
⋮----
result = await manager.get_record("test-id")
⋮----
def test_save_record_to_file(self, record_manager, sample_config, temp_dir)
⋮----
"""Test saving a record to file."""
⋮----
file_path = temp_dir / "test-record.yml"
result = record_manager.save_record_to_file(record, file_path)
⋮----
def test_load_record_from_file(self, record_manager, sample_config, temp_dir)
⋮----
"""Test loading a record from file."""
⋮----
loaded_record = record_manager.load_record_from_file(file_path)
⋮----
def test_load_record_from_nonexistent_file(self, record_manager)
⋮----
"""Test loading a record from a non-existent file."""
result = record_manager.load_record_from_file(Path("nonexistent.yml"))
````

## File: tests/test_record_models.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.record.models
"""
⋮----
class TestMetagitRecord
⋮----
"""Test MetagitRecord class."""
⋮----
def test_metagit_record_basic(self)
⋮----
"""Test basic MetagitRecord creation."""
record = record_models.MetagitRecord(
⋮----
def test_metagit_record_with_detection_attributes(self)
⋮----
"""Test MetagitRecord with detection-specific attributes."""
timestamp = datetime.now()
branches = [models.Branch(name="main", environment="production")]
metrics = models.Metrics(
metadata = models.RepoMetadata(
⋮----
def test_metagit_record_inheritance(self)
⋮----
"""Test that MetagitRecord properly inherits from MetagitConfig."""
⋮----
# Should have MetagitConfig attributes
⋮----
# Should also have MetagitRecord-specific attributes
⋮----
def test_metagit_record_serialization(self)
⋮----
"""Test MetagitRecord serialization."""
⋮----
# Test that it can be serialized to dict
record_dict = record.model_dump()
⋮----
def test_metagit_record_from_config(self)
⋮----
"""Test creating MetagitRecord from existing MetagitConfig."""
config = models.MetagitConfig(
⋮----
# Create record from config, excluding None values
config_data = config.model_dump(exclude_none=True)
⋮----
def test_metagit_record_validation_error(self)
⋮----
"""Test MetagitRecord validation error."""
⋮----
record_models.MetagitRecord()  # Missing required name field
⋮----
class TestMetagitRecordDetectionAttributes
⋮----
"""Test MetagitRecord detection-specific attributes."""
⋮----
def test_branches_attribute(self)
⋮----
"""Test branches attribute in MetagitRecord."""
branches = [
⋮----
def test_metrics_attribute(self)
⋮----
"""Test metrics attribute in MetagitRecord."""
⋮----
def test_metadata_attribute(self)
⋮----
"""Test metadata attribute in MetagitRecord."""
⋮----
def test_detection_timestamp_attribute(self)
⋮----
"""Test detection_timestamp attribute in MetagitRecord."""
⋮----
def test_detection_source_attribute(self)
⋮----
"""Test detection_source attribute in MetagitRecord."""
⋮----
def test_detection_version_attribute(self)
⋮----
"""Test detection_version attribute in MetagitRecord."""
⋮----
class TestMetagitConfigMetagitRecordSeparation
⋮----
"""Test the separation between MetagitConfig and MetagitRecord."""
⋮----
def test_config_does_not_have_detection_attributes(self)
⋮----
"""Test that MetagitConfig does not have detection attributes."""
config = models.MetagitConfig(name="test-project")
⋮----
# These should not be attributes of MetagitConfig
⋮----
def test_record_has_detection_attributes(self)
⋮----
"""Test that MetagitRecord has detection attributes."""
record = record_models.MetagitRecord(name="test-project")
⋮----
# These should be attributes of MetagitRecord
⋮----
def test_config_save_does_not_include_detection_data(self)
⋮----
"""Test that MetagitConfig serialization doesn't include detection data."""
⋮----
config_dict = config.model_dump()
⋮----
# Should not contain detection-specific keys
⋮----
def test_record_save_includes_detection_data(self)
⋮----
"""Test that MetagitRecord serialization includes detection data."""
⋮----
# Should contain detection-specific keys
⋮----
# Should also contain config keys
````

## File: tests/test_utils_common_integration.py
````python
#!/usr/bin/env python
"""
Integration test for metagit.core.utils.common
"""
⋮----
def test_common_integration(tmp_path)
⋮----
# Create a dict, flatten, merge, and pretty print
d1 = {"a": {"b": 1}, "c": 2}
d2 = {"a": {"b": 3}, "d": 4}
flat1 = common.flatten_dict(d1)
flat2 = common.flatten_dict(d2)
merged = common.merge_dicts(d1.copy(), d2)
pretty_str = common.pretty(merged)
````

## File: tests/test_utils_common.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.common
"""
⋮----
def test_normalize_git_url()
⋮----
"""Test URL normalization function."""
# Test removing trailing slashes
⋮----
# Test URLs without trailing slashes (should remain unchanged)
⋮----
# Test edge cases
⋮----
def test_normalize_git_url_with_httpurl_object()
⋮----
"""Test URL normalization with HttpUrl objects."""
⋮----
# Test with HttpUrl object
http_url = HttpUrl("https://github.com/user/repo/")
result = common.normalize_git_url(http_url)
⋮----
def test_create_vscode_workspace()
⋮----
project_name = "TestProject"
repo_paths = ["/path/to/repo1", "/path/to/repo2"]
result = common.create_vscode_workspace(project_name, repo_paths)
⋮----
data = json.loads(result)
⋮----
def test_create_vscode_workspace_error(monkeypatch)
⋮----
# Simulate error in json.dumps
⋮----
result = common.create_vscode_workspace("x", ["/bad/path"])
⋮----
def test_open_editor_file_not_exist(tmp_path)
⋮----
result = common.open_editor("echo", str(tmp_path / "nope.txt"))
⋮----
def test_open_editor_success(tmp_path, monkeypatch)
⋮----
file_path = tmp_path / "file.txt"
⋮----
result = common.open_editor("echo", str(file_path))
⋮----
def test_open_editor_failure(tmp_path, monkeypatch)
⋮----
class FakeResult
⋮----
returncode = 1
stderr = "fail"
⋮----
def test_flatten_dict()
⋮----
d = {"a": {"b": 1}, "c": 2}
flat = common.flatten_dict(d)
⋮----
def test_flatten_dict_error(monkeypatch)
⋮----
result = common.flatten_dict({"a": 1})
⋮----
def test_regex_replace()
⋮----
s = "hello world"
out = common.regex_replace(s, "world", "pytest")
⋮----
def test_regex_replace_error(monkeypatch)
⋮----
result = common.regex_replace("a", "b", "c")
⋮----
def test_env_override(monkeypatch)
⋮----
def test_env_override_error(monkeypatch)
⋮----
result = common.env_override("a", "b")
⋮----
def test_to_yaml_dict()
⋮----
d = {"a": 1}
y = common.to_yaml(d)
⋮----
def test_to_yaml_str()
⋮----
s = "already yaml"
⋮----
def test_to_yaml_error(monkeypatch)
⋮----
# Mock the yaml module that's imported as 'yaml' in common.py
⋮----
result = common.to_yaml({"a": 1})
⋮----
def test_pretty()
⋮----
out = common.pretty(d, indent=2)
⋮----
def test_pretty_error(monkeypatch)
⋮----
result = common.pretty({"a": 1})
⋮----
result = e
⋮----
def test_merge_dicts()
⋮----
a = {"x": 1, "y": {"z": 2}}
b = {"y": {"z": 3}, "w": 4}
out = common.merge_dicts(a, b)
⋮----
def test_merge_dicts_error(monkeypatch)
⋮----
result = common.merge_dicts({"a": 1}, {"b": 2})
⋮----
def test_parse_checksum_file(tmp_path)
⋮----
file = tmp_path / "checksums.txt"
⋮----
out = common.parse_checksum_file(str(file))
⋮----
def test_parse_checksum_file_error(tmp_path)
⋮----
out = common.parse_checksum_file(str(tmp_path / "nope.txt"))
````

## File: tests/test_utils_files.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.files
"""
⋮----
def test_is_binary_file(tmp_path)
⋮----
# Create a text file
text_file = tmp_path / "file.txt"
⋮----
# Create a binary file
bin_file = tmp_path / "file.bin"
⋮----
def test_get_file_size(tmp_path)
⋮----
f = tmp_path / "a.txt"
⋮----
def test_list_files(tmp_path)
⋮----
files_list = files.list_files(str(tmp_path))
⋮----
def test_read_file_lines(tmp_path)
⋮----
lines = files.read_file_lines(str(f))
⋮----
def test_write_file_lines(tmp_path)
⋮----
def test_copy_file(tmp_path)
⋮----
src = tmp_path / "src.txt"
dst = tmp_path / "dst.txt"
⋮----
# Copy non-existent file
⋮----
def test_remove_file(tmp_path)
⋮----
def test_make_dir(tmp_path)
⋮----
d = tmp_path / "newdir"
⋮----
# Already exists
⋮----
def test_remove_dir(tmp_path)
⋮----
d = tmp_path / "d"
````

## File: tests/test_utils_logging.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.logging
"""
⋮----
def test_get_logger_returns_logger()
⋮----
logger = metagit_logging.get_logger("test_logger")
⋮----
def test_logger_log_levels(monkeypatch)
⋮----
logger = metagit_logging.get_logger("test_logger2")
# Test that the logger has the expected methods
⋮----
# Test setting log level
result = logger.set_level("DEBUG")
⋮----
# Test logging methods (they should not raise exceptions)
debug_result = logger.debug("debug message")
info_result = logger.info("info message")
error_result = logger.error("error message")
````

## File: tests/test_utils_userprompt.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.userprompt
"""
⋮----
def test_yes_no_prompt_yes(monkeypatch)
⋮----
def test_yes_no_prompt_no(monkeypatch)
⋮----
def test_yes_no_prompt_invalid(monkeypatch)
⋮----
responses = iter(["maybe", "y"])
````

## File: tests/test_utils_yaml_class.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.yaml_class
"""
⋮----
def test_yaml_load()
⋮----
yaml_str = "a: 1\nb: [2, 3]"
loaded = yaml_class.load(yaml_str)
⋮----
def test_yaml_load_error()
⋮----
result = yaml_class.load(": not yaml :")
⋮----
def test_yaml_load_empty()
````

## File: .cursorignore
````
.venv
.vscode
node_modules
````

## File: .dockerignore
````
dist/
.github/
tasks/
Taskfile.yml
mkdocs.yml
site/
.venv/
````

## File: .editorconfig
````
# EditorConfig is awesome: https://editorconfig.org

# top-most EditorConfig file
root = true

# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true

# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,py}]
charset = utf-8

[*.py]
indent_style = space
indent_size = 2

# Tab indentation (no size specified)
[Makefile]
indent_style = tab

# Indentation override for all JS under lib directory
[lib/**.js]
indent_style = space
indent_size = 2

# Matches the exact files either package.json or .travis.yml
[{package.json,.travis.yml}]
indent_style = space
indent_size = 2
````

## File: .envrc
````
set -a
if [ -f .env ]; then
  source .env
fi
set +a
````

## File: .gitleaksignore
````
07f8423d068299ff902645172db6468fc2f1f57c:.metagit.yml:slack-webhook-url:99
````

## File: Dockerfile
````dockerfile
# Build stage
FROM python:3.13-slim AS builder

# Install curl and uv (recommended install method for uv)
RUN apt-get update && apt-get install -y curl git \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* \
    && pip install uv
ARG DEPLOY_VERSION
# Set environment variables
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    VERSION=$DEPLOY_VERSION

WORKDIR /app

# Copy full source including .git for dynamic versioning
COPY . /app

RUN \
  if [ -n "$VERSION" ]; then \
    SETUPTOOLS_SCM_PRETEND_VERSION=$VERSION; \
  fi && \
  uv sync --frozen && \
  uv build --wheel

# Final stage - only the installed application
FROM python:3.13-slim

RUN apt-get update && apt-get install -y git

# Set environment variables
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Install uv
RUN pip install uv

# Copy the wheel file from builder stage
COPY --from=builder /app/dist/*.whl /tmp/

# Install the wheel file
RUN uv pip install --system /tmp/*.whl && rm -rf /tmp/*.whl

# Create non-root user
RUN useradd --create-home --shell /bin/bash app
USER app

# Run the application as a CLI
ENTRYPOINT ["metagit"]
CMD []
````

## File: MANIFEST.in
````
include README.md LICENSE 
recursive-include src/metagit/ *.py
recursive-include src/metagit/data *
exclude tests/*
exclude tasks/*
exclude examples/*
exclude Dockerfile
exclude site/*
exclude .github/*
exclude .vscode/*
exclude .envrc
exclude .env
exclude .gitignore
exclude .gitmodules
exclude .gitignore
exclude .cursor/*
````

## File: .cursor/skills/gitnexus/gitnexus-cli/SKILL.md
````markdown
---
name: gitnexus-cli
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
---

# GitNexus CLI Commands

All commands work via `npx` — no global install required.

## Commands

### analyze — Build or refresh the index

```bash
npx gitnexus analyze
```

Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.

| Flag           | Effect                                                           |
| -------------- | ---------------------------------------------------------------- |
| `--force`      | Force full re-index even if up to date                           |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |

**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.

### status — Check index freshness

```bash
npx gitnexus status
```

Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.

### clean — Delete the index

```bash
npx gitnexus clean
```

Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.

| Flag      | Effect                                            |
| --------- | ------------------------------------------------- |
| `--force` | Skip confirmation prompt                          |
| `--all`   | Clean all indexed repos, not just the current one |

### wiki — Generate documentation from the graph

```bash
npx gitnexus wiki
```

Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).

| Flag                | Effect                                    |
| ------------------- | ----------------------------------------- |
| `--force`           | Force full regeneration                   |
| `--model <model>`   | LLM model (default: minimax/minimax-m2.5) |
| `--base-url <url>`  | LLM API base URL                          |
| `--api-key <key>`   | LLM API key                               |
| `--concurrency <n>` | Parallel LLM calls (default: 3)           |
| `--gist`            | Publish wiki as a public GitHub Gist      |

### list — Show all indexed repos

```bash
npx gitnexus list
```

Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.

## After Indexing

1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task

## Troubleshooting

- **"Not inside a git repository"**: Run from a directory inside a git repo
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
````

## File: .cursor/skills/gitnexus/gitnexus-debugging/SKILL.md
````markdown
---
name: gitnexus-debugging
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
---

# Debugging with GitNexus

## When to Use

- "Why is this function failing?"
- "Trace where this error comes from"
- "Who calls this method?"
- "This endpoint returns 500"
- Investigating bugs, errors, or unexpected behavior

## Workflow

```
1. gitnexus_query({query: "<error or symptom>"})            → Find related execution flows
2. gitnexus_context({name: "<suspect>"})                    → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name}                → Trace execution flow
4. gitnexus_cypher({query: "MATCH path..."})                 → Custom traces if needed
```

> If "Index is stale" → run `npx gitnexus analyze` in terminal.

## Checklist

```
- [ ] Understand the symptom (error message, unexpected behavior)
- [ ] gitnexus_query for error text or related code
- [ ] Identify the suspect function from returned processes
- [ ] gitnexus_context to see callers and callees
- [ ] Trace execution flow via process resource if applicable
- [ ] gitnexus_cypher for custom call chain traces if needed
- [ ] Read source files to confirm root cause
```

## Debugging Patterns

| Symptom              | GitNexus Approach                                          |
| -------------------- | ---------------------------------------------------------- |
| Error message        | `gitnexus_query` for error text → `context` on throw sites |
| Wrong return value   | `context` on the function → trace callees for data flow    |
| Intermittent failure | `context` → look for external calls, async deps            |
| Performance issue    | `context` → find symbols with many callers (hot paths)     |
| Recent regression    | `detect_changes` to see what your changes affect           |

## Tools

**gitnexus_query** — find code related to error:

```
gitnexus_query({query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```

**gitnexus_context** — full context for a suspect:

```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates (external API!)
→ Processes: CheckoutFlow (step 3/7)
```

**gitnexus_cypher** — custom call chain traces:

```cypher
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
RETURN [n IN nodes(path) | n.name] AS chain
```

## Example: "Payment endpoint returns 500 intermittently"

```
1. gitnexus_query({query: "payment error handling"})
   → Processes: CheckoutFlow, ErrorHandling
   → Symbols: validatePayment, handlePaymentError

2. gitnexus_context({name: "validatePayment"})
   → Outgoing calls: verifyCard, fetchRates (external API!)

3. READ gitnexus://repo/my-app/process/CheckoutFlow
   → Step 3: validatePayment → calls fetchRates (external)

4. Root cause: fetchRates calls external API without proper timeout
```
````

## File: .cursor/skills/gitnexus/gitnexus-exploring/SKILL.md
````markdown
---
name: gitnexus-exploring
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
---

# Exploring Codebases with GitNexus

## When to Use

- "How does authentication work?"
- "What's the project structure?"
- "Show me the main components"
- "Where is the database logic?"
- Understanding code you haven't seen before

## Workflow

```
1. READ gitnexus://repos                          → Discover indexed repos
2. READ gitnexus://repo/{name}/context             → Codebase overview, check staleness
3. gitnexus_query({query: "<what you want to understand>"})  → Find related execution flows
4. gitnexus_context({name: "<symbol>"})            → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name}      → Trace full execution flow
```

> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.

## Checklist

```
- [ ] READ gitnexus://repo/{name}/context
- [ ] gitnexus_query for the concept you want to understand
- [ ] Review returned processes (execution flows)
- [ ] gitnexus_context on key symbols for callers/callees
- [ ] READ process resource for full execution traces
- [ ] Read source files for implementation details
```

## Resources

| Resource                                | What you get                                            |
| --------------------------------------- | ------------------------------------------------------- |
| `gitnexus://repo/{name}/context`        | Stats, staleness warning (~150 tokens)                  |
| `gitnexus://repo/{name}/clusters`       | All functional areas with cohesion scores (~300 tokens) |
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens)              |
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens)              |

## Tools

**gitnexus_query** — find execution flows related to a concept:

```
gitnexus_query({query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```

**gitnexus_context** — 360-degree view of a symbol:

```
gitnexus_context({name: "validateUser"})
→ Incoming calls: loginHandler, apiMiddleware
→ Outgoing calls: checkToken, getUserById
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
```

## Example: "How does payment processing work?"

```
1. READ gitnexus://repo/my-app/context       → 918 symbols, 45 processes
2. gitnexus_query({query: "payment processing"})
   → CheckoutFlow: processPayment → validateCard → chargeStripe
   → RefundFlow: initiateRefund → calculateRefund → processRefund
3. gitnexus_context({name: "processPayment"})
   → Incoming: checkoutHandler, webhookHandler
   → Outgoing: validateCard, chargeStripe, saveTransaction
4. Read src/payments/processor.ts for implementation details
```
````

## File: .cursor/skills/gitnexus/gitnexus-guide/SKILL.md
````markdown
---
name: gitnexus-guide
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
---

# GitNexus Guide

Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.

## Always Start Here

For any task involving code understanding, debugging, impact analysis, or refactoring:

1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**

> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.

## Skills

| Task                                         | Skill to read       |
| -------------------------------------------- | ------------------- |
| Understand architecture / "How does X work?" | `gitnexus-exploring`         |
| Blast radius / "What breaks if I change X?"  | `gitnexus-impact-analysis`   |
| Trace bugs / "Why is X failing?"             | `gitnexus-debugging`         |
| Rename / extract / split / refactor          | `gitnexus-refactoring`       |
| Tools, resources, schema reference           | `gitnexus-guide` (this file) |
| Index, status, clean, wiki CLI commands      | `gitnexus-cli`               |

## Tools Reference

| Tool             | What it gives you                                                        |
| ---------------- | ------------------------------------------------------------------------ |
| `query`          | Process-grouped code intelligence — execution flows related to a concept |
| `context`        | 360-degree symbol view — categorized refs, processes it participates in  |
| `impact`         | Symbol blast radius — what breaks at depth 1/2/3 with confidence         |
| `detect_changes` | Git-diff impact — what do your current changes affect                    |
| `rename`         | Multi-file coordinated rename with confidence-tagged edits               |
| `cypher`         | Raw graph queries (read `gitnexus://repo/{name}/schema` first)           |
| `list_repos`     | Discover indexed repos                                                   |

## Resources Reference

Lightweight reads (~100-500 tokens) for navigation:

| Resource                                       | Content                                   |
| ---------------------------------------------- | ----------------------------------------- |
| `gitnexus://repo/{name}/context`               | Stats, staleness check                    |
| `gitnexus://repo/{name}/clusters`              | All functional areas with cohesion scores |
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members                              |
| `gitnexus://repo/{name}/processes`             | All execution flows                       |
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace                        |
| `gitnexus://repo/{name}/schema`                | Graph schema for Cypher                   |

## Graph Schema

**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS

```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
RETURN caller.name, caller.filePath
```
````

## File: .cursor/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
````markdown
---
name: gitnexus-impact-analysis
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
---

# Impact Analysis with GitNexus

## When to Use

- "Is it safe to change this function?"
- "What will break if I modify X?"
- "Show me the blast radius"
- "Who uses this code?"
- Before making non-trivial code changes
- Before committing — to understand what your changes affect

## Workflow

```
1. gitnexus_impact({target: "X", direction: "upstream"})  → What depends on this
2. READ gitnexus://repo/{name}/processes                   → Check affected execution flows
3. gitnexus_detect_changes()                               → Map current git changes to affected flows
4. Assess risk and report to user
```

> If "Index is stale" → run `npx gitnexus analyze` in terminal.

## Checklist

```
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
- [ ] Review d=1 items first (these WILL BREAK)
- [ ] Check high-confidence (>0.8) dependencies
- [ ] READ processes to check affected execution flows
- [ ] gitnexus_detect_changes() for pre-commit check
- [ ] Assess risk level and report to user
```

## Understanding Output

| Depth | Risk Level       | Meaning                  |
| ----- | ---------------- | ------------------------ |
| d=1   | **WILL BREAK**   | Direct callers/importers |
| d=2   | LIKELY AFFECTED  | Indirect dependencies    |
| d=3   | MAY NEED TESTING | Transitive effects       |

## Risk Assessment

| Affected                       | Risk     |
| ------------------------------ | -------- |
| <5 symbols, few processes      | LOW      |
| 5-15 symbols, 2-5 processes    | MEDIUM   |
| >15 symbols or many processes  | HIGH     |
| Critical path (auth, payments) | CRITICAL |

## Tools

**gitnexus_impact** — the primary tool for symbol blast radius:

```
gitnexus_impact({
  target: "validateUser",
  direction: "upstream",
  minConfidence: 0.8,
  maxDepth: 3
})

→ d=1 (WILL BREAK):
  - loginHandler (src/auth/login.ts:42) [CALLS, 100%]
  - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]

→ d=2 (LIKELY AFFECTED):
  - authRouter (src/routes/auth.ts:22) [CALLS, 95%]
```

**gitnexus_detect_changes** — git-diff based impact analysis:

```
gitnexus_detect_changes({scope: "staged"})

→ Changed: 5 symbols in 3 files
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
→ Risk: MEDIUM
```

## Example: "What breaks if I change validateUser?"

```
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
   → d=1: loginHandler, apiMiddleware (WILL BREAK)
   → d=2: authRouter, sessionManager (LIKELY AFFECTED)

2. READ gitnexus://repo/my-app/processes
   → LoginFlow and TokenRefresh touch validateUser

3. Risk: 2 direct callers, 2 processes = MEDIUM
```
````

## File: .cursor/skills/gitnexus/gitnexus-refactoring/SKILL.md
````markdown
---
name: gitnexus-refactoring
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
---

# Refactoring with GitNexus

## When to Use

- "Rename this function safely"
- "Extract this into a module"
- "Split this service"
- "Move this to a new file"
- Any task involving renaming, extracting, splitting, or restructuring code

## Workflow

```
1. gitnexus_impact({target: "X", direction: "upstream"})  → Map all dependents
2. gitnexus_query({query: "X"})                            → Find execution flows involving X
3. gitnexus_context({name: "X"})                           → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```

> If "Index is stale" → run `npx gitnexus analyze` in terminal.

## Checklists

### Rename Symbol

```
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
- [ ] gitnexus_detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
```

### Extract Module

```
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
- [ ] Define new module interface
- [ ] Extract code, update imports
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```

### Split Function/Service

```
- [ ] gitnexus_context({name: target}) — understand all callees
- [ ] Group callees by responsibility
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
- [ ] Create new functions/services
- [ ] Update callers
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```

## Tools

**gitnexus_rename** — automated multi-file rename:

```
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 ast_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```

**gitnexus_impact** — map all dependents first:

```
gitnexus_impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware, testUtils
→ Affected Processes: LoginFlow, TokenRefresh
```

**gitnexus_detect_changes** — verify your changes after refactoring:

```
gitnexus_detect_changes({scope: "all"})
→ Changed: 8 files, 12 symbols
→ Affected processes: LoginFlow, TokenRefresh
→ Risk: MEDIUM
```

**gitnexus_cypher** — custom reference queries:

```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
RETURN caller.name, caller.filePath ORDER BY caller.filePath
```

## Risk Rules

| Risk Factor         | Mitigation                                |
| ------------------- | ----------------------------------------- |
| Many callers (>5)   | Use gitnexus_rename for automated updates |
| Cross-area refs     | Use detect_changes after to verify scope  |
| String/dynamic refs | gitnexus_query to find them               |
| External/public API | Version and deprecate properly            |

## Example: Rename `validateUser` to `authenticateUser`

```
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
   → 12 edits: 10 graph (safe), 2 ast_search (review)
   → Files: validator.ts, login.ts, middleware.ts, config.json...

2. Review ast_search edits (config.json: dynamic reference!)

3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
   → Applied 12 edits across 8 files

4. gitnexus_detect_changes({scope: "all"})
   → Affected: LoginFlow, TokenRefresh
   → Risk: MEDIUM — run tests for these flows
```
````

## File: .cursor/skills/humanizer/SKILL.md
````markdown
---
name: humanizer
version: 2.5.1
description: |
  Remove signs of AI-generated writing from text. Use when editing or reviewing
  text to make it sound more natural and human-written. Based on Wikipedia's
  comprehensive "Signs of AI writing" guide. Detects and fixes patterns including:
  inflated symbolism, promotional language, superficial -ing analyses, vague
  attributions, em dash overuse, rule of three, AI vocabulary words, passive
  voice, negative parallelisms, and filler phrases.
license: MIT
compatibility: claude-code opencode
allowed-tools:
  - Read
  - Write
  - Edit
  - Grep
  - Glob
  - AskUserQuestion
---

# Humanizer: Remove AI Writing Patterns

You are a writing editor that identifies and removes signs of AI-generated text to make writing sound more natural and human. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup.

## Your Task

When given text to humanize:

1. **Identify AI patterns** - Scan for the patterns listed below
2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives
3. **Preserve meaning** - Keep the core message intact
4. **Maintain voice** - Match the intended tone (formal, casual, technical, etc.)
5. **Add soul** - Don't just remove bad patterns; inject actual personality
6. **Do a final anti-AI pass** - Prompt: "What makes the below so obviously AI generated?" Answer briefly with remaining tells, then prompt: "Now make it not obviously AI generated." and revise


## Voice Calibration (Optional)

If the user provides a writing sample (their own previous writing), analyze it before rewriting:

1. **Read the sample first.** Note:
   - Sentence length patterns (short and punchy? Long and flowing? Mixed?)
   - Word choice level (casual? academic? somewhere between?)
   - How they start paragraphs (jump right in? Set context first?)
   - Punctuation habits (lots of dashes? Parenthetical asides? Semicolons?)
   - Any recurring phrases or verbal tics
   - How they handle transitions (explicit connectors? Just start the next point?)

2. **Match their voice in the rewrite.** Don't just remove AI patterns - replace them with patterns from the sample. If they write short sentences, don't produce long ones. If they use "stuff" and "things," don't upgrade to "elements" and "components."

3. **When no sample is provided,** fall back to the default behavior (natural, varied, opinionated voice from the PERSONALITY AND SOUL section below).

### How to provide a sample
- Inline: "Humanize this text. Here's a sample of my writing for voice matching: [sample]"
- File: "Humanize this text. Use my writing style from [file path] as a reference."


## PERSONALITY AND SOUL

Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it.

### Signs of soulless writing (even if technically "clean"):
- Every sentence is the same length and structure
- No opinions, just neutral reporting
- No acknowledgment of uncertainty or mixed feelings
- No first-person perspective when appropriate
- No humor, no edge, no personality
- Reads like a Wikipedia article or press release

### How to add voice:

**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons.

**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up.

**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive."

**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking.

**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human.

**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching."

### Before (clean but soulless):
> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear.

### After (has a pulse):
> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night.


## CONTENT PATTERNS

### 1. Undue Emphasis on Significance, Legacy, and Broader Trends

**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted

**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic.

**Before:**
> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance.

**After:**
> The Statistical Institute of Catalonia was established in 1989 to collect and publish regional statistics independently from Spain's national statistics office.


### 2. Undue Emphasis on Notability and Media Coverage

**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence

**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context.

**Before:**
> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers.

**After:**
> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods.


### 3. Superficial Analyses with -ing Endings

**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing...

**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth.

**Before:**
> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land.

**After:**
> The temple uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast.


### 4. Promotional and Advertisement-like Language

**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning

**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics.

**Before:**
> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty.

**After:**
> Alamata Raya Kobo is a town in the Gonder region of Ethiopia, known for its weekly market and 18th-century church.


### 5. Vague Attributions and Weasel Words

**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited)

**Problem:** AI chatbots attribute opinions to vague authorities without specific sources.

**Before:**
> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem.

**After:**
> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences.


### 6. Outline-like "Challenges and Future Prospects" Sections

**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook

**Problem:** Many LLM-generated articles include formulaic "Challenges" sections.

**Before:**
> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth.

**After:**
> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods.


## LANGUAGE AND GRAMMAR PATTERNS

### 7. Overused "AI Vocabulary" Words

**High-frequency AI words:** Actually, additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant

**Problem:** These words appear far more frequently in post-2023 text. They often co-occur.

**Before:**
> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet.

**After:**
> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south.


### 8. Avoidance of "is"/"are" (Copula Avoidance)

**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a]

**Problem:** LLMs substitute elaborate constructions for simple copulas.

**Before:**
> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet.

**After:**
> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet.


### 9. Negative Parallelisms and Tailing Negations

**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused. So are clipped tailing-negation fragments such as "no guessing" or "no wasted motion" tacked onto the end of a sentence instead of written as a real clause.

**Before:**
> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement.

**After:**
> The heavy beat adds to the aggressive tone.

**Before (tailing negation):**
> The options come from the selected item, no guessing.

**After:**
> The options come from the selected item without forcing the user to guess.


### 10. Rule of Three Overuse

**Problem:** LLMs force ideas into groups of three to appear comprehensive.

**Before:**
> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights.

**After:**
> The event includes talks and panels. There's also time for informal networking between sessions.


### 11. Elegant Variation (Synonym Cycling)

**Problem:** AI has repetition-penalty code causing excessive synonym substitution.

**Before:**
> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home.

**After:**
> The protagonist faces many challenges but eventually triumphs and returns home.


### 12. False Ranges

**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale.

**Before:**
> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter.

**After:**
> The book covers the Big Bang, star formation, and current theories about dark matter.


### 13. Passive Voice and Subjectless Fragments

**Problem:** LLMs often hide the actor or drop the subject entirely with lines like "No configuration file needed" or "The results are preserved automatically." Rewrite these when active voice makes the sentence clearer and more direct.

**Before:**
> No configuration file needed. The results are preserved automatically.

**After:**
> You do not need a configuration file. The system preserves the results automatically.


## STYLE PATTERNS

### 14. Em Dash Overuse

**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing. In practice, most of these can be rewritten more cleanly with commas, periods, or parentheses.

**Before:**
> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents.

**After:**
> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" aress, yet this mislabeling continues in official documents.


### 15. Overuse of Boldface

**Problem:** AI chatbots emphasize phrases in boldface mechanically.

**Before:**
> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**.

**After:**
> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard.


### 16. Inline-Header Vertical Lists

**Problem:** AI outputs lists where items start with bolded headers followed by colons.

**Before:**
> - **User Experience:** The user experience has been significantly improved with a new interface.
> - **Performance:** Performance has been enhanced through optimized algorithms.
> - **Security:** Security has been strengthened with end-to-end encryption.

**After:**
> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption.


### 17. Title Case in Headings

**Problem:** AI chatbots capitalize all main words in headings.

**Before:**
> ## Strategic Negotiations And Global Partnerships

**After:**
> ## Strategic negotiations and global partnerships


### 18. Emojis

**Problem:** AI chatbots often decorate headings or bullet points with emojis.

**Before:**
> 🚀 **Launch Phase:** The product launches in Q3
> 💡ey Insight:** Users prefer simplicity
> ✅ **Next Steps:** Schedule follow-up meeting

**After:**
> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting.


### 19. Curly Quotation Marks

**Problem:** ChatGPT uses curly quotes (“...”) instead of straight quotes ("...").

**Before:**
> He said “the project is on track” but others disagreed.

**After:**
> He said "the project is on track" but others disagreed.


## COMMUNICATION PATTERNS

### 20. Collaborative Communication Artifacts

**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely rld you like..., let me know, here is a...

**Problem:** Text meant as chatbot correspondence gets pasted as content.

**Before:**
> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section.

**After:**
> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest.


### 21. Knowledge-Cutoff Disclaimers

**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information...

**Problem:** AI disclaimers about incomplete information get left in text.

**Before:**
> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s.

**After:**
> The company was founded in 1994, according to its registration documents.


### 22. Sycophantic/Servile Tone

**Problem:** Overly positive, people-pleasing language.

**Before:**
> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors.

**After:**
> The economic factors you mentioned are relevant here.


## FILLER AND HEDGING

### 23. Filler Phrases

**Before → After:**
- "In order to achieve this goal" → "To achieve this"
- "Due to the fact  it was raining" → "Because it was raining"
- "At this point in time" → "Now"
- "In the event that you need help" → "If you need help"
- "The system has the ability to process" → "The system can process"
- "It is important to note that the data shows" → "The data shows"


### 24. Excessive Hedging

**Problem:** Over-qualifying statements.

**Before:**
> It could potentially possibly be argued that the policy might have some effect on outcomes.

**After:**
> The policy may affect outcomes.


### 25. Generic Positive Conclusions

**Problem:** Vague ungs.

**Before:**
> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction.

**After:**
> The company plans to open two more locations next year.


### 26. Hyphenated Word Pair Overuse

**Words to watch:** third-party, cross-functional, client-facing, data-driven, decision-making, well-known, high-quality, real-time, long-term, end-to-end

**Problem:** AI hyphenates common word pairs with perfect consistency. Humans rarely hyphenate these uniformly, and when they do, it's inconsistent. Less common or technical compound modifiers are fine to hyphenate.

**Before:**
> The cross-functional team delivered a high-quality, data-driven report on our client-facing tools. Their decision-making process was well-known for being thorough and detail-oriented.

**After:**
> The cross functional team delivered a high quality, data driven report on our client facing tools. Their decision making process was known for being thorough and detail oriented.


### 27. Persuasive Authority Tropes

**Phrases to watch:** The real question is, at its core, in reality, what really matters, fundamentally, the deeper issue, the heart of the matter

**Problem:** LLMs use these phrases to pretend they are cutting through noise to some deeper truth, when the sentence that follows usually just restates an ordinary point with extra ceremony.

**Before:**
> The real question is whether teams can adapt. At its core, what really matters is organizational readiness.

**After:**
> The question is whether teams can adapt. That mostly depends on whether the organization is ready to change its habits.


### 28. Signposting and Announcements

**Phrases to watch:** Let's dive in, let's explore, let's break this down, here's what you need to know, now let's look at, without further ado

**Problem:** LLMs announce what they are about to do instead of doing it. This meta-commentary slows the writing down and gives it a tutorial-script feel.

**Before:**
> Let's dive into how caching works in Next.js. Here's what you need to know.

**After:**
> Next.js caches data at multiple layers, including request memoization, the data cache, and the router cache.


### 29. Fragmented Headers

**Signs to watch:** A heading followed by a one-line paragraph that simply restates the heading before the real content begins.

**Problem:** LLMs often add a generic sentence after a heading as a rhetorical warm-up. It usually adds nothing and makes the prose feel padded.

**Before:**
> ## Performance
>
> Speed matters.
>
> When users hit a slow page, they leave.

**After:**
> ## Performance
>
> When users hit a slow page, they leave.

---

## Process

1. Read the input text carefully
2. Identify all instances of the patterns above
3. Rewrite each problematic section
4. Ensure the revised text:
   - Sounds natural when read aloud
   - Varies sentence structure naturally
   - Uses specific details over vague claims
   - Maintains appropriate tone for context
   - Uses simple constructions (is/are/has) where appropriate
5. Present a draft humanized version
6. Prompt: "What makes the below so obviously AI generated?"
7. Answer briefly with the remaining tells (if any)
8. Prompt: "Now make it not obviously AI generated."
9. Present the final version (revised after the audit)

## Output Format

Provide:
1. Draft rewrite
2. "What makes the below so obviously AI generated?" (brief bullets)
3. Final rewrite
4. A brief summary of changes made (optional, if helpful)


## Full Example

**Before (AI-sounding):**
> Great question! Here is an essay on this topic. I hope this helps!
>
> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
>
> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation.
>
> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The techgy has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment.
>
> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers.
> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards.
> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends.
>
> While specific details are limited based on available information, it could potentially be argued that these tools mave some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices.
>
> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if you’d like me to expand on any section!

**Draft rewrite:**
> AI coding assistants speed up some tasks. In a 2024 study by , developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
>
> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention.
>
> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library.
>
> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants.
>
> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right.

**What makes the below so obviously AI generated?**
- The rhythm is still a bit too tidy (clean contrasts, evenly paced paragraphs).
- The named people and study citations can read like plausible-but-made-up placeholders unless they're real and sourced.
- The closer leans a touch slogan-y ("If you do not have tests...") rather than sounding like a person talking.

**Now make it not obviously AI generated.**
> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture.
>
> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention.
>
> People I talk to tend to land in two camps. Some use it like autocomplete for chores and review every line. Others disable it after it keeps suggesting patterns they don't want. Both feel reasonable.
>
> The productivity metrics are slippery. GitHub can say Copilot users "accept 30% of suggestions," but acceptance isn't correctness, and correctness isn't value. If you don't have tests, you're basically guessing.

**Changes made:**
- Removed chatbot artifacts ("Great question!", "I hope this helps!", "Let me know if...")
- Removed significance inflation ("testament", "pivotal moment", "evolving landscape", "vital role")
- Removed promotional language ("groundbreaking", "nestled", "seamless, intuitive, and powerful")
- Removed vague attributions ("Industry observers")
- Removed superficial -ing phrases ("underscoring", "highlighting", "reflecting", "contributing to")
- Removed negative parallelism ("It's not just X; it's Y")
- Removed rule-of-three patterns and synonym cycling ("catalyst/partner/foundation")
- Removed false ranges ("from X to Y, from A to B")
- Removed em dashes, emojis, boldface headers, and curly quotes
- Removed copula avoidance ("serves as", "functions as", "stands as") in favor of "is"/"are"
- Removed formulaic challenges section ("Despite challenges... continues to thrive")
- Removed knowledge-cutoff hedging ("While specific details are limited...")
- Removed excessive hedging ("could potentially be argued that... might have some")
- Removed filler phrases and persuasive framing ("In order to", "At its core")
- Removed generic positive conclusion ("the future looks bright", "exciting times lie ahead")
- Made the voice more personal and less "assembled" (varied rhythm, fewer placeholders)


## Reference

This skill is based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia.

Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."
````

## File: .cursor/skills/metagit-bootstrap/scripts/bootstrap-config.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
FORCE="${2:-false}"
TARGET="$ROOT/.metagit.yml"

uv run python - "$ROOT" "$TARGET" "$FORCE" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import create_metagit_config
from metagit.core.config.manager import MetagitConfigManager

root = Path(sys.argv[1]).resolve()
target = Path(sys.argv[2]).resolve()
force = sys.argv[3].lower() in {"1", "true", "yes", "force"}

if target.exists() and not force:
    mgr = MetagitConfigManager(config_path=target)
    result = mgr.load_config()
    state = "valid" if not isinstance(result, Exception) else "invalid"
    print(f"status=exists\tvalidity={state}\tpath={target}")
    raise SystemExit(0)

yaml_out = create_metagit_config(name=root.name, kind="application", as_yaml=True)
if isinstance(yaml_out, Exception):
    print(f"status=error\tmessage={yaml_out}")
    raise SystemExit(1)

target.write_text(yaml_out, encoding="utf-8")
mgr = MetagitConfigManager(config_path=target)
result = mgr.load_config()
state = "valid" if not isinstance(result, Exception) else "invalid"
print(f"status=written\tvalidity={state}\tpath={target}")
PY
````

## File: .cursor/skills/metagit-bootstrap/SKILL.md
````markdown
---
name: metagit-bootstrap
description: Use when generating or refining local .metagit.yml files using deterministic discovery plus MCP sampling.
---

# Metagit MCP Bootstrap Skill

Use this skill to create a local `.metagit.yml` using discovery-driven prompts and MCP sampling.

## Purpose

Generate schema-compliant `.metagit.yml` files with high contextual quality while preserving safety and explicit user control.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for local bootstrap tasks:
- `./scripts/bootstrap-config.zsh [root_path] [force]`

Behavior:
- Writes `.metagit.yml` when missing
- Validates via Metagit config models
- Returns a compact status line for agents

## Workflow

1. Gather deterministic discovery data from the target repository:
   - source language/framework indicators
   - package/lock/build files
   - Dockerfiles and CI workflows
   - terraform files and module usage
2. Build a strict prompt package:
   - output format contract: valid YAML only
   - required schema fields and constraints
   - extracted discovery evidence
3. If sampling is supported, call `sampling/createMessage`.
4. Validate generated YAML with Metagit config models.
5. Retry with validation feedback up to a fixed max attempt count.
6. Return draft output and write only on explicit confirmation.

## Output Modes

- **Plan-only mode**: return prompt + discovery summary if sampling unavailable.
- **Draft mode**: return `.metagit.generated.yml` content.
- **Confirmed write mode**: write to `.metagit.yml` only with explicit parameter (`confirm_write=true`).

## Quality Bar

- Preserve discovered evidence in structured fields.
- Include workspace project and related repo entries where detectable.
- Avoid invented repositories or unverifiable dependencies.

## Safety Rules

- Never overwrite `.metagit.yml` silently.
- Never emit secrets in cleartext.
- Prefer placeholders for credentials or tokens.
````

## File: .cursor/skills/metagit-cli/metagit-cli/SKILL.md
````markdown
---
name: metagit-cli
description: CLI-only shortcuts for metagit agents — workspace catalog, discovery, prompts, sync, layout, and config. Use instead of MCP or HTTP API when operating from a shell or agent_mode session.
---

# Metagit CLI (agent shortcuts)

Use this skill when an agent should drive metagit **only through the `metagit` command**. Do not call MCP tools or `metagit api` from workflows covered here unless the user explicitly asks.

Set non-interactive defaults when automating:

```bash
export METAGIT_AGENT_MODE=true
```

Global flags (most commands):

- `-c path/to/metagit.config.yaml` — app config (default `metagit.config.yaml`)
- Workspace manifest: `--definition` / `-c` on catalog commands (default `.metagit.yml`)

---

## Prompt commands (all kinds)

List built-in prompt kinds:

```bash
metagit prompt list
metagit prompt list --json
```

Emit prompts (`--text-only` for paste into agent context; `--json` for structured output; `--no-instructions` to omit manifest layers):

| Scope | Command | Default kind |
|-------|---------|--------------|
| Workspace | `metagit prompt workspace -c <definition> -k <kind>` | `instructions` |
| Project | `metagit prompt project -p <project> -c <definition> -k <kind>` | `instructions` |
| Repo | `metagit prompt repo -p <project> -n <repo> -c <definition> -k <kind>` | `instructions` |

### Prompt kinds by scope

| Kind | Workspace | Project | Repo | Purpose |
|------|:---------:|:-------:|:----:|---------|
| `instructions` | yes | yes | yes | Composed `agent_instructions` from manifest layers |
| `session-start` | yes | — | — | Session bootstrap checklist |
| `catalog-edit` | yes | yes | — | Search-before-create; catalog registration |
| `health-preflight` | yes | yes | — | Pre-work workspace/repo status pass |
| `sync-safe` | yes | yes | yes | Guarded sync rules |
| `subagent-handoff` | — | yes | yes | Delegate single-repo work |
| `layout-change` | yes | yes | yes | Rename/move dry-run workflow |
| `repo-enrich` | — | — | yes | **Discover + merge** workspace repo entry |

### Prompt shortcuts (copy-paste)

```bash
# Session bootstrap
metagit prompt workspace -k session-start --text-only -c .metagit.yml

# Composed instructions at each level
metagit prompt workspace -k instructions --text-only -c .metagit.yml
metagit prompt project -p default -k instructions --text-only -c .metagit.yml
metagit prompt repo -p default -n my-api -k instructions --text-only -c .metagit.yml

# Repo catalog enrichment (detect + merge manifest entry)
metagit prompt repo -p default -n my-api -k repo-enrich --text-only -c .metagit.yml

# Catalog registration discipline
metagit prompt workspace -k catalog-edit --text-only -c .metagit.yml

# Safe sync reminder
metagit prompt repo -p default -n my-api -k sync-safe --text-only -c .metagit.yml

# Subagent handoff
metagit prompt repo -p default -n my-api -k subagent-handoff --text-only -c .metagit.yml
```

---

## Repo enrich workflow (`repo-enrich`)

Run the prompt, then execute its steps:

```bash
metagit prompt repo -p <project> -n <repo> -k repo-enrich --text-only -c .metagit.yml
```

Typical discovery chain on the checkout:

```bash
cd "$(metagit search '<repo>' -c .metagit.yml --path-only)"
metagit detect repository -p . -o json
metagit detect repo -p . -o yaml
metagit detect repo_map -p . -o json
```

Provider metadata (dry-run):

```bash
metagit project source sync --provider github --org <org> --mode discover --no-apply
```

After merging fields into `workspace.projects[].repos[]`:

```bash
metagit config validate -c .metagit.yml
metagit workspace repo list --project <project> --json
```

---

## Workspace and catalog

Per-project dedupe override in `.metagit.yml` (overrides `workspace.dedupe.enabled` in `metagit.config.yaml` for that project only):

```yaml
workspace:
  projects:
    - name: local
      dedupe:
        enabled: false
      repos: []
```

```bash
metagit appconfig show --format json
metagit config info -c .metagit.yml
metagit config show -c .metagit.yml
metagit config validate -c .metagit.yml

metagit workspace list -c .metagit.yml --json
metagit workspace project list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --project <name> --json

metagit workspace project add --name <name> --json
metagit workspace repo add --project <name> --name <repo> --url <url> --json
metagit workspace project remove --name <name> --json
metagit workspace repo remove --project <name> --name <repo> --json
```

Search managed repos (always before creating entries):

```bash
metagit search "<query>" -c .metagit.yml --json
metagit search "<query>" -c .metagit.yml --path-only
metagit search "<query>" -c .metagit.yml --tag tier=1 --project <name>
```

---

## Project operations

```bash
metagit project list --config .metagit.yml --all --json
metagit project add --name <name> --json
metagit project remove --name <name> --json
metagit project rename --name <old> --new-name <new> --dry-run --json
metagit project select
metagit project sync

metagit project repo list --json
metagit project repo add --project <name> --name <repo> --url <url>
metagit project repo remove --name <repo> --json
metagit project repo rename --name <old> --new-name <new> --dry-run --json
metagit project repo move --name <repo> --to-project <other> --dry-run --json
metagit project repo prune --project <name> --dry-run

metagit project source sync --provider github --org <org> --mode discover --no-apply
metagit project source sync --provider github --org <org> --mode additive --apply
```

Layout (manifest + disk; always dry-run first):

```bash
metagit workspace project rename --name <old> --new-name <new> --dry-run --json
metagit workspace repo rename --project <p> --name <old> --new-name <new> --dry-run --json
metagit workspace repo move --project <p> --name <repo> --to-project <other> --dry-run --json
```

---

## Discovery and local metadata

```bash
metagit detect project -p <path> -o yaml
metagit detect repo -p <path> -o yaml
metagit detect repo_map -p <path> -o json
metagit detect repository -p <path> -o json
metagit detect repository -p <path> -o metagit
# --save only with operator approval (blocked in agent_mode)
```

Bootstrap new trees:

```bash
metagit init --kind application
metagit init --kind umbrella --template hermes-orchestrator
```

---

## Selection and scope

```bash
metagit workspace select --project <name>
metagit project select
metagit project repo select
```

---

## Config and appconfig

```bash
metagit appconfig validate
metagit appconfig get workspace.path
metagit config example
metagit config schema
metagit config providers
```

---

## Records, skills, version

```bash
metagit record search "<query>"
metagit skills list
metagit skills show metagit-cli
metagit skills install --skill metagit-cli
metagit version
metagit info
```

---

## Agent habits

1. **Search before create** — `metagit search` then catalog add.
2. **Validate after manifest edits** — `metagit config validate`.
3. **Emit prompts instead of rewriting playbooks** — `metagit prompt … --text-only`.
4. **Enrich stale repo entries** — `metagit prompt repo … -k repo-enrich` then detect + merge.
5. **Dry-run layout** — always `--dry-run --json` before apply.
6. **Prefer `METAGIT_AGENT_MODE=true`** in CI and agent loops to skip fuzzy finder and confirm dialogs.

## Related bundled skills

Use topic skills when you need deeper playbooks (some mention MCP): `metagit-projects`, `metagit-workspace-scope`, `metagit-workspace-sync`, `metagit-config-refresh`. This skill is the **CLI-only** index and prompt reference.
````

## File: .cursor/skills/metagit-config-refresh/SKILL.md
````markdown
---
name: metagit-config-refresh
description: Refresh or bootstrap `.metagit.yml` using deterministic discovery and validation flows. Use when configuration is missing, stale, or incomplete for workspace operations.
---

# Refreshing Project Config

Use this skill to keep `.metagit.yml` accurate and operational.

## Workflow

1. Check workspace activation and config validity.
2. Run bootstrap plan mode first.
3. Review generated changes against expected workspace topology.
4. Apply config updates and validate schema before continuing.

## Command Wrapper

- `zsh ./skills/metagit-bootstrap/scripts/bootstrap-config.zsh [root_path] [mode] [seed_context]`

## Output Contract

Return:
- config health state before/after
- generated update summary
- any manual follow-up needed

## Safety

- Prefer plan/dry-run first for large config updates.
- Keep changes bounded to target workspace intent.
````

## File: .cursor/skills/metagit-gating/scripts/gate-status.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"

uv run python - "$ROOT" <<'PY'
import os
import sys
from metagit.core.mcp.gate import WorkspaceGate
from metagit.core.mcp.root_resolver import WorkspaceRootResolver
from metagit.core.mcp.tool_registry import ToolRegistry

root = sys.argv[1]
resolver = WorkspaceRootResolver()
gate = WorkspaceGate()
registry = ToolRegistry()

resolved = resolver.resolve(cwd=os.path.abspath(root), cli_root=root)
status = gate.evaluate(root_path=resolved)
tools = registry.list_tools(status)
print(f"state={status.state.value}\troot={status.root_path or 'none'}\ttools={len(tools)}")
PY
````

## File: .cursor/skills/metagit-gating/SKILL.md
````markdown
---
name: metagit-gating
description: Use when implementing or operating Metagit MCP server activation and tool exposure rules based on .metagit.yml presence and validity.
---

# Metagit MCP Gating Skill

Use this skill whenever you need to control whether Metagit MCP tools/resources are exposed.

## Purpose

Ensure high-risk tooling and multi-repo context are only available when a valid `.metagit.yml` exists at the resolved workspace root.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for all gating checks:
- `./scripts/gate-status.zsh [root_path]`

Expected output (single line, tab-delimited):
- `state=<value>\troot=<path|none>\ttools=<count>`

## Activation Workflow

1. Resolve workspace root:
   - `METAGIT_WORKSPACE_ROOT`
   - CLI `--root`
   - upward directory walk
2. Check for `.metagit.yml` in resolved root.
3. Validate config through existing Metagit config models.
4. Derive activation state: missing, invalid, or active.
5. Register tool surface based on state.

## Tool Exposure Contract

### Inactive (missing or invalid config)
Expose only:
- `metagit_workspace_status`
- `metagit_bootstrap_config_plan_only`

### Active (valid config)
Expose full set:
- `metagit_workspace_status`
- `metagit_workspace_index`
- `metagit_workspace_search`
- `metagit_upstream_hints`
- `metagit_repo_inspect`
- `metagit_repo_sync`
- `metagit_bootstrap_config`

## Error Handling

- Return explicit, machine-readable state and reason.
- Avoid stack traces in user-facing outputs.
- Log parser/validation errors with enough detail for debugging.

## Safety Rules

- Never expose mutation-capable tools in inactive state.
- Never operate outside validated workspace boundaries.
- Keep defaults read-only unless user/agent explicitly opts in.
````

## File: .cursor/skills/metagit-gitnexus/scripts/analyze-targets.zsh
````zsh
#!/usr/bin/env zsh

set -euo pipefail

workspace_root="${1:-.}"
project_name="${2:-default}"

if [[ ! -f ".metagit.yml" ]]; then
  echo "ERROR: .metagit.yml not found in current directory"
  exit 2
fi

echo "analyze repo=$(pwd)"
npx gitnexus analyze

tmp_output="$(mktemp)"
uv run python - "$workspace_root" "$project_name" <<'PY' > "$tmp_output"
import sys
from pathlib import Path
import yaml

workspace_root = Path(sys.argv[1]).expanduser().resolve()
project_name = sys.argv[2]
cfg = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8")) or {}
workspace = (cfg.get("workspace") or {})
projects = workspace.get("projects") or []
target = next((p for p in projects if p.get("name") == project_name), None)
if not target:
    print(f"warn project_not_found={project_name}")
    raise SystemExit(0)

for repo in target.get("repos") or []:
    name = repo.get("name")
    if not name:
        continue
    repo_path = workspace_root / project_name / name
    if repo_path.exists() and repo_path.is_dir():
        print(f"repo_path={repo_path}")
    else:
        print(f"skip_missing={repo_path}")
PY

while IFS= read -r line; do
  case "$line" in
    repo_path=*)
      path="${line#repo_path=}"
      echo "analyze repo=${path}"
      (cd "$path" && npx gitnexus analyze) || echo "fail repo=${path}"
      ;;
    *)
      echo "$line"
      ;;
  esac
done < "$tmp_output"

rm -f "$tmp_output"
````

## File: .cursor/skills/metagit-multi-repo/SKILL.md
````markdown
---
name: metagit-multi-repo
description: Coordinate implementation tasks across multiple repositories using metagit status, search, and scoped sync workflows. Use when one objective spans several repositories.
---

# Coordinating Multi-Repo Implementation

Use this skill for cross-repository feature or fix delivery.

## Workflow

1. Define objective and affected repositories.
2. Verify workspace scope and dependency hints.
3. Sequence work by dependency order.
4. Sync only required repositories.
5. Track progress and blockers per repository.

## Command Wrapper

- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- objective-to-repository map
- execution order
- current blocker + next step

## Safety

- Keep scope bounded to configured workspace repositories.
- Prefer deterministic evidence for cross-repo assumptions.
````

## File: .cursor/skills/metagit-projects/SKILL.md
````markdown
---
name: metagit-projects
description: Ongoing workspace and project management for OpenClaw and Hermes agents. Use when starting work, organizing repos, or before creating a new project folder so existing metagit projects are reused instead of duplicated.
---

# Ongoing Project Management

Use this skill when the user starts new work, reorganizes repositories, or asks you to create a project folder. Metagit is the source of truth for what already exists in the workspace.

## Concepts

Metagit uses a three-level hierarchy (see project terminology docs):

| Level | Meaning |
|-------|---------|
| **Workspace** | Root folder where projects are synced (from app config `workspace.path`, often `./.metagit/`). Holds many projects. |
| **Project** | Named group of one or more Git repositories. Multi-repo products are one project; unrelated repos can also share a workspace under different project names. |
| **Repo** | A single Git repository entry under `workspace.projects[].repos` in `.metagit.yml`. |

A **project** is not always “one product.” It is whatever grouping helps the user and agents reason about related (or intentionally grouped) repositories. A workspace may contain unrelated projects side by side (for example `default`, `client-a`, `experiments`).

The umbrella `.metagit.yml` (workspace definition, often `kind: umbrella`) lives in a coordinating repository or central config checkout. Application repos may have their own `.metagit.yml` for metadata mode.

## Mandatory: check before creating folders

**Never** create a new project directory or clone into the workspace until you have checked metagit for an existing match.

1. **Locate the workspace definition**
   - Prefer the user’s umbrella `.metagit.yml` if known.
   - Otherwise use `.metagit.yml` in the current repo with `--definition /path/to/.metagit.yml`.

2. **List configured projects and repo counts**
   ```bash
   metagit config info --config-path /path/to/.metagit.yml
   metagit project list --config /path/to/.metagit.yml --project default
   ```
   Repeat `--project` for each project name returned by `config info`.

3. **Search managed repos by name, URL fragment, or tag**
   ```bash
   metagit search "<proposed-name-or-url>" --definition /path/to/.metagit.yml
   metagit search "<name>" --definition /path/to/.metagit.yml --json
   ```

4. **Inspect on disk** (workspace path from app config, default `./.metagit/`)
   - Expected layout: `{workspace.path}/{project_name}/{repo_name}/`
   - If the directory already exists, **reuse it**; do not create a parallel tree.

5. **Decide**
   - **Match found** → use existing project/repo; run `metagit project sync` only if the user wants checkouts refreshed.
   - **No match** → proceed with registration steps below (still add to workspace; do not leave orphan folders).

## Registering new work in the workspace

### New repository in an existing project

From the directory containing the workspace `.metagit.yml` (or pass `--config`):

```bash
metagit project repo add --project <project_name> --prompt
# or non-interactive:
metagit project repo add --project <project_name> --name <repo> --url <git-url>
metagit config validate --config-path .metagit.yml
metagit project sync --project <project_name>
```

In the new application repo (if applicable):

```bash
cd /path/to/new/repo
metagit init
metagit detect repo --force   # optional: enrich .metagit.yml
```

### New project group (new `workspace.projects[]` entry)

There is no separate `project create` CLI today. Add a project block to `.metagit.yml`:

```yaml
workspace:
  projects:
    - name: my-new-project
      description: Short purpose for agents and humans
      repos: []
```

Then validate, add repos, and sync:

```bash
metagit config validate --config-path .metagit.yml
metagit project repo add --project my-new-project --prompt
metagit project sync --project my-new-project
```

Choose a **distinct project name**; avoid duplicating an existing `workspace.projects[].name`.

### New umbrella workspace

When bootstrapping a workspace coordinator repo:

```bash
metagit init --kind umbrella
metagit project repo add --project default --prompt
metagit project sync
```

## Ongoing session habits

At the start of sustained work:

1. Run **metagit-workspace-scope** (or `metagit mcp serve --status-once` when MCP is available).
2. Confirm **active project** matches the user’s intent (`metagit workspace select --project <name>` when switching).
3. Use **`metagit search`** before assuming a repo is missing or lives elsewhere.
4. For multi-repo tasks, prefer **metagit-control-center** or **metagit-multi-repo** over ad-hoc cloning.

When the user names a target folder:

- Resolve it against managed config first.
- If unmanaged but present on disk under the project sync folder, report it and offer to add via `metagit project repo add` rather than recreating.

## OpenClaw and Hermes setup

Install bundled skills (including this one) for agent hosts:

```bash
metagit skills list
metagit skills install --scope user --target openclaw --target hermes
metagit mcp install --scope user --target openclaw --target hermes
```

Use `--scope project` when installing into a specific umbrella repository checkout.

## Output contract

After project-management actions, report:

- workspace definition path used
- whether the target was **existing** or **newly registered**
- project name and repo name(s) affected
- sync status if `project sync` was run
- recommended next command (`workspace select`, `project select`, or `detect`)

## Safety

- Do not clone, delete, or overwrite sync directories without explicit user approval.
- Do not edit `.metagit.yml` without validating afterward (`metagit config validate`).
- Prefer reusing configured repos over creating duplicate checkouts.
- Keep unrelated experiments in separate `workspace.projects` entries when the user wants clear boundaries.
````

## File: .cursor/skills/metagit-upstream-scan/scripts/upstream-scan.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-}"
MAX_RESULTS="${4:-20}"

if [[ -z "$QUERY" ]]; then
  echo "status=error\tmessage=query-required"
  exit 1
fi

uv run python - "$ROOT" "$QUERY" "$PRESET" "$MAX_RESULTS" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.mcp.services.workspace_search import WorkspaceSearchService
from metagit.core.mcp.services.upstream_hints import UpstreamHintService

root = Path(sys.argv[1]).resolve()
query = sys.argv[2]
preset = sys.argv[3] or None
max_results = int(sys.argv[4])

manager = MetagitConfigManager(config_path=root / ".metagit.yml")
cfg = manager.load_config()
if isinstance(cfg, Exception):
    print(f"status=error\tmessage=config-invalid\tdetail={cfg}")
    raise SystemExit(1)

index = WorkspaceIndexService().build_index(config=cfg, workspace_root=str(root))
repo_paths = [row["repo_path"] for row in index if row.get("exists")]
search_hits = WorkspaceSearchService().search(query=query, repo_paths=repo_paths, preset=preset, max_results=max_results)
ranked = UpstreamHintService().rank(blocker=query, repo_context=index)[:5]

print(f"status=ok\trepos={len(index)}\thits={len(search_hits)}")
for row in ranked:
    print(f"hint\trepo={row['repo_name']}\tscore={row['score']}")
for hit in search_hits[:5]:
    print(f"hit\tfile={hit['file_path']}\tline={hit['line_number']}")
PY
````

## File: .cursor/skills/metagit-upstream-scan/SKILL.md
````markdown
---
name: metagit-upstream-scan
description: Use when a coding agent encounters likely upstream blockers and must find related workspace repositories, files, and probable root causes.
---

# Metagit Upstream Discovery Skill

Use this skill when the current repo does not appear to contain the full fix and related repositories may hold the source issue.

## Supported Use Cases

- Missing Terraform input in a shared module
- Docker base image/version mismatch across repos
- Shared infrastructure definitions causing local failures
- CI pipeline breakages tied to upstream templates/workflows

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for upstream discovery tasks:
- `./scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

Output format:
- compact status line
- top ranked repo hints (`hint`)
- top search file hits (`hit`)

## Workflow

1. Read workspace repository map from active `.metagit.yml`.
2. Run `metagit_workspace_index` to verify repo availability and sync state.
3. Use `metagit_workspace_search` with category preset (`terraform`, `docker`, `infra`, `ci`).
4. Use `metagit_upstream_hints` to rank candidate repositories and files.
5. Return a concise action plan:
   - top candidate repos
   - likely files/definitions
   - whether sync is needed before deeper analysis

## Search Strategy

- Start narrow with issue-specific terms (error, module, variable, image tag).
- Expand to broader shared terms if no hits.
- Prefer repositories referenced by workspace metadata before searching unknown repos.

## Output Contract

Return:
- ranked candidates with rationale
- suggested next file openings
- confidence level and unresolved assumptions

## Safety Rules

- Restrict search to configured workspace repositories.
- Cap result size and duration.
- Keep this workflow read-only unless an explicit sync action is requested.
````

## File: .cursor/skills/metagit-upstream-triage/SKILL.md
````markdown
---
name: metagit-upstream-triage
description: Triage cross-repository blockers by ranking likely upstream repositories and files with metagit search and hinting tools. Use when local fixes appear incomplete.
---

# Triaging Upstream Blockers

Use this skill for failures likely rooted in another repository.

## Workflow

1. Run workspace index and search with issue-specific terms.
2. Run upstream hint ranking to prioritize repositories/files.
3. Open the top candidates and validate root-cause evidence.
4. Return a short fix path (repo, file, next action).

## Command Wrapper

- `zsh ./skills/metagit-upstream-scan/scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

## Output Contract

Return:
- ranked candidate repositories
- probable root-cause files
- confidence and assumptions

## Safety

- Keep this flow read-only unless sync is explicitly requested.
````

## File: .github/workflows/docker.yaml
````yaml
name: Docker Image

on:
  push:
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository_owner }}/metagit-cli

jobs:
  image-deployment:
    name: Publish Docker Image - ${{ github.ref_name }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
      
    steps:
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Set version
        id: version
        run: |
          if [[ "${{ github.ref_type }}" == "tag" ]]; then
            VERSION=${GITHUB_REF#refs/tags/}
          else
            VERSION=dev-$(git rev-parse --short HEAD)
          fi
          echo "VERSION=$VERSION" >> $GITHUB_ENV
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "Version: $VERSION"

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
      
      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=semver,pattern={{major}}
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push Docker image
        uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
        id: build
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' && github.ref_type == 'tag' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: DEPLOY_VERSION=${{ steps.version.outputs.version }}


      - name: Generate artifact attestation
        if: github.event_name != 'pull_request' && github.ref_type == 'tag'
        uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
        with:
          subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

      - name: Logout from Docker Registry
        run: docker logout ${{ env.REGISTRY }}
````

## File: .github/workflows/docs.yaml
````yaml
name: Docs

on:
  push:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  build-and-deploy-docs:
    name: Build and Deploy Docs - ${{ github.ref_name }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.13"

      - name: Install uv
        run: |
          curl -LsSf https://astral.sh/uv/install.sh | sh
          source "$HOME/.cargo/env"
          
      - name: Install dependencies
        run: |
          uv venv
          uv pip install --system -e ".[docs]"

      - name: Build docs
        run: mkdocs build

      - name: Upload artifact
        uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
        with:
          path: site

      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
````

## File: .mex/context/decisions.md
````markdown
---
name: decisions
description: Key architectural and technical decisions with reasoning. Load when making design choices or understanding why something is built a certain way.
triggers:
  - "why do we"
  - "why is it"
  - "decision"
  - "alternative"
  - "we chose"
edges:
  - target: context/architecture.md
    condition: when a decision relates to system structure
  - target: context/stack.md
    condition: when a decision relates to technology choice
  - target: context/mcp-runtime.md
    condition: when decisions involve MCP capability scope, transport, or gating
last_updated: 2026-05-05
---

# Decisions

## Decision Log

### Keep project intelligence in repository-local `.metagit.yml`
**Date:** 2026-05-05  
**Status:** Active  
**Decision:** Project/workspace situational awareness is modeled in `.metagit.yml` and related schema/model files.  
**Reasoning:** Keeps context version-controlled, shareable, and inspectable by both humans and agents without external state dependencies.  
**Alternatives considered:** External-only metadata store (rejected — higher setup burden and weaker local/offline UX), ad-hoc markdown notes (rejected — no schema/validation guarantees).  
**Consequences:** Changes to project/workspace metadata must preserve model/schema compatibility and validation paths.

### Use Python CLI + core service modules as primary architecture
**Date:** 2026-05-05  
**Status:** Active  
**Decision:** Core functionality stays in Python modules under `src/metagit/core/*`, with Click command wrappers under `src/metagit/cli/commands/*`.  
**Reasoning:** Separates UX routing from business logic and keeps features testable in isolation.  
**Alternatives considered:** Fat command handlers (rejected — logic duplication and weaker test boundaries), full web-service-first architecture (rejected — project is CLI-first).  
**Consequences:** New features should be implemented in core services/managers with thin command glue.

### Gate MCP capabilities by valid workspace configuration
**Date:** 2026-05-05  
**Status:** Active  
**Decision:** MCP tool/resource surface is state-aware and only fully enabled when a valid `.metagit.yml` is present at resolved workspace root.  
**Reasoning:** Prevents unsafe or context-poor actions and aligns multi-repo operations with explicit workspace declarations.  
**Alternatives considered:** Always-on toolset (rejected — increases misuse risk), hard failure when config missing (rejected — poorer diagnostic/bootstrapping UX).  
**Consequences:** MCP runtime must keep gate checks, limited inactive tooling, and explicit mutation guardrails for sync operations.
````

## File: .mex/context/setup.md
````markdown
---
name: setup
description: Dev environment setup and commands. Load when setting up the project for the first time or when environment issues arise.
triggers:
  - "setup"
  - "install"
  - "environment"
  - "getting started"
  - "how do I run"
  - "local development"
edges:
  - target: context/stack.md
    condition: when specific technology versions or library details are needed
  - target: context/architecture.md
    condition: when understanding how components connect during setup
  - target: context/conventions.md
    condition: when setup issues are caused by project-specific command/style rules
last_updated: 2026-05-05
---

# Setup

## Prerequisites
- Python 3.12+ (project requires `>=3.12`).
- `uv` package/environment manager (used for install/run/test/lint commands).
- `task` CLI (Taskfile runner used for standard project workflows).
- Git (required for repository features and many metagit operations).

## First-time Setup
1. `./configure.sh`
2. `task install`
3. `uv pip install -e ".[test]"`
4. `task lint`
5. `task test`

## Environment Variables
- `GITHUB_PERSONAL_ACCESS_TOKEN` (conditionally required) — used by `task start:mcp` for GitHub MCP server container.
- `LEGACY_YAML_LOADER` (optional) — toggles legacy duplicate-key behavior in custom YAML loader.
- `.env` / `.SECRETS.env` (optional/conditional) — loaded by Taskfile; values depend on enabled workflows/providers.

## Common Commands
- `task lint` — runs `ruff check` and `ruff format --check`.
- `task format` — formats code with `ruff format`.
- `task test` — installs test extras and runs pytest suite.
- `uv run metagit --help` — verifies CLI entrypoint and available commands.
- `uv run pytest tests/core/mcp -v` — focused MCP runtime/service regression suite.
- `task docs` — builds docs with mkdocs after schema generation.

## Common Issues
**`task lint` fails after runtime edits:** run `uv run ruff format src/metagit/core/mcp/runtime.py` then rerun `task lint`.  
**MCP command blocks in tests/manual checks:** use `metagit mcp serve --status-once` for non-blocking state diagnostics.  
**Config validation failures during workspace/MCP flows:** validate and inspect `.metagit.yml` shape via `uv run metagit config validate` and model-driven fixes.  
**Tooling mismatch after dependency changes:** run `uv sync` then rerun `task install` to restore expected environment.
````

## File: .mex/context/stack.md
````markdown
---
name: stack
description: Technology stack, library choices, and the reasoning behind them. Load when working with specific technologies or making decisions about libraries and tools.
triggers:
  - "library"
  - "package"
  - "dependency"
  - "which tool"
  - "technology"
edges:
  - target: context/decisions.md
    condition: when the reasoning behind a tech choice is needed
  - target: context/conventions.md
    condition: when understanding how to use a technology in this codebase
  - target: context/setup.md
    condition: when stack choices affect local tooling or command execution
  - target: context/mcp-runtime.md
    condition: when working with MCP JSON-RPC transport, tools, and resources
last_updated: 2026-05-05
---

# Stack

## Core Technologies
- **Python 3.12** — primary language runtime (`requires-python >=3.12` in `pyproject.toml`).
- **Click** — CLI framework used by `metagit.cli.main` and command modules.
- **Pydantic v2** — schema and model validation for config/app/workspace/records.
- **uv** — package/environment and command runner used across Taskfile and setup.
- **Taskfile (`task`)** — command orchestration layer for lint/test/build/docs workflows.

## Key Libraries
- **`pydantic`** — all structured config/workspace records are modeled and validated with explicit models.
- **`PyYAML` + custom loader (`metagit.core.utils.yaml_class`)** — YAML parsing with include/envvar and duplicate-key behavior.
- **`GitPython`** — repository metadata and sync operations instead of raw shell git calls in core logic.
- **`pytest`** — primary testing framework for unit/integration tests in `tests/`.
- **`ruff`** — primary lint + format tool for current local workflow (`task lint`, `task format`).
- **`litellm` / `crewai`** — agent-oriented integrations present in dependencies and examples for AI-assisted flows.

## What We Deliberately Do NOT Use
- No JavaScript/TypeScript runtime as the primary execution path; core implementation is Python CLI/service modules.
- No monolithic web framework dependency (e.g., Django/FastAPI app server) for core product behavior.
- No direct subprocess-heavy git orchestration as the primary pattern where GitPython is available.

## Version Constraints
- Python must be 3.12+ for supported runtime behavior.
- Pydantic is v2-style; legacy v1 patterns should not be introduced.
- MCP protocol version emitted by runtime initialize response is `2024-11-05`.
````

## File: .mex/patterns/add-cli-command.md
````markdown
---
name: add-cli-command
description: Add or extend a metagit CLI command with core logic boundaries and test coverage.
triggers:
  - "add command"
  - "click command"
  - "new subcommand"
edges:
  - target: context/architecture.md
    condition: when deciding where command logic belongs vs core service logic
  - target: context/conventions.md
    condition: when implementing command/module naming and verify checklist items
  - target: patterns/debug-mcp-runtime.md
    condition: when the new command affects MCP runtime behavior and tests fail
last_updated: 2026-05-05
---

# Add CLI Command

## Context
Load `context/architecture.md` and `context/conventions.md` first. Confirm whether the task is a new command file under `src/metagit/cli/commands/` or an extension of existing command behavior.

## Steps
1. Create or update command module in `src/metagit/cli/commands/`.
2. Keep handler thin: instantiate/call core service/manager classes in `src/metagit/core/*`.
3. Register command in `src/metagit/cli/main.py` if it is a top-level subcommand.
4. Add command tests under `tests/cli/commands/` and integration tests if behavior crosses modules.
5. Run focused tests first, then project lint.

## Gotchas
- Putting substantial logic directly in Click handlers increases duplication and weakens testing boundaries.
- Forgetting registration in `main.py` makes command exist in code but unavailable in CLI.
- Command output contracts should remain stable (especially in automation-facing flows).

## Verify
- [ ] Command appears in `uv run metagit --help` or relevant group help output.
- [ ] Unit/CLI tests cover new path.
- [ ] Core behavior test exists if command bridges multiple services.
- [ ] `task lint` passes.

## Debug
- If command is missing: check `cli.add_command(...)` registration in `src/metagit/cli/main.py`.
- If context/logger missing: verify `@click.pass_context` and `ctx.obj` usage path.
- If tests hang on MCP command: use `--status-once` in test paths.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
````

## File: .mex/patterns/add-managed-repo-search.md
````markdown
---
name: add-managed-repo-search
description: Extend or debug managed-only repo lookup across CLI, MCP, and the local JSON API.
triggers:
  - "managed repo search"
  - "metagit_repo_search"
  - "repos/search"
  - "ManagedRepoSearchService"
edges:
  - target: context/architecture.md
    condition: when changing how search flows connect CLI, MCP, and HTTP surfaces
  - target: context/mcp-runtime.md
    condition: when adjusting MCP tool schema or dispatch for managed repo search
  - target: patterns/add-mcp-tool.md
    condition: when only the MCP tool contract or registry changes
last_updated: 2026-05-12
---

# Add or change managed repo search

## Context
Managed repo search is **only** the repos declared under `workspace.projects[].repos` in `.metagit.yml`. It does not scan the filesystem for unmanaged clones. Shared logic lives in `src/metagit/core/project/search_service.py` (`ManagedRepoSearchService`) and index rows from `WorkspaceIndexService.build_index`.

## Steps
1. Confirm ranking/filter behavior in `ManagedRepoSearchService` (`search` / `resolve_one`) and adjust tests in `tests/test_project_search_service.py`.
2. **CLI:** `src/metagit/cli/commands/search.py` — keep thin; delegate to the service + `MetagitConfigManager`.
3. **MCP:** register in `tool_registry.py`, add `inputSchema` + dispatch in `runtime.py` (`metagit_repo_search`); extend `tests/core/mcp/test_runtime.py` (and integration if gating changes).
4. **HTTP API:** `src/metagit/core/api/server.py` — GET handlers only; keep JSON stable; add tests under `tests/api/`.
5. Regenerate schema if `.metagit.yml` / models affecting tags change (`task generate:schema`).

## Gotchas
- Workspace root for path resolution is the **directory containing** `.metagit.yml`, not the file path itself.
- `metagit_workspace_search` (MCP) searches **paths on disk** inside the workspace; `metagit_repo_search` searches **configured managed repos** — different semantics; do not merge without updating docs and clients.
- HTTP `409` on `/v1/repos/resolve` is expected for `ambiguous_match`; clients must handle it (e.g. `urllib` raises `HTTPError`).

## Verify
- [ ] `uv run pytest tests/test_project_search_service.py tests/cli/commands/test_search.py tests/core/mcp/test_runtime.py tests/api/test_repo_search_api.py -q`
- [ ] `task lint` and `task test` green after broader changes.

## Debug
- Empty MCP/API results: gate inactive, invalid config, or `workspace_root` mismatch vs repo `path:` entries.
- CLI vs API mismatch: compare `--definition` parent directory to API `--root`.

## Update Scaffold
- [ ] Update `docs/cli_reference.md` if flags or endpoints change.
- [ ] Update `.mex/ROUTER.md` if user-visible surfaces change materially.
````

## File: .mex/patterns/add-mcp-tool.md
````markdown
---
name: add-mcp-tool
description: Add a new MCP tool to metagit runtime with schema, dispatch, gating, and tests.
triggers:
  - "add mcp tool"
  - "tools/list"
  - "tools/call"
edges:
  - target: context/mcp-runtime.md
    condition: when implementing runtime method handlers, schemas, and dispatch logic
  - target: context/conventions.md
    condition: when validating guardrails and verify checklist compliance
  - target: patterns/debug-mcp-runtime.md
    condition: when framed message flow or tool calls produce protocol errors
last_updated: 2026-05-05
---

# Add MCP Tool

## Context
Load `context/mcp-runtime.md` first. The source of truth is `src/metagit/core/mcp/runtime.py` plus supporting services under `src/metagit/core/mcp/services/`.

## Steps
1. Define tool name and argument contract.
2. Add `inputSchema` entry to runtime `_tool_schemas`.
3. Ensure tool name is included in state-appropriate registry in `tool_registry.py`.
4. Implement dispatch branch in `_dispatch_tool` (or call a new dedicated service method).
5. Add tests in `tests/core/mcp/test_runtime.py` for list + call + invalid args.
6. Add/adjust integration tests if state-gating behavior changes.

## Gotchas
- If schema and dispatcher validation drift, clients may pass checks but fail at runtime.
- New tools must respect gate state; inactive state should not expose high-risk operations.
- Return content should be JSON-serializable and stable for agent consumption.

## Verify
- [ ] Tool is visible in `tools/list` only in intended states.
- [ ] `tools/call` succeeds with valid args and returns structured content.
- [ ] Invalid args map to `-32602` with `invalid_arguments` data.
- [ ] `task lint` and MCP test suite pass.

## Debug
- For missing tool in list: check `ToolRegistry` and state snapshot logic.
- For call rejection: inspect gate state and allowed tool set.
- For protocol mismatch: inspect framed read/write handling and JSON envelope fields.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
````

## File: .mex/patterns/bootstrap-metagit-config.md
````markdown
---
name: bootstrap-metagit-config
description: Create or repair `.metagit.yml` using model validation and optional MCP sampling.
triggers:
  - "bootstrap config"
  - "generate .metagit.yml"
  - "metagit config invalid"
edges:
  - target: context/setup.md
    condition: when environment/command prerequisites impact config generation
  - target: context/stack.md
    condition: when config/schema model constraints need confirmation
  - target: context/mcp-runtime.md
    condition: when bootstrap is performed through MCP tool calls and sampling flow
last_updated: 2026-05-05
---

# Bootstrap Metagit Config

## Context
Use this when `.metagit.yml` is missing or invalid, or when onboarding a new workspace. Relevant modules are `metagit.core.config.manager` and MCP bootstrap services.

## Steps
1. Check for existing `.metagit.yml` and validate (`uv run metagit config validate`).
2. If missing/invalid, create baseline config using manager/create flow or bootstrap wrapper.
3. Re-validate using model-driven load path (not string-only checks).
4. If running through MCP, use `metagit_bootstrap_config` and verify returned mode (`plan_only` vs `sampled`).
5. Keep writes explicit when replacing existing config.

## Gotchas
- Writing guessed YAML without model validation creates downstream gate failures.
- Overwriting a valid `.metagit.yml` silently can erase workspace repo mappings.
- Sampling output must still pass strict config model validation before use.

## Verify
- [ ] `.metagit.yml` loads through `MetagitConfigManager.load_config()` without exception.
- [ ] Workspace section shape is valid for project/repo operations.
- [ ] MCP gate moves to active when expected.
- [ ] `task lint` and relevant tests pass.

## Debug
- If validation fails: inspect exact model error path and adjust YAML fields/types.
- If gate remains inactive: verify resolver root points to intended workspace directory.
- If sampling output fails repeatedly: run plan-only mode and patch fields manually.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
````

## File: .mex/patterns/debug-mcp-runtime.md
````markdown
---
name: debug-mcp-runtime
description: Diagnose MCP runtime failures across framing, initialize capabilities, tool dispatch, and resource reads.
triggers:
  - "mcp error"
  - "tools/call failed"
  - "resources/read failed"
  - "stdio framing"
edges:
  - target: context/mcp-runtime.md
    condition: when tracing runtime internals and protocol method handling
  - target: context/architecture.md
    condition: when failure source may be outside runtime (service/config layer)
  - target: patterns/add-mcp-tool.md
    condition: when bug appears while adding/changing tool schemas or dispatch behavior
last_updated: 2026-05-05
---

# Debug MCP Runtime

## Context
Primary files: `src/metagit/core/mcp/runtime.py`, `tool_registry.py`, gate/root resolver, and MCP service modules. Primary tests: `tests/core/mcp/test_runtime.py` and MCP integration tests.

## Steps
1. Reproduce with smallest path first (`metagit mcp serve --status-once` for gate snapshot).
2. Check gate state and allowed tool set before debugging individual tool behavior.
3. Validate request envelope fields (`jsonrpc`, `id`, `method`, `params`) and framed transport boundaries.
4. Run focused runtime tests, then full MCP suite.
5. If method-specific failure persists, add/adjust regression tests before patching.

## Gotchas
- Mistaking inactive-gate tool denial for runtime logic failure.
- Schema/dispatcher mismatch causing valid-looking requests to fail.
- Framing bugs can masquerade as random parse/protocol errors.

## Verify
- [ ] Runtime methods (`initialize`, `tools/list`, `tools/call`, `resources/*`) pass tests.
- [ ] Invalid arguments consistently return `-32602`.
- [ ] Sampling capability detection reflects initialize input.
- [ ] MCP suite and lint pass after fix.

## Debug
- Use `tests/core/mcp/test_runtime.py` as first failure boundary.
- For tool visibility bugs, inspect `ToolRegistry.list_tools` with active/inactive statuses.
- For resource failures, inspect `ResourcePublisher` payload path and `uri` handling.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
````

## File: .mex/patterns/debug-workspace-discovery.md
````markdown
---
name: debug-workspace-discovery
description: Diagnose missing/incorrect upstream repo discovery results in workspace index/search/hints flows.
triggers:
  - "upstream discovery"
  - "workspace search no results"
  - "hints look wrong"
edges:
  - target: context/architecture.md
    condition: when discovery issues involve config/workspace structure assumptions
  - target: context/mcp-runtime.md
    condition: when failures occur through MCP tool calls rather than direct service tests
  - target: patterns/bootstrap-metagit-config.md
    condition: when root issue is invalid or incomplete `.metagit.yml`
last_updated: 2026-05-05
---

# Debug Workspace Discovery

## Context
Discovery path spans `WorkspaceIndexService`, `WorkspaceSearchService`, and `UpstreamHintService`, usually driven by valid workspace config entries.

## Steps
1. Validate `.metagit.yml` and confirm `workspace.projects[].repos` is populated as expected.
2. Build index and inspect resolved `repo_path`, `exists`, and `sync` fields.
3. Check search inputs (`query`, `preset`, `max_results`) and whether target repo paths actually exist locally.
4. Inspect upstream hint scoring inputs (repo metadata + blocker text).
5. Run focused service tests and patch deterministic scoring/search behavior with regression tests.

## Gotchas
- Empty or invalid workspace repo definitions produce zero-index and cascade into no search/hints.
- Query term selection can be too strict or too broad depending on preset merge behavior.
- Local clone state (`exists=False`) can silently remove repos from search scope.

## Verify
- [ ] Index rows match expected repo definitions and path resolution.
- [ ] Search returns bounded hits from intended repositories/files.
- [ ] Hint ranking surfaces expected top candidates for blocker text.
- [ ] MCP tool path returns equivalent outputs when invoked via runtime.

## Debug
- If index is empty: fix config shape/path first.
- If hints rank poorly: inspect term overlap and score contributions.
- If MCP and direct service outputs differ: compare runtime dispatch argument parsing.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
````

## File: .mex/patterns/mcp-cross-project-dependencies.md
````markdown
---
name: mcp-cross-project-dependencies
description: Map or extend MCP cross-project dependency graph tooling.
triggers:
  - "cross project dependencies"
  - "metagit_cross_project_dependencies"
edges:
  - target: context/mcp-runtime.md
    condition: when changing runtime schemas or services
  - target: patterns/mcp-project-context.md
    condition: when combining with project context switch workflows
last_updated: 2026-05-15
---

# MCP Cross-Project Dependencies

## Context
Load `context/mcp-runtime.md`. Core logic: `cross_project_dependencies.py`, `import_hint_scanner.py`, `gitnexus_registry.py`.

## Layers
1. **declared/ref** — `.metagit.yml` tags, `ProjectPath.ref`, root `dependencies` / `components`
2. **shared_config/url_match** — identical URLs or `configured_path` across projects
3. **imports** — `package.json`, `pyproject.toml`, `go.mod`, terraform module paths between local repos
4. **GitNexus status** — `~/.gitnexus/registry.json` + optional `npx gitnexus status` per repo (not full graph export)

## Steps
1. Extend collectors in `CrossProjectDependencyService._collect_edges`.
2. Add MCP schema + dispatch in `runtime.py` and `tool_registry.py`.
3. Mock `GitNexusRegistryAdapter` in unit tests to avoid slow `npx` calls.
4. Update `metagit-repo-impact` skill when agent workflow changes.

## Verify
- [ ] `uv run pytest tests/core/mcp/services/test_cross_project_dependencies.py -q`
- [ ] `docs/cli_reference.md` documents tool parameters
````

## File: .mex/patterns/mcp-project-context.md
````markdown
---
name: mcp-project-context
description: Add or operate MCP project context switch, session store, and workspace snapshot tools.
triggers:
  - "project context switch"
  - "workspace snapshot"
  - "metagit_project_context_switch"
edges:
  - target: context/mcp-runtime.md
    condition: when changing runtime schemas, dispatch, or services
  - target: patterns/add-mcp-tool.md
    condition: when adding another MCP tool in the same area
last_updated: 2026-05-15
---

# MCP Project Context

## Context
Load `context/mcp-runtime.md`. Core code lives in `session_store.py`, `project_context.py`, `workspace_snapshot.py`, and `context_models.py`.

## Steps
1. Extend Pydantic models in `context_models.py` for any new persisted fields.
2. Update `SessionStore` paths under `.metagit/sessions/` — never store secret values.
3. Implement behavior in `ProjectContextService` / `WorkspaceSnapshotService`; keep git restore out of scope (metadata only).
4. Register tool schema + dispatch in `runtime.py` and `tool_registry.py`.
5. Add unit tests under `tests/core/mcp/services/` and runtime tests in `test_runtime.py`.

## Gotchas
- `metagit_workspace_state_restore` does not checkout branches or reset dirty repos.
- `project_name` session filenames must match `^[\w.-]+$`.
- Env exports skip sensitive variable refs and session overrides are validated.

## Verify
- [ ] `uv run pytest tests/core/mcp/services/test_project_context.py tests/core/mcp/services/test_workspace_snapshot.py tests/core/mcp/test_runtime.py -q`
- [ ] Skills and `docs/cli_reference.md` mention new tools when behavior is user-visible.
````

## File: .mex/patterns/README.md
````markdown
# Patterns

This folder contains task-specific guidance — the things you would tell your agent if you were sitting next to it. Not generic instructions. Project-specific accumulated wisdom.

## How patterns get created

**During setup:** After the context/ files are populated, the agent generates starter patterns based on the project's actual stack, architecture, and conventions. These are stack-specific — a Flask API project gets different patterns than a React SPA or a CLI tool.

**Over time:** You or your agent add patterns as they emerge from real work — when something breaks, when a task has a non-obvious gotcha, when you've explained the same thing twice.

## What belongs here

A pattern file is worth creating when:
- A task type is common in this project and has a repeatable workflow
- There are integration gotchas between components that aren't obvious from code
- Something broke and you want to prevent it from breaking the same way again
- A verify checklist specific to one type of task would catch mistakes early

## When to skip a pattern

Default to generating a pattern. Only skip if:
- The exact same guidance is already in `context/conventions.md` with concrete examples
- The task truly has no project-specific gotchas (e.g. "how to write a for loop")

If in doubt, generate the pattern. A pattern that turns out to be obvious costs nothing. A missing pattern costs a broken codebase.

## Format

### Single-task pattern (one file = one task)

```markdown
---
name: [pattern-name]
description: [one line — what this pattern covers and when to use it]
triggers:
  - "[keyword that should trigger loading this file]"
edges:
  - target: "[related file path, e.g. context/conventions.md]"
    condition: "[when to follow this edge]"
last_updated: [YYYY-MM-DD]
---

# [Pattern Name]

## Context
[What to load or know before starting this task type]

## Steps
[The workflow — what to do, in what order]

## Gotchas
[The things that go wrong. What to watch out for.]

## Verify
[Checklist to run after completing this task type]

## Debug
[What to check when this task type breaks]

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
```

### Multi-section pattern (one file = multiple related tasks)

Use this when tasks share context but differ in steps. Each task gets its own
`## Task: ...` heading with sub-sections. The Context section is shared at the top.

```markdown
---
name: [pattern-name]
description: [one line — what this pattern file covers]
triggers:
  - "[keyword]"
edges:
  - target: "[related file path]"
    condition: "[when to follow this edge]"
last_updated: [YYYY-MM-DD]
---

# [Pattern Name]

## Context
[Shared context for all tasks in this file]

## Task: [First Task Name]

### Steps
[...]

### Gotchas
[...]

### Verify
[...]

## Task: [Second Task Name]

### Steps
[...]

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
```

Do NOT combine unrelated tasks into one file just to reduce file count.
Only group tasks that genuinely share context.

## How many patterns to generate

Do not use a fixed number. Generate one pattern per:
- Each major task type a developer does repeatedly in this project
- Each external dependency with non-obvious integration gotchas
- Each major failure boundary in the architecture flow

For a simple project this may be 3-4 files. For a complex project this may be 10-15.
Do not cap based on a number — cap based on whether the pattern adds real value.

## Pattern categories

Walk through each category below. For each one, check the relevant context files
and generate patterns for everything that applies to this project.

### Category 1 — Common task patterns

The repeatable tasks in this project. What does a developer do most often?

Derive from: `context/architecture.md` (what are the major components?) and
`context/conventions.md` (what patterns exist for extending them?)

Examples by project type:
- API: "add new endpoint", "add new model/entity", "add auth to a route"
- Frontend: "add new page/route", "add new component", "add form with validation"
- CLI: "add new command", "add new flag/option"
- Pipeline: "add new pipeline stage", "add new data source"
- SaaS: "add payment flow", "add user-facing feature", "add admin operation"

### Category 2 — Integration patterns

How to work with the external dependencies in this project.

Every entry in `context/stack.md` "Key Libraries" or `context/architecture.md`
"External Dependencies" that has non-obvious setup, gotchas, or failure modes
deserves a pattern. These are the most dangerous areas — the agent will
confidently write integration code that looks right but misses project-specific
configuration, error handling, or rate limiting.

Examples: "calling the payments API", "running database migrations",
"adding a new third-party service client", "configuring auth provider"

### Category 3 — Debug/diagnosis patterns

When something breaks, where do you look?

Derive from the architecture flow — each boundary between components is a
potential failure point. One debug pattern per major boundary.

Examples: "debug webhook failures", "debug pipeline stage failures",
"diagnose auth/permission issues", "debug background job failures"

### Category 4 — Deploy/release patterns

Only generate if `context/setup.md` reveals non-trivial deployment.

Examples: "deploy to staging", "rollback a release", "update environment config",
"run database migration in production"
````

## File: .mex/patterns/run-graphify-analysis.md
````markdown
---
name: run-graphify-analysis
description: Run focused `graphify` analysis on a repo or subtree, especially when a full-repo graph would be too large or noisy.
triggers:
  - "graphify"
  - "knowledge graph"
  - "graph report"
edges:
  - target: "context/architecture.md"
    condition: "when naming communities or interpreting cross-command relationships"
  - target: "context/conventions.md"
    condition: "when verifying scaffold updates after the graph run"
last_updated: 2026-05-11
---

# Run Graphify Analysis

## Context
- Use this when a user invokes `/graphify` or asks for a repo knowledge graph.
- Start with corpus detection before committing to a full run; this repo has generated/docs-heavy areas that can drown out the interesting command and core logic relationships.
- Prefer focused subtrees when the corpus is over the graphify warning threshold or when the user only needs one part of the codebase.

## Steps
1. Resolve a stable `graphify` Python interpreter and write it to `graphify-out/.graphify_python`.
2. Run `graphify.detect` first and summarize the corpus by file type.
3. If the corpus is over the graphify threshold, show the busiest subdirectories and ask the user which subtree to analyze.
4. Run structural extraction for code files. If the selected scope is code-only, skip semantic subagents and continue with the AST-only flow.
5. Build the graph, cluster it, then label communities with plain-language names before exporting HTML.
6. If the corpus is large enough for benchmarking, run `graphify benchmark`.
7. Save manifest/cost data, clean temporary graphify files, and surface the report's God Nodes, Surprising Connections, and Suggested Questions.

## Gotchas
- `uv run --with graphifyy` can resolve to a temporary interpreter path; use the installed `graphify` tool's shebang-backed Python for multi-step runs.
- Full-repo runs can be skewed by generated assets like `site/assets/*`; detect first instead of assuming `.` is the right scope.
- Code-only runs still produce useful graph structure and avoid unnecessary semantic extraction cost.
- Community labels matter; leaving generic `Community N` names makes the report and `graph.html` much harder to navigate.

## Verify
- Confirm `graphify-out/graph.html`, `graphify-out/GRAPH_REPORT.md`, and `graphify-out/graph.json` exist in the processed directory.
- Confirm the report includes named communities instead of placeholder community labels.
- If benchmark ran, capture its reduction summary for the user.
- Confirm cleanup removed temp extraction files while preserving the final outputs.

## Debug
- If interpreter resolution breaks between steps, regenerate `graphify-out/.graphify_python` from the installed `graphify` binary.
- If the graph is empty, inspect the detect output first; the scope may contain unsupported files or only skipped content.
- If the corpus crosses the threshold, do not push through the full run blindly; narrow the scope and rerun.
- If community names look mixed or misleading, inspect the top labels per community from `graph.json` before regenerating the report.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [x] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
````

## File: .mex/patterns/update-release-workflow.md
````markdown
---
name: update-release-workflow
description: Safely replace or adjust GitHub release automation when post-merge release runs fail.
triggers:
  - "release workflow failing after merge"
  - "replace release automation"
  - "github actions release pipeline"
edges:
  - target: "context/conventions.md"
    condition: "before final verification to ensure required checks are run"
  - target: "ROUTER.md"
    condition: "after completing workflow changes to keep project state current"
last_updated: 2026-05-08
---

# Update Release Workflow

## Context
- Identify the active release workflows under `.github/workflows/`.
- Locate known-good reference workflows (for example `.github/example/`).
- Confirm expected release trigger model: `main` push for tag generation, tag push for publishing.

## Steps
1. Compare current release workflow files against the known-good copies.
2. Replace release orchestration logic first (for example `release-please` to semantic-tag flow).
3. Ensure publish workflow gates package publishing on tag refs only.
4. Ensure artifact lifecycle is intact (`build` -> `publish-test` -> `publish-prod` -> GitHub release).
5. Remove deprecated/conflicting workflows so only one release orchestration path is active.
6. Update `.mex/ROUTER.md` project state to match the new release mechanism.

## Gotchas
- Do not leave both `release-please` and semantic-tag workflows enabled unless intentionally dual-tracked.
- Keep token usage consistent with repo secrets (`PAT_TOKEN` vs `GITHUB_TOKEN`) and branch protection.
- Align tag format expectations between semantic tag creator and publish workflow filters.

## Verify
- Confirm all edited workflow YAML files parse in GitHub Actions syntax.
- Confirm semantic release workflow triggers on `push` to `main`.
- Confirm publish workflow only publishes when `github.ref` is a tag.
- Confirm no obsolete release workflow file remains active.

## Debug
- If release automation fails immediately on merge, inspect missing secret or permissions scope first.
- If publish never runs, inspect tag format mismatch versus `startsWith(github.ref, 'refs/tags/...')`.
- If GitHub release creation fails, confirm release job has `contents: write`.

## Update Scaffold
- [x] Update `.mex/ROUTER.md` "Current Project State" if release behavior changed
- [x] Update relevant `.mex/patterns/*` for this task type
- [x] Add this pattern to `.mex/patterns/INDEX.md`
````

## File: .mex/config.json
````json
{
  "aiTools": [
    "cursor"
  ]
}
````

## File: docs/reference/workspace-layout-api.md
````markdown
# Workspace layout API (v2)

Rename and move operations for workspace projects and repositories. Intended for agents, CLI, MCP, and a future web UI.

## Sync root

Layout operations apply disk changes under `workspace.path` from app config (`metagit.config.yaml`). The HTTP server’s `--root` is the directory containing `.metagit.yml`.

## Endpoints

| Method | Path | Body |
|--------|------|------|
| POST | `/v2/projects/{from}/rename` | `{ "to_name": "apps" }` |
| POST | `/v2/repos/{project}/{repo}/rename` | `{ "to_name": "new-name" }` |
| POST | `/v2/repos/{project}/{repo}/move` | `{ "to_project": "platform" }` |

Query flags (or JSON body): `dry_run`, `manifest_only`, `force`, `no_update_sessions` (project rename only).

## Response shape

Same as catalog mutations:

```json
{
  "ok": true,
  "entity": "repo",
  "operation": "move",
  "project_name": "platform",
  "repo_name": "svc-a",
  "from_project": "portfolio",
  "to_project": "platform",
  "config_path": "/path/.metagit.yml",
  "data": {
    "dry_run": false,
    "manifest_changes": ["..."],
    "disk_steps": [{"action": "move", "source": "...", "target": "..."}],
    "warnings": [],
    "manifest_updated": true
  }
}
```

## CLI equivalents

```bash
metagit workspace project rename alpha apps --dry-run --json
metagit workspace repo rename -p alpha svc-a svc-b --json
metagit workspace repo move -p alpha -n svc-a --to-project beta --json
```

## MCP tools

- `metagit_workspace_project_rename`
- `metagit_workspace_repo_rename`
- `metagit_workspace_repo_move`

Always pass `dry_run: true` first when an agent is unsure about disk impact.
````

## File: docs/app.logic.md
````markdown
# Metagit CLI Application Logic

This document outlines the architecture and logic of the Metagit CLI application. It includes a Mermaid diagram to visualize the interaction between different components.

## Core Components

The application is built around a few core components:

*   **CLI (Click):** The command-line interface is implemented using the `click` library. The main entry point is in `src/metagit/cli/main.py`, which defines the main `cli` group and subcommands.
*   **Configuration:** The application uses a YAML-based configuration file (`metagit.config.yaml`) to manage settings. The schema for this file is defined in `schemas/metagit_config.schema.json`.
*   **Logging:** A unified logging system is implemented in `src/metagit/core/utils/logging.py` to provide consistent logging across the application.
*   **Subcommands:** The application is organized into several subcommands, each with its own module in `src/metagit/cli/commands`.

## Mermaid Diagram

```mermaid
graph TD
    A[User] --> B{metagit CLI};
    B --> C{main.py};
    C --> D{Click Library};
    D --> E{Subcommands};
    E --> F[detect];
    E --> G[appconfig];
    E --> H[project];
    E --> I[workspace];
    E --> J[config];
    E --> K[record];
    E --> L[init];
    C --> M{Configuration};
    M --> N[metagit.config.yaml];
    C --> O{Logging};
    O --> P[UnifiedLogger];
```

## Component Interaction

1.  The user interacts with the application through the `metagit` command-line interface.
2.  The `main.py` script is the entry point, which initializes the `click` CLI application.
3.  The `click` library parses the command-line arguments and invokes the appropriate subcommand.
4.  Each subcommand has its own dedicated module that contains the logic for that command.
5.  The application loads its configuration from `metagit.config.yaml`.
6.  Logging is handled by the `UnifiedLogger` class, which provides a consistent logging format.
````

## File: docs/architecture-diagram.py
````python
#! /usr/bin/env python3
⋮----
# Architecture Diagram Generator: Multi-Agent Git Project Graph
# Requires: uv sync --group diagrams  (optional dependency group in pyproject.toml)
⋮----
user = User("Developer / API Client")
⋮----
git_sources = [Github("GitHub"), Custom("GitLab", "./icons/gitlab.png")]
fetcher = Python("Git Fetcher")
parser = Python("Build Parser / ORT")
storage = Storage("Repo Cache")
⋮----
extractor = Python("Artifact Extractor")
normalizer = Python("Normalizer")
artifact_store = Storage("Artifact Metadata")
⋮----
graphdb = Neptune("Graph DB (Neo4j / Neptune)")
⋮----
agent_router = Python("Agent Router")
kafka = Kafka("Event Bus")
redis = Redis("Agent Memory")
⋮----
agents = [
⋮----
api = Python("FastAPI / GraphQL")
dashboard = Custom("UI Dashboard", "./icons/webapp.png")
````

## File: docs/hermes-iac-workspace-guide.md
````markdown
# Hermes agents and organization-wide IaC

This guide shows how a **Hermes** controller agent uses **Metagit** as the control plane for an entire organization's infrastructure-as-code (IaC) surface: Terraform, OpenTofu, Pulumi, policy repos, modules, and the application repos they provision.

Metagit does not replace Terraform or your cloud provider. It gives Hermes a **single, validated map** of every managed repository, plus MCP tools to search, inspect, sync, triage, and hand **layered instructions** to subagents working in specific repos.

---

## Roles in the stack

| Role | Responsibility |
|------|----------------|
| **Hermes (controller)** | Owns the user objective across many repos; reads workspace manifest; launches subagents with composed `agent_instructions`; never clones blindly. |
| **Metagit** | Source of truth for workspace layout, repo metadata, health, search, and guarded git operations. |
| **Subagents (workers)** | Execute in one repo or path at a time (module bump, policy fix, drift investigation) using instructions from the controller. |
| **Git + CI** | Actual IaC execution (`plan`/`apply`, policy checks) stay in each repository's pipelines. |

```mermaid
flowchart TB
  subgraph human [Human operator]
    U[Platform / SRE lead]
  end

  subgraph hermes [Hermes controller]
    H[Controller agent]
    H -->|composed instructions| S1[Subagent: networking]
    H -->|composed instructions| S2[Subagent: eks-modules]
    H -->|composed instructions| S3[Subagent: policy]
  end

  subgraph metagit [Metagit control plane]
    M[MCP + CLI]
    Y[".metagit.yml"]
    M --> Y
  end

  subgraph surface [IaC surface on disk]
    R1[terraform-live/]
    R2[modules/]
    R3[policy/]
    R4[gitops/]
  end

  U --> H
  H --> M
  M --> R1
  M --> R2
  M --> R3
  M --> R4
  S1 --> R1
  S2 --> R2
  S3 --> R3
```

---

## Mapping org IaC to Metagit

Think of one **umbrella workspace** that models the whole platform engineering estate.

```text
Organization IaC estate
└── Metagit workspace (umbrella .metagit.yml)
    ├── Project: platform-live          # Terragrunt / root modules per env
    │   ├── Repo: terraform-live-prod
    │   └── Repo: terraform-live-staging
    ├── Project: shared-modules         # Reusable TF modules
    │   ├── Repo: terraform-aws-vpc
    │   └── Repo: terraform-aws-eks
    ├── Project: policy                 # OPA, Sentinel, Checkov baselines
    │   └── Repo: org-policy
    └── Project: delivery               # Pipelines, Atlantis, runners
        └── Repo: cicd-terraform
```

On disk (typical layout under app config `workspace.path`, often `.metagit/`):

```text
.metagit/
├── platform-live/
│   ├── terraform-live-prod/
│   └── terraform-live-staging/
├── shared-modules/
│   ├── terraform-aws-vpc/
│   └── terraform-aws-eks/
├── policy/
│   └── org-policy/
└── delivery/
    └── cicd-terraform/
```

**Tags** on each `repos[]` entry (for example `tier: "1"`, `iac: "terraform"`, `env: "prod"`) let Hermes filter with `metagit_repo_search` / `metagit search` without scanning the filesystem.

---

## Layered `agent_instructions`

Instructions are optional at four levels. Hermes composes them when switching project context or launching a subagent focused on a repo.

| Layer | YAML location | Typical IaC content |
|-------|---------------|---------------------|
| **File** | top-level `agent_instructions` | Org-wide rules: naming, approval, no `apply` in prod without ticket. |
| **Workspace** | `workspace.agent_instructions` | How to use this umbrella: backends, state buckets, module sources. |
| **Project** | `workspace.projects[].agent_instructions` | Project boundary: "platform-live = Terragrunt only; never edit modules here." |
| **Repo** | `workspace.projects[].repos[].agent_instructions` | Repo-specific: "Subagent: only `env/prod/**`; run `terraform fmt` before commit." |

Legacy manifests may still use `agent_prompt`; Metagit accepts it on load and writes `agent_instructions` going forward.

**Example manifest excerpt:**

```yaml
name: acme-platform-iac
kind: umbrella
agent_instructions: |
  Controller: You manage Acme's IaC estate. Never run terraform apply.
  Coordinate subagents per repo. Always run plan-only unless user explicitly approves apply.

workspace:
  agent_instructions: |
    Remote state: s3://acme-tf-state/{project}/{env}.
    Module sources: git::ssh://git@github.com/acme/terraform-modules.git//...
  projects:
    - name: platform-live
      description: Live infrastructure roots per environment
      agent_instructions: |
        Use Terragrunt. Respect dependency order: network before compute.
      repos:
        - name: terraform-live-prod
          path: platform-live/terraform-live-prod
          url: git@github.com:acme/terraform-live-prod.git
          sync: true
          tags:
            iac: terraform
            env: prod
          agent_instructions: |
            Subagent scope: env/prod only. Required checks: fmt, validate, tflint.
        - name: terraform-live-staging
          path: platform-live/terraform-live-staging
          sync: true
          tags:
            iac: terraform
            env: staging
    - name: shared-modules
      agent_instructions: |
        Semver tag modules on release. No environment-specific values in modules/.
      repos:
        - name: terraform-aws-vpc
          path: shared-modules/terraform-aws-vpc
          sync: true
          tags:
            iac: terraform
            layer: module
```

**Composed output** (what subagents receive via `effective_agent_instructions`):

```text
[FILE]
Controller: You manage Acme's IaC estate...

---

[WORKSPACE]
Remote state: s3://acme-tf-state/...

---

[PROJECT]
Use Terragrunt. Respect dependency order...

---

[REPO]
Subagent scope: env/prod only...
```

---

## One-time setup for Hermes

Install the CLI and agent integrations on the host where Hermes runs (or in the umbrella repo CI image):

```bash
uv tool install metagit-cli
metagit version

# Bundled skills (control center, multi-repo, gitnexus, projects, …)
metagit skills install --scope user --target hermes

# MCP server for tool calls from Hermes
metagit mcp install --scope user --target hermes
```

Bootstrap the umbrella coordinator (if not already present):

```bash
cd /path/to/platform-umbrella
metagit init --kind umbrella
metagit project repo add --project platform-live --prompt
metagit config validate
metagit project sync
```

Point Hermes MCP at the workspace root (directory containing `.metagit.yml`):

```bash
metagit mcp serve --root /path/to/platform-umbrella
# or: metagit mcp serve --status-once   # verify gate is active
```

!!! note "Gate behavior"
    Until `.metagit.yml` exists and validates, MCP exposes only safe tools (`metagit_workspace_status`, bootstrap plan). Full workspace tools unlock when the gate is **active**.

---

## Controller session lifecycle

```mermaid
sequenceDiagram
  participant U as Operator
  participant H as Hermes controller
  participant M as Metagit MCP
  participant S as Subagent

  U->>H: Objective: bump VPC module in prod roots
  H->>M: metagit_workspace_status
  M-->>H: active gate, workspace root
  H->>M: metagit_workspace_health_check
  M-->>H: missing clones, stale branches, GitNexus
  H->>M: metagit_repo_search (tags/layer/module)
  M-->>H: terraform-aws-vpc, terraform-live-prod
  H->>M: metagit_cross_project_dependencies
  M-->>H: declared + import hints
  H->>M: metagit_project_context_switch (platform-live)
  M-->>H: effective_agent_instructions + repos
  H->>M: metagit_workspace_sync (dry_run / fetch)
  H->>S: Task + effective_agent_instructions (module repo)
  S->>S: Edit module, PR
  H->>S: Task + instructions (live repo, prod paths)
  S->>S: Bump version ref, plan-only
  H->>M: metagit_session_update (notes)
  H->>U: Summary + open PRs
```

### Step-by-step (controller checklist)

1. **Orient** — `metagit_workspace_status`, read `metagit://workspace/config` and `metagit://workspace/repos/status`.
2. **Hygiene** — `metagit_workspace_health_check` (clone missing repos, note drift/stale integration).
3. **Scope** — `metagit_repo_search` / `metagit search` with tags (`iac=terraform`, `env=prod`).
4. **Blast radius** — `metagit_cross_project_dependencies` from the module or live project; optional GitNexus `analyze` + semantic search for unknown callers.
5. **Focus** — `metagit_project_context_switch` with `project_name` and optional `primary_repo` for the first subagent.
6. **Refresh** — `metagit_workspace_sync` with `mode: fetch` (default); `pull`/`clone` only with explicit approval.
7. **Delegate** — Pass `effective_agent_instructions` and repo path to each subagent; use per-repo `agent_instructions` when tasks split across repos.
8. **Remember** — `metagit_session_update` before switching projects; optional `metagit_workspace_state_snapshot` for audit metadata.

---

## IaC-focused Metagit capabilities

### Discover and search

| Need | Tool | Notes |
|------|------|--------|
| Find all `.tf` under managed repos | `metagit_workspace_discover` | `intent: terraform` or `pattern: "**/*.tf"` |
| Grep module sources / backends | `metagit_workspace_search` | `preset: terraform`, `query: "module \""` |
| Concept-level search | `metagit_workspace_semantic_search` | Requires GitNexus index per repo |
| List repos by tag | `metagit_repo_search` | `tags`, `status`, `sync_enabled` |

```mermaid
flowchart LR
  subgraph discover [Discovery]
    D1[workspace_discover]
    D2[workspace_search preset=terraform]
    D3[semantic_search]
  end

  subgraph repos [Managed repos only]
    R[.metagit.yml registry]
  end

  R --> D1
  R --> D2
  R --> D3
```

### Upstream triage

When a subagent hits an error in one repo (provider version, shared module API), the controller uses **`metagit_upstream_hints`** with a blocker string instead of guessing which module repo to open.

Typical flow:

1. Subagent reports: `Error: Unsupported argument "xyz" in module vpc`.
2. Controller runs upstream hints + workspace search for `module "vpc"` / path hints.
3. Controller reassigns or spawns subagent on `terraform-aws-vpc` with module-level `agent_instructions`.

### Health and drift

`metagit_workspace_health_check` surfaces:

- Repos not cloned (`configured_missing` → `clone` recommendation)
- Dirty trees and commits behind origin (`sync`)
- Branch age / merge-base staleness (`review_branch_age`, `reconcile_integration`)
- GitNexus index stale (`analyze`)

For IaC teams, **integration staleness** often means live roots have not merged default branch since a module release.

### Cross-project dependencies

`metagit_cross_project_dependencies` combines:

- Declared edges in `.metagit.yml`
- Import hints from manifests (`package.json`, etc., where relevant)
- URL matches across repos

Use this before org-wide module version bumps to build an ordered rollout: **modules → policy → live roots**.

---

## Subagent launch pattern

Hermes should treat each subagent as a **scoped worker**:

```mermaid
flowchart TD
  C[Hermes controller]
  C -->|1. context_switch + primary_repo| M[Metagit MCP]
  M -->|effective_agent_instructions| C
  C -->|2. task envelope| W[Subagent]
  W -->|3. work only in repo_path| G[Git checkout]

  subgraph envelope [Task envelope]
    E1[effective_agent_instructions]
    E2[repo_path / suggested_cwd]
    E3[objective + constraints]
    E4[forbidden: apply in prod]
  end

  C --> envelope
  envelope --> W
```

**Task envelope fields (recommended):**

- `effective_agent_instructions` — full composed stack from Metagit.
- `repo_path` — absolute path from context bundle (`suggested_cwd` or `primary_repo`).
- `objective` — one sentence the controller owns.
- `evidence` — links to search hits, dependency graph snippet, health row.
- `stop_conditions` — e.g. "stop after plan output; do not push."

Subagents should **not** maintain a parallel repo list; they trust Metagit's index for path resolution.

---

## Example: org-wide provider bump

**Objective:** Bump AWS provider `~> 5.0` across all Terraform roots.

| Phase | Controller action | Metagit tools |
|-------|-------------------|---------------|
| Inventory | List prod/staging roots | `metagit_repo_search`, tags `iac=terraform` |
| Order | Modules before live | `metagit_cross_project_dependencies` |
| Hygiene | Fetch latest | `metagit_workspace_sync`, `only_if: behind_origin`, `dry_run` first |
| Work 1 | Subagent on each module repo | `project_context_switch` + repo instructions |
| Work 2 | Subagent per live repo | `workspace_search` for `required_providers` |
| Verify | Health + notes | `workspace_health_check`, `session_update` |

---

## Skills Hermes should load

Install bundled skills with `metagit skills install --target hermes`. High-value skills for IaC controllers:

| Skill | Use when |
|-------|----------|
| `metagit-control-center` | Every multi-repo session |
| `metagit-projects` | Before creating folders or registering repos |
| `metagit-workspace-scope` | Session start |
| `metagit-multi-repo` | Features spanning repos |
| `metagit-workspace-sync` | Refreshing checkouts |
| `metagit-upstream-triage` | Errors that may originate in another repo |
| `metagit-gitnexus` | Before semantic search or impact questions |
| `metagit-repo-impact` | Planning module or API changes |
| `metagit-gating` | MCP inactive / missing config |
| `metagit-release-audit` | Before hand-off (`task qa:prepush` on metagit-cli itself) |

---

## Safety rules (IaC)

- **No `apply` in controller or subagent prompts** unless the human explicitly requests it; prefer plan-only language in `agent_instructions`.
- **No unscoped sync** — use `repos: ["project/repo"]`, not `all`, unless the objective requires it.
- **Mutations** — `pull` / `clone` require `allow_mutation: true` in MCP; default to `fetch`.
- **Secrets** — never put credentials in `agent_instructions` or `env_overrides`; use existing secrets tooling and variable refs in `.metagit.yml`.
- **Config edits** — validate after every manifest change: `metagit config validate`.
- **Register before clone** — follow `metagit-projects`: search managed config before creating directories.

---

## Related documentation

- [Terminology](terminology.md) — workspace, project, repo definitions
- [CLI Reference](cli_reference.md) — MCP tool parameters
- [Installation](install.md) — CLI and skills install
- [Application Logic](app.logic.md) — component overview

For local JSON automation without MCP, `metagit api serve` exposes managed-repo search under `/v1/repos/search` (read-only); the Hermes-oriented MCP surface remains the primary control-plane integration.
````

## File: docs/hermes-orchestrator-workspace.md
````markdown
# Hermes orchestrator workspace

Use this guide when a **Hermes** controller agent should act as the DevOps and project-management
entrypoint for a portfolio of repositories and local publish paths. Metagit holds the manifest;
Hermes holds the objective across projects.

For Terraform-heavy estates, also read [Hermes & org IaC guide](hermes-iac-workspace-guide.md).

## Quick start

1. Create or choose an umbrella coordinator repository.
2. Initialize from the bundled template (interactive prompts or an answers file):

```bash
metagit init --list-templates
metagit init ./hermes-control-plane --create --template hermes-orchestrator
# non-interactive:
metagit init --target ./hermes-control-plane --create --template hermes-orchestrator \
  --answers-file examples/hermes-orchestrator/answers.example.yml \
  --no-prompt
```

   Or copy [examples/hermes-orchestrator/.metagit.yml](../examples/hermes-orchestrator/.metagit.yml)
   manually and adjust projects, repos, and instructions.

3. Enable workspace dedupe in app config when the same URL appears in multiple projects:

```yaml
config:
  workspace:
    path: ./.metagit
    dedupe:
      enabled: true
      scope: workspace
```

4. Validate and sync:

```bash
metagit config validate
metagit project sync --project portfolio
metagit project sync --project local
```

5. Serve MCP for Hermes:

```bash
metagit mcp serve --root /path/to/coordinator
```

## Projects in the example manifest

| Project | Purpose |
|---------|---------|
| `portfolio` | Git-backed services and applications |
| `local` | Non-git `path` repos for static sites and local publish workflows |
| `platform` | Optional IaC / shared infra (empty until you add repos) |

The `local` project is the pattern for “publish a folder on disk” without a remote. Each entry
uses `path` + `sync: true`; metagit symlinks into `workspace.path` (or a canonical store when
dedupe is enabled).

## Controller responsibilities

The root `agent_instructions` in the example manifest define the controller loop:

- Orient with workspace status and health.
- Search before creating directories or clones.
- Register work in `.metagit.yml` and validate.
- Switch project context; delegate single-repo work to subagents.
- Sync conservatively (fetch by default).
- Keep descriptions and per-repo instructions accurate.

## Template apply

To copy helper files into an existing synced project folder:

```bash
metagit mcp serve --root /path/to/coordinator
# Tool: metagit_project_template_apply
#   template: hermes-orchestrator
#   target_projects: ["portfolio"]
#   dry_run: true
```

Or use the MCP tool from your agent host with `confirm_apply` when ready.

## Related docs

- [Hermes & org IaC guide](hermes-iac-workspace-guide.md) — Terraform / module rollout patterns
- [Configuration exemplar](reference/metagit-config.md) — full `.metagit.yml` field reference sample
- [Skills](skills.md) — `metagit-projects`, `metagit-control-center`, workspace sync skills
````

## File: docs/secrets.analysis.md
````markdown
# Secrets and Variables Analysis

This document outlines the secrets and variables used in the `metagit-detect` project, their sources, and their roles in the application's runtime and CI/CD workflows.

## Runtime Secrets and Variables

These secrets and variables are sourced from `.env` files and are used by the application at runtime. The `.env.example` file provides a template for these variables.

-   **`GITHUB_TOKEN`**:
    -   **Description**: A GitHub Personal Access Token (PAT) used to authenticate with the GitHub API. This is required for analyzing repositories, fetching metadata, and other interactions with GitHub.
    -   **Source**: `.env` file.
    -   **Usage**: Used by the `github` provider in the application.

-   **`GITLAB_TOKEN`**:
    -   **Description**: A GitLab Personal Access Token (PAT) used to authenticate with the GitLab API. This is necessary for interacting with GitLab repositories.
    -   **Source**: `.env` file.
    -   **Usage**: Used by the `gitlab` provider in the application.

-   **`METAGIT_LLM_TOKEN`**:
    -   **Description**: The API token for the configured Large Language Model (LLM) provider. This is used for features that leverage LLMs.
    -   **Source**: `.env` file.
    -   **Usage**: Used by the LLM client in the application.

-   **`OPENROUTER_API_KEY`**:
    -   **Description**: The API key for the OpenRouter service. This is used when `openrouter` is configured as the LLM provider.
    -   **Source**: `.env` file.
    -   **Usage**: Used by the LLM client when the provider is set to `openrouter`.

## CI/CD Secrets and Variables

These secrets are configured in the GitHub repository's secrets and are used in the CI/CD workflows.

-   **`secrets.GITHUB_TOKEN`**:
    -   **Description**: A GitHub token that is automatically generated by GitHub Actions. It is used to authenticate with the GitHub API for various tasks within the CI/CD pipelines.
    -   **Source**: GitHub repository secrets.
    -   **Usage**:
        -   Publishing Docker images to the GitHub Container Registry (`ghcr.io`).
        -   Creating and managing GitHub releases.

-   **`secrets.PYPI_API_TOKEN`**:
    -   **Description**: An API token for PyPI, used to authenticate and publish the Python package to the Python Package Index.
    -   **Source**: GitHub repository secrets, configured as a trusted publisher.
    -   **Usage**: Publishing the package to PyPI in the `release.yaml` workflow.
````

## File: docs/secrets.definitions.yml
````yaml
# Secrets and Variables Definitions

secrets:
  - name: GITHUB_TOKEN
    description: "GitHub Personal Access Token for API authentication."
    provenance: "User-provided, stored in .env file."
    placement: "runtime"
    type: "string"

  - name: GITLAB_TOKEN
    description: "GitLab Personal Access Token for API authentication."
    provenance: "User-provided, stored in .env file."
    placement: "runtime"
    type: "string"

  - name: METAGIT_LLM_TOKEN
    description: "API token for the configured LLM provider."
    provenance: "User-provided, stored in .env file."
    placement: "runtime"
    type: "string"

  - name: OPENROUTER_API_KEY
    description: "API key for the OpenRouter service."
    provenance: "User-provided, stored in .env file."
    placement: "runtime"
    type: "string"

  - name: CI_GITHUB_TOKEN
    description: "GitHub token for CI/CD operations."
    provenance: "GitHub repository secrets (secrets.GITHUB_TOKEN)."
    placement: "cicd"
    type: "string"

  - name: CI_PYPI_API_TOKEN
    description: "PyPI API token for publishing the package."
    provenance: "GitHub repository secrets (secrets.PYPI_API_TOKEN)."
    placement: "cicd"
    type: "string"
````

## File: examples/hermes-orchestrator/.metagit.yml
````yaml
name: hermes-control-plane
description: |
  Umbrella workspace for a Hermes controller agent that orchestrates DevOps and
  project-management work across many repositories and local publish paths.
kind: umbrella
agent_instructions: |
  You are the Hermes controller for this workspace: the DevOps and project-management
  entrypoint for the operator. You do not improvise workspace layout.

  Session start (every time):
  1. metagit_workspace_status — confirm gate is active and note workspace root.
  2. metagit_workspace_health_check — surface missing clones, broken mounts, duplicate URLs.
  3. Read metagit://workspace/config when you need the full manifest.

  New or continued work:
  - Search before create: metagit_repo_search / `metagit search` for names, URLs, tags.
  - Reuse existing workspace.projects[] and repos[] entries; never clone into ad-hoc folders.
  - Register changes in .metagit.yml (catalog tools or validated YAML), then metagit config validate.
  - metagit_project_context_switch to focus one project; pass effective_agent_instructions to subagents.
  - Stay controller for cross-project objectives; delegate single-repo implementation to subagents.
  - metagit_workspace_sync: fetch by default; pull/clone only with explicit operator approval.
  - metagit_session_update before switching projects or ending the session.

  Documentation duty:
  - Every repo or path must have a clear description in this manifest.
  - Record build, deploy, and publish steps in per-repo agent_instructions when non-obvious.
  - When paths, URLs, or ownership change, update .metagit.yml before claiming work is done.

workspace:
  description: |
    Portfolio registry: git-backed services plus local-only publish paths. Metagit is the
    source of truth for what exists on disk under the configured workspace.path.
  agent_instructions: |
    Validate after manifest edits (`metagit config validate`). Prefer metagit catalog and
    MCP tools over hand-editing repo lists without validation. Enable workspace.dedupe in
    app config when the same remote URL appears in multiple projects.
  projects:
    - name: portfolio
      description: Git-backed applications and shared services under active development.
      agent_instructions: |
        Git operations are allowed per repo policy. Launch subagents with repo-scoped
        effective_agent_instructions for implementation; you coordinate sequencing and PRs.
      repos:
        - name: example-api
          description: Sample HTTP API service (replace with your repository).
          url: https://github.com/example-org/example-api.git
          sync: true
          kind: service
          tags:
            tier: application
          agent_instructions: |
            Run unit tests and lint before opening PRs. Note breaking API changes in PR text.

    - name: local
      description: |
        Local-only paths (no git remotes). Used to build and publish static web apps and
        personal sites for the operator.
      agent_instructions: |
        Repos here use `path`, not `url`. Do not git clone or pull. Sync creates symlinks
        into the workspace. Document publish targets in each repo's agent_instructions.
      repos:
        - name: example-site
          description: Sample static site published from a folder on disk.
          path: ~/Sites/example-site
          sync: true
          kind: website
          tags:
            publish: static
          agent_instructions: |
            Build: npm run build (or project README). Publish: copy dist/ to the host named
            in README. Update `path` in .metagit.yml if the site directory moves.

    - name: platform
      description: Optional shared infrastructure (Terraform, modules, policy). See docs/hermes-iac-workspace-guide.md.
      agent_instructions: |
        Treat IaC changes as high blast-radius. Use metagit_cross_project_dependencies and
        GitNexus before module version bumps. Subagents run plan-only unless operator approves apply.
      repos: []
````

## File: examples/hermes-orchestrator/answers.example.yml
````yaml
# Example answers for non-interactive init:
#   metagit init --template hermes-orchestrator --answers-file examples/hermes-orchestrator/answers.example.yml --no-prompt
name: hermes-control-plane
description: Umbrella workspace for a Hermes controller agent.
url: ""
portfolio_repo_name: example-api
portfolio_repo_url: https://github.com/example-org/example-api.git
local_site_name: example-site
local_site_path: ~/Sites/example-site
````

## File: examples/metagit.umbrella.yml
````yaml
description: Umbrella workspace for metagit projects
kind: umbrella
name: example_workspace
url: https://github.com/metagit-io/metagit_umbrella
workspace:
  projects:
  - name: default
    repos:
    - name: taskfiles
      description: "Shared taskfiles"
      url: git@github.com:zloeber/taskfiles.git
    - name: dotfiles
      description: "Personal dotfiles"
      url: git@github.com:zloeber/dotfiles.git
    - name: k8s-lab-terraform-libvirt
      description: "Kubernetes lab with Terraform and Libvirt"
      url: git@github.com:zloeber/k8s-lab-terraform-libvirt.git
    - name: crewai-openrouter-lab
      description: "Openrouter lab for crewai"
      url: git@github.com:zloeber/crewai-openrouter-lab.git
    - name: metagit_cli
      description: "Metagit CLI project (symlinked)"
      path: ./src/metagit/cli
````

## File: scripts/prepush-gate.zsh
````zsh
#!/usr/bin/env zsh

set -euo pipefail

if command -v uv >/dev/null 2>&1; then
  exec uv run python "./scripts/prepush-gate.py" "$@"
fi

exec python3 "./scripts/prepush-gate.py" "$@"
````

## File: scripts/validate_skills.py
````python
#!/usr/bin/env python3
"""
Validate every SKILL.md under skills/ against Claude Code skill conventions.

Usage:
    python3 scripts/validate_skills.py

Checks performed on each skills/<name>/SKILL.md:
  - YAML frontmatter is present and parseable
  - Required fields ``name`` and ``description`` are present
  - ``name`` is lowercase-hyphenated and matches the directory name
  - ``description`` is a non-empty string within the length budget
  - SKILL.md body has substantive content

Exit codes:
  0  all skills passed
  1  one or more skills failed validation
  2  configuration error (skills directory missing, PyYAML not installed)
"""
⋮----
REPO_ROOT = Path(__file__).parent.parent
SKILLS_DIR = REPO_ROOT / "skills"
⋮----
NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]*[a-z0-9]$")
MAX_NAME_LEN = 64
MAX_DESCRIPTION_LEN = 1024
MIN_DESCRIPTION_LEN = 20
MIN_BODY_LEN = 200
⋮----
REQUIRED_FIELDS = {"name", "description"}
KNOWN_FIELDS = {"name", "description", "license", "allowed-tools", "metadata"}
⋮----
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
⋮----
def validate_skill(skill_dir: Path) -> list[str]
⋮----
errors: list[str] = []
skill_file = skill_dir / "SKILL.md"
⋮----
rel = skill_file.relative_to(REPO_ROOT)
⋮----
text = skill_file.read_text(encoding="utf-8")
match = FRONTMATTER_RE.match(text)
⋮----
frontmatter = yaml.safe_load(frontmatter_raw)
⋮----
missing = REQUIRED_FIELDS - frontmatter.keys()
⋮----
unknown = set(frontmatter.keys()) - KNOWN_FIELDS
⋮----
name = frontmatter.get("name")
⋮----
desc = frontmatter.get("description")
⋮----
stripped = desc.strip()
⋮----
body_stripped = body.strip()
⋮----
def main() -> int
⋮----
skill_dirs = sorted(
⋮----
failed = 0
⋮----
errs = validate_skill(skill_dir)
⋮----
total = len(skill_dirs)
````

## File: skills/metagit-bootstrap/scripts/bootstrap-config.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
FORCE="${2:-false}"
TARGET="$ROOT/.metagit.yml"

uv run python - "$ROOT" "$TARGET" "$FORCE" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import create_metagit_config
from metagit.core.config.manager import MetagitConfigManager

root = Path(sys.argv[1]).resolve()
target = Path(sys.argv[2]).resolve()
force = sys.argv[3].lower() in {"1", "true", "yes", "force"}

if target.exists() and not force:
    mgr = MetagitConfigManager(config_path=target)
    result = mgr.load_config()
    state = "valid" if not isinstance(result, Exception) else "invalid"
    print(f"status=exists\tvalidity={state}\tpath={target}")
    raise SystemExit(0)

yaml_out = create_metagit_config(name=root.name, kind="application", as_yaml=True)
if isinstance(yaml_out, Exception):
    print(f"status=error\tmessage={yaml_out}")
    raise SystemExit(1)

target.write_text(yaml_out, encoding="utf-8")
mgr = MetagitConfigManager(config_path=target)
result = mgr.load_config()
state = "valid" if not isinstance(result, Exception) else "invalid"
print(f"status=written\tvalidity={state}\tpath={target}")
PY
````

## File: skills/metagit-bootstrap/SKILL.md
````markdown
---
name: metagit-bootstrap
description: Use when generating or refining local .metagit.yml files using deterministic discovery plus MCP sampling.
---

# Metagit MCP Bootstrap Skill

Use this skill to create a local `.metagit.yml` using discovery-driven prompts and MCP sampling.

## Purpose

Generate schema-compliant `.metagit.yml` files with high contextual quality while preserving safety and explicit user control.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for local bootstrap tasks:
- `./scripts/bootstrap-config.zsh [root_path] [force]`

Behavior:
- Writes `.metagit.yml` when missing
- Validates via Metagit config models
- Returns a compact status line for agents

## Workflow

1. Gather deterministic discovery data from the target repository:
   - source language/framework indicators
   - package/lock/build files
   - Dockerfiles and CI workflows
   - terraform files and module usage
2. Build a strict prompt package:
   - output format contract: valid YAML only
   - required schema fields and constraints
   - extracted discovery evidence
3. If sampling is supported, call `sampling/createMessage`.
4. Validate generated YAML with Metagit config models.
5. Retry with validation feedback up to a fixed max attempt count.
6. Return draft output and write only on explicit confirmation.

## Output Modes

- **Plan-only mode**: return prompt + discovery summary if sampling unavailable.
- **Draft mode**: return `.metagit.generated.yml` content.
- **Confirmed write mode**: write to `.metagit.yml` only with explicit parameter (`confirm_write=true`).

## Quality Bar

- Preserve discovered evidence in structured fields.
- Include workspace project and related repo entries where detectable.
- Avoid invented repositories or unverifiable dependencies.

## Safety Rules

- Never overwrite `.metagit.yml` silently.
- Never emit secrets in cleartext.
- Prefer placeholders for credentials or tokens.
````

## File: skills/metagit-cli/metagit-cli/SKILL.md
````markdown
---
name: metagit-cli
description: CLI-only shortcuts for metagit agents — workspace catalog, discovery, prompts, sync, layout, and config. Use instead of MCP or HTTP API when operating from a shell or agent_mode session.
---

# Metagit CLI (agent shortcuts)

Use this skill when an agent should drive metagit **only through the `metagit` command**. Do not call MCP tools or `metagit api` from workflows covered here unless the user explicitly asks.

Set non-interactive defaults when automating:

```bash
export METAGIT_AGENT_MODE=true
```

Global flags (most commands):

- `-c path/to/metagit.config.yaml` — app config (default `metagit.config.yaml`)
- Workspace manifest: `--definition` / `-c` on catalog commands (default `.metagit.yml`)

---

## Prompt commands (all kinds)

List built-in prompt kinds:

```bash
metagit prompt list
metagit prompt list --json
```

Emit prompts (`--text-only` for paste into agent context; `--json` for structured output; `--no-instructions` to omit manifest layers):

| Scope | Command | Default kind |
|-------|---------|--------------|
| Workspace | `metagit prompt workspace -c <definition> -k <kind>` | `instructions` |
| Project | `metagit prompt project -p <project> -c <definition> -k <kind>` | `instructions` |
| Repo | `metagit prompt repo -p <project> -n <repo> -c <definition> -k <kind>` | `instructions` |

### Prompt kinds by scope

| Kind | Workspace | Project | Repo | Purpose |
|------|:---------:|:-------:|:----:|---------|
| `instructions` | yes | yes | yes | Composed `agent_instructions` from manifest layers |
| `session-start` | yes | — | — | Session bootstrap checklist |
| `catalog-edit` | yes | yes | — | Search-before-create; catalog registration |
| `health-preflight` | yes | yes | — | Pre-work workspace/repo status pass |
| `sync-safe` | yes | yes | yes | Guarded sync rules |
| `subagent-handoff` | — | yes | yes | Delegate single-repo work |
| `layout-change` | yes | yes | yes | Rename/move dry-run workflow |
| `repo-enrich` | — | — | yes | **Discover + merge** workspace repo entry |

### Prompt shortcuts (copy-paste)

```bash
# Session bootstrap
metagit prompt workspace -k session-start --text-only -c .metagit.yml

# Composed instructions at each level
metagit prompt workspace -k instructions --text-only -c .metagit.yml
metagit prompt project -p default -k instructions --text-only -c .metagit.yml
metagit prompt repo -p default -n my-api -k instructions --text-only -c .metagit.yml

# Repo catalog enrichment (detect + merge manifest entry)
metagit prompt repo -p default -n my-api -k repo-enrich --text-only -c .metagit.yml

# Catalog registration discipline
metagit prompt workspace -k catalog-edit --text-only -c .metagit.yml

# Safe sync reminder
metagit prompt repo -p default -n my-api -k sync-safe --text-only -c .metagit.yml

# Subagent handoff
metagit prompt repo -p default -n my-api -k subagent-handoff --text-only -c .metagit.yml
```

---

## Repo enrich workflow (`repo-enrich`)

Run the prompt, then execute its steps:

```bash
metagit prompt repo -p <project> -n <repo> -k repo-enrich --text-only -c .metagit.yml
```

Typical discovery chain on the checkout:

```bash
cd "$(metagit search '<repo>' -c .metagit.yml --path-only)"
metagit detect repository -p . -o json
metagit detect repo -p . -o yaml
metagit detect repo_map -p . -o json
```

Provider metadata (dry-run):

```bash
metagit project source sync --provider github --org <org> --mode discover --no-apply
```

After merging fields into `workspace.projects[].repos[]`:

```bash
metagit config validate -c .metagit.yml
metagit workspace repo list --project <project> --json
```

---

## Workspace and catalog

Per-project dedupe override in `.metagit.yml` (overrides `workspace.dedupe.enabled` in `metagit.config.yaml` for that project only):

```yaml
workspace:
  projects:
    - name: local
      dedupe:
        enabled: false
      repos: []
```

```bash
metagit appconfig show --format json
metagit config info -c .metagit.yml
metagit config show -c .metagit.yml
metagit config validate -c .metagit.yml

metagit workspace list -c .metagit.yml --json
metagit workspace project list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --project <name> --json

metagit workspace project add --name <name> --json
metagit workspace repo add --project <name> --name <repo> --url <url> --json
metagit workspace project remove --name <name> --json
metagit workspace repo remove --project <name> --name <repo> --json
```

Search managed repos (always before creating entries):

```bash
metagit search "<query>" -c .metagit.yml --json
metagit search "<query>" -c .metagit.yml --path-only
metagit search "<query>" -c .metagit.yml --tag tier=1 --project <name>
```

---

## Project operations

```bash
metagit project list --config .metagit.yml --all --json
metagit project add --name <name> --json
metagit project remove --name <name> --json
metagit project rename --name <old> --new-name <new> --dry-run --json
metagit project select
metagit project sync

metagit project repo list --json
metagit project repo add --project <name> --name <repo> --url <url>
metagit project repo remove --name <repo> --json
metagit project repo rename --name <old> --new-name <new> --dry-run --json
metagit project repo move --name <repo> --to-project <other> --dry-run --json
metagit project repo prune --project <name> --dry-run

metagit project source sync --provider github --org <org> --mode discover --no-apply
metagit project source sync --provider github --org <org> --mode additive --apply
```

Layout (manifest + disk; always dry-run first):

```bash
metagit workspace project rename --name <old> --new-name <new> --dry-run --json
metagit workspace repo rename --project <p> --name <old> --new-name <new> --dry-run --json
metagit workspace repo move --project <p> --name <repo> --to-project <other> --dry-run --json
```

---

## Discovery and local metadata

```bash
metagit detect project -p <path> -o yaml
metagit detect repo -p <path> -o yaml
metagit detect repo_map -p <path> -o json
metagit detect repository -p <path> -o json
metagit detect repository -p <path> -o metagit
# --save only with operator approval (blocked in agent_mode)
```

Bootstrap new trees:

```bash
metagit init --kind application
metagit init --kind umbrella --template hermes-orchestrator
```

---

## Selection and scope

```bash
metagit workspace select --project <name>
metagit project select
metagit project repo select
```

---

## Config and appconfig

```bash
metagit appconfig validate
metagit appconfig get workspace.path
metagit config example
metagit config schema
metagit config providers
```

---

## Records, skills, version

```bash
metagit record search "<query>"
metagit skills list
metagit skills show metagit-cli
metagit skills install --skill metagit-cli
metagit version
metagit info
```

---

## Agent habits

1. **Search before create** — `metagit search` then catalog add.
2. **Validate after manifest edits** — `metagit config validate`.
3. **Emit prompts instead of rewriting playbooks** — `metagit prompt … --text-only`.
4. **Enrich stale repo entries** — `metagit prompt repo … -k repo-enrich` then detect + merge.
5. **Dry-run layout** — always `--dry-run --json` before apply.
6. **Prefer `METAGIT_AGENT_MODE=true`** in CI and agent loops to skip fuzzy finder and confirm dialogs.

## Related bundled skills

Use topic skills when you need deeper playbooks (some mention MCP): `metagit-projects`, `metagit-workspace-scope`, `metagit-workspace-sync`, `metagit-config-refresh`. This skill is the **CLI-only** index and prompt reference.
````

## File: skills/metagit-config-refresh/SKILL.md
````markdown
---
name: metagit-config-refresh
description: Refresh or bootstrap `.metagit.yml` using deterministic discovery and validation flows. Use when configuration is missing, stale, or incomplete for workspace operations.
---

# Refreshing Project Config

Use this skill to keep `.metagit.yml` accurate and operational.

## Workflow

1. Check workspace activation and config validity.
2. Run bootstrap plan mode first.
3. Review generated changes against expected workspace topology.
4. Apply config updates and validate schema before continuing.

## Command Wrapper

- `zsh ./skills/metagit-bootstrap/scripts/bootstrap-config.zsh [root_path] [mode] [seed_context]`

## Output Contract

Return:
- config health state before/after
- generated update summary
- any manual follow-up needed

## Safety

- Prefer plan/dry-run first for large config updates.
- Keep changes bounded to target workspace intent.
````

## File: skills/metagit-gating/scripts/gate-status.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"

uv run python - "$ROOT" <<'PY'
import os
import sys
from metagit.core.mcp.gate import WorkspaceGate
from metagit.core.mcp.root_resolver import WorkspaceRootResolver
from metagit.core.mcp.tool_registry import ToolRegistry

root = sys.argv[1]
resolver = WorkspaceRootResolver()
gate = WorkspaceGate()
registry = ToolRegistry()

resolved = resolver.resolve(cwd=os.path.abspath(root), cli_root=root)
status = gate.evaluate(root_path=resolved)
tools = registry.list_tools(status)
print(f"state={status.state.value}\troot={status.root_path or 'none'}\ttools={len(tools)}")
PY
````

## File: skills/metagit-gating/SKILL.md
````markdown
---
name: metagit-gating
description: Use when implementing or operating Metagit MCP server activation and tool exposure rules based on .metagit.yml presence and validity.
---

# Metagit MCP Gating Skill

Use this skill whenever you need to control whether Metagit MCP tools/resources are exposed.

## Purpose

Ensure high-risk tooling and multi-repo context are only available when a valid `.metagit.yml` exists at the resolved workspace root.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for all gating checks:
- `./scripts/gate-status.zsh [root_path]`

Expected output (single line, tab-delimited):
- `state=<value>\troot=<path|none>\ttools=<count>`

## Activation Workflow

1. Resolve workspace root:
   - `METAGIT_WORKSPACE_ROOT`
   - CLI `--root`
   - upward directory walk
2. Check for `.metagit.yml` in resolved root.
3. Validate config through existing Metagit config models.
4. Derive activation state: missing, invalid, or active.
5. Register tool surface based on state.

## Tool Exposure Contract

### Inactive (missing or invalid config)
Expose only:
- `metagit_workspace_status`
- `metagit_bootstrap_config_plan_only`

### Active (valid config)
Expose full set:
- `metagit_workspace_status`
- `metagit_workspace_index`
- `metagit_workspace_search`
- `metagit_upstream_hints`
- `metagit_repo_inspect`
- `metagit_repo_sync`
- `metagit_bootstrap_config`

## Error Handling

- Return explicit, machine-readable state and reason.
- Avoid stack traces in user-facing outputs.
- Log parser/validation errors with enough detail for debugging.

## Safety Rules

- Never expose mutation-capable tools in inactive state.
- Never operate outside validated workspace boundaries.
- Keep defaults read-only unless user/agent explicitly opts in.
````

## File: skills/metagit-gitnexus/scripts/analyze-targets.zsh
````zsh
#!/usr/bin/env zsh

set -euo pipefail

workspace_root="${1:-.}"
project_name="${2:-default}"

if [[ ! -f ".metagit.yml" ]]; then
  echo "ERROR: .metagit.yml not found in current directory"
  exit 2
fi

echo "analyze repo=$(pwd)"
npx gitnexus analyze

tmp_output="$(mktemp)"
uv run python - "$workspace_root" "$project_name" <<'PY' > "$tmp_output"
import sys
from pathlib import Path
import yaml

workspace_root = Path(sys.argv[1]).expanduser().resolve()
project_name = sys.argv[2]
cfg = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8")) or {}
workspace = (cfg.get("workspace") or {})
projects = workspace.get("projects") or []
target = next((p for p in projects if p.get("name") == project_name), None)
if not target:
    print(f"warn project_not_found={project_name}")
    raise SystemExit(0)

for repo in target.get("repos") or []:
    name = repo.get("name")
    if not name:
        continue
    repo_path = workspace_root / project_name / name
    if repo_path.exists() and repo_path.is_dir():
        print(f"repo_path={repo_path}")
    else:
        print(f"skip_missing={repo_path}")
PY

while IFS= read -r line; do
  case "$line" in
    repo_path=*)
      path="${line#repo_path=}"
      echo "analyze repo=${path}"
      (cd "$path" && npx gitnexus analyze) || echo "fail repo=${path}"
      ;;
    *)
      echo "$line"
      ;;
  esac
done < "$tmp_output"

rm -f "$tmp_output"
````

## File: skills/metagit-multi-repo/SKILL.md
````markdown
---
name: metagit-multi-repo
description: Coordinate implementation tasks across multiple repositories using metagit status, search, and scoped sync workflows. Use when one objective spans several repositories.
---

# Coordinating Multi-Repo Implementation

Use this skill for cross-repository feature or fix delivery.

## Workflow

1. Define objective and affected repositories.
2. Verify workspace scope and dependency hints.
3. Sequence work by dependency order.
4. Sync only required repositories.
5. Track progress and blockers per repository.

## Command Wrapper

- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- objective-to-repository map
- execution order
- current blocker + next step

## Safety

- Keep scope bounded to configured workspace repositories.
- Prefer deterministic evidence for cross-repo assumptions.
````

## File: skills/metagit-projects/SKILL.md
````markdown
---
name: metagit-projects
description: Ongoing workspace and project management for OpenClaw and Hermes agents. Use when starting work, organizing repos, or before creating a new project folder so existing metagit projects are reused instead of duplicated.
---

# Ongoing Project Management

Use this skill when the user starts new work, reorganizes repositories, or asks you to create a project folder. Metagit is the source of truth for what already exists in the workspace.

## Concepts

Metagit uses a three-level hierarchy (see project terminology docs):

| Level | Meaning |
|-------|---------|
| **Workspace** | Root folder where projects are synced (from app config `workspace.path`, often `./.metagit/`). Holds many projects. |
| **Project** | Named group of one or more Git repositories. Multi-repo products are one project; unrelated repos can also share a workspace under different project names. |
| **Repo** | A single Git repository entry under `workspace.projects[].repos` in `.metagit.yml`. |

A **project** is not always “one product.” It is whatever grouping helps the user and agents reason about related (or intentionally grouped) repositories. A workspace may contain unrelated projects side by side (for example `default`, `client-a`, `experiments`).

The umbrella `.metagit.yml` (workspace definition, often `kind: umbrella`) lives in a coordinating repository or central config checkout. Application repos may have their own `.metagit.yml` for metadata mode.

## Mandatory: check before creating folders

**Never** create a new project directory or clone into the workspace until you have checked metagit for an existing match.

1. **Locate the workspace definition**
   - Prefer the user’s umbrella `.metagit.yml` if known.
   - Otherwise use `.metagit.yml` in the current repo with `--definition /path/to/.metagit.yml`.

2. **List configured projects and repo counts**
   ```bash
   metagit config info --config-path /path/to/.metagit.yml
   metagit project list --config /path/to/.metagit.yml --project default
   ```
   Repeat `--project` for each project name returned by `config info`.

3. **Search managed repos by name, URL fragment, or tag**
   ```bash
   metagit search "<proposed-name-or-url>" --definition /path/to/.metagit.yml
   metagit search "<name>" --definition /path/to/.metagit.yml --json
   ```

4. **Inspect on disk** (workspace path from app config, default `./.metagit/`)
   - Expected layout: `{workspace.path}/{project_name}/{repo_name}/`
   - If the directory already exists, **reuse it**; do not create a parallel tree.

5. **Decide**
   - **Match found** → use existing project/repo; run `metagit project sync` only if the user wants checkouts refreshed.
   - **No match** → proceed with registration steps below (still add to workspace; do not leave orphan folders).

## Registering new work in the workspace

### New repository in an existing project

From the directory containing the workspace `.metagit.yml` (or pass `--config`):

```bash
metagit project repo add --project <project_name> --prompt
# or non-interactive:
metagit project repo add --project <project_name> --name <repo> --url <git-url>
metagit config validate --config-path .metagit.yml
metagit project sync --project <project_name>
```

In the new application repo (if applicable):

```bash
cd /path/to/new/repo
metagit init
metagit detect repo --force   # optional: enrich .metagit.yml
```

### New project group (new `workspace.projects[]` entry)

There is no separate `project create` CLI today. Add a project block to `.metagit.yml`:

```yaml
workspace:
  projects:
    - name: my-new-project
      description: Short purpose for agents and humans
      repos: []
```

Then validate, add repos, and sync:

```bash
metagit config validate --config-path .metagit.yml
metagit project repo add --project my-new-project --prompt
metagit project sync --project my-new-project
```

Choose a **distinct project name**; avoid duplicating an existing `workspace.projects[].name`.

### New umbrella workspace

When bootstrapping a workspace coordinator repo:

```bash
metagit init --kind umbrella
metagit project repo add --project default --prompt
metagit project sync
```

## Ongoing session habits

At the start of sustained work:

1. Run **metagit-workspace-scope** (or `metagit mcp serve --status-once` when MCP is available).
2. Confirm **active project** matches the user’s intent (`metagit workspace select --project <name>` when switching).
3. Use **`metagit search`** before assuming a repo is missing or lives elsewhere.
4. For multi-repo tasks, prefer **metagit-control-center** or **metagit-multi-repo** over ad-hoc cloning.

When the user names a target folder:

- Resolve it against managed config first.
- If unmanaged but present on disk under the project sync folder, report it and offer to add via `metagit project repo add` rather than recreating.

## OpenClaw and Hermes setup

Install bundled skills (including this one) for agent hosts:

```bash
metagit skills list
metagit skills install --scope user --target openclaw --target hermes
metagit mcp install --scope user --target openclaw --target hermes
```

Use `--scope project` when installing into a specific umbrella repository checkout.

## Output contract

After project-management actions, report:

- workspace definition path used
- whether the target was **existing** or **newly registered**
- project name and repo name(s) affected
- sync status if `project sync` was run
- recommended next command (`workspace select`, `project select`, or `detect`)

## Safety

- Do not clone, delete, or overwrite sync directories without explicit user approval.
- Do not edit `.metagit.yml` without validating afterward (`metagit config validate`).
- Prefer reusing configured repos over creating duplicate checkouts.
- Keep unrelated experiments in separate `workspace.projects` entries when the user wants clear boundaries.
````

## File: skills/metagit-upstream-scan/scripts/upstream-scan.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-}"
MAX_RESULTS="${4:-20}"

if [[ -z "$QUERY" ]]; then
  echo "status=error\tmessage=query-required"
  exit 1
fi

uv run python - "$ROOT" "$QUERY" "$PRESET" "$MAX_RESULTS" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.mcp.services.workspace_search import WorkspaceSearchService
from metagit.core.mcp.services.upstream_hints import UpstreamHintService

root = Path(sys.argv[1]).resolve()
query = sys.argv[2]
preset = sys.argv[3] or None
max_results = int(sys.argv[4])

manager = MetagitConfigManager(config_path=root / ".metagit.yml")
cfg = manager.load_config()
if isinstance(cfg, Exception):
    print(f"status=error\tmessage=config-invalid\tdetail={cfg}")
    raise SystemExit(1)

index = WorkspaceIndexService().build_index(config=cfg, workspace_root=str(root))
repo_paths = [row["repo_path"] for row in index if row.get("exists")]
search_hits = WorkspaceSearchService().search(query=query, repo_paths=repo_paths, preset=preset, max_results=max_results)
ranked = UpstreamHintService().rank(blocker=query, repo_context=index)[:5]

print(f"status=ok\trepos={len(index)}\thits={len(search_hits)}")
for row in ranked:
    print(f"hint\trepo={row['repo_name']}\tscore={row['score']}")
for hit in search_hits[:5]:
    print(f"hit\tfile={hit['file_path']}\tline={hit['line_number']}")
PY
````

## File: skills/metagit-upstream-scan/SKILL.md
````markdown
---
name: metagit-upstream-scan
description: Use when a coding agent encounters likely upstream blockers and must find related workspace repositories, files, and probable root causes.
---

# Metagit Upstream Discovery Skill

Use this skill when the current repo does not appear to contain the full fix and related repositories may hold the source issue.

## Supported Use Cases

- Missing Terraform input in a shared module
- Docker base image/version mismatch across repos
- Shared infrastructure definitions causing local failures
- CI pipeline breakages tied to upstream templates/workflows

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for upstream discovery tasks:
- `./scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

Output format:
- compact status line
- top ranked repo hints (`hint`)
- top search file hits (`hit`)

## Workflow

1. Read workspace repository map from active `.metagit.yml`.
2. Run `metagit_workspace_index` to verify repo availability and sync state.
3. Use `metagit_workspace_search` with category preset (`terraform`, `docker`, `infra`, `ci`).
4. Use `metagit_upstream_hints` to rank candidate repositories and files.
5. Return a concise action plan:
   - top candidate repos
   - likely files/definitions
   - whether sync is needed before deeper analysis

## Search Strategy

- Start narrow with issue-specific terms (error, module, variable, image tag).
- Expand to broader shared terms if no hits.
- Prefer repositories referenced by workspace metadata before searching unknown repos.

## Output Contract

Return:
- ranked candidates with rationale
- suggested next file openings
- confidence level and unresolved assumptions

## Safety Rules

- Restrict search to configured workspace repositories.
- Cap result size and duration.
- Keep this workflow read-only unless an explicit sync action is requested.
````

## File: skills/metagit-upstream-triage/SKILL.md
````markdown
---
name: metagit-upstream-triage
description: Triage cross-repository blockers by ranking likely upstream repositories and files with metagit search and hinting tools. Use when local fixes appear incomplete.
---

# Triaging Upstream Blockers

Use this skill for failures likely rooted in another repository.

## Workflow

1. Run workspace index and search with issue-specific terms.
2. Run upstream hint ranking to prioritize repositories/files.
3. Open the top candidates and validate root-cause evidence.
4. Return a short fix path (repo, file, next action).

## Command Wrapper

- `zsh ./skills/metagit-upstream-scan/scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

## Output Contract

Return:
- ranked candidate repositories
- probable root-cause files
- confidence and assumptions

## Safety

- Keep this flow read-only unless sync is explicitly requested.
````

## File: src/metagit/cli/commands/api.py
````python
#!/usr/bin/env python
"""
Local JSON HTTP API command group.
"""
⋮----
@click.group()
def api() -> None
⋮----
"""Local JSON API commands."""
⋮----
def serve(root: str, host: str, port: int, status_once: bool) -> None
⋮----
"""Serve managed-repo search JSON endpoints on localhost."""
root_abs = str(Path(root).resolve())
server = build_server(root=root_abs, host=host, port=port)
bound_port = server.server_address[1]
````

## File: src/metagit/cli/commands/project_source.py
````python
#!/usr/bin/env python
"""
Project source sync subcommand.
"""
⋮----
@click.group(name="source")
@click.pass_context
def source(ctx: click.Context) -> None
⋮----
"""Source-backed project sync operations."""
⋮----
"""Discover and sync repositories from provider sources."""
logger: UnifiedLogger = ctx.obj["logger"]
app_config: AppConfig = ctx.obj["config"]
project_name: str = ctx.obj["project"]
local_config: MetagitConfig = ctx.obj["local_config"]
config_path: str = ctx.obj["config_path"]
⋮----
spec = SourceSpec(
⋮----
workspace_project: Optional[WorkspaceProject] = next(
⋮----
service = SourceSyncService(app_config, logger)
discovered_result = service.discover(spec)
⋮----
discovered = discovered_result
⋮----
sync_mode = SourceSyncMode(mode)
plan = service.plan(spec, workspace_project, discovered, sync_mode)
⋮----
updated_project = service.apply_plan(workspace_project, plan, sync_mode)
⋮----
config_manager = MetagitConfigManager(config_path=config_path)
save_result = config_manager.save_config(local_config, config_path)
````

## File: src/metagit/cli/commands/prompt.py
````python
#!/usr/bin/env python
"""
Emit metagit prompts for workspace, project, and repo scopes.
"""
⋮----
def _load_manifest(definition_path: str) -> MetagitConfig
⋮----
manager = MetagitConfigManager(definition_path)
loaded = manager.load_config()
⋮----
app_config: AppConfig = ctx.obj["config"]
config = _load_manifest(definition_path)
workspace_root = str(Path(app_config.workspace.path).expanduser().resolve())
⋮----
def _kind_choice(scope: str) -> click.Choice
⋮----
[str(item) for item in kinds_for_scope(scope)],  # type: ignore[arg-type]
⋮----
result = PromptService().emit(
⋮----
kind=kind,  # type: ignore[arg-type]
scope=scope,  # type: ignore[arg-type]
⋮----
@click.group(name="prompt", invoke_without_command=True)
@click.pass_context
def prompt(ctx: click.Context) -> None
⋮----
"""Emit metagit prompts for workspace, project, and repo scopes."""
⋮----
@click.pass_context
def prompt_list(ctx: click.Context, as_json: bool) -> None
⋮----
"""List available prompt kinds and valid scopes."""
_ = ctx
entries = PromptService().list_entries()
⋮----
scopes = ", ".join(entry.scopes)
⋮----
"""Emit a prompt for the whole workspace manifest."""
⋮----
"""Emit a prompt scoped to one workspace project."""
⋮----
"""Emit a prompt scoped to one repository entry."""
````

## File: src/metagit/cli/commands/web.py
````python
#!/usr/bin/env python
"""Local web UI command group."""
⋮----
@click.group()
def web() -> None
⋮----
"""Local web UI commands."""
⋮----
"""Serve the metagit web UI and workspace API on localhost."""
root_abs = str(Path(root).resolve())
appconfig_path = appconfig
⋮----
appconfig_path = str(ctx.obj["config_path"])
server = build_web_server(
bound_port = server.server_address[1]
url = f"http://{host}:{bound_port}/"
````

## File: src/metagit/cli/config_patch_ops.py
````python
#!/usr/bin/env python
"""Shared helpers for config patch/preview CLI commands."""
⋮----
def parse_cli_value(raw: str) -> Any
⋮----
"""Parse --value as JSON when possible, otherwise return the raw string."""
stripped = raw.strip()
⋮----
def load_operations_file(path: str) -> list[ConfigOperation]
⋮----
"""Load operations from a JSON file (ConfigPatchRequest or operations array)."""
file_path = Path(path)
⋮----
payload = json.loads(file_path.read_text(encoding="utf-8"))
⋮----
request = ConfigPatchRequest.model_validate(payload)
⋮----
"""Resolve operations from --file or a single --op/--path/--value triplet."""
⋮----
op_kind = ConfigOpKind(op.lower())
⋮----
allowed = ", ".join(item.value for item in ConfigOpKind)
⋮----
parsed_value = parse_cli_value(value) if value is not None else None
⋮----
"""Print patch outcome and abort on failure when not saving with errors."""
⋮----
"""Print or write YAML preview."""
⋮----
payload = result.model_dump(mode="json", exclude_none=True)
⋮----
"""Print schema tree."""
⋮----
def _print_tree_node(node: Any, *, indent: int) -> None
⋮----
prefix = "  " * indent
label = node.type_label or node.type
enabled = "on" if node.enabled else "off"
path = node.path or "(root)"
⋮----
value = node.value
⋮----
extra = ""
⋮----
extra = f", items={node.item_count}"
⋮----
extra = f"{extra}, appendable"
````

## File: src/metagit/core/api/__init__.py
````python
#!/usr/bin/env python
"""Local HTTP JSON API helpers for Metagit."""
⋮----
__all__ = ["build_server"]
````

## File: src/metagit/core/api/catalog_handler.py
````python
#!/usr/bin/env python
"""
HTTP handlers for workspace catalog list and mutation (v2 API).
"""
⋮----
JsonResponder = Callable[[int, dict[str, Any]], None]
⋮----
class CatalogApiHandler
⋮----
"""Route catalog operations for the local JSON HTTP API."""
⋮----
def __init__(self, workspace_root: str, config_path: str) -> None
⋮----
"""Dispatch catalog routes; return True when handled."""
parsed_path = urlparse(path).path
params = parse_qs(query, keep_blank_values=True)
config = self._load_config(respond)
⋮----
result = self._service.list_workspace(
⋮----
result = self._service.list_projects(config)
⋮----
payload = self._parse_body(body, respond)
⋮----
name = str(payload.get("name", "")).strip()
mutation = self._service.add_project(
⋮----
name = unquote(parsed_path.removeprefix("/v2/projects/").strip("/"))
mutation = self._service.remove_project(
⋮----
project = self._first(params, "project")
result = self._service.list_repos(
⋮----
project_name = str(payload.get("project", "")).strip()
built = self._service.build_repo_from_fields(
⋮----
mutation = self._service.add_repo(
⋮----
remainder = parsed_path.removeprefix("/v2/repos/").strip("/")
⋮----
mutation = self._service.remove_repo(
⋮----
def _load_config(self, respond: JsonResponder) -> MetagitConfig | None
⋮----
manager = MetagitConfigManager(self._config_path)
loaded = manager.load_config()
⋮----
def _parse_body(self, body: bytes, respond: JsonResponder) -> dict[str, Any] | None
⋮----
parsed = json.loads(body.decode("utf-8"))
⋮----
status = 200 if mutation.ok else 409
⋮----
status = 404
⋮----
status = 400
⋮----
@staticmethod
    def _first(params: dict[str, list[str]], key: str) -> str | None
⋮----
values = params.get(key)
⋮----
first = values[0].strip()
````

## File: src/metagit/core/api/layout_handler.py
````python
#!/usr/bin/env python
"""
HTTP handlers for workspace layout rename and move (v2 API).
"""
⋮----
JsonResponder = Callable[[int, dict[str, Any]], None]
⋮----
class LayoutApiHandler
⋮----
"""Route layout rename/move operations for the local JSON HTTP API."""
⋮----
def __init__(self, definition_root: str, config_path: str) -> None
⋮----
"""Dispatch layout routes; return True when handled."""
parsed_path = urlparse(path).path
params = parse_qs(query, keep_blank_values=True)
config = self._load_config(respond)
⋮----
flags = self._layout_flags(params, body, respond)
⋮----
from_name = unquote(
payload = flags.get("body") or {}
to_name = str(
result = self._service.rename_project(
⋮----
remainder = (
⋮----
result = self._service.rename_repo(
⋮----
result = self._service.move_repo(
⋮----
payload: dict[str, Any] = {}
⋮----
parsed = json.loads(body.decode("utf-8"))
⋮----
payload = parsed
⋮----
def _bool_param(key: str, default: bool = False) -> bool
⋮----
raw = params.get(key, [str(payload.get(key, default)).lower()])
value = raw[0] if raw else str(default).lower()
⋮----
manifest_only = _bool_param("manifest_only") or bool(
⋮----
def _load_config(self, respond: JsonResponder) -> MetagitConfig | None
⋮----
manager = MetagitConfigManager(self._config_path)
loaded = manager.load_config()
⋮----
status = 200 if mutation.ok else 409
⋮----
status = 404
⋮----
status = 400
⋮----
status = 403
⋮----
@staticmethod
    def _first(params: dict[str, list[str]], key: str) -> str | None
⋮----
values = params.get(key)
⋮----
first = values[0].strip()
````

## File: src/metagit/core/appconfig/agent_mode.py
````python
#!/usr/bin/env python
"""
Agent-mode detection for non-interactive, agent-optimized interfaces.
"""
⋮----
_ENV_VAR = "METAGIT_AGENT_MODE"
_TRUTHY = frozenset({"1", "true", "yes", "on"})
⋮----
def env_agent_mode_enabled() -> bool | None
⋮----
"""
    Read METAGIT_AGENT_MODE from the environment.

    Returns None when the variable is unset (caller should use file config).
    """
raw = os.getenv(_ENV_VAR)
⋮----
def resolve_agent_mode(config: AppConfig) -> bool
⋮----
"""Effective agent mode: METAGIT_AGENT_MODE overrides appconfig.agent_mode."""
from_env = env_agent_mode_enabled()
⋮----
def apply_agent_mode_override(config: AppConfig) -> AppConfig
⋮----
"""Apply METAGIT_AGENT_MODE to config after load."""
````

## File: src/metagit/core/appconfig/display.py
````python
#!/usr/bin/env python
"""
Render full application configuration for CLI display.
"""
⋮----
OutputFormat = Literal["yaml", "json", "minimal-yaml"]
⋮----
"""Build the document shown by `metagit appconfig show`."""
⋮----
config_body = config.model_dump(
⋮----
config_body = config.model_dump(mode="json")
⋮----
"""Serialize appconfig show output for the requested format."""
payload = build_appconfig_payload(
````

## File: src/metagit/core/config/__init__.py
````python
#!/usr/bin/env python
"""
Project package for metagit.

This package provides Pydantic models and methods for parsing and validating
.metagit.yml configuration files.
"""
⋮----
__all__ = [
⋮----
# Main configuration model
⋮----
# Enums
⋮----
# Models
````

## File: src/metagit/core/config/documentation_models.py
````python
#!/usr/bin/env python
"""
Documentation source models for .metagit.yml knowledge-graph ingestion.
"""
⋮----
def _looks_like_url(value: str) -> bool
⋮----
trimmed = value.strip().lower()
⋮----
"""
    Accept documentation as strings or dicts; normalize to DocumentationSource dicts.

    - Bare strings: markdown path or web URL (inferred from prefix).
    - Dicts: explicit kind/path/url/tags/metadata for graph pipelines.
    """
⋮----
normalized: list[dict[str, Any]] = []
⋮----
trimmed = item.strip()
⋮----
class DocumentationSource(BaseModel)
⋮----
"""One documentation source for agents and knowledge-graph ingestion."""
⋮----
model_config = ConfigDict(extra="forbid", use_enum_values=True)
⋮----
kind: str = Field(
path: Optional[str] = Field(
url: Optional[str] = Field(None, description="Remote documentation URL")
title: Optional[str] = Field(None, description="Human-readable title")
description: Optional[str] = Field(
tags: dict[str, str] = Field(
metadata: dict[str, Any] = Field(
⋮----
@field_validator("kind", mode="before")
@classmethod
    def _normalize_kind(cls, value: object) -> str
⋮----
@field_validator("tags", mode="before")
@classmethod
    def _normalize_tags(cls, value: object) -> dict[str, str]
⋮----
@model_validator(mode="after")
    def _require_path_or_url(self) -> DocumentationSource
⋮----
def graph_node_payload(self) -> dict[str, Any]
⋮----
"""Serialize for knowledge-graph or export pipelines."""
payload: dict[str, Any] = {
⋮----
DocumentationEntry = Union[str, DocumentationSource]
````

## File: src/metagit/core/config/graph_cypher_export.py
````python
#!/usr/bin/env python
"""Export .metagit.yml workspace graph data as GitNexus-ingestible Cypher."""
⋮----
class GraphCypherNode(BaseModel)
⋮----
"""Workspace graph node for export."""
⋮----
id: str
kind: Literal["workspace", "project", "repo", "documentation"]
label: str
workspace: Optional[str] = None
project: Optional[str] = None
repo: Optional[str] = None
path: Optional[str] = None
properties: dict[str, Any] = Field(default_factory=dict)
⋮----
class GraphCypherEdge(BaseModel)
⋮----
"""Workspace graph edge for export."""
⋮----
from_id: str
to_id: str
type: str
source: Literal["manual", "structure", "documentation"] = "manual"
label: Optional[str] = None
⋮----
class GraphCypherToolCall(BaseModel)
⋮----
"""MCP-shaped call for gitnexus_cypher."""
⋮----
tool: str = "gitnexus_cypher"
arguments: dict[str, Any] = Field(default_factory=dict)
⋮----
class GraphCypherExportResult(BaseModel)
⋮----
"""Cypher export bundle for CLI, MCP, and agent pipelines."""
⋮----
ok: bool = True
workspace_name: str = ""
gitnexus_repo: str = ""
schema_statements: list[str] = Field(default_factory=list)
statements: list[str] = Field(default_factory=list)
tool_calls: list[GraphCypherToolCall] = Field(default_factory=list)
nodes: list[GraphCypherNode] = Field(default_factory=list)
edges: list[GraphCypherEdge] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
⋮----
def _escape_cypher_string(value: str) -> str
⋮----
def _literal_string(value: str) -> str
⋮----
def _literal_json(payload: dict[str, Any]) -> str
⋮----
class GraphCypherExportService
⋮----
"""Build Metagit workspace overlay nodes/edges and Cypher ingest statements."""
⋮----
_schema_statements: tuple[str, ...] = (
⋮----
"""Export manifest graph data as Cypher statements and MCP tool calls."""
rows = self._index.build_index(config=config, workspace_root=workspace_root)
project_names = {
workspace_name = config.name or "workspace"
target_repo = gitnexus_repo or workspace_name
⋮----
nodes: dict[str, GraphCypherNode] = {}
edges: list[GraphCypherEdge] = []
warnings: list[str] = []
⋮----
manual_added = self._add_manual_edges(
⋮----
node_list = sorted(nodes.values(), key=lambda item: item.id)
schema = list(self._schema_statements) if with_schema else []
statements = self._build_statements(node_list, edges)
tool_calls = [
⋮----
node_id = f"workspace:{workspace_name}"
⋮----
node_id = f"project:{project_name}"
⋮----
node_id = f"repo:{row['project_name']}/{row['repo_name']}"
⋮----
project_id = f"project:{row['project_name']}"
repo_id = f"repo:{row['project_name']}/{row['repo_name']}"
⋮----
payload = entry.graph_node_payload()
doc_id = f"documentation:{index}"
label = entry.title or entry.path or entry.url or doc_id
⋮----
added = 0
⋮----
from_id = resolve_graph_endpoint_id(
to_id = resolve_graph_endpoint_id(
⋮----
rel_id = rel.id or f"manual:{from_id}->{to_id}:{rel.type}"
⋮----
project = node_id.split(":", 1)[1]
⋮----
body = node_id.split(":", 1)[1]
⋮----
row = next(
⋮----
statements: list[str] = []
⋮----
def _merge_node_statement(self, node: GraphCypherNode) -> str
⋮----
props = dict(node.properties)
⋮----
def _create_edge_statement(self, edge: GraphCypherEdge) -> str
⋮----
rel_props = {
````

## File: src/metagit/core/config/graph_models.py
````python
#!/usr/bin/env python
"""
Manual workspace graph relationships for cross-repo knowledge graphs.
"""
⋮----
class GraphEndpoint(BaseModel)
⋮----
"""Endpoint for a manual cross-repo relationship."""
⋮----
model_config = ConfigDict(extra="forbid")
⋮----
project: Optional[str] = Field(
repo: Optional[str] = Field(
path: Optional[str] = Field(
⋮----
class GraphRelationship(BaseModel)
⋮----
"""Manually declared edge between workspace projects or repos."""
⋮----
model_config = ConfigDict(extra="forbid", populate_by_name=True)
⋮----
id: Optional[str] = Field(
from_endpoint: GraphEndpoint = Field(
to: GraphEndpoint = Field(..., description="Relationship target")
type: str = Field(
label: Optional[str] = Field(None, description="Short label for graph UIs")
description: Optional[str] = Field(
tags: dict[str, str] = Field(
metadata: dict[str, Any] = Field(
⋮----
@field_validator("type", mode="before")
@classmethod
    def _normalize_type(cls, value: object) -> str
⋮----
class WorkspaceGraph(BaseModel)
⋮----
"""Top-level manual graph data on a .metagit.yml manifest."""
⋮----
relationships: list[GraphRelationship] = Field(
````

## File: src/metagit/core/config/graph_resolver.py
````python
#!/usr/bin/env python
"""
Resolve manual graph endpoints to workspace dependency node ids.
"""
⋮----
"""
    Map a graph endpoint to a dependency node id (project:… or repo:…/…).

    Requires project when repo is set. Repo-only matches the first indexed row.
    """
⋮----
project = endpoint.project
````

## File: src/metagit/core/config/patch_service.py
````python
#!/usr/bin/env python
"""Apply schema-tree config operations for CLI and web consumers."""
⋮----
ConfigTarget = Literal["metagit", "appconfig"]
⋮----
class PatchResult(BaseModel)
⋮----
"""Outcome of applying config patch operations."""
⋮----
ok: bool
target: ConfigTarget
config_path: str
validation_errors: list[dict[str, str]] = Field(default_factory=list)
saved: bool = False
tree: SchemaFieldNode | None = None
⋮----
class PreviewResult(BaseModel)
⋮----
"""YAML preview after optional draft operations."""
⋮----
style: PreviewStyle
yaml: str
draft: bool = False
⋮----
class TreeResult(BaseModel)
⋮----
"""Schema tree for a loaded config."""
⋮----
tree: SchemaFieldNode
⋮----
class ConfigPatchService
⋮----
"""Load, mutate, preview, and save metagit and appconfig via schema operations."""
⋮----
def __init__(self) -> None
⋮----
"""Build schema tree for the config at config_path."""
resolved = str(Path(config_path).resolve())
⋮----
loaded = self._load_metagit(resolved)
⋮----
loaded = self._load_appconfig(resolved)
⋮----
model_class = MetagitConfig if target == "metagit" else AppConfig
tree = self._schema.build_tree(
⋮----
"""Render YAML preview with optional draft operations."""
⋮----
model_class = MetagitConfig
⋮----
model_class = AppConfig
⋮----
config = loaded
validation_errors: list[dict[str, str]] = []
draft = bool(operations)
⋮----
yaml_text = read_disk_text(resolved)
⋮----
yaml_text = render_metagit_yaml(config, style=style)
⋮----
yaml_text = render_appconfig_yaml(
⋮----
"""Apply operations; optionally persist when save=True and validation passes."""
⋮----
saved = False
⋮----
save_result = MetagitConfigManager(resolved).save_config(updated)
⋮----
save_result = save_appconfig(resolved, updated)
⋮----
saved = True
tree = None
⋮----
def _load_metagit(self, config_path: str) -> MetagitConfig | Exception
⋮----
manager = MetagitConfigManager(config_path=config_path)
loaded = manager.load_config()
⋮----
def _load_appconfig(self, config_path: str) -> AppConfig | Exception
⋮----
loaded = load_appconfig(config_path)
````

## File: src/metagit/core/config/yaml_display.py
````python
#!/usr/bin/env python
"""
Human-readable YAML serialization for Metagit config display.
"""
⋮----
def _represent_str(dumper: yaml.Dumper, value: str) -> yaml.nodes.ScalarNode
⋮----
"""Use literal block style for multiline strings; preserve Unicode."""
⋮----
class _ReadableYamlDumper(yaml.SafeDumper)
⋮----
"""Dumper tuned for terminal-friendly config output."""
⋮----
def dump_config_dict(payload: dict[str, Any]) -> str
⋮----
"""
    Serialize a config dict for `metagit config show --normalized`.

    Multiline fields use `|` blocks; Unicode is not escaped.
    """
````

## File: src/metagit/core/detect/detectors/git.py
````python
#!/usr/bin/env python3
"""
Git repository detection plugin.

This module provides functionality to detect Git repository information
including branch checksum, tags, last commit timestamp, origin branch count,
and local dirty status.
"""
⋮----
class GitDetector(Detector)
⋮----
"""Git repository detection plugin."""
⋮----
name = "git"
⋮----
def should_run(self, ctx: ProjectScanContext) -> bool
⋮----
"""
        Determine if this detector should run on the given context.

        Args:
            ctx: Project scan context

        Returns:
            bool: True if the path is a Git repository
        """
⋮----
# Check if the path is a Git repository
_ = Repo(str(ctx.root_path))
⋮----
def run(self, ctx: ProjectScanContext) -> Optional[DiscoveryResult]
⋮----
"""
        Run Git repository detection.

        Args:
            ctx: Project scan context

        Returns:
            DiscoveryResult with Git repository information
        """
⋮----
repo = Repo(str(ctx.root_path))
⋮----
# Get current branch
⋮----
current_branch = repo.active_branch.name
⋮----
# Handle detached HEAD state
current_branch = "HEAD"
⋮----
# Get current branch checksum (commit hash)
checksum = repo.head.commit.hexsha
⋮----
# Get last commit timestamp
last_commit_timestamp = datetime.fromtimestamp(repo.head.commit.committed_date)
⋮----
# Get tags
tags = [tag.name for tag in repo.tags]
⋮----
# Get remote branch count
origin_branch_count = 0
⋮----
origin = repo.remotes.origin
⋮----
origin_branch_count = len(list(origin.refs))
⋮----
# No origin remote or no refs
⋮----
# Check for local dirty status
is_dirty = repo.is_dirty()
⋮----
# Create structured data
data = {
⋮----
# Create tags for the discovery result
discovery_tags = ["git", "vcs", "repository"]
````

## File: src/metagit/core/detect/__init__.py
````python
#!/usr/bin/env python3
"""
Detection module for metagit.

This module provides comprehensive repository detection and analysis capabilities,
including language detection, project classification, branch analysis, CI/CD detection,
and metrics collection.
"""
⋮----
# from .manager import DetectionManager
# from .models import (
#     BranchInfo,
#     CIConfigAnalysis,
#     DetectionManagerConfig,
#     GitBranchAnalysis,
#     LanguageDetection,
#     ProjectTypeDetection,
# )
⋮----
# __all__ = [
#     "DetectionManager",
#     "DetectionManagerConfig",
#     "LanguageDetection",
#     "ProjectTypeDetection",
#     "GitBranchAnalysis",
#     "CIConfigAnalysis",
#     "BranchInfo",
# ]
````

## File: src/metagit/core/init/__init__.py
````python
#!/usr/bin/env python
"""Metagit project initialization (templates and minimal profiles)."""
⋮----
__all__ = ["InitService", "InitWriteResult"]
````

## File: src/metagit/core/init/models.py
````python
#!/usr/bin/env python
"""Pydantic models for metagit init templates."""
⋮----
class InitPromptSpec(BaseModel)
⋮----
"""One copier-style prompt for template variable collection."""
⋮----
model_config = ConfigDict(extra="forbid")
⋮----
name: str = Field(..., description="Template variable name")
label: str = Field(..., description="Human-readable prompt label")
default: Optional[str] = Field(None, description="Static default value")
default_from: Optional[Literal["directory_name", "git_remote_url"]] = Field(
required: bool = Field(
secret: bool = Field(default=False, description="Hide input in interactive prompts")
⋮----
class InitTemplateFileSpec(BaseModel)
⋮----
"""Rendered file mapping for a template."""
⋮----
template: str = Field(
output: str = Field(
optional: bool = Field(
⋮----
class InitTemplateManifest(BaseModel)
⋮----
"""Manifest for a bundled init template (copier-style)."""
⋮----
id: str = Field(..., description="Template identifier for --template")
label: str = Field(..., description="Short label for --list-templates")
description: str = Field(..., description="Longer description of the template")
kind: str = Field(..., description="Default MetagitConfig kind for this template")
prompts: list[InitPromptSpec] = Field(default_factory=list)
files: list[InitTemplateFileSpec] = Field(default_factory=list)
````

## File: src/metagit/core/init/prompts.py
````python
#!/usr/bin/env python
"""Collect init template answers from defaults, files, and interactive prompts."""
⋮----
PromptFn = Callable[..., str]
⋮----
def load_answers_file(path: Path) -> dict[str, str]
⋮----
"""Load answers from a YAML or JSON mapping file."""
text = path.read_text(encoding="utf-8")
⋮----
loaded = json.loads(text)
⋮----
loaded = yaml.safe_load(text)
⋮----
"""Built-in default resolvers for template prompts."""
⋮----
"""Resolve the default value for one prompt spec."""
⋮----
"""
    Merge answers file, overrides, and interactive prompts.

    Raises click.UsageError when required values are missing in no_prompt mode.
    """
merged: dict[str, str] = {}
⋮----
builtins = build_builtin_defaults(
ask = prompt_fn or click.prompt
⋮----
default = resolve_prompt_default(prompt, builtins)
⋮----
value = ask(
````

## File: src/metagit/core/init/registry.py
````python
#!/usr/bin/env python
"""Load bundled init templates from package data."""
⋮----
class InitTemplateRegistry
⋮----
"""Discover and load init templates shipped under data/init-templates."""
⋮----
def __init__(self, root: Optional[Path] = None) -> None
⋮----
@property
    def root(self) -> Path
⋮----
def list_templates(self) -> list[InitTemplateManifest]
⋮----
"""Return all valid template manifests sorted by id."""
manifests: list[InitTemplateManifest] = []
⋮----
manifest = self.load_manifest(entry.name)
⋮----
def load_manifest(self, template_id: str) -> Optional[InitTemplateManifest]
⋮----
"""Load template.yaml for a template id."""
manifest_path = self._safe_template_path(template_id) / "template.yaml"
⋮----
raw = yaml.safe_load(handle)
⋮----
def template_dir(self, template_id: str) -> Path
⋮----
"""Return the directory containing template sources."""
⋮----
def _safe_template_path(self, template_id: str) -> Path
````

## File: src/metagit/core/init/renderer.py
````python
#!/usr/bin/env python
"""Render init template files and validate Metagit manifests."""
⋮----
_PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}")
⋮----
def render_placeholders(content: str, context: dict[str, str]) -> str
⋮----
"""Replace ``{{ name }}`` placeholders (copier-style, no extra deps)."""
⋮----
def _replace(match: re.Match[str]) -> str
⋮----
key = match.group(1)
⋮----
def clean_manifest_payload(payload: dict[str, Any]) -> dict[str, Any]
⋮----
"""Remove empty optional keys before validation."""
cleaned = dict(payload)
⋮----
value = cleaned.get(key)
⋮----
def validate_metagit_yaml(content: str) -> MetagitConfig
⋮----
"""Parse and validate rendered .metagit.yml content."""
loaded = yaml.safe_load(content)
⋮----
class InitTemplateRenderer
⋮----
"""Render template files for a target directory."""
⋮----
source = template_dir / file_spec.template
⋮----
raw = source.read_text(encoding="utf-8")
⋮----
"""Render all files declared in the manifest."""
rendered: list[tuple[InitTemplateFileSpec, str]] = []
⋮----
content = self.render_file(template_dir, file_spec, context)
````

## File: src/metagit/core/init/service.py
````python
#!/usr/bin/env python
"""Orchestrate metagit init from templates or minimal kind profiles."""
⋮----
@dataclass
class InitWriteResult
⋮----
"""Files written during init."""
⋮----
metagit_yml: Path
extra_files: list[Path] = field(default_factory=list)
⋮----
@dataclass
class InitService
⋮----
"""Create .metagit.yml and optional companion files from templates or kinds."""
⋮----
registry: InitTemplateRegistry = field(default_factory=InitTemplateRegistry)
renderer: InitTemplateRenderer = field(default_factory=InitTemplateRenderer)
⋮----
def list_templates(self) -> list[InitTemplateManifest]
⋮----
def resolve_template_id(self, template: Optional[str], kind: Optional[str]) -> str
⋮----
"""Map --template or --kind to a bundled template id when a bundle exists."""
⋮----
candidate = kind.strip().lower()
⋮----
manifest = self.registry.load_manifest(template_id)
⋮----
file_answers: dict[str, str] = {}
⋮----
file_answers = load_answers_file(answers_file)
⋮----
context = collect_answers(
⋮----
template_dir = self.registry.template_dir(template_id)
rendered_files = self.renderer.render_manifest(template_dir, manifest, context)
⋮----
metagit_path = target_dir / ".metagit.yml"
⋮----
extra_paths: list[Path] = []
metagit_content: Optional[str] = None
⋮----
destination = target_dir / file_spec.output
⋮----
metagit_content = content
⋮----
"""Create a minimal validated manifest without a bundled template directory."""
⋮----
kind_value = ProjectKind(kind)
⋮----
allowed = ", ".join(item.value for item in ProjectKind)
⋮----
manager = MetagitConfigManager()
config_result = manager.create_config(
⋮----
payload = config_result.model_dump(
content = yaml.safe_dump(
````

## File: src/metagit/core/mcp/services/discovery_context.py
````python
#!/usr/bin/env python
"""
Deterministic discovery context packaging for config bootstrap.
"""
⋮----
class DiscoveryContextService
⋮----
"""Create deterministic discovery payloads for sampling prompts."""
⋮----
def build_context(self, repo_root: str) -> dict[str, str]
⋮----
"""Build a minimal deterministic context from repository files."""
root = Path(repo_root).expanduser().resolve()
hints: list[str] = []
⋮----
path = root / candidate
````

## File: src/metagit/core/mcp/services/gitnexus_registry.py
````python
#!/usr/bin/env python
"""
Read GitNexus global registry and per-repo index status.
"""
⋮----
class GitNexusRegistryAdapter
⋮----
"""Resolve GitNexus index metadata for local repository paths."""
⋮----
def __init__(self, registry_path: Optional[Path] = None) -> None
⋮----
def load_entries(self) -> list[dict[str, Any]]
⋮----
"""Load registry entries or return an empty list."""
⋮----
payload = json.loads(self._registry_path.read_text(encoding="utf-8"))
⋮----
def lookup_by_path(self, repo_path: str) -> Optional[dict[str, Any]]
⋮----
"""Find registry entry for a resolved repository path."""
target = str(Path(repo_path).expanduser().resolve())
⋮----
entry_path = entry.get("path")
⋮----
def index_status(self, repo_path: str) -> str
⋮----
"""Return indexed, stale, missing, or error for a repository path."""
entry = self.lookup_by_path(repo_path=repo_path)
⋮----
cli_status = self._status_via_cli(repo_path=repo_path)
⋮----
def _status_via_cli(self, repo_path: str) -> Optional[str]
⋮----
"""Parse `gitnexus status` output when the CLI is available."""
⋮----
completed = subprocess.run(
⋮----
output = (completed.stdout or "") + (completed.stderr or "")
⋮----
def summarize_for_paths(self, repo_paths: list[str]) -> dict[str, str]
⋮----
"""Map repo paths to gitnexus status strings."""
⋮----
def registry_name_for_path(self, repo_path: str) -> Optional[str]
⋮----
"""GitNexus registry alias for a checkout path (--repo flag value)."""
⋮----
name = entry.get("name")
````

## File: src/metagit/core/mcp/services/import_hint_scanner.py
````python
#!/usr/bin/env python
"""
Lightweight import/reference scanning between workspace repositories.
"""
⋮----
_PATH_DEP_PATTERN = re.compile(
_GO_REPLACE_PATTERN = re.compile(r"^\s*replace\s+[^\s]+\s+=>\s+(.+)$", re.MULTILINE)
_TERRAFORM_MODULE_PATTERN = re.compile(r'source\s*=\s*"([^"]+)"', re.IGNORECASE)
⋮----
class ImportHintScanner
⋮----
"""Detect cross-repo references from common manifest files."""
⋮----
"""Return import edges from one repo to other workspace repos."""
root = Path(repo_path).expanduser().resolve()
⋮----
hints: list[dict[str, Any]] = []
candidates = [
⋮----
text = candidate.read_text(encoding="utf-8")
⋮----
"""Scan npm package.json for file/workspace path dependencies."""
⋮----
payload = json.loads(text)
⋮----
sections = []
⋮----
value = payload.get(key)
⋮----
target_id = self._resolve_reference(
⋮----
"""Scan go.mod replace directives for local paths."""
⋮----
reference = match.group(1).strip()
⋮----
"""Scan generic text for file:/path: references."""
⋮----
"""Scan terraform files for module sources pointing at sibling repos."""
⋮----
text = tf_file.read_text(encoding="utf-8")
⋮----
"""Resolve a relative reference to another workspace repo node id."""
_ = file_path
cleaned = reference.removeprefix("file:").strip()
⋮----
resolved = (root / cleaned).resolve()
⋮----
repo_root = Path(repo_path).resolve()
````

## File: src/metagit/core/mcp/services/ops_log.py
````python
#!/usr/bin/env python
"""
Bounded operations log for MCP runtime.
"""
⋮----
class OperationsLogService
⋮----
"""Store a bounded in-memory operations trail."""
⋮----
def __init__(self, capacity: int = 100) -> None
⋮----
def append(self, action: str, detail: str) -> None
⋮----
"""Append an operation log entry."""
⋮----
def list_entries(self) -> list[dict[str, str]]
⋮----
"""List operation log entries."""
````

## File: src/metagit/core/mcp/services/repo_git_stats.py
````python
#!/usr/bin/env python
"""
Git statistics helpers for repository inspect and snapshot flows.
"""
⋮----
def inspect_repo_state(repo_path: str) -> dict[str, str | bool | int | float | None]
⋮----
"""Return branch, dirty flag, ahead/behind, uncommitted count, and age metrics."""
⋮----
repo = Repo(repo_path)
branch = (
dirty = repo.is_dirty(untracked_files=True)
⋮----
uncommitted = _uncommitted_count(repo=repo, dirty=dirty)
head_age_days = head_commit_age_days(repo=repo)
merge_age_days = merge_base_age_days(repo=repo)
⋮----
def _ahead_behind(repo: Repo) -> tuple[Optional[int], Optional[int]]
⋮----
"""Return ahead and behind counts relative to upstream when configured."""
⋮----
tracking = repo.active_branch.tracking_branch()
⋮----
counts = repo.git.rev_list(
parts = counts.split("\t")
⋮----
behind = int(parts[0])
ahead = int(parts[1])
⋮----
def head_commit_age_days(repo: Repo) -> Optional[float]
⋮----
"""Return days elapsed since HEAD commit timestamp (committed datetime)."""
⋮----
commit = repo.head.commit
authored = getattr(commit, "committed_datetime", None)
⋮----
authored = authored.replace(tzinfo=timezone.utc)
delta = datetime.now(timezone.utc) - authored.astimezone(timezone.utc)
⋮----
def merge_base_age_days(repo: Repo) -> Optional[float]
⋮----
"""
    Return days since merge-base(H, default remote branch) committed.

    Signals how stale the integration point with the remote default branch is.
    """
⋮----
head_ref = str(repo.head.commit.hexsha)
⋮----
head_ref = "HEAD"
default_ref = _resolve_origin_default(repo=repo)
⋮----
bases = repo.git.merge_base(head_ref, default_ref).strip()
⋮----
base_hex = bases.split()[0].strip()
commit = repo.commit(base_hex)
⋮----
def _resolve_origin_default(repo: Repo) -> Optional[str]
⋮----
"""Return ref like origin/main usable for merge-base, or None."""
⋮----
out = repo.git.symbolic_ref("refs/remotes/origin/HEAD").strip()
⋮----
def _uncommitted_count(repo: Repo, dirty: bool) -> int
⋮----
"""Estimate uncommitted change count."""
⋮----
staged = len(repo.index.diff("HEAD"))
unstaged = len(repo.index.diff(None))
untracked = len(repo.untracked_files)
````

## File: src/metagit/core/mcp/services/workspace_semantic_search.py
````python
#!/usr/bin/env python
"""
GitNexus semantic workspace search (vector-ranked query per repository).
"""
⋮----
class WorkspaceSemanticSearchService
⋮----
"""Run `gitnexus query` across workspace checkouts with a registry name."""
⋮----
_gitnexus_pkg = "gitnexus@1.6.4"
⋮----
def __init__(self, registry: Optional[GitNexusRegistryAdapter] = None) -> None
⋮----
"""Run semantic query for each indexed GitNexus repo path."""
trimmed = query.strip()
⋮----
results: list[dict[str, Any]] = []
⋮----
name = self._registry.registry_name_for_path(repo_path=repo_path)
⋮----
any_ok = any(item.get("ok") for item in results)
⋮----
"""Execute gitnexus query and parse JSON payload from stdout."""
cmd: list[str] = [
⋮----
completed = subprocess.run(
⋮----
combined = (completed.stdout or "") + "\n" + (completed.stderr or "")
payload = self._parse_query_json(stdout=combined)
⋮----
warning = payload.get("warning") if isinstance(payload, dict) else None
⋮----
def _parse_query_json(self, stdout: str) -> Optional[dict[str, Any]]
⋮----
"""Extract the primary JSON object with processes from CLI output."""
⋮----
line = line.strip()
⋮----
decoded = json.loads(line)
````

## File: src/metagit/core/mcp/services/workspace_snapshot.py
````python
#!/usr/bin/env python
"""
Workspace snapshot create and restore for MCP tools.
"""
⋮----
class WorkspaceSnapshotService
⋮----
"""Capture and restore workspace git-state manifests."""
⋮----
"""Create a snapshot manifest for scoped repositories."""
store = SessionStore(workspace_root=workspace_root)
meta = store.get_workspace_meta()
active_project = project_name or meta.active_project
rows = self._index.build_index(config=config, workspace_root=workspace_root)
scoped_rows = (
⋮----
snapshot_id = str(uuid.uuid4())
repo_states: list[SnapshotRepoState] = []
⋮----
env_key_names: list[str] = []
⋮----
env_key_names = self._context.list_env_export_keys(
⋮----
session_ref = (
snapshot = WorkspaceSnapshot(
⋮----
"""Restore session metadata from a snapshot; does not mutate git state."""
snapshot = self._load_snapshot(
⋮----
notes = [
context = None
⋮----
context = self._context.switch(
⋮----
session_path = Path(workspace_root) / snapshot.session_ref
⋮----
def _snapshot_repo_row(self, row: dict[str, Any]) -> SnapshotRepoState
⋮----
"""Build snapshot repo state from an index row."""
exists = bool(row.get("exists"))
branch: Optional[str] = None
dirty = False
ahead: Optional[int] = None
behind: Optional[int] = None
uncommitted: Optional[int] = None
inspect_error: Optional[str] = None
⋮----
inspected = inspect_repo_state(repo_path=str(row["repo_path"]))
⋮----
branch = str(inspected["branch"]) if inspected.get("branch") else None
dirty = bool(inspected.get("dirty", False))
ahead_val = inspected.get("ahead")
behind_val = inspected.get("behind")
ahead = int(ahead_val) if isinstance(ahead_val, int) else None
behind = int(behind_val) if isinstance(behind_val, int) else None
uncommitted_val = inspected.get("uncommitted_count")
uncommitted = (
⋮----
inspect_error = str(inspected.get("error", "inspect failed"))
⋮----
def _write_snapshot(self, workspace_root: str, snapshot: WorkspaceSnapshot) -> Path
⋮----
"""Write snapshot JSON to workspace .metagit/snapshots."""
snapshots_dir = Path(workspace_root) / ".metagit" / "snapshots"
⋮----
path = snapshots_dir / f"{snapshot.snapshot_id}.json"
⋮----
"""Load snapshot by id."""
path = Path(workspace_root) / ".metagit" / "snapshots" / f"{snapshot_id}.json"
⋮----
payload = json.loads(path.read_text(encoding="utf-8"))
⋮----
"""Copy a snapshot-linked session file into the live session store."""
⋮----
payload = json.loads(session_path.read_text(encoding="utf-8"))
⋮----
session = ProjectSession.model_validate(payload)
````

## File: src/metagit/core/mcp/services/workspace_sync.py
````python
#!/usr/bin/env python
"""
Batch workspace repository synchronization for MCP tools.
"""
⋮----
class WorkspaceSyncService
⋮----
"""Synchronize many workspace repositories with guardrails."""
⋮----
def __init__(self, repo_ops: Optional[RepoOperationsService] = None) -> None
⋮----
"""Sync selected repositories and return per-repo results."""
selected_rows = self._select_rows(repo_rows=repo_rows, repos=repos)
normalized_mode = mode.lower()
normalized_only_if = only_if.lower()
⋮----
parallel = max(1, min(max_parallel, 16))
results: list[dict[str, Any]] = []
⋮----
def run_row(row: dict[str, Any]) -> dict[str, Any]
⋮----
futures = {executor.submit(run_row, row): row for row in selected_rows}
⋮----
summary = {
⋮----
"""Filter index rows by repo selectors."""
⋮----
selectors = {item.strip() for item in repos if item.strip()}
selected: list[dict[str, Any]] = []
⋮----
repo_path = str(row.get("repo_path", ""))
repo_name = str(row.get("repo_name", ""))
project_name = str(row.get("project_name", ""))
keys = {repo_path, repo_name, f"{project_name}/{repo_name}"}
⋮----
"""Sync one repository row."""
base = {
⋮----
exists = bool(row.get("exists"))
is_git_repo = bool(row.get("is_git_repo"))
⋮----
origin_url = str(row.get("url")) if row.get("url") else None
⋮----
outcome = self._repo_ops.sync(
⋮----
"""Determine whether a repository should be synchronized."""
⋮----
inspected = inspect_repo_state(repo_path=repo_path)
⋮----
behind = inspected.get("behind")
````

## File: src/metagit/core/mcp/services/workspace_template.py
````python
#!/usr/bin/env python
"""
Apply packaged workspace templates to workspace projects.
"""
⋮----
class WorkspaceTemplateService
⋮----
"""Copy template files into workspace project directories."""
⋮----
def __init__(self, index_service: Optional[WorkspaceIndexService] = None) -> None
⋮----
"""Preview or apply a template to target workspace projects."""
template_dir = self._resolve_template_dir(template=template)
⋮----
rows = self._index.build_index(config=config, workspace_root=workspace_root)
project_names = {project.name for project in (config.workspace.projects or [])}
results: list[dict[str, Any]] = []
⋮----
target_root = self._project_target_root(
⋮----
planned = self._plan_copy(
⋮----
written = self._execute_copy(
⋮----
ok = all(item.get("ok") for item in results)
⋮----
def list_templates(self) -> list[str]
⋮----
"""Return available template names."""
root = self._templates_root()
⋮----
def _templates_root(self) -> Path
⋮----
"""Return packaged templates directory."""
⋮----
def _resolve_template_dir(self, template: str) -> Optional[Path]
⋮----
"""Resolve template directory if it exists."""
⋮----
candidate = self._templates_root() / template
⋮----
"""Choose a directory to receive template files for a project."""
project_rows = [row for row in rows if row.get("project_name") == project_name]
⋮----
candidate = Path(workspace_root) / project_name
⋮----
def _plan_copy(self, template_dir: Path, target_root: str) -> list[dict[str, str]]
⋮----
"""List files that would be copied."""
planned: list[dict[str, str]] = []
target = Path(target_root)
⋮----
relative = source.relative_to(template_dir)
destination = target / relative
⋮----
"""Copy planned files, skipping destinations that already exist."""
written: list[dict[str, str]] = []
⋮----
relative = item["relative_path"]
````

## File: src/metagit/core/mcp/tools/bootstrap_plan_only.py
````python
#!/usr/bin/env python
"""
Plan-only bootstrap MCP tool implementation.
"""
⋮----
def metagit_bootstrap_config_plan_only(reason: Optional[str]) -> dict[str, str]
⋮----
"""Return bootstrap guidance when MCP sampling is not yet available."""
details = reason or "Workspace is not active. Generate or fix .metagit.yml first."
````

## File: src/metagit/core/mcp/tools/workspace_status.py
````python
#!/usr/bin/env python
"""
Workspace status MCP tool implementation.
"""
⋮----
def metagit_workspace_status(status: WorkspaceStatus) -> dict[str, str | None]
⋮----
"""Return structured workspace status details."""
````

## File: src/metagit/core/mcp/__init__.py
````python
#!/usr/bin/env python
"""
Metagit MCP core package.
"""
⋮----
__all__ = ["McpActivationState", "WorkspaceStatus"]
````

## File: src/metagit/core/mcp/gate.py
````python
#!/usr/bin/env python
"""
Workspace gate evaluation for Metagit MCP runtime.
"""
⋮----
class WorkspaceGate
⋮----
"""Evaluate whether workspace is active for MCP tool exposure."""
⋮----
_config_file_name: str = ".metagit.yml"
⋮----
def evaluate(self, root_path: Optional[str]) -> WorkspaceStatus
⋮----
"""Evaluate root state as active, missing config, or invalid config."""
⋮----
config_path = os.path.join(root_path, self._config_file_name)
⋮----
manager = MetagitConfigManager(config_path=Path(config_path))
result = manager.load_config()
````

## File: src/metagit/core/mcp/models.py
````python
#!/usr/bin/env python
"""
Pydantic models for Metagit MCP runtime state.
"""
⋮----
class McpActivationState(str, Enum)
⋮----
"""Activation state for MCP workspace gating."""
⋮----
ACTIVE = "active"
INACTIVE_MISSING_CONFIG = "inactive_missing_config"
INACTIVE_INVALID_CONFIG = "inactive_invalid_config"
⋮----
class WorkspaceStatus(BaseModel)
⋮----
"""Current workspace status used by MCP tools and resources."""
⋮----
state: McpActivationState = Field(
root_path: Optional[str] = Field(
reason: Optional[str] = Field(
````

## File: src/metagit/core/mcp/protocols.py
````python
#!/usr/bin/env python
"""
Protocol contracts for Metagit MCP components.
"""
⋮----
class WorkspaceRootResolverProtocol(Protocol)
⋮----
"""Resolve the effective workspace root for the MCP runtime."""
⋮----
def resolve(self, cwd: str, cli_root: str | None = None) -> str | None
⋮----
"""Resolve and return a workspace root path, if available."""
⋮----
class WorkspaceGateProtocol(Protocol)
⋮----
"""Evaluate whether MCP should expose active tooling."""
⋮----
def evaluate(self, root_path: str | None) -> WorkspaceStatus
⋮----
"""Return the current workspace status for tool gating."""
````

## File: src/metagit/core/mcp/root_resolver.py
````python
#!/usr/bin/env python
"""
Workspace root resolution for Metagit MCP runtime.
"""
⋮----
class WorkspaceRootResolver
⋮----
"""Resolve workspace root for `.metagit.yml` gating."""
⋮----
_config_file_name: str = ".metagit.yml"
_env_var_name: str = "METAGIT_WORKSPACE_ROOT"
⋮----
def resolve(self, cwd: str, cli_root: Optional[str] = None) -> Optional[str]
⋮----
"""Resolve workspace root by env var, CLI option, then upward walk."""
env_root = os.getenv(self._env_var_name)
⋮----
def _walk_for_config(self, cwd: str) -> Optional[str]
⋮----
"""Walk up the directory tree until `.metagit.yml` is found."""
current = Path(cwd).expanduser().resolve()
⋮----
config_path = os.path.join(str(current), self._config_file_name)
⋮----
current = current.parent
````

## File: src/metagit/core/project/search_models.py
````python
#!/usr/bin/env python
"""
Runtime models for managed repository search and resolve results.
"""
⋮----
class ManagedRepoStatus(BaseModel)
⋮----
"""Resolved sync state for a managed workspace repository row."""
⋮----
resolved_path: str
exists: bool
is_git_repo: bool
sync_enabled: bool
status: str
⋮----
class ManagedRepoMatch(BaseModel)
⋮----
"""One search hit with scoring metadata."""
⋮----
project_name: str
repo_name: str
url: str | None = None
configured_path: str | None = None
tags: dict[str, str] = Field(default_factory=dict)
status: ManagedRepoStatus
match_reasons: list[str] = Field(default_factory=list)
score: int
⋮----
class ManagedRepoSearchResult(BaseModel)
⋮----
"""Ordered search hits for a query."""
⋮----
query: str
matches: list[ManagedRepoMatch] = Field(default_factory=list)
⋮----
class ManagedRepoError(BaseModel)
⋮----
"""Structured error from resolve_one."""
⋮----
kind: str
message: str
⋮----
class ManagedRepoResolveResult(BaseModel)
⋮----
"""Single-match resolution or an error."""
⋮----
match: ManagedRepoMatch | None = None
error: ManagedRepoError | None = None
````

## File: src/metagit/core/project/source_models.py
````python
#!/usr/bin/env python
"""
Models for provider-based recursive repository discovery and sync planning.
"""
⋮----
class SourceProvider(str, Enum)
⋮----
"""Supported source providers for recursive discovery."""
⋮----
GITHUB = "github"
GITLAB = "gitlab"
⋮----
class SourceSyncMode(str, Enum)
⋮----
"""Supported sync planning modes."""
⋮----
DISCOVER = "discover"
ADDITIVE = "additive"
RECONCILE = "reconcile"
⋮----
class SourceSpec(BaseModel)
⋮----
"""Input specification for source-backed repository discovery."""
⋮----
provider: SourceProvider = Field(..., description="Source provider")
org: Optional[str] = Field(None, description="GitHub organization")
user: Optional[str] = Field(None, description="GitHub user")
group: Optional[str] = Field(None, description="GitLab group path")
recursive: bool = Field(
include_archived: bool = Field(
include_forks: bool = Field(False, description="Include forked repositories")
path_prefix: Optional[str] = Field(
⋮----
@model_validator(mode="after")
    def validate_scope(self) -> "SourceSpec"
⋮----
selectors = [self.org, self.user]
⋮----
@property
    def namespace_key(self) -> str
⋮----
"""Canonical source namespace used for provenance and reconcile boundaries."""
⋮----
class DiscoveredRepo(BaseModel)
⋮----
"""Normalized repository shape discovered from provider APIs."""
⋮----
provider: SourceProvider
namespace: str
full_name: str
name: str
clone_url: str
default_branch: Optional[str] = None
description: Optional[str] = None
repo_id: Optional[str] = None
archived: bool = False
fork: bool = False
private: Optional[bool] = None
⋮----
class SourceSyncPlan(BaseModel)
⋮----
"""Computed workspace changes for source-backed sync modes."""
⋮----
discovered_count: int = 0
unchanged: int = 0
to_add: List[ProjectPath] = Field(default_factory=list)
to_update: List[ProjectPath] = Field(default_factory=list)
to_remove: List[ProjectPath] = Field(default_factory=list)
````

## File: src/metagit/core/prompt/__init__.py
````python
#!/usr/bin/env python
"""
Metagit prompt emission for agent workflows.
"""
⋮----
__all__ = [
````

## File: src/metagit/core/prompt/catalog.py
````python
#!/usr/bin/env python
"""
Built-in operational prompts for metagit agents by scope.
"""
⋮----
_CATALOG: list[PromptCatalogEntry] = [
⋮----
_SCOPE_KINDS: dict[PromptScope, frozenset[PromptKind]] = {
⋮----
def list_catalog() -> list[PromptCatalogEntry]
⋮----
"""Return all registered prompt kinds."""
⋮----
def kinds_for_scope(scope: PromptScope) -> list[PromptKind]
⋮----
"""Prompt kinds valid for a scope level."""
⋮----
def is_kind_allowed(kind: PromptKind, scope: PromptScope) -> bool
⋮----
"""True when kind can be emitted at scope."""
⋮----
"""Return built-in prompt text for a kind and scope."""
project_label = project_name or "<project>"
repo_label = repo_name or "<repo>"
templates: dict[PromptKind, str] = {
⋮----
body = templates.get(kind, "")
````

## File: src/metagit/core/prompt/models.py
````python
#!/usr/bin/env python
"""
Pydantic models for metagit prompt emission.
"""
⋮----
PromptScope = Literal["workspace", "project", "repo"]
PromptKind = Literal[
⋮----
class PromptCatalogEntry(BaseModel)
⋮----
"""Metadata for one built-in prompt kind."""
⋮----
kind: PromptKind
title: str
description: str
scopes: list[PromptScope]
⋮----
class PromptEmitResult(BaseModel)
⋮----
"""Emitted prompt for agents or humans."""
⋮----
ok: bool = True
⋮----
scope: PromptScope
project_name: Optional[str] = None
repo_name: Optional[str] = None
definition_path: str = ""
instruction_layers: list[AgentInstructionLayer] = Field(default_factory=list)
text: str = ""
metadata: dict[str, Any] = Field(default_factory=dict)
````

## File: src/metagit/core/prompt/service.py
````python
#!/usr/bin/env python
"""
Emit metagit prompts for workspace, project, and repo scopes.
"""
⋮----
class PromptServiceError(Exception)
⋮----
"""Raised when prompt emission cannot proceed."""
⋮----
class PromptService
⋮----
"""Resolve and render prompts for agent consumption."""
⋮----
def __init__(self) -> None
⋮----
def list_entries(self) -> list[PromptCatalogEntry]
⋮----
"""List catalog metadata for all prompt kinds."""
⋮----
"""Emit a prompt for the requested kind and scope."""
⋮----
allowed = ", ".join(kinds_for_scope(scope))
⋮----
composition = self._resolver.resolve(
⋮----
text = composition.effective
⋮----
body = template_body(
sections: list[str] = [body]
⋮----
project = next(
⋮----
repo = self._resolver.find_repo(project, repo_name=repo_name)
⋮----
project_count = len(config.workspace.projects) if config.workspace else 0
repo_count = 0
⋮----
repo_count = sum(len(item.repos) for item in config.workspace.projects)
effective_dedupe: Optional[bool] = None
project_dedupe_override: Optional[bool] = None
⋮----
effective = resolve_effective_dedupe(workspace_dedupe, project)
effective_dedupe = effective is not None
⋮----
project_dedupe_override = project.dedupe.enabled
````

## File: src/metagit/core/utils/logging.py
````python
#!/usr/bin/env python
⋮----
"""
logging class that does general logging via loguru and prints to the console via rich.
"""
⋮----
class LoggerProtocol(Protocol)
⋮----
"""
    Protocol for the logger interface.
    This includes:
        Standard log methods (info, debug, error, etc.)
        Rich printing methods (print_json, print_info, print_task_status, etc.)
        Optional console formatting methods (header, footer, etc.)
        Return type of Union[None, Exception]
    Notes:
        - All the ... are placeholders for the actual methods per the Protocol definition
        - This is a workaround to allow for the logger to be injected into classes that don't inherit from LoggingModel
    """
⋮----
# === Core logging methods ===
def debug(self, message: str) -> Union[None, Exception]: ...
def info(self, message: str) -> Union[None, Exception]: ...
def warning(self, message: str) -> Union[None, Exception]: ...
def error(self, message: str) -> Union[None, Exception]: ...
def critical(self, message: str) -> Union[None, Exception]: ...
def exception(self, message: str) -> Union[None, Exception]: ...
⋮----
# === Print helpers ===
def print_info(self, message: str) -> Union[None, Exception]: ...
⋮----
def print_error(self, error_message: str) -> Union[None, Exception]: ...
def print_success(self, message: str) -> Union[None, Exception]: ...
def print_input(self, input_data: dict[str, Any]) -> Union[None, Exception]: ...
def print_output(self, output_data: Any) -> Union[None, Exception]: ...
⋮----
# === Console formatting ===
⋮----
def footer(self, text: str, console: bool = True) -> Union[None, Exception]: ...
def line(self, console: bool = True) -> Union[None, Exception]: ...
def success(self, text: str, console: bool = True) -> Union[None, Exception]: ...
def proc_out(self, text: str, console: bool = True) -> Union[None, Exception]: ...
⋮----
# Add any others you rely on via injection (e.g. print_json, print_info)
⋮----
# Logger configuration
class LoggerConfig(BaseModel)
⋮----
"""
    Pydantic model for unified logger configuration.
    """
⋮----
# Logging configuration
name: Optional[str] = Field(default="metagit", description="Logger name.")
log_level: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "TRACE"] = (
log_to_file: bool = Field(default=False, description="Whether to log to a file.")
log_file_path: str = Field(default="app.log", description="Path to the log file.")
json_logs: bool = Field(
rotation: str = Field(default="10 MB", description="Log file rotation policy.")
retention: str = Field(default="7 days", description="Log file retention policy.")
backtrace: bool = Field(default=False, description="Enable backtrace in logs.")
diagnose: bool = Field(default=False, description="Enable diagnose in logs.")
⋮----
# Console output configuration
minimal_console: bool = Field(
use_rich_console: bool = Field(
terse: bool = Field(
⋮----
class Config
⋮----
env_prefix = "LOG_"
⋮----
LOG_LEVEL_MAP: dict[str, int] = {
⋮----
LOG_LEVELS: dict[int, int] = {
⋮----
}  #: a mapping of `verbose` option counts to logging levels
⋮----
class UnifiedLogger(LoggerProtocol)
⋮----
def __init__(self, config: LoggerConfig)
⋮----
# Initialize rich console if enabled
⋮----
theme = Theme(
⋮----
# Remove default loguru handler
⋮----
# Choose formatting
⋮----
log_format = "{message}"
serialize = config.json_logs
⋮----
log_format = (
serialize = False
⋮----
# Console sink
⋮----
# Optional file sink
⋮----
"""
        Set the logging level for all handlers.
        Args:
            level: The new logging level to set
        """
⋮----
# Update stdout handler
⋮----
# Update file handler if it exists
⋮----
def _intercept_std_logging(self) -> Union[None, Exception]
⋮----
"""Intercept standard logging module output to loguru."""
⋮----
class InterceptHandler(logging.Handler)
⋮----
def emit(self, record: logging.LogRecord) -> None
⋮----
level = logger.level(record.levelname).name
⋮----
level = record.levelno
⋮----
frame = frame.f_back
⋮----
def get_logger(self) -> Union[Any, Exception]
⋮----
"""Get the underlying loguru logger instance."""
⋮----
"""Print debug messages with rich formatting."""
⋮----
"""Print a message from an agent with rich formatting."""
⋮----
"""Print task status information with rich formatting."""
⋮----
content = f"{task_name}\nStatus: {status}"
⋮----
"""Print crew status messages with rich formatting."""
⋮----
def print_input(self, input_data: dict[str, Any]) -> Union[None, Exception]
⋮----
"""Print input data with rich formatting."""
⋮----
def print_output(self, output_data: Any) -> Union[None, Exception]
⋮----
"""Print output data with rich formatting."""
⋮----
def print_error(self, error_message: str) -> Union[None, Exception]
⋮----
"""Print error messages with rich formatting."""
⋮----
def print_success(self, message: str) -> Union[None, Exception]
⋮----
"""Print success messages with rich formatting."""
⋮----
def print_info(self, message: str) -> Union[None, Exception]
⋮----
"""Print informational messages with rich formatting."""
⋮----
"""Print JSON data with rich formatting and syntax highlighting."""
⋮----
json_str = json.dumps(data, indent=2)
⋮----
"""Print JSON data only if in debug mode."""
⋮----
# Direct loguru methods
def debug(self, message: str) -> Union[None, Exception]
⋮----
"""Log a debug message."""
⋮----
def info(self, message: str) -> Union[None, Exception]
⋮----
"""Log an info message."""
⋮----
def warning(self, message: str) -> Union[None, Exception]
⋮----
"""Log a warning message."""
⋮----
def error(self, message: str) -> Union[None, Exception]
⋮----
"""Log an error message."""
⋮----
def critical(self, message: str) -> Union[None, Exception]
⋮----
"""Log a critical message."""
⋮----
def exception(self, message: str) -> Union[None, Exception]
⋮----
"""Log an exception with traceback."""
⋮----
"""
        Helper to format and print output using rich console.
        Args:
            message (str): The message to print.
            style (str): The rich style to use.
            title (str, optional): The panel title. Defaults to None.
        """
⋮----
panel_title = f"[{style}]{title}[/{style}]" if title else None
⋮----
# Fallback for non-rich, non-json logging
⋮----
def header(self, text: str, console: bool = None) -> Union[None, Exception]
⋮----
"""Prints a header"""
⋮----
console = self.config.use_rich_console
⋮----
"""Prints a parameter line"""
⋮----
"""Prints a config element"""
⋮----
def footer(self, text: str, console: bool = True) -> Union[None, Exception]
⋮----
"""Prints a footer"""
⋮----
def proc_out(self, text: str, console: bool = True) -> Union[None, Exception]
⋮----
"""Prints a process output"""
⋮----
def line(self, console: bool = True) -> Union[None, Exception]
⋮----
"""Prints a line"""
⋮----
def success(self, text: str, console: bool = True) -> Union[None, Exception]
⋮----
"""Prints a success message"""
⋮----
"""
        Echo text to console with optional color and dimming.
        Args:
            text: Text to echo
            color: Color to use
            dim: Whether to dim the text
            console: Whether to output to console
        """
⋮----
style = f"{color} dim" if dim else color
⋮----
def get_logger(name: str = "metagit") -> Any
⋮----
"""
    Get a logger instance with default configuration.

    Args:
        name: Logger name

    Returns:
        UnifiedLogger instance
    """
config = LoggerConfig(name=name)
⋮----
class LoggingModel(BaseModel)
⋮----
_logger: Any = PrivateAttr()
⋮----
def __init__(self, **kwargs)
⋮----
@property
    def logger(self)
⋮----
def set_logger(self, logger)
````

## File: src/metagit/core/utils/userprompt.py
````python
#!/usr/bin/env python
"""
UserPrompt utility for dynamically prompting users for Pydantic object properties.
"""
⋮----
T = TypeVar("T", bound=BaseModel)
⋮----
_pt_cache: Optional[SimpleNamespace] = None
_prompt_style_cache: Any = None
⋮----
def _promptkit() -> SimpleNamespace
⋮----
"""
    Lazily import prompt_toolkit so CLI modules can load without it.

    Interactive commands require the dependency; install with metagit-cli or
    ``pip install 'prompt-toolkit>=3.0'``.
    """
⋮----
_pt_cache = SimpleNamespace(
⋮----
def _prompt_style() -> Any
⋮----
pk = _promptkit()
_prompt_style_cache = pk.Style.from_dict(
⋮----
class UserPrompt
⋮----
"""
    A utility class for prompting users to input values for Pydantic model properties.

    This class provides methods to interactively collect user input for required
    fields of any Pydantic model, with validation and type conversion using prompt_toolkit.
    """
⋮----
def __init__(self) -> None
⋮----
"""
        Prompt the user for fields of a Pydantic model.

        Args:
            model_class: The Pydantic model class to prompt for
            existing_data: Optional existing data to pre-populate fields
            title: Optional title to display at the top of the prompt
            fields_to_prompt: Optional list of field names to prompt for.
                             If None, prompts for all fields. If specified,
                             only prompts for these fields and uses defaults/None for others.

        Returns:
            An instance of the specified Pydantic model

        Raises:
            ValueError: If the model_class is not a valid Pydantic model
        """
⋮----
existing_data = existing_data or {}
field_data = {}
prompt_instance = UserPrompt()
⋮----
# Get model fields
model_fields = model_class.model_fields
⋮----
# Print title
title_text = pk.FormattedText([("class:title", f"\n=== {title} ===\n")])
⋮----
# Skip if field already has a value
⋮----
success_text = pk.FormattedText(
⋮----
# If fields_to_prompt is specified, only prompt for those fields
⋮----
# Use default value or None for non-prompted fields
⋮----
default_value = field_info.get_default()
⋮----
default_value = None
⋮----
# Check if field is required
is_required = field_info.is_required()
⋮----
value = prompt_instance._prompt_for_field(field_name, field_info)
⋮----
# For optional fields, prompt directly with [Optional] indicator
value = prompt_instance._prompt_for_optional_field(
⋮----
# Only assign if a value was provided (not None)
⋮----
# Create and validate the model instance
⋮----
error_text = pk.FormattedText(
⋮----
# Retry with corrected data
⋮----
"""
        Prompt the user for a specific field value.

        Args:
            field_name: Name of the field
            field_info: Field information from Pydantic

        Returns:
            The user input value, converted to appropriate type
        """
⋮----
field_type = field_info.annotation
description = field_info.description or ""
⋮----
# Get the actual default value for Pydantic v2
⋮----
# Filter out PydanticUndefined
⋮----
# Build formatted prompt message
prompt_parts = [("class:field", f"\n{field_name}")]
⋮----
prompt_text = pk.FormattedText(prompt_parts)
⋮----
# Create validator for the field type
validator = self._create_field_validator(field_type, field_info)
⋮----
# Get user input with validation
⋮----
user_input = self.session.prompt(
⋮----
# Handle default value
⋮----
# Handle empty input for required fields
⋮----
# Convert and return the input
converted_value = self._convert_input(user_input, field_type)
⋮----
# This should be caught by the validator, but as a fallback
⋮----
"""
        Prompt the user for an optional field value.

        Args:
            field_name: Name of the field
            field_info: Field information from Pydantic

        Returns:
            The user input value (converted to appropriate type) or None if no value provided
        """
⋮----
# Build formatted prompt message with [Optional] indicator
⋮----
# Add [Optional] indicator
⋮----
# Handle empty input for optional fields - return None
⋮----
"""
        Create a validator for the given field type.

        Args:
            field_type: The type to validate against
            field_info: Field information from Pydantic (optional)
            field_name: Name of the field (optional)

        Returns:
            A prompt_toolkit Validator instance or None
        """
⋮----
def validate_type(text: str) -> bool
⋮----
return True  # Allow empty input for optional fields
⋮----
# Check if this is a boolean field
is_bool_field = False
⋮----
# Method 1: Check if field_info.annotation is bool
⋮----
# Handle Optional[bool], which is Union[bool, None]
⋮----
is_bool_field = True
⋮----
# For other types, try to convert
⋮----
@staticmethod
    def _convert_input(user_input: str, target_type: Any) -> Union[Any, Exception]
⋮----
"""
        Convert user input to the target type, handling various types like lists and JSON.

        Args:
            user_input: Raw user input string
            target_type: Target type to convert to

        Returns:
            The converted value
        """
⋮----
# Handle Optional types
⋮----
# Get the non-None type from Optional[T] or Union[T, None]
args = [arg for arg in target_type.__args__ if arg is not type(None)]
⋮----
target_type = args[0]
⋮----
# For complex unions, we can't reliably convert, so just return string
⋮----
# Handle lists
⋮----
item_type = (
⋮----
)  # Default to list of strings
# Split by comma and strip whitespace
items = [item.strip() for item in user_input.split(",")]
# Convert each item to the target type
converted_list = [
exception_items = [
⋮----
# Handle JSON/Dict
⋮----
# Handle boolean conversion
⋮----
# Default conversion
⋮----
"""
        Prompt the user for a single field value.

        Args:
            field_name: Name of the field
            field_type: Type of the field
            description: Optional description of the field
            default: Optional default value

        Returns:
            The user input value, converted to the specified type
        """
⋮----
_ = {
# Mock field_info object for _prompt_for_field
mock_field_info = SimpleNamespace(
⋮----
@staticmethod
    def confirm_action(message: str = "Continue?") -> Union[bool, Exception]
⋮----
"""
        Prompt the user for a yes/no confirmation.

        Args:
            message: The confirmation message

        Returns:
            bool: True if user confirms, False otherwise
        """
⋮----
session = pk.PromptSession(style=_prompt_style())
prompt_text = pk.FormattedText([("class:field", f"\n{message} (y/n): ")])
⋮----
response = session.prompt(prompt_text).strip().lower()
⋮----
"""
        Prompt the user for specific fields of a Pydantic model.

        Args:
            model_class: The Pydantic model class to prompt for
            fields_to_prompt: List of field names to prompt for
            existing_data: Optional existing data to pre-populate fields
            title: Optional title to display at the top of the prompt

        Returns:
            An instance of the specified Pydantic model
        """
⋮----
def yes_no_prompt(message: str = "Continue?") -> bool
⋮----
"""
    Simple yes/no prompt function.

    Args:
        message: The message to display

    Returns:
        True for 'y' or 'yes', False for 'n' or 'no'
    """
⋮----
response = input(f"{message} (y/n): ").lower().strip()
````

## File: src/metagit/core/utils/yaml_class.py
````python
#!/usr/bin/env python
⋮----
"""
yaml class that can load yaml files with includes and envvars and check for duplicate keys.
"""
⋮----
LegacyYAMLLoader = (os.getenv("LEGACY_YAML_LOADER", "false")).lower() == "true"
⋮----
"""Check for duplicate keys."""
⋮----
mapping = {}
⋮----
key = loader.construct_object(key_node, deep=deep)
value = loader.construct_object(value_node, deep=deep)
⋮----
class ExtLoaderMeta(type)
⋮----
"""External yaml loader metadata class."""
⋮----
"""Add constructers to class."""
⋮----
cls = super().__new__(metacls, __name__, __bases__, __dict__)
⋮----
# register the include constructors on the class
⋮----
class ExtLoader(yaml.Loader, metaclass=ExtLoaderMeta)
⋮----
"""YAML Loader with additional constructors."""
⋮----
def __init__(self, stream: Any) -> None
⋮----
"""Initialise Loader."""
⋮----
streamdata = stream if isinstance(stream, str) else stream.name
⋮----
def construct_include(self, node: yaml.Node) -> Union[Any, Exception]
⋮----
"""Include file referenced at node."""
⋮----
file_name = os.path.abspath(
extension = os.path.splitext(file_name)[1].lstrip(".")
⋮----
data = yaml.load(  # nosec B506 — trusted workspace includes; custom Loader tags
⋮----
data = json.load(f)
⋮----
includedata = []
line = f.readline()
cnt = 0
⋮----
data = "".join(includedata)
⋮----
data = '"' + "\\n".join(includedata) + '"'
⋮----
def construct_envvar(self, node: yaml.Node) -> Union[str, None, Exception]
⋮----
"""Expand env variable at node"""
⋮----
def load(*args: Any, **kwargs: Any) -> Union[Any, Exception]
````

## File: src/metagit/core/web/__init__.py
````python
#!/usr/bin/env python
"""Local web UI server components."""
````

## File: src/metagit/core/web/config_preview.py
````python
#!/usr/bin/env python
"""Render YAML previews for metagit web config editor."""
⋮----
PreviewStyle = Literal["normalized", "minimal", "disk"]
⋮----
_SENSITIVE_KEYS = SchemaTreeService.SENSITIVE_KEYS
⋮----
def read_disk_text(path: str) -> str
⋮----
"""Return on-disk file contents or empty string when missing."""
file_path = Path(path)
⋮----
def redact_secrets(payload: Any) -> Any
⋮----
"""Return a copy of nested dict/list data with sensitive string values masked."""
⋮----
redacted: dict[str, Any] = {}
⋮----
suffix = value[-4:] if len(value) > 4 else ""
⋮----
"""Serialize a metagit manifest for preview."""
⋮----
payload = config.model_dump(
⋮----
payload = config.model_dump(exclude_none=True, mode="json")
⋮----
"""Serialize application config for preview."""
⋮----
config_body = config.model_dump(
payload: dict[str, Any] = {"config": config_body}
⋮----
config_body = config.model_dump(mode="json")
payload = {
⋮----
payload = redact_secrets(payload)
````

## File: src/metagit/core/web/graph_service.py
````python
#!/usr/bin/env python
"""
Build unified workspace graph views for the web UI.
"""
⋮----
class GraphViewNode(BaseModel)
⋮----
"""Renderable node in the workspace relationship diagram."""
⋮----
id: str
label: str
kind: Literal["project", "repo"]
project_name: Optional[str] = None
repo_name: Optional[str] = None
⋮----
class GraphViewEdge(BaseModel)
⋮----
"""Renderable edge in the workspace relationship diagram."""
⋮----
from_id: str
to_id: str
type: str
label: Optional[str] = None
source: Literal["manual", "inferred", "structure"] = "inferred"
⋮----
class WorkspaceGraphView(BaseModel)
⋮----
"""Nodes and edges for diagram rendering."""
⋮----
ok: bool = True
nodes: list[GraphViewNode] = Field(default_factory=list)
edges: list[GraphViewEdge] = Field(default_factory=list)
manual_edge_count: int = 0
inferred_edge_count: int = 0
structure_edge_count: int = 0
⋮----
class WorkspaceGraphService
⋮----
"""Assemble manifest manual graph data with optional inferred dependencies."""
⋮----
"""Return diagram-ready nodes and edges."""
rows = self._index.build_index(config=config, workspace_root=workspace_root)
project_names = {
nodes: list[GraphViewNode] = []
node_ids: set[str] = set()
⋮----
node_id = f"project:{project_name}"
⋮----
node_id = f"repo:{row['project_name']}/{row['repo_name']}"
⋮----
edges: list[GraphViewEdge] = []
edge_keys: set[tuple[str, str, str]] = set()
⋮----
project_id = f"project:{row['project_name']}"
repo_id = f"repo:{row['project_name']}/{row['repo_name']}"
⋮----
manual_count = self._append_manual_edges(
⋮----
inferred_count = 0
⋮----
inferred_count = self._append_inferred_edges(
⋮----
structure_count = sum(1 for edge in edges if edge.source == "structure")
⋮----
added = 0
⋮----
from_id = resolve_graph_endpoint_id(
to_id = resolve_graph_endpoint_id(
⋮----
label = rel.label or rel.type
⋮----
result = self._dependencies.map_dependencies(
⋮----
label = dep_edge.type
⋮----
label = str(dep_edge.evidence[0])[:48]
⋮----
key = (from_id, to_id, edge_type)
⋮----
edge_key = edge_id or f"{from_id}->{to_id}:{edge_type}"
````

## File: src/metagit/core/web/job_store.py
````python
#!/usr/bin/env python
"""In-memory sync job tracking for web UI SSE flows."""
⋮----
class SyncJobStore
⋮----
"""Thread-safe in-memory sync job tracking."""
⋮----
def __init__(self) -> None
⋮----
def create_job(self) -> str
⋮----
"""Create a pending job and return its id (uuid4 hex)."""
job_id = uuid.uuid4().hex
⋮----
def mark_running(self, job_id: str) -> None
⋮----
"""Mark an existing job as running."""
⋮----
status = self._jobs.get(job_id)
⋮----
def append_event(self, job_id: str, event: dict[str, Any]) -> None
⋮----
"""Append a server-sent event payload for a job."""
⋮----
"""Mark a job completed with summary and per-repo results."""
⋮----
def fail(self, job_id: str, error: str) -> None
⋮----
"""Mark a job as failed with an error message."""
⋮----
def get(self, job_id: str) -> SyncJobStatus | None
⋮----
"""Return a snapshot of job status, or None if unknown."""
⋮----
def drain_events(self, job_id: str) -> list[dict[str, Any]]
⋮----
"""Return pending SSE-style events for a job and clear the buffer."""
⋮----
pending = self._pending_events.pop(job_id, [])
````

## File: src/metagit/core/web/static_handler.py
````python
#!/usr/bin/env python
"""Serve bundled static assets for the metagit web UI."""
⋮----
_API_PREFIXES = ("/v1", "/v2", "/v3")
⋮----
class StaticWebHandler
⋮----
"""Serve SPA assets from the packaged web data directory."""
⋮----
def __init__(self, web_root: str | None = None) -> None
⋮----
root = Path(web_root) if web_root is not None else Path(DATA_PATH) / "web"
⋮----
"""Serve static files for non-API GET requests."""
⋮----
parsed_path = urlparse(path).path
⋮----
file_path = self._resolve_file(parsed_path)
⋮----
file_path = self._web_root / "index.html"
⋮----
def _resolve_file(self, parsed_path: str) -> Path | None
⋮----
relative = unquote(parsed_path.lstrip("/"))
candidate = (self._web_root / relative).resolve()
web_root_resolved = self._web_root.resolve()
⋮----
content_type = "application/octet-stream"
body = file_path.read_bytes()
⋮----
@staticmethod
    def is_api_path(path: str) -> bool
⋮----
"""Return True when the path belongs to a versioned API route."""
````

## File: src/metagit/core/workspace/agent_instructions.py
````python
#!/usr/bin/env python
"""
Compose layered agent instructions from .metagit.yml for controller and subagents.
"""
⋮----
_LAYER_HEADERS: dict[str, str] = {
⋮----
class AgentInstructionLayer(BaseModel)
⋮----
"""One non-empty instruction layer from the manifest."""
⋮----
layer: Literal["file", "workspace", "project", "repo"]
text: str = Field(..., min_length=1)
⋮----
class AgentInstructionsComposition(BaseModel)
⋮----
"""Structured and composed agent instructions for a scope."""
⋮----
layers: list[AgentInstructionLayer] = Field(default_factory=list)
effective: str = ""
⋮----
class AgentInstructionsResolver
⋮----
"""Build instruction stacks for workspace, project, and repo scopes."""
⋮----
"""Compose instructions from file → workspace → project → repo."""
layers: list[AgentInstructionLayer] = []
file_text = _normalized(config.agent_instructions)
⋮----
workspace_text = _normalized(config.workspace.agent_instructions)
⋮----
project_text = _normalized(project.agent_instructions)
⋮----
repo_text = _normalized(repo.agent_instructions)
⋮----
"""Locate a configured repo entry by name or resolved path."""
⋮----
normalized_path = repo_path.strip() if repo_path else None
⋮----
def _normalized(value: Optional[str]) -> Optional[str]
⋮----
stripped = value.strip()
⋮----
def _compose_text(layers: list[AgentInstructionLayer]) -> str
⋮----
blocks: list[str] = []
⋮----
header = _LAYER_HEADERS.get(item.layer, item.layer.upper())
````

## File: src/metagit/core/workspace/dedupe_resolver.py
````python
#!/usr/bin/env python
"""
Resolve effective workspace dedupe settings with per-project manifest overrides.
"""
⋮----
"""
    Return dedupe config to apply for a project, or None when dedupe is off.

    App-config ``workspace.dedupe`` supplies strategy and canonical_dir. A project
    may set ``dedupe.enabled`` in ``.metagit.yml`` to override only the enabled flag.
    """
enabled = workspace_dedupe.enabled
⋮----
enabled = project.dedupe.enabled
⋮----
"""Resolve dedupe for a named workspace project."""
project = find_project(config, project_name)
⋮----
"""Resolve dedupe for layout/sync CLI using optional project scope."""
````

## File: src/metagit/core/workspace/health_models.py
````python
#!/usr/bin/env python
"""
Pydantic models for workspace health check results.
"""
⋮----
class HealthRecommendation(BaseModel)
⋮----
"""Actionable workspace maintenance recommendation."""
⋮----
severity: Literal["info", "warning", "critical"] = "info"
action: str
message: str
project_name: Optional[str] = None
repo_name: Optional[str] = None
repo_path: Optional[str] = None
⋮----
class RepoHealthRow(BaseModel)
⋮----
"""Per-repository health signals."""
⋮----
project_name: str
repo_name: str
repo_path: str
status: str
exists: bool
is_git_repo: bool
branch: Optional[str] = None
dirty: Optional[bool] = None
ahead: Optional[int] = None
behind: Optional[int] = None
gitnexus_status: Optional[str] = None
head_commit_age_days: Optional[float] = None
merge_base_age_days: Optional[float] = None
⋮----
class WorkspaceHealthResult(BaseModel)
⋮----
"""Aggregate workspace health report."""
⋮----
ok: bool = True
workspace_root: str = ""
summary: dict[str, int] = Field(default_factory=dict)
repos: list[RepoHealthRow] = Field(default_factory=list)
recommendations: list[HealthRecommendation] = Field(default_factory=list)
````

## File: src/metagit/core/workspace/hydrate.py
````python
#!/usr/bin/env python
"""
Materialize symlinked project mounts into full directory trees.
"""
⋮----
def collect_file_copy_jobs(source: Path) -> list[tuple[Path, Path]]
⋮----
"""Return (source_file, destination_file) pairs for a recursive copy."""
root = source.resolve()
⋮----
jobs: list[tuple[Path, Path]] = []
⋮----
base = Path(dirpath)
rel = base.relative_to(root)
⋮----
src = base / name
⋮----
"""
    Replace a symlink mount with a full copy of its resolved target.

    Returns (changed, error_message). ``changed`` is False when the mount is not
    a symlink (already material or missing).
    """
⋮----
source = mount.resolve()
⋮----
label = repo_label or mount.name
⋮----
jobs = collect_file_copy_jobs(source)
⋮----
desc = f"  💧 {label}"
bar_format = (
⋮----
dest = mount / rel
⋮----
name = rel.name if len(str(rel)) <= 48 else f"…{rel.name}"
````

## File: src/metagit/core/workspace/layout_context.py
````python
#!/usr/bin/env python
"""
Resolve sync root and dedupe settings for layout operations.
"""
⋮----
"""
    Return (sync_root, dedupe) for layout operations.

    Uses app config workspace.path when load succeeds; otherwise definition_root.
    When definition_path and project_name are set, applies per-project dedupe override.
    """
loaded = AppConfig.load()
⋮----
sync_root = str(Path(loaded.workspace.path).expanduser().resolve())
dedupe = loaded.workspace.dedupe
⋮----
manager = MetagitConfigManager(definition_path)
manifest = manager.load_config()
````

## File: src/metagit/core/workspace/layout_executor.py
````python
#!/usr/bin/env python
"""
Apply planned workspace layout filesystem steps.
"""
⋮----
def apply_plan(plan: LayoutPlan, *, dry_run: bool) -> list[LayoutStep]
⋮----
"""Execute disk steps from a layout plan."""
applied: list[LayoutStep] = []
⋮----
result = _apply_step(step)
⋮----
class LayoutExecutionError(Exception)
⋮----
"""Raised when a layout step cannot be applied."""
⋮----
def _apply_step(step: LayoutStep) -> LayoutStep
⋮----
def _apply_rename_or_move(step: LayoutStep) -> LayoutStep
⋮----
source = Path(step.source)
target = Path(step.target)
⋮----
def _apply_unlink(step: LayoutStep) -> LayoutStep
⋮----
path = Path(step.source)
⋮----
def _apply_symlink(step: LayoutStep) -> LayoutStep
⋮----
mount = Path(step.target)
target = Path(step.source)
⋮----
detail = "created symlink" if changed else "symlink already correct"
⋮----
def _apply_vscode(step: LayoutStep) -> LayoutStep
⋮----
project_dir = Path(step.source)
project_name = step.target
repo_names = [
⋮----
content = create_vscode_workspace(project_name, repo_names)
⋮----
out_path = project_dir / "workspace.code-workspace"
⋮----
def _apply_session(step: LayoutStep) -> LayoutStep
````

## File: src/metagit/core/workspace/layout_models.py
````python
#!/usr/bin/env python
"""
Pydantic models for workspace layout rename and move operations.
"""
⋮----
class LayoutStep(BaseModel)
⋮----
"""Single filesystem or auxiliary layout action."""
⋮----
action: Literal[
source: Optional[str] = None
target: Optional[str] = None
detail: Optional[str] = None
applied: bool = False
⋮----
class LayoutPlan(BaseModel)
⋮----
"""Planned manifest and disk changes before execution."""
⋮----
operation: Literal["rename_project", "rename_repo", "move_repo"] = "rename_project"
dry_run: bool = False
manifest_changes: list[str] = Field(default_factory=list)
disk_steps: list[LayoutStep] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
⋮----
class LayoutMutationResult(BaseModel)
⋮----
"""Result of rename/move layout operations."""
⋮----
ok: bool = True
error: Optional[CatalogError] = None
entity: Literal["project", "repo"] = "project"
operation: Literal["rename", "move"] = "rename"
project_name: str = ""
repo_name: Optional[str] = None
from_project: Optional[str] = None
to_project: Optional[str] = None
config_path: str = ""
data: Optional[dict[str, Any]] = None
````

## File: src/metagit/core/workspace/layout_resolver.py
````python
#!/usr/bin/env python
"""
Resolve sync-root paths for workspace layout operations.
"""
⋮----
_NAME_PATTERN = re.compile(r"^[\w][\w.-]*$")
⋮----
def validate_layout_name(name: str, *, label: str = "name") -> Optional[str]
⋮----
"""Return an error message when name is invalid for paths and sessions."""
trimmed = name.strip()
⋮----
def sync_root_path(workspace_path: str) -> Path
⋮----
"""Resolved workspace sync root."""
⋮----
def project_dir(workspace_path: Path, project_name: str) -> Path
⋮----
"""Project folder under the sync root."""
⋮----
"""Repo mount path under a project folder."""
⋮----
"""Locate a workspace project by name."""
⋮----
"""Locate a repo entry on a project."""
⋮----
def dedupe_enabled(dedupe: Optional[WorkspaceDedupeConfig]) -> bool
⋮----
"""True when workspace dedupe layout applies."""
⋮----
"""Canonical checkout path when dedupe applies."""
identity = workspace_dedupe.build_repo_identity(repo)
````

## File: src/metagit/core/workspace/layout_service.py
````python
#!/usr/bin/env python
"""
Rename and move workspace projects and repositories (manifest + sync layout).
"""
⋮----
class WorkspaceLayoutService
⋮----
"""Rename/move workspace catalog entries and aligned sync folders."""
⋮----
"""Rename a workspace project in the manifest and on disk."""
_ = dedupe
source = from_name.strip()
target = to_name.strip()
err = validate_layout_name(source, label="from_name") or validate_layout_name(
⋮----
project = find_project(config, source)
⋮----
root = sync_root_path(workspace_path)
plan = LayoutPlan(
old_dir = project_dir(root, source)
new_dir = project_dir(root, target)
⋮----
working = copy.deepcopy(config)
working_project = find_project(working, source)
⋮----
save_err = self._save(working, config_path)
⋮----
store = SessionStore(workspace_root=str(root))
⋮----
meta = store.get_workspace_meta()
⋮----
"""Rename a repository entry and its sync mount when present."""
project_key = project_name.strip()
⋮----
project = find_project(config, project_key)
⋮----
repo = find_repo(project, source)
⋮----
old_mount = repo_mount_path(root, project_key, source)
new_mount = repo_mount_path(root, project_key, target)
⋮----
identity = workspace_dedupe.build_repo_identity(repo)
⋮----
canonical = workspace_dedupe.canonical_path(
⋮----
proj_path = project_dir(root, project_key)
⋮----
save_err = self._save(config, config_path)
⋮----
"""Move a repository entry from one project to another."""
source_project_name = from_project.strip()
target_project_name = to_project.strip()
repo_key = repo_name.strip()
⋮----
err = validate_layout_name(repo_key, label="repo_name")
⋮----
source_project = find_project(config, source_project_name)
⋮----
target_project = find_project(config, target_project_name)
⋮----
repo = find_repo(source_project, repo_key)
⋮----
existing_target = find_repo(target_project, repo_key)
⋮----
old_mount = repo_mount_path(root, source_project_name, repo_key)
new_mount = repo_mount_path(root, target_project_name, repo_key)
⋮----
proj_path = project_dir(root, proj_name)
⋮----
pop_index = next(
moved_repo = source_project.repos.pop(pop_index)
⋮----
def _git_warnings(self, mount: Path) -> list[str]
⋮----
"""Warn when a git checkout under mount has a dirty working tree."""
warnings: list[str] = []
git_dir = mount / ".git"
⋮----
repo = git.Repo(str(mount))
⋮----
def _save(self, config: MetagitConfig, config_path: str) -> Optional[Exception]
⋮----
manager = MetagitConfigManager(metagit_config=config)
result = manager.save_config(config, Path(config_path))
⋮----
data: dict[str, Any] = {
````

## File: src/metagit/core/workspace/workspace_dedupe.py
````python
#!/usr/bin/env python
"""
Workspace-scoped repository deduplication helpers (canonical store + symlinks).
"""
⋮----
@dataclass(frozen=True)
class RepoIdentity
⋮----
"""Stable identity for a workspace repo entry."""
⋮----
repo_key: str
url: Optional[str] = None
local_path: Optional[str] = None
⋮----
def build_repo_identity(repo: ProjectPath) -> Optional[RepoIdentity]
⋮----
"""
    Build a stable repo key for deduplication within one workspace manifest.

    Branch-specific entries (ref or branches) receive distinct keys.
    """
branch_suffix = _branch_suffix(repo)
⋮----
normalized = normalize_git_url(str(repo.url)) or ""
⋮----
base_key = _slugify(normalized, digest_prefix="url")
⋮----
resolved = str(Path(repo.path).expanduser().resolve())
base_key = _slugify(resolved, digest_prefix="path")
⋮----
"""Return (project_name, repo_name) pairs sharing the same identity as repo."""
target = build_repo_identity(repo)
⋮----
matches: list[tuple[str, str]] = []
⋮----
existing_identity = build_repo_identity(existing)
⋮----
"""Absolute path to the canonical checkout directory for repo_key."""
⋮----
"""Absolute path where a project exposes a repo (symlink or directory)."""
⋮----
def ensure_symlink(mount: Path, target: Path) -> tuple[bool, Optional[str]]
⋮----
"""
    Ensure mount is a symlink to target.

    Returns (changed, error_message).
    """
target_resolved = target.resolve()
⋮----
current = Path(os.readlink(mount))
⋮----
current = (mount.parent / current).resolve()
⋮----
current = current.resolve()
⋮----
"""
    Map repo_key -> list of (project_name, repo_name) manifest entries referencing it.
    """
references: dict[str, list[tuple[str, str]]] = {}
⋮----
identity = build_repo_identity(repo)
⋮----
"""Canonical directories with no manifest reference (by repo_key)."""
root = workspace_path / dedupe.canonical_dir
⋮----
referenced = set(references.keys())
orphans: list[Path] = []
⋮----
def _branch_suffix(repo: ProjectPath) -> str
⋮----
joined = "-".join(sorted(str(branch) for branch in repo.branches))
⋮----
def _slugify(value: str, *, digest_prefix: str) -> str
⋮----
compact = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip().lower()).strip("-")
⋮----
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
short = compact[:40].strip("-") if compact else digest_prefix
````

## File: src/metagit/data/init-templates/application/.metagit.yml.tpl
````
name: {{ name }}
description: |
  {{ description }}
kind: application
url: {{ url }}
````

## File: src/metagit/data/init-templates/application/template.yaml
````yaml
id: application
label: Application (single repo)
description: Minimal .metagit.yml for one application repository.
kind: application
prompts:
  - name: name
    label: Project name
    default_from: directory_name
  - name: description
    label: Short description
    default: Application project managed by metagit.
  - name: url
    label: Git remote URL (optional)
    default_from: git_remote_url
    required: false
files:
  - template: .metagit.yml.tpl
    output: .metagit.yml
````

## File: src/metagit/data/init-templates/hermes-orchestrator/.metagit.yml.tpl
````
name: {{ name }}
description: |
  {{ description }}
kind: umbrella
url: {{ url }}
agent_instructions: |
  You are the Hermes controller for this workspace: the DevOps and project-management
  entrypoint for the operator. You do not improvise workspace layout.

  Session start (every time):
  1. metagit_workspace_status — confirm gate is active and note workspace root.
  2. metagit_workspace_health_check — surface missing clones, broken mounts, duplicate URLs.
  3. Read metagit://workspace/config when you need the full manifest.

  New or continued work:
  - Search before create: metagit_repo_search / `metagit search` for names, URLs, tags.
  - Reuse existing workspace.projects[] and repos[] entries; never clone into ad-hoc folders.
  - Register changes in .metagit.yml (catalog tools or validated YAML), then metagit config validate.
  - metagit_project_context_switch to focus one project; pass effective_agent_instructions to subagents.
  - Stay controller for cross-project objectives; delegate single-repo implementation to subagents.
  - metagit_workspace_sync: fetch by default; pull/clone only with explicit operator approval.
  - metagit_session_update before switching projects or ending the session.

  Documentation duty:
  - Every repo or path must have a clear description in this manifest.
  - Record build, deploy, and publish steps in per-repo agent_instructions when non-obvious.
  - When paths, URLs, or ownership change, update .metagit.yml before claiming work is done.

workspace:
  description: |
    Portfolio registry: git-backed services plus local-only publish paths. Metagit is the
    source of truth for what exists on disk under the configured workspace.path.
  agent_instructions: |
    Validate after manifest edits (`metagit config validate`). Prefer metagit catalog and
    MCP tools over hand-editing repo lists without validation. Enable workspace.dedupe in
    app config when the same remote URL appears in multiple projects.
  projects:
    - name: portfolio
      description: Git-backed applications and shared services under active development.
      agent_instructions: |
        Git operations are allowed per repo policy. Launch subagents with repo-scoped
        effective_agent_instructions for implementation; you coordinate sequencing and PRs.
      repos:
        - name: {{ portfolio_repo_name }}
          description: Sample HTTP API service (replace with your repository).
          url: {{ portfolio_repo_url }}
          sync: true
          kind: service
          tags:
            tier: application
          agent_instructions: |
            Run unit tests and lint before opening PRs. Note breaking API changes in PR text.

    - name: local
      description: |
        Local-only paths (no git remotes). Used to build and publish static web apps and
        personal sites for the operator.
      agent_instructions: |
        Repos here use `path`, not `url`. Do not git clone or pull. Sync creates symlinks
        into the workspace. Document publish targets in each repo's agent_instructions.
      repos:
        - name: {{ local_site_name }}
          description: Sample static site published from a folder on disk.
          path: {{ local_site_path }}
          sync: true
          kind: website
          tags:
            publish: static
          agent_instructions: |
            Build: npm run build (or project README). Publish: copy dist/ to the host named
            in README. Update `path` in .metagit.yml if the site directory moves.

    - name: platform
      description: Optional shared infrastructure (Terraform, modules, policy). See docs/hermes-iac-workspace-guide.md.
      agent_instructions: |
        Treat IaC changes as high blast-radius. Use metagit_cross_project_dependencies and
        GitNexus before module version bumps. Subagents run plan-only unless operator approves apply.
      repos: []
````

## File: src/metagit/data/init-templates/hermes-orchestrator/AGENTS.md.tpl
````
# Hermes orchestrator coordinator

This repository was initialized with the `hermes-orchestrator` metagit template.

## Controller role

You are the DevOps and project-management entrypoint for workspace **{{ name }}**.
Read root and layered `agent_instructions` in `.metagit.yml` before changing workspace layout.

## Session checklist

1. `metagit_workspace_status` and `metagit_workspace_health_check`
2. Search before create (`metagit search` / `metagit_repo_search`)
3. `metagit config validate` after manifest edits
4. `metagit_project_context_switch` when focusing a project
5. Delegate single-repo work to subagents with `effective_agent_instructions`
6. `metagit_session_update` on handoff

## Docs

- [Hermes orchestrator workspace](https://metagit-ai.github.io/metagit-cli/hermes-orchestrator-workspace/)
- [Hermes & org IaC guide](https://metagit-ai.github.io/metagit-cli/hermes-iac-workspace-guide/)
````

## File: src/metagit/data/init-templates/hermes-orchestrator/template.yaml
````yaml
id: hermes-orchestrator
label: Hermes orchestrator
description: |
  DevOps / project-management controller workspace with portfolio, local path,
  and platform projects plus AGENTS.md for the coordinator repo.
kind: umbrella
prompts:
  - name: name
    label: Workspace / manifest name
    default: hermes-control-plane
  - name: description
    label: Manifest description
    default: Umbrella workspace for a Hermes controller agent.
  - name: url
    label: Coordinator git URL (optional)
    default_from: git_remote_url
    required: false
  - name: portfolio_repo_name
    label: Example portfolio repo entry name
    default: example-api
  - name: portfolio_repo_url
    label: Example portfolio git URL
    default: https://github.com/example-org/example-api.git
  - name: local_site_name
    label: Local site entry name (non-git)
    default: example-site
  - name: local_site_path
    label: Local site filesystem path
    default: ~/Sites/example-site
files:
  - template: .metagit.yml.tpl
    output: .metagit.yml
  - template: AGENTS.md.tpl
    output: AGENTS.md
````

## File: src/metagit/data/init-templates/umbrella/.metagit.yml.tpl
````
name: {{ name }}
description: |
  {{ description }}
kind: umbrella
url: {{ url }}
workspace:
  description: Workspace projects synced under the configured workspace.path.
  projects:
    - name: default
      description: Default project group for managed repositories.
      repos: []
````

## File: src/metagit/data/init-templates/umbrella/template.yaml
````yaml
id: umbrella
label: Umbrella workspace
description: Coordinator manifest with an empty default workspace project.
kind: umbrella
prompts:
  - name: name
    label: Workspace / manifest name
    default_from: directory_name
  - name: description
    label: Short description
    default: Umbrella workspace coordinating multiple repositories.
  - name: url
    label: Coordinator git URL (optional)
    default_from: git_remote_url
    required: false
files:
  - template: .metagit.yml.tpl
    output: .metagit.yml
````

## File: src/metagit/data/prompts/gemini_prompt_example.md
````markdown
## Prompt 1

Create a yaml file named .metagit.example.yml in the root of this project that adheres to the jsonschema file
./schemas/metagit_config.schema.json that is based on the folders and files within this project. Be certain to adhere to
.gitignore when processing files. Only actually read in the file contents for any discovered CICD files, docker image files,
and other files that may have external dependency references. Do your best to infer directory purpose without reading in
everything. For example, tests with several dozen .py files would be unit tests and not valuable. Intelligently trace for
important files and project structure by using the build_files.important list found in ./src/metagit/data/build-files.yaml
as a compass. The end goal will be to create the file as instructed so that it accurately represents the languages, used
frameworks, dependencies, and other project data.

╭──────────────────────────────────╮
│                                  │
│  Agent powering down. Goodbye!   │
│                                  │
│                                  │
│  Cumulative Stats (1 Turns)      │
│                                  │
│  Input Tokens           134,416  │
│  Output Tokens            1,607  │
│  Thoughts Tokens          2,186  │
│  ──────────────────────────────  │
│  Total Tokens           138,209  │
│                                  │
│  Total duration (API)     48.1s  │
│  Total duration (wall)  16m 57s  │
│                                  │
╰──────────────────────────────────╯

## Prompt 2

Perform a comprehensive analysis of the files in this project. Update the yaml file named .metagit.example.yml in the root of this project in a manner that adheres to the jsonschema file located at ./schemas/metagit_config.schema.json. Ensure you skip files matching the patterns in .gitignore. Do your best to update even optional elements of this schema. Pay special attention to dependencies found in Dockerfiles, CICD files, and mcp definitions. When complete also create a project.metagit.md file with additional documentation on the project components that includes a mermaid diagram of how they interact.

╭─────────────────────────────────╮
│                                 │
│  Agent powering down. Goodbye!  │
│                                 │
│                                 │
│  Cumulative Stats (1 Turns)     │
│                                 │
│  Input Tokens          134,880  │
│  Output Tokens           2,037  │
│  Thoughts Tokens         1,790  │
│  ─────────────────────────────  │
│  Total Tokens          138,707  │
│                                 │
│  Total duration (API)    49.4s  │
│  Total duration (wall)  2m 26s  │
│                                 │
╰─────────────────────────────────╯

## Prompt 3

You are an expert devops and software engineer. Intelligently explore this project for its use of secrets and variables paying special attention to only those affecting the runtime behavior of the code and the cicd workflows used to release the project's artifacts. Create a report in ./docs/secrets.analysis.md on each of the secrets found and were they are sourced from. Using this data create a yaml file at ./docs/secrets.definitions.yml that represents the secrets, their provenance, and where they should be placed.

## Prompt 4 - Simplified Project Docs

Perform a comprehensive analysis of the files in this project. Update the yaml file named .metagit.example.yml in the root of this project in a manner that adheres to the jsonschema file located at ./schemas/metagit_config.schema.json. Ensure you skip files matching the patterns in .gitignore. Do your best to update even optional elements of this schema. Pay special attention to dependencies found in Dockerfiles, CICD files, and mcp definitions. When complete also create ./docs/app.logic.md with additional documentation on the project components that includes a mermaid diagram of how they interact.
````

## File: src/metagit/data/skills/metagit-bootstrap/scripts/bootstrap-config.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
FORCE="${2:-false}"
TARGET="$ROOT/.metagit.yml"

uv run python - "$ROOT" "$TARGET" "$FORCE" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import create_metagit_config
from metagit.core.config.manager import MetagitConfigManager

root = Path(sys.argv[1]).resolve()
target = Path(sys.argv[2]).resolve()
force = sys.argv[3].lower() in {"1", "true", "yes", "force"}

if target.exists() and not force:
    mgr = MetagitConfigManager(config_path=target)
    result = mgr.load_config()
    state = "valid" if not isinstance(result, Exception) else "invalid"
    print(f"status=exists\tvalidity={state}\tpath={target}")
    raise SystemExit(0)

yaml_out = create_metagit_config(name=root.name, kind="application", as_yaml=True)
if isinstance(yaml_out, Exception):
    print(f"status=error\tmessage={yaml_out}")
    raise SystemExit(1)

target.write_text(yaml_out, encoding="utf-8")
mgr = MetagitConfigManager(config_path=target)
result = mgr.load_config()
state = "valid" if not isinstance(result, Exception) else "invalid"
print(f"status=written\tvalidity={state}\tpath={target}")
PY
````

## File: src/metagit/data/skills/metagit-bootstrap/SKILL.md
````markdown
---
name: metagit-bootstrap
description: Use when generating or refining local .metagit.yml files using deterministic discovery plus MCP sampling.
---

# Metagit MCP Bootstrap Skill

Use this skill to create a local `.metagit.yml` using discovery-driven prompts and MCP sampling.

## Purpose

Generate schema-compliant `.metagit.yml` files with high contextual quality while preserving safety and explicit user control.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for local bootstrap tasks:
- `./scripts/bootstrap-config.zsh [root_path] [force]`

Behavior:
- Writes `.metagit.yml` when missing
- Validates via Metagit config models
- Returns a compact status line for agents

## Workflow

1. Gather deterministic discovery data from the target repository:
   - source language/framework indicators
   - package/lock/build files
   - Dockerfiles and CI workflows
   - terraform files and module usage
2. Build a strict prompt package:
   - output format contract: valid YAML only
   - required schema fields and constraints
   - extracted discovery evidence
3. If sampling is supported, call `sampling/createMessage`.
4. Validate generated YAML with Metagit config models.
5. Retry with validation feedback up to a fixed max attempt count.
6. Return draft output and write only on explicit confirmation.

## Output Modes

- **Plan-only mode**: return prompt + discovery summary if sampling unavailable.
- **Draft mode**: return `.metagit.generated.yml` content.
- **Confirmed write mode**: write to `.metagit.yml` only with explicit parameter (`confirm_write=true`).

## Quality Bar

- Preserve discovered evidence in structured fields.
- Include workspace project and related repo entries where detectable.
- Avoid invented repositories or unverifiable dependencies.

## Safety Rules

- Never overwrite `.metagit.yml` silently.
- Never emit secrets in cleartext.
- Prefer placeholders for credentials or tokens.
````

## File: src/metagit/data/skills/metagit-config-refresh/SKILL.md
````markdown
---
name: metagit-config-refresh
description: Refresh or bootstrap `.metagit.yml` using deterministic discovery and validation flows. Use when configuration is missing, stale, or incomplete for workspace operations.
---

# Refreshing Project Config

Use this skill to keep `.metagit.yml` accurate and operational.

## Workflow

1. Check workspace activation and config validity.
2. Run bootstrap plan mode first.
3. Review generated changes against expected workspace topology.
4. Apply config updates and validate schema before continuing.

## Command Wrapper

- `zsh ./skills/metagit-bootstrap/scripts/bootstrap-config.zsh [root_path] [mode] [seed_context]`

## Output Contract

Return:
- config health state before/after
- generated update summary
- any manual follow-up needed

## Safety

- Prefer plan/dry-run first for large config updates.
- Keep changes bounded to target workspace intent.
````

## File: src/metagit/data/skills/metagit-gating/scripts/gate-status.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"

uv run python - "$ROOT" <<'PY'
import os
import sys
from metagit.core.mcp.gate import WorkspaceGate
from metagit.core.mcp.root_resolver import WorkspaceRootResolver
from metagit.core.mcp.tool_registry import ToolRegistry

root = sys.argv[1]
resolver = WorkspaceRootResolver()
gate = WorkspaceGate()
registry = ToolRegistry()

resolved = resolver.resolve(cwd=os.path.abspath(root), cli_root=root)
status = gate.evaluate(root_path=resolved)
tools = registry.list_tools(status)
print(f"state={status.state.value}\troot={status.root_path or 'none'}\ttools={len(tools)}")
PY
````

## File: src/metagit/data/skills/metagit-gating/SKILL.md
````markdown
---
name: metagit-gating
description: Use when implementing or operating Metagit MCP server activation and tool exposure rules based on .metagit.yml presence and validity.
---

# Metagit MCP Gating Skill

Use this skill whenever you need to control whether Metagit MCP tools/resources are exposed.

## Purpose

Ensure high-risk tooling and multi-repo context are only available when a valid `.metagit.yml` exists at the resolved workspace root.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for all gating checks:
- `./scripts/gate-status.zsh [root_path]`

Expected output (single line, tab-delimited):
- `state=<value>\troot=<path|none>\ttools=<count>`

## Activation Workflow

1. Resolve workspace root:
   - `METAGIT_WORKSPACE_ROOT`
   - CLI `--root`
   - upward directory walk
2. Check for `.metagit.yml` in resolved root.
3. Validate config through existing Metagit config models.
4. Derive activation state: missing, invalid, or active.
5. Register tool surface based on state.

## Tool Exposure Contract

### Inactive (missing or invalid config)
Expose only:
- `metagit_workspace_status`
- `metagit_bootstrap_config_plan_only`

### Active (valid config)
Expose full set:
- `metagit_workspace_status`
- `metagit_workspace_index`
- `metagit_workspace_search`
- `metagit_upstream_hints`
- `metagit_repo_inspect`
- `metagit_repo_sync`
- `metagit_bootstrap_config`

## Error Handling

- Return explicit, machine-readable state and reason.
- Avoid stack traces in user-facing outputs.
- Log parser/validation errors with enough detail for debugging.

## Safety Rules

- Never expose mutation-capable tools in inactive state.
- Never operate outside validated workspace boundaries.
- Keep defaults read-only unless user/agent explicitly opts in.
````

## File: src/metagit/data/skills/metagit-gitnexus/scripts/analyze-targets.zsh
````zsh
#!/usr/bin/env zsh

set -euo pipefail

workspace_root="${1:-.}"
project_name="${2:-default}"

if [[ ! -f ".metagit.yml" ]]; then
  echo "ERROR: .metagit.yml not found in current directory"
  exit 2
fi

echo "analyze repo=$(pwd)"
npx gitnexus analyze

tmp_output="$(mktemp)"
uv run python - "$workspace_root" "$project_name" <<'PY' > "$tmp_output"
import sys
from pathlib import Path
import yaml

workspace_root = Path(sys.argv[1]).expanduser().resolve()
project_name = sys.argv[2]
cfg = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8")) or {}
workspace = (cfg.get("workspace") or {})
projects = workspace.get("projects") or []
target = next((p for p in projects if p.get("name") == project_name), None)
if not target:
    print(f"warn project_not_found={project_name}")
    raise SystemExit(0)

for repo in target.get("repos") or []:
    name = repo.get("name")
    if not name:
        continue
    repo_path = workspace_root / project_name / name
    if repo_path.exists() and repo_path.is_dir():
        print(f"repo_path={repo_path}")
    else:
        print(f"skip_missing={repo_path}")
PY

while IFS= read -r line; do
  case "$line" in
    repo_path=*)
      path="${line#repo_path=}"
      echo "analyze repo=${path}"
      (cd "$path" && npx gitnexus analyze) || echo "fail repo=${path}"
      ;;
    *)
      echo "$line"
      ;;
  esac
done < "$tmp_output"

rm -f "$tmp_output"
````

## File: src/metagit/data/skills/metagit-multi-repo/SKILL.md
````markdown
---
name: metagit-multi-repo
description: Coordinate implementation tasks across multiple repositories using metagit status, search, and scoped sync workflows. Use when one objective spans several repositories.
---

# Coordinating Multi-Repo Implementation

Use this skill for cross-repository feature or fix delivery.

## Workflow

1. Define objective and affected repositories.
2. Verify workspace scope and dependency hints.
3. Sequence work by dependency order.
4. Sync only required repositories.
5. Track progress and blockers per repository.

## Command Wrapper

- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- objective-to-repository map
- execution order
- current blocker + next step

## Safety

- Keep scope bounded to configured workspace repositories.
- Prefer deterministic evidence for cross-repo assumptions.
````

## File: src/metagit/data/skills/metagit-upstream-scan/scripts/upstream-scan.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-}"
MAX_RESULTS="${4:-20}"

if [[ -z "$QUERY" ]]; then
  echo "status=error\tmessage=query-required"
  exit 1
fi

uv run python - "$ROOT" "$QUERY" "$PRESET" "$MAX_RESULTS" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.mcp.services.workspace_search import WorkspaceSearchService
from metagit.core.mcp.services.upstream_hints import UpstreamHintService

root = Path(sys.argv[1]).resolve()
query = sys.argv[2]
preset = sys.argv[3] or None
max_results = int(sys.argv[4])

manager = MetagitConfigManager(config_path=root / ".metagit.yml")
cfg = manager.load_config()
if isinstance(cfg, Exception):
    print(f"status=error\tmessage=config-invalid\tdetail={cfg}")
    raise SystemExit(1)

index = WorkspaceIndexService().build_index(config=cfg, workspace_root=str(root))
repo_paths = [row["repo_path"] for row in index if row.get("exists")]
search_hits = WorkspaceSearchService().search(query=query, repo_paths=repo_paths, preset=preset, max_results=max_results)
ranked = UpstreamHintService().rank(blocker=query, repo_context=index)[:5]

print(f"status=ok\trepos={len(index)}\thits={len(search_hits)}")
for row in ranked:
    print(f"hint\trepo={row['repo_name']}\tscore={row['score']}")
for hit in search_hits[:5]:
    print(f"hit\tfile={hit['file_path']}\tline={hit['line_number']}")
PY
````

## File: src/metagit/data/skills/metagit-upstream-scan/SKILL.md
````markdown
---
name: metagit-upstream-scan
description: Use when a coding agent encounters likely upstream blockers and must find related workspace repositories, files, and probable root causes.
---

# Metagit Upstream Discovery Skill

Use this skill when the current repo does not appear to contain the full fix and related repositories may hold the source issue.

## Supported Use Cases

- Missing Terraform input in a shared module
- Docker base image/version mismatch across repos
- Shared infrastructure definitions causing local failures
- CI pipeline breakages tied to upstream templates/workflows

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for upstream discovery tasks:
- `./scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

Output format:
- compact status line
- top ranked repo hints (`hint`)
- top search file hits (`hit`)

## Workflow

1. Read workspace repository map from active `.metagit.yml`.
2. Run `metagit_workspace_index` to verify repo availability and sync state.
3. Use `metagit_workspace_search` with category preset (`terraform`, `docker`, `infra`, `ci`).
4. Use `metagit_upstream_hints` to rank candidate repositories and files.
5. Return a concise action plan:
   - top candidate repos
   - likely files/definitions
   - whether sync is needed before deeper analysis

## Search Strategy

- Start narrow with issue-specific terms (error, module, variable, image tag).
- Expand to broader shared terms if no hits.
- Prefer repositories referenced by workspace metadata before searching unknown repos.

## Output Contract

Return:
- ranked candidates with rationale
- suggested next file openings
- confidence level and unresolved assumptions

## Safety Rules

- Restrict search to configured workspace repositories.
- Cap result size and duration.
- Keep this workflow read-only unless an explicit sync action is requested.
````

## File: src/metagit/data/skills/metagit-upstream-triage/SKILL.md
````markdown
---
name: metagit-upstream-triage
description: Triage cross-repository blockers by ranking likely upstream repositories and files with metagit search and hinting tools. Use when local fixes appear incomplete.
---

# Triaging Upstream Blockers

Use this skill for failures likely rooted in another repository.

## Workflow

1. Run workspace index and search with issue-specific terms.
2. Run upstream hint ranking to prioritize repositories/files.
3. Open the top candidates and validate root-cause evidence.
4. Return a short fix path (repo, file, next action).

## Command Wrapper

- `zsh ./skills/metagit-upstream-scan/scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

## Output Contract

Return:
- ranked candidate repositories
- probable root-cause files
- confidence and assumptions

## Safety

- Keep this flow read-only unless sync is explicitly requested.
````

## File: src/metagit/data/templates/agent-standard/AGENTS.md.fragment
````
# Agent workspace conventions

- Read `.metagit.yml` before making cross-repository changes.
- Use `metagit_project_context_switch` to scope work to one workspace project.
- Run `metagit_workspace_health_check` when the workspace feels out of date.
````

## File: src/metagit/data/templates/hermes-orchestrator/AGENTS.md.fragment
````
# Hermes orchestrator (workspace coordinator)

This directory received the `hermes-orchestrator` workspace template.

1. Copy or merge `examples/hermes-orchestrator/.metagit.yml` into your umbrella coordinator repository.
2. Run `metagit config validate` on the manifest.
3. Register real repos with `metagit project repo add` or MCP catalog tools.
4. Point Hermes MCP at the coordinator root: `metagit mcp serve --root <path>`.

Controller agents should follow root `agent_instructions` in `.metagit.yml`, not this file alone.
````

## File: src/metagit/data/templates/hermes-orchestrator/README.md
````markdown
# hermes-orchestrator template

Files here are copied into workspace project directories via `metagit_project_template_apply`
(template id: `hermes-orchestrator`).

The umbrella manifest example lives at `examples/hermes-orchestrator/.metagit.yml` in the
metagit-cli repository. See `docs/hermes-orchestrator-workspace.md` for the full guide.
````

## File: src/metagit/data/web/assets/index-B315j_NF.css
````css
:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--font-sans:system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--font-mono:ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;--radius-sm:.25rem;--radius-md:.5rem;--radius-lg:.75rem;--shadow-sm:0 1px 2px #0f172a0f;--shadow-md:0 4px 12px #0f172a14;--transition-theme:color .2s ease, background-color .2s ease, border-color .2s ease, box-shadow .2s ease}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root,[data-theme=light]{--color-bg:#f6f7fb;--color-bg-elevated:#fff;--color-bg-muted:#eef1f6;--color-border:#d8dee9;--color-border-strong:#c5cedb;--color-text:#0f172a;--color-text-muted:#5b6475;--color-text-subtle:#7c8798;--color-accent:#4f46e5;--color-accent-hover:#4338ca;--color-accent-soft:#4f46e51f;--color-danger:#dc2626;--color-danger-soft:#dc26261a;--color-success:#059669;--color-focus:#4f46e559;--color-tree-hover:#4f46e514;--color-tree-selected:#4f46e529;--graph-edge-manual:#7c3aed;--graph-edge-inferred:#0ea5e9;--graph-edge-structure:#94a3b8}[data-theme=dark]{--color-bg:#0f1117;--color-bg-elevated:#171a22;--color-bg-muted:#1f2430;--color-border:#2a3140;--color-border-strong:#3a4356;--color-text:#ffffffeb;--color-text-muted:#ffffffa6;--color-text-subtle:#ffffff73;--color-accent:#818cf8;--color-accent-hover:#a5b4fc;--color-accent-soft:#818cf82e;--color-danger:#f87171;--color-danger-soft:#f8717126;--color-success:#34d399;--color-focus:#818cf866;--color-tree-hover:#818cf81f;--color-tree-selected:#818cf838;--graph-edge-manual:#a78bfa;--graph-edge-inferred:#38bdf8;--graph-edge-structure:#64748b}@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--color-bg:#0f1117;--color-bg-elevated:#171a22;--color-bg-muted:#1f2430;--color-border:#2a3140;--color-border-strong:#3a4356;--color-text:#ffffffeb;--color-text-muted:#ffffffa6;--color-text-subtle:#ffffff73;--color-accent:#818cf8;--color-accent-hover:#a5b4fc;--color-accent-soft:#818cf82e;--color-danger:#f87171;--color-danger-soft:#f8717126;--color-success:#34d399;--color-focus:#818cf866;--color-tree-hover:#818cf81f;--color-tree-selected:#818cf838;--graph-edge-manual:#a78bfa;--graph-edge-inferred:#38bdf8;--graph-edge-structure:#64748b}}*{box-sizing:border-box}html{transition:var(--transition-theme)}body{min-width:320px;min-height:100vh;font-family:var(--font-sans);color:var(--color-text);background-color:var(--color-bg);font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;transition:var(--transition-theme);margin:0;font-weight:400;line-height:1.5}#root{min-height:100vh}button,input,select,textarea{font:inherit}._shell_1gsdz_1{min-height:100vh;transition:var(--transition-theme);flex-direction:column;display:flex}._header_1gsdz_8{border-bottom:1px solid var(--color-border);background:var(--color-bg-elevated);transition:var(--transition-theme);flex-wrap:wrap;align-items:center;gap:1rem 1.5rem;padding:1rem 1.5rem;display:flex}._title_1gsdz_19{color:var(--color-text);margin:0;font-size:1.25rem;font-weight:600}._nav_1gsdz_26{flex-wrap:wrap;flex:1;gap:.5rem;display:flex}._navLink_1gsdz_33{color:var(--color-text-muted);border-radius:var(--radius-md);transition:var(--transition-theme);padding:.35rem .65rem;text-decoration:none}._navLink_1gsdz_33:hover{color:var(--color-text);background:var(--color-tree-hover)}._navLinkActive_1gsdz_46{color:var(--color-text);background:var(--color-accent-soft)}._themeToggle_1gsdz_51{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-muted);width:2.25rem;height:2.25rem;color:var(--color-text);cursor:pointer;transition:var(--transition-theme);justify-content:center;align-items:center;margin-left:auto;display:inline-flex}._themeToggle_1gsdz_51:hover{border-color:var(--color-border-strong);background:var(--color-tree-hover)}._themeIcon_1gsdz_71{font-size:1.1rem;line-height:1}._main_1gsdz_76{flex:1;width:100%;max-width:80rem;margin:0 auto;padding:1.5rem}._previewPanel_tee41_1{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);min-height:12rem;max-height:calc(100vh - 12rem);transition:var(--transition-theme);flex-direction:column;gap:.75rem;padding:1rem;display:flex}._header_tee41_15{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem 1rem;display:flex}._title_tee41_23{text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-subtle);margin:0;font-size:.8rem;font-weight:600}._controls_tee41_32{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}._select_tee41_39{color:var(--color-text);background:var(--color-bg);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:.35rem .5rem;font-size:.85rem}._badge_tee41_48{text-transform:uppercase;letter-spacing:.04em;border-radius:var(--radius-sm);background:var(--color-accent-muted);color:var(--color-accent);padding:.15rem .45rem;font-size:.7rem;font-weight:600}._badgeInvalid_tee41_59{background:color-mix(in srgb, var(--color-danger) 18%, transparent);color:var(--color-danger)}._codeWrap_tee41_64{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-code,var(--color-bg));flex:1;min-height:0;overflow:auto}._code_tee41_64{font-family:var(--font-mono);white-space:pre;color:var(--color-text);margin:0;padding:.85rem 1rem;font-size:.78rem;line-height:1.45}._state_tee41_83{color:var(--color-text-muted);margin:0;font-size:.9rem}._errors_tee41_89{color:var(--color-danger);margin:0;padding-left:1.1rem;font-size:.85rem}._panel_4l6gj_1{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);transition:var(--transition-theme);flex-direction:column;gap:1rem;padding:1.25rem;display:flex}._empty_4l6gj_13{color:var(--color-text-muted);margin:0}._header_4l6gj_18{flex-direction:column;gap:.35rem;display:flex}._title_4l6gj_24{margin:0;font-size:1.1rem;font-weight:600}._path_4l6gj_30{font-family:var(--font-mono);color:var(--color-text-subtle);word-break:break-all;margin:0;font-size:.8rem}._meta_4l6gj_38{color:var(--color-text-muted);flex-wrap:wrap;gap:.5rem;font-size:.8rem;display:flex}._badge_4l6gj_46{border-radius:var(--radius-sm);background:var(--color-bg-muted);border:1px solid var(--color-border);font-family:var(--font-mono);padding:.15rem .45rem}._description_4l6gj_54{color:var(--color-text-muted);margin:0;font-size:.9rem;line-height:1.5}._hint_4l6gj_61{background:var(--color-bg-muted);border-radius:var(--radius-md);color:var(--color-text-muted);margin:0;padding:.75rem 1rem;font-size:.9rem}._field_4l6gj_70{flex-direction:column;gap:.35rem;display:flex}._label_4l6gj_76{color:var(--color-text-muted);font-size:.85rem;font-weight:500}._input_4l6gj_82,._select_4l6gj_83,._textarea_4l6gj_84{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg);width:100%;color:var(--color-text);font:inherit;transition:var(--transition-theme);padding:.55rem .75rem}._input_4l6gj_82:focus,._select_4l6gj_83:focus,._textarea_4l6gj_84:focus{border-color:var(--color-accent);box-shadow:0 0 0 3px var(--color-focus);outline:none}._input_4l6gj_82:disabled,._select_4l6gj_83:disabled{opacity:.6;cursor:not-allowed}._actions_4l6gj_109{flex-wrap:wrap;gap:.5rem;padding-top:.25rem;display:flex}._button_4l6gj_116{border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-bg-muted);color:var(--color-text);font:inherit;cursor:pointer;transition:var(--transition-theme);padding:.5rem 1rem;font-size:.875rem}._button_4l6gj_116:hover:not(:disabled){border-color:var(--color-border-strong)}._button_4l6gj_116:disabled{opacity:.55;cursor:not-allowed}._buttonPrimary_4l6gj_137{background:var(--color-accent);border-color:var(--color-accent);color:#fff}._buttonPrimary_4l6gj_137:hover:not(:disabled){background:var(--color-accent-hover);border-color:var(--color-accent-hover)}._errors_4l6gj_148{background:var(--color-danger-soft);border:1px solid var(--color-danger);border-radius:var(--radius-md);margin:0;padding:.75rem 1rem;list-style:none}._errors_4l6gj_148 li{color:var(--color-danger);margin:.25rem 0;font-size:.85rem}._errors_4l6gj_148 li:first-child{margin-top:0}._errors_4l6gj_148 li:last-child{margin-bottom:0}._status_4l6gj_171{color:var(--color-text-subtle);font-size:.8rem}._tree_1q1u5_1{margin:0;padding:0;list-style:none}._nested_1q1u5_7{border-left:1px solid var(--color-border);margin-left:1rem;padding-left:.5rem}._row_1q1u5_13{border-radius:var(--radius-sm);cursor:pointer;transition:var(--transition-theme);align-items:center;gap:.5rem;padding:.35rem .5rem;display:flex}._row_1q1u5_13:hover{background:var(--color-tree-hover)}._rowSelected_1q1u5_27{background:var(--color-tree-selected)}._rowDisabled_1q1u5_31{opacity:.5}._checkbox_1q1u5_35{width:1rem;height:1rem;accent-color:var(--color-accent);flex-shrink:0}._checkboxPlaceholder_1q1u5_42{flex-shrink:0;width:1rem}._label_1q1u5_47{flex:1;align-items:baseline;gap:.5rem;min-width:0;display:flex}._key_1q1u5_55{color:var(--color-text);font-weight:500}._type_1q1u5_60{color:var(--color-text-subtle);font-size:.75rem;font-family:var(--font-mono)}._required_1q1u5_66{color:var(--color-accent);text-transform:uppercase;letter-spacing:.04em;font-size:.65rem}._state_1q1u5_73{color:var(--color-text-muted);margin:1rem 0}._error_1q1u5_78{color:var(--color-danger)}._expandBtn_1q1u5_82{border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg-muted);width:1.5rem;height:1.5rem;color:var(--color-text-muted);cursor:pointer;flex-shrink:0;padding:0;font-size:.85rem;line-height:1}._expandBtn_1q1u5_82:hover{border-color:var(--color-border-strong);color:var(--color-text)}._listBtn_1q1u5_101{border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg-muted);width:1.5rem;height:1.5rem;color:var(--color-accent);cursor:pointer;flex-shrink:0;padding:0;font-size:.95rem;line-height:1}._listBtn_1q1u5_101:hover{border-color:var(--color-accent)}._listBtnDanger_1q1u5_119{color:var(--color-danger)}._listBtnDanger_1q1u5_119:hover{border-color:var(--color-danger)}._count_1q1u5_127{color:var(--color-text-subtle);font-size:.7rem}._page_2nlim_1{flex-direction:column;gap:1rem;display:flex}._header_2nlim_7{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem 1.5rem;display:flex}._title_2nlim_15{margin:0;font-size:1.5rem;font-weight:600}._subtitle_2nlim_21{color:var(--color-text-muted);font-size:.9rem;font-family:var(--font-mono);word-break:break-all;margin:.25rem 0 0}._layout_2nlim_29{grid-template-columns:minmax(14rem,.9fr) minmax(16rem,1fr) minmax(16rem,1fr);align-items:start;gap:1.25rem;display:grid}@media (width<=1200px){._layout_2nlim_29{grid-template-columns:minmax(14rem,1fr) minmax(16rem,1.1fr)}._layout_2nlim_29>:last-child{grid-column:1/-1}}@media (width<=900px){._layout_2nlim_29{grid-template-columns:1fr}._layout_2nlim_29>:last-child{grid-column:auto}}._treePanel_2nlim_56{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);max-height:calc(100vh - 12rem);transition:var(--transition-theme);padding:1rem;overflow:auto}._treeHeading_2nlim_67{text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-subtle);margin:0 0 .75rem;font-size:.8rem;font-weight:600}._wrap_6qkdh_1{flex-direction:column;gap:.75rem;display:flex}._legend_6qkdh_7{color:var(--color-text-muted);flex-wrap:wrap;gap:.75rem 1.25rem;font-size:.8rem;display:flex}._legendItem_6qkdh_15{align-items:center;gap:.4rem;display:inline-flex}._legendSwatch_6qkdh_21{border-radius:2px;width:1.25rem;height:.2rem}._canvasScroll_6qkdh_27{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-elevated);min-height:14rem;max-height:28rem;overflow:auto}._canvas_6qkdh_27{min-width:100%;height:auto;display:block}._nodeProject_6qkdh_42{fill:var(--color-accent-soft);stroke:var(--color-accent);stroke-width:1.5px}._nodeRepo_6qkdh_48{fill:var(--color-bg);stroke:var(--color-border-strong);stroke-width:1px}._nodeLabel_6qkdh_54{fill:var(--color-text);font-size:12px;font-family:var(--font-mono);pointer-events:none}._edgeLabel_6qkdh_61{fill:var(--color-text-muted);font-size:10px;font-family:var(--font-sans)}._empty_6qkdh_67{text-align:center;color:var(--color-text-muted);border:1px dashed var(--color-border);border-radius:var(--radius-md);margin:0;padding:1.5rem}._panel_uwnxw_1{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);transition:var(--transition-theme);flex-direction:column;gap:1rem;padding:1.25rem;display:flex}._heading_uwnxw_13{text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-subtle);margin:0;font-size:.8rem;font-weight:600}._section_uwnxw_22{flex-direction:column;gap:.65rem;display:flex}._sectionTitle_uwnxw_28{margin:0;font-size:.95rem;font-weight:600}._field_uwnxw_34{flex-direction:column;gap:.35rem;display:flex}._label_uwnxw_40{color:var(--color-text-muted);font-size:.85rem;font-weight:500}._select_uwnxw_46{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg);width:100%;color:var(--color-text);padding:.55rem .75rem}._button_uwnxw_55{border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-bg-muted);color:var(--color-text);cursor:pointer;transition:var(--transition-theme);padding:.5rem 1rem}._button_uwnxw_55:hover:not(:disabled){border-color:var(--color-border-strong)}._button_uwnxw_55:disabled{opacity:.55;cursor:not-allowed}._buttonPrimary_uwnxw_74{background:var(--color-accent);border-color:var(--color-accent);color:#fff}._buttonPrimary_uwnxw_74:hover:not(:disabled){background:var(--color-accent-hover);border-color:var(--color-accent-hover)}._buttonDanger_uwnxw_85{background:var(--color-danger);border-color:var(--color-danger);color:#fff}._buttonDanger_uwnxw_85:hover:not(:disabled){opacity:.9}._hint_uwnxw_95{color:var(--color-text-muted);margin:0;font-size:.8rem}._candidateList_uwnxw_101{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg);max-height:10rem;margin:0;padding:0;list-style:none;overflow:auto}._candidateList_uwnxw_101 li{border-bottom:1px solid var(--color-border);font-family:var(--font-mono);word-break:break-all;padding:.45rem .65rem;font-size:.75rem}._candidateList_uwnxw_101 li:last-child{border-bottom:none}._checkboxRow_uwnxw_124{color:var(--color-text-muted);align-items:flex-start;gap:.5rem;font-size:.85rem;display:flex}._status_uwnxw_132{color:var(--color-text-subtle);margin:0;font-size:.8rem}._statusError_uwnxw_138{color:var(--color-danger)}._divider_uwnxw_142{background:var(--color-border);height:1px;margin:.25rem 0}._overlay_uwnxw_148{z-index:100;background:#0f172a73;justify-content:center;align-items:center;padding:1rem;display:flex;position:fixed;inset:0}._modal_uwnxw_159{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);width:min(40rem,100%);max-height:calc(100vh - 2rem);box-shadow:var(--shadow-md);padding:1.25rem;overflow:auto}._modalTitle_uwnxw_170{margin:0 0 .75rem;font-size:1.15rem;font-weight:600}._summaryGrid_uwnxw_176{grid-template-columns:repeat(auto-fill,minmax(6rem,1fr));gap:.5rem;margin-bottom:1rem;display:grid}._summaryChip_uwnxw_183{border-radius:var(--radius-md);background:var(--color-bg-muted);padding:.5rem .65rem;font-size:.8rem}._summaryChip_uwnxw_183 strong{font-size:1rem;display:block}._recommendations_uwnxw_195{margin:0 0 1rem;padding:0;list-style:none}._recommendations_uwnxw_195 li{border-radius:var(--radius-md);background:var(--color-bg-muted);margin:.35rem 0;padding:.5rem .65rem;font-size:.85rem}._severityCritical_uwnxw_209{border-left:3px solid var(--color-danger)}._severityWarning_uwnxw_213{border-left:3px solid #d97706}._severityInfo_uwnxw_217{border-left:3px solid var(--color-accent)}._repoTable_uwnxw_221{border-collapse:collapse;width:100%;font-size:.8rem}._repoTable_uwnxw_221 th,._repoTable_uwnxw_221 td{text-align:left;border-bottom:1px solid var(--color-border);padding:.4rem .5rem}._repoTable_uwnxw_221 th{color:var(--color-text-subtle);text-transform:uppercase;font-size:.72rem}._modalActions_uwnxw_240{justify-content:flex-end;margin-top:1rem;display:flex}._tableWrap_1nm7q_1{border:1px solid var(--color-border);border-radius:var(--radius-lg);background:var(--color-bg-elevated);box-shadow:var(--shadow-sm);transition:var(--transition-theme);overflow:auto}._table_1nm7q_1{border-collapse:collapse;width:100%;font-size:.875rem}._table_1nm7q_1 th{text-align:left;text-transform:uppercase;letter-spacing:.05em;color:var(--color-text-subtle);background:var(--color-bg-muted);border-bottom:1px solid var(--color-border);padding:.65rem .85rem;font-size:.75rem;font-weight:600}._table_1nm7q_1 td{border-bottom:1px solid var(--color-border);vertical-align:middle;padding:.55rem .85rem}._projectRow_1nm7q_34 td{background:var(--color-bg-muted);border-bottom:1px solid var(--color-border-strong)}._projectHeader_1nm7q_39{flex-wrap:wrap;align-items:center;gap:.5rem 1rem;display:flex}._expandButton_1nm7q_46{color:var(--color-text);font:inherit;cursor:pointer;background:0 0;border:none;align-items:center;gap:.35rem;padding:0;font-weight:600;display:inline-flex}._expandButton_1nm7q_46:hover{color:var(--color-accent)}._projectMeta_1nm7q_63{color:var(--color-text-muted);font-size:.8rem;font-weight:400}._repoName_1nm7q_69{font-weight:500}._path_1nm7q_73{font-family:var(--font-mono);color:var(--color-text-muted);word-break:break-all;font-size:.78rem}._badge_1nm7q_80{border-radius:var(--radius-sm);text-transform:uppercase;letter-spacing:.04em;padding:.15rem .5rem;font-size:.75rem;font-weight:600;display:inline-block}._badgeSynced_1nm7q_90{color:var(--color-success);background:#0596691f}._badgeMissing_1nm7q_95{color:#d97706;background:#d9770624}[data-theme=dark] ._badgeMissing_1nm7q_95{color:#fbbf24}._actions_1nm7q_104{flex-wrap:wrap;gap:.35rem;display:flex}._button_1nm7q_110{border-radius:var(--radius-sm);border:1px solid var(--color-border);background:var(--color-bg);color:var(--color-text);cursor:pointer;transition:var(--transition-theme);padding:.35rem .65rem;font-size:.8rem}._button_1nm7q_110:hover{border-color:var(--color-border-strong);background:var(--color-bg-muted)}._buttonPrimary_1nm7q_126{background:var(--color-accent-soft);border-color:var(--color-accent);color:var(--color-accent)}._buttonPrimary_1nm7q_126:hover{background:var(--color-accent);color:#fff}._empty_1nm7q_137{text-align:center;color:var(--color-text-muted);margin:0;padding:2rem 1rem}._overlay_1lfb6_1{z-index:100;background:#0f172a73;justify-content:center;align-items:center;padding:1rem;display:flex;position:fixed;inset:0}._dialog_1lfb6_12{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);width:min(32rem,100%);max-height:calc(100vh - 2rem);box-shadow:var(--shadow-md);transition:var(--transition-theme);padding:1.25rem;overflow:auto}._title_1lfb6_24{margin:0 0 .25rem;font-size:1.15rem;font-weight:600}._subtitle_1lfb6_30{color:var(--color-text-muted);margin:0 0 1rem;font-size:.875rem}._field_1lfb6_36{flex-direction:column;gap:.35rem;margin-bottom:.85rem;display:flex}._label_1lfb6_43{color:var(--color-text-muted);font-size:.85rem;font-weight:500}._select_1lfb6_49{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg);width:100%;color:var(--color-text);padding:.55rem .75rem}._checkboxRow_1lfb6_58{color:var(--color-text-muted);align-items:center;gap:.5rem;margin-bottom:1rem;font-size:.9rem;display:flex}._actions_1lfb6_67{flex-wrap:wrap;justify-content:flex-end;gap:.5rem;display:flex}._button_1lfb6_74{border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-bg-muted);color:var(--color-text);cursor:pointer;transition:var(--transition-theme);padding:.5rem 1rem}._button_1lfb6_74:hover:not(:disabled){border-color:var(--color-border-strong)}._button_1lfb6_74:disabled{opacity:.55;cursor:not-allowed}._buttonPrimary_1lfb6_93{background:var(--color-accent);border-color:var(--color-accent);color:#fff}._buttonPrimary_1lfb6_93:hover:not(:disabled){background:var(--color-accent-hover);border-color:var(--color-accent-hover)}._status_1lfb6_104{background:var(--color-bg-muted);border-radius:var(--radius-md);color:var(--color-text-muted);margin:0 0 1rem;padding:.75rem 1rem;font-size:.875rem}._statusError_1lfb6_113{background:var(--color-danger-soft);color:var(--color-danger)}._summary_1lfb6_118{color:var(--color-text-muted);margin:0 0 1rem;padding:0;font-size:.85rem;list-style:none}._summary_1lfb6_118 li{font-family:var(--font-mono);margin:.2rem 0}._page_uygcw_1{flex-direction:column;gap:1rem;display:flex}._header_uygcw_7{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem 1.5rem;display:flex}._title_uygcw_15{margin:0;font-size:1.5rem;font-weight:600}._subtitle_uygcw_21{color:var(--color-text-muted);font-size:.9rem;font-family:var(--font-mono);word-break:break-all;margin:.25rem 0 0}._chips_uygcw_29{flex-wrap:wrap;gap:.5rem;display:flex}._chip_uygcw_29{border-radius:var(--radius-md);background:var(--color-bg-elevated);border:1px solid var(--color-border);color:var(--color-text-muted);transition:var(--transition-theme);padding:.35rem .75rem;font-size:.85rem}._chip_uygcw_29 strong{color:var(--color-text);margin-right:.25rem}._toolbar_uygcw_50{flex-wrap:wrap;align-items:center;gap:.75rem 1rem;display:flex}._tabs_uygcw_57{flex-wrap:wrap;gap:.35rem;display:flex}._tab_uygcw_57{border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-bg-elevated);color:var(--color-text-muted);cursor:pointer;transition:var(--transition-theme);padding:.4rem .85rem;font-size:.85rem}._tab_uygcw_57:hover{border-color:var(--color-border-strong);color:var(--color-text)}._tabActive_uygcw_79{background:var(--color-accent-soft);border-color:var(--color-accent);color:var(--color-accent)}._search_uygcw_85{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-elevated);min-width:12rem;max-width:22rem;color:var(--color-text);flex:1;padding:.5rem .75rem}._search_uygcw_85:focus{border-color:var(--color-accent);box-shadow:0 0 0 3px var(--color-focus);outline:none}._layout_uygcw_102{grid-template-columns:minmax(0,1fr) minmax(14rem,18rem);align-items:start;gap:1.25rem;display:grid}@media (width<=960px){._layout_uygcw_102{grid-template-columns:1fr}}._loading_uygcw_115,._error_uygcw_116{border-radius:var(--radius-md);margin:0;padding:1rem}._loading_uygcw_115{color:var(--color-text-muted);background:var(--color-bg-elevated);border:1px solid var(--color-border)}._error_uygcw_116{color:var(--color-danger);background:var(--color-danger-soft);border:1px solid var(--color-danger)}._graphFilters_uygcw_134{flex-wrap:wrap;align-items:center;gap:.75rem 1.25rem;display:flex}._checkLabel_uygcw_141{color:var(--color-text-muted);cursor:pointer;align-items:center;gap:.4rem;font-size:.85rem;display:inline-flex}._graphPanel_uygcw_150{min-width:0}
````

## File: src/metagit/data/web/assets/index-DOullneW.js
````javascript
var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t=
⋮----
`+Ce+e+we}var Te=!1;function Ee(e,t)
⋮----
`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(`<anonymous>`)&&(u=u.replace(`<anonymous>`,e.displayName)),u}while(1<=r&&0<=i);break}}}finally
⋮----
`+e.stack}}var ke=Object.prototype.hasOwnProperty,Ae=t.unstable_scheduleCallback,je=t.unstable_cancelCallback,Me=t.unstable_shouldYield,Ne=t.unstable_requestPaint,Pe=t.unstable_now,Fe=t.unstable_getCurrentPriorityLevel,Ie=t.unstable_ImmediatePriority,Le=t.unstable_UserBlockingPriority,Re=t.unstable_NormalPriority,ze=t.unstable_LowPriority,Be=t.unstable_IdlePriority,Ve=t.log,He=t.unstable_setDisableYieldValue,Ue=null,We=null;function Ge(e)
`).replace(Ad,``)}function Md(e,t)
⋮----
Please change the parent <Route path="$
````

## File: src/metagit/data/web/favicon.svg
````xml
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
````

## File: src/metagit/data/web/icons.svg
````xml
<svg xmlns="http://www.w3.org/2000/svg">
  <symbol id="bluesky-icon" viewBox="0 0 16 17">
    <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
    <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
  </symbol>
  <symbol id="discord-icon" viewBox="0 0 20 19">
    <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
  </symbol>
  <symbol id="documentation-icon" viewBox="0 0 21 20">
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
  </symbol>
  <symbol id="github-icon" viewBox="0 0 19 19">
    <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
  </symbol>
  <symbol id="social-icon" viewBox="0 0 20 20">
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
  </symbol>
  <symbol id="x-icon" viewBox="0 0 19 19">
    <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
  </symbol>
</svg>
````

## File: src/metagit/__init__.py
````python
#!/usr/bin/env python
"""
Metagit detection tool

.. currentmodule:: metagit
.. moduleauthor:: Metagit <zloeber@gmail.com>
"""
⋮----
here = path.abspath(path.dirname(__file__))
⋮----
__version__ = version("metagit-cli")
⋮----
__version__ = "0.0.0"
⋮----
SCRIPT_PATH = os.path.abspath(os.path.split(__file__)[0])
CONFIG_PATH = os.getenv(
DATA_PATH = os.getenv("METAGIT_DATA", os.path.join(SCRIPT_PATH, "data"))
DEFAULT_CONFIG = os.path.join(DATA_PATH, "metagit.config.yaml")
````

## File: src/metagit/__main__.py
````python
#!/usr/bin/env python
"""Compatibility module for legacy metagit entrypoints."""
````

## File: tasks/Taskfile.gemini.yml
````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: "3"
silent: true
vars:
  GEMINI_API_KEY:
    sh: echo ${GEMINI_API_KEY:-""}

tasks:
  show:
    desc: Show python variables for this task
    cmds:
      - |
        echo "GEMINI_API_KEY: {{.GEMINI_API_KEY}}"
  docs:
    desc: Build the docs via gemini
    cmds:
      - |
        gemini -y -p 'You are an expert devops and software engineer. Explore this project for its use of secrets and variables paying special attention to only those affecting the runtime behavior of the code and the cicd workflows used to release the projects artifacts. Create a report in ./docs/secrets.analysis.md on each of the secrets found and were they are sourced from. Using this data create a yaml file at ./docs/secrets.definitions.yml that represents the secrets, their provenance, and where they should be placed.'
        gemini -y -p 'Perform a comprehensive analysis of the files in this project. Update the yaml file named .metagit.example.yml in the root of this project in a manner that adheres to the jsonschema file located at ./schemas/metagit_config.schema.json. Ensure you skip files matching the patterns in .gitignore. Do your best to update even optional elements of this schema. Pay special attention to dependencies found in Dockerfiles, CICD files, and mcp definitions. When complete also create ./docs/app.logic.md with additional documentation on the project components that includes a mermaid diagram of how they interact.'
````

## File: tasks/Taskfile.github.yml
````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json

version: "3"
silent: true
vars:
  GITHUB_PATH:
    sh: |
      git remote get-url origin 2>/dev/null | sed -Ee 's/.*:(.+)\.git/\1/'
  GITHUB_URL:
    sh: 'echo ${GITHUB_URL:-"https://www.github.com"}'
  GITHUB_UI_PATH: "{{.GITHUB_URL}}/{{.GITHUB_PATH}}"
  GITHUB_TOKEN:
    sh: echo ${GITHUB_TOKEN:-""}

tasks:
  show:
    desc: Show github variables for this task
    cmds:
      - |
        echo "GITHUB_PATH: {{.GITHUB_PATH}}"
        echo "GITHUB_UI_PATH: {{.GITHUB_UI_PATH}}"

  ui:
    desc: Open website for github project
    cmds:
      - |
        GITHUB_UI_PATH=$(git remote get-url origin 2>/dev/null | sed -Ee 's/.*:(.+)\.git/\1/' | awk '{print "https://www.github.com/" $1}')
        echo "path: ${GITHUB_UI_PATH}"
        if [[ "{{OS}}" == "darwin"* ]]; then
          open ${GITHUB_UI_PATH}
        else
          xdg-open ${GITHUB_UI_PATH}
        fi

  actions:ui:
    desc: Open website for github project actions
    cmds:
      - |
        if [[ "$OSTYPE" == "darwin"* ]]; then
          open {{.GITHUB_UI_PATH}}/actions
        else
          xdg-open {{.GITHUB_UI_PATH}}/actions
        fi

  models:init:
    desc: Initialize models for github project
    cmds:
      - gh extension install https://github.com/github/gh-models || true

  models:list:
    desc: List models for github project
    cmds:
      - gh models list
````

## File: tasks/Taskfile.mcp.yml
````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json

version: "3"
silent: true

tasks:
  show:
    desc: Show MCP variables for this task
    cmds:
      - |
        echo "OLLAMA_HOST: {{.OLLAMA_HOST}}"

  inspector:
    desc: Start MCP Inspector
    cmds:
      - npx -y @modelcontextprotocol/inspector
    env:
      DANGEROUSLY_OMIT_AUTH: true

  scan:
    desc: Scan MCP package for issues
    env:
      MCP_SCANNER_LLM_API_KEY: test
      MCP_SCANNER_API_KEY: test
      MCP_SCANNER_LLM_ENDPOINT: $OLLAMA_HOST
      MCP_SCANNER_LLM_MODEL: gemma4:31b
    cmds:
      - |
        uv tool install cisco-ai-mcp-scanner
        uv tool upgrade cisco-ai-mcp-scanner
        mcp-scanner --format summary \
          --stdio-command uvx \
          --stdio-arg=pl-cli --stdio-arg=mcp --stdio-arg=-c --stdio-arg=config.yaml \
          --stdio-arg=--no-ingest-on-startup \
          --format table \
          --analyzers yara \
          -o security_scan_results.md
````

## File: tests/api/test_catalog_api.py
````python
#!/usr/bin/env python
"""HTTP API tests for workspace catalog v2 endpoints."""
⋮----
def test_catalog_project_and_repo_crud(tmp_path: Path) -> None
⋮----
server = build_server(root=str(tmp_path), host="127.0.0.1", port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
⋮----
port = server.server_address[1]
base = f"http://127.0.0.1:{port}"
⋮----
projects = json.loads(
⋮----
add_body = json.dumps({"name": "platform"}).encode("utf-8")
add_req = urllib.request.Request(
added = json.loads(urllib.request.urlopen(add_req, timeout=5).read().decode("utf-8"))
⋮----
repo_body = json.dumps(
repo_req = urllib.request.Request(
repo_added = json.loads(
⋮----
repos = json.loads(
⋮----
delete_repo = urllib.request.Request(
⋮----
delete_project = urllib.request.Request(
````

## File: tests/api/test_layout_api.py
````python
#!/usr/bin/env python
"""HTTP API tests for workspace layout v2 endpoints."""
⋮----
def test_layout_project_rename_api(tmp_path: Path) -> None
⋮----
sync_root = tmp_path / "sync"
⋮----
server = build_server(root=str(tmp_path), host="127.0.0.1", port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
⋮----
port = server.server_address[1]
body = json.dumps({"to_name": "apps"}).encode("utf-8")
req = urllib.request.Request(
payload = json.loads(urllib.request.urlopen(req, timeout=5).read().decode("utf-8"))
````

## File: tests/api/test_repo_search_api.py
````python
#!/usr/bin/env python
"""
HTTP API tests for managed repository search.
"""
⋮----
def test_repo_search_endpoint_returns_matches(tmp_path: Path) -> None
⋮----
repo_dir = tmp_path / "platform" / "abacus-app"
⋮----
server = build_server(root=str(tmp_path), host="127.0.0.1", port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
⋮----
port = server.server_address[1]
url = f"http://127.0.0.1:{port}/v1/repos/search?q=abacus"
payload = json.loads(
⋮----
def test_repo_resolve_ambiguous_returns_409(tmp_path: Path) -> None
⋮----
app_repo = tmp_path / "platform" / "abacus-app"
mod_repo = tmp_path / "shared" / "abacus-module"
⋮----
url = f"http://127.0.0.1:{port}/v1/repos/resolve?q=abacus"
req = urllib.request.Request(url)
⋮----
def test_unknown_path_returns_404(tmp_path: Path) -> None
⋮----
url = f"http://127.0.0.1:{port}/v1/no-such"
````

## File: tests/cli/commands/test_api.py
````python
#!/usr/bin/env python
"""
CLI tests for metagit api commands.
"""
⋮----
def test_api_cli_status_once_reports_bound_port(tmp_path: Path) -> None
⋮----
runner = CliRunner()
result = runner.invoke(
````

## File: tests/cli/commands/test_config_patch.py
````python
#!/usr/bin/env python
⋮----
"""CLI tests for config patch/preview/tree commands."""
⋮----
def _minimal_metagit(path: Path) -> None
⋮----
def test_config_patch_single_op_save() -> None
⋮----
runner = CliRunner()
⋮----
result = runner.invoke(
⋮----
on_disk = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8"))
⋮----
def test_config_patch_operations_file() -> None
⋮----
ops_path = Path("ops.json")
⋮----
def test_config_preview_json() -> None
⋮----
payload = json.loads(result.output)
````

## File: tests/cli/commands/test_project_source.py
````python
#!/usr/bin/env python
"""
CLI tests for project source sync commands.
"""
⋮----
def test_project_source_sync_dry_run(monkeypatch, tmp_path) -> None
⋮----
config_path = tmp_path / ".metagit.yml"
⋮----
runner = CliRunner()
result = runner.invoke(
⋮----
def test_project_source_sync_reconcile_requires_yes(monkeypatch, tmp_path) -> None
````

## File: tests/cli/commands/test_search.py
````python
#!/usr/bin/env python
"""
CLI tests for metagit search / find.
"""
⋮----
def test_search_command_returns_json_matches() -> None
⋮----
runner = CliRunner()
⋮----
repo_dir = Path("platform") / "abacus-app"
⋮----
result = runner.invoke(
⋮----
def test_find_alias_matches_search_command() -> None
⋮----
result = runner.invoke(cli, ["find", "--help"])
````

## File: tests/cli/commands/test_web.py
````python
#!/usr/bin/env python
"""CLI tests for metagit web commands."""
⋮----
def test_web_serve_status_once(tmp_path: Path) -> None
⋮----
runner = CliRunner()
⋮----
result = runner.invoke(
````

## File: tests/core/config/test_graph_cypher_export.py
````python
#!/usr/bin/env python
⋮----
"""Tests for workspace graph Cypher export."""
⋮----
def test_export_manual_relationships_produces_cypher(tmp_path: Path) -> None
⋮----
workspace_root = tmp_path / ".metagit"
⋮----
config = MetagitConfig(
⋮----
result = GraphCypherExportService().export(
⋮----
def test_export_tool_calls_only_format() -> None
````

## File: tests/core/config/test_patch_service.py
````python
#!/usr/bin/env python
⋮----
"""Tests for ConfigPatchService."""
⋮----
def _write_metagit(path: Path, payload: dict) -> None
⋮----
def test_patch_metagit_set_name_dry_run(tmp_path: Path) -> None
⋮----
config_path = tmp_path / ".metagit.yml"
⋮----
service = ConfigPatchService()
result = service.patch(
⋮----
on_disk = yaml.safe_load(config_path.read_text(encoding="utf-8"))
⋮----
def test_patch_metagit_set_name_save(tmp_path: Path) -> None
⋮----
def test_patch_append_workspace_project(tmp_path: Path) -> None
````

## File: tests/core/init/test_init_service.py
````python
#!/usr/bin/env python
"""Tests for metagit init template service."""
⋮----
def test_list_templates_includes_hermes() -> None
⋮----
service = InitService()
ids = {item.id for item in service.list_templates()}
⋮----
def test_init_hermes_with_answers_file(tmp_path: Path) -> None
⋮----
answers = tmp_path / "answers.yml"
⋮----
target = tmp_path / "coordinator"
⋮----
result = service.initialize(
manifest = yaml.safe_load((target / ".metagit.yml").read_text(encoding="utf-8"))
⋮----
local = next(p for p in manifest["workspace"]["projects"] if p["name"] == "local")
⋮----
def test_init_minimal_library_kind(tmp_path: Path) -> None
⋮----
target = tmp_path / "lib"
````

## File: tests/core/mcp/services/test_bootstrap_sampling.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.bootstrap_sampling
"""
⋮----
def test_sampling_disabled_returns_plan_only_payload() -> None
⋮----
service = BootstrapSamplingService(sampling_supported=False)
⋮----
result = service.generate(context={"repo_root": "/tmp/repo"}, confirm_write=False)
⋮----
def test_sampling_success_returns_draft_yaml() -> None
⋮----
def sampler(payload: dict[str, str]) -> str
⋮----
_ = payload
⋮----
service = BootstrapSamplingService(sampling_supported=True, sampler=sampler)
⋮----
result = service.generate(context={"repo_root": "/tmp/repo"}, confirm_write=True)
````

## File: tests/core/mcp/services/test_cross_project_dependencies.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.cross_project_dependencies
"""
⋮----
def _workspace_config(tmp_path: Path) -> tuple[MetagitConfig, str]
⋮----
root = tmp_path / "workspace"
shared_url = "https://github.com/example/shared-lib.git"
alpha_repo = root / "alpha" / "api"
beta_repo = root / "beta" / "worker"
⋮----
relative_api = os.path.relpath(alpha_repo, beta_repo)
⋮----
def test_map_dependencies_finds_url_match_and_imports(tmp_path: Path) -> None
⋮----
registry = MagicMock()
⋮----
service = CrossProjectDependencyService(registry=registry)
⋮----
result = service.map_dependencies(
⋮----
edge_types = {edge.type for edge in result.edges}
⋮----
def test_map_dependencies_unknown_project(tmp_path: Path) -> None
⋮----
service = CrossProjectDependencyService(registry=MagicMock())
⋮----
def test_map_dependencies_respects_depth(tmp_path: Path) -> None
⋮----
shallow = service.map_dependencies(
deep = service.map_dependencies(
````

## File: tests/core/mcp/services/test_import_hint_scanner.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.import_hint_scanner
"""
⋮----
def test_scan_package_json_file_dependency(tmp_path: Path) -> None
⋮----
lib = tmp_path / "lib-repo"
app = tmp_path / "app-repo"
⋮----
path_to_id = {
scanner = ImportHintScanner()
⋮----
hints = scanner.scan_repo(repo_path=str(app), path_to_repo_id=path_to_id)
````

## File: tests/core/mcp/services/test_repo_ops.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.repo_ops
"""
⋮----
def test_pull_requires_explicit_mutation_enable(tmp_path: Path) -> None
⋮----
repo_dir = tmp_path / "repo"
⋮----
service = RepoOperationsService()
⋮----
result = service.sync(repo_path=str(repo_dir), mode="pull", allow_mutation=False)
⋮----
def test_inspect_reports_repo_status(tmp_path: Path) -> None
⋮----
result = service.inspect(repo_path=str(repo_dir))
````

## File: tests/core/mcp/services/test_session_store.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.session_store
"""
⋮----
def test_get_workspace_meta_returns_defaults_when_missing(tmp_path: Path) -> None
⋮----
store = SessionStore(workspace_root=str(tmp_path))
meta = store.get_workspace_meta()
⋮----
def test_set_active_project_persists_workspace_meta(tmp_path: Path) -> None
⋮----
meta = store.set_active_project(project_name="alpha")
⋮----
reloaded = store.get_workspace_meta()
⋮----
def test_project_session_roundtrip(tmp_path: Path) -> None
⋮----
session = store.update_project_session(
reloaded = store.get_project_session(project_name="alpha")
⋮----
def test_corrupt_project_session_returns_empty(tmp_path: Path) -> None
⋮----
path = store.sessions_dir / "alpha.json"
⋮----
session = store.get_project_session(project_name="alpha")
⋮----
def test_env_override_secret_value_rejected(tmp_path: Path) -> None
⋮----
def test_save_workspace_meta_writes_json(tmp_path: Path) -> None
⋮----
raw = json.loads((store.sessions_dir / "_workspace.json").read_text(encoding="utf-8"))
````

## File: tests/core/mcp/services/test_upstream_hints.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.upstream_hints
"""
⋮----
def test_terraform_blocker_ranks_infra_repos_higher() -> None
⋮----
service = UpstreamHintService()
repo_context = [
⋮----
ranked = service.rank(
````

## File: tests/core/mcp/services/test_workspace_health.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_health
"""
⋮----
def _config(tmp_path: Path) -> tuple[MetagitConfig, str]
⋮----
root = tmp_path / "workspace"
present = root / "alpha" / "api"
⋮----
def test_health_check_reports_missing_repo(tmp_path: Path) -> None
⋮----
registry = MagicMock()
⋮----
service = WorkspaceHealthService(registry=registry)
⋮----
result = service.check(
⋮----
actions = {item.action for item in result.recommendations}
⋮----
repo = next(r for r in result.repos if r.repo_name == "api")
````

## File: tests/core/mcp/services/test_workspace_index.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_index
"""
⋮----
def test_workspace_index_resolves_repo_paths(tmp_path: Path) -> None
⋮----
workspace_root = tmp_path / "workspace"
⋮----
repo_path = workspace_root / "repo-a"
⋮----
config = MetagitConfig(
service = WorkspaceIndexService()
⋮----
rows = service.build_index(config=config, workspace_root=str(workspace_root))
````

## File: tests/core/mcp/services/test_workspace_semantic_search.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_semantic_search
"""
⋮----
def test_search_across_repos_empty_query() -> None
⋮----
service = WorkspaceSemanticSearchService(registry=MagicMock())
result = service.search_across_repos(query="   ", repo_paths=["/a"])
⋮----
def test_search_runs_gitnexus_and_parses_json(mock_run: MagicMock) -> None
⋮----
registry = MagicMock()
⋮----
proc = MagicMock()
⋮----
service = WorkspaceSemanticSearchService(registry=registry)
out = service.search_across_repos(
⋮----
row = out["results"][0]
⋮----
call_kw = mock_run.call_args.kwargs
⋮----
cmd = mock_run.call_args.args[0]
⋮----
def test_parse_query_json_finds_embedded_line() -> None
⋮----
blob = "log line\n" + json.dumps({"processes": [], "note": "x"}) + "\n"
parsed = service._parse_query_json(blob)
````

## File: tests/core/mcp/services/test_workspace_snapshot.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_snapshot
"""
⋮----
def _write_workspace(tmp_path: Path) -> str
⋮----
repo_path = tmp_path / "alpha" / "repo-one"
⋮----
def _load_config(tmp_path: Path)
⋮----
manager = MetagitConfigManager(config_path=tmp_path / ".metagit.yml")
loaded = manager.load_config()
⋮----
def test_create_writes_snapshot_file(tmp_path: Path) -> None
⋮----
root = _write_workspace(tmp_path)
config = _load_config(tmp_path)
service = WorkspaceSnapshotService()
⋮----
payload = service.create(
⋮----
snapshot_path = tmp_path / ".metagit" / "snapshots" / f"{payload['snapshot_id']}.json"
⋮----
raw = json.loads(snapshot_path.read_text(encoding="utf-8"))
⋮----
def test_restore_missing_snapshot_returns_error(tmp_path: Path) -> None
⋮----
result = service.restore(
⋮----
def test_restore_switches_active_project(tmp_path: Path) -> None
⋮----
context = ProjectContextService()
⋮----
snapshot_service = WorkspaceSnapshotService()
created = snapshot_service.create(config=config, workspace_root=root)
⋮----
store = SessionStore(workspace_root=root)
⋮----
cleared = store.get_workspace_meta()
⋮----
restored = snapshot_service.restore(
⋮----
meta = store.get_workspace_meta()
````

## File: tests/core/mcp/services/test_workspace_sync.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_sync
"""
⋮----
def test_sync_many_dry_run_skips_git_calls(tmp_path: Path) -> None
⋮----
repo_path = tmp_path / "alpha" / "repo-one"
⋮----
rows = [
repo_ops = MagicMock()
service = WorkspaceSyncService(repo_ops=repo_ops)
⋮----
payload = service.sync_many(rows, dry_run=True)
⋮----
def test_sync_many_only_if_missing_skips_existing(tmp_path: Path) -> None
⋮----
payload = service.sync_many(rows, only_if="missing", dry_run=False)
````

## File: tests/core/mcp/services/test_workspace_template.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_template
"""
⋮----
def test_template_apply_dry_run_lists_files(tmp_path: Path) -> None
⋮----
root = tmp_path / "workspace"
repo = root / "alpha" / "api"
⋮----
config = MetagitConfig(
service = WorkspaceTemplateService()
⋮----
result = service.apply(
⋮----
def test_template_apply_requires_confirm_when_not_dry_run(tmp_path: Path) -> None
````

## File: tests/core/mcp/test_gate.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.gate
"""
⋮----
def test_missing_root_is_inactive_missing() -> None
⋮----
gate = WorkspaceGate()
⋮----
result = gate.evaluate(root_path=None)
⋮----
def test_missing_config_file_is_inactive_missing(tmp_path: Path) -> None
⋮----
result = gate.evaluate(root_path=str(tmp_path))
⋮----
def test_invalid_config_file_is_inactive_invalid(tmp_path: Path) -> None
⋮----
config_path = tmp_path / ".metagit.yml"
⋮----
def test_valid_config_file_is_active(tmp_path: Path) -> None
````

## File: tests/core/mcp/test_models.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.models
"""
⋮----
def test_activation_state_values() -> None
⋮----
def test_workspace_status_model() -> None
⋮----
status = WorkspaceStatus(
````

## File: tests/core/mcp/test_resources.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.resources
"""
⋮----
def test_workspace_resources_available_when_active() -> None
⋮----
ops = OperationsLogService()
⋮----
publisher = ResourcePublisher(ops_log=ops)
config = MetagitConfig(
⋮----
config_resource = publisher.get_resource(
repos_resource = publisher.get_resource(
ops_resource = publisher.get_resource(uri="metagit://workspace/ops-log")
````

## File: tests/core/mcp/test_root_resolver.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.root_resolver
"""
⋮----
def test_env_root_has_highest_precedence(monkeypatch, tmp_path: Path) -> None
⋮----
env_root = tmp_path / "env-root"
⋮----
resolver = WorkspaceRootResolver()
⋮----
result = resolver.resolve(cwd=str(tmp_path), cli_root=str(tmp_path / "cli-root"))
⋮----
def test_cli_root_used_when_env_unset(monkeypatch, tmp_path: Path) -> None
⋮----
cli_root = tmp_path / "cli-root"
⋮----
result = resolver.resolve(cwd=str(tmp_path), cli_root=str(cli_root))
⋮----
def test_walk_up_finds_workspace_root(monkeypatch, tmp_path: Path) -> None
⋮----
root = tmp_path / "workspace-root"
nested = root / "services" / "api"
⋮----
result = resolver.resolve(cwd=str(nested), cli_root=None)
````

## File: tests/core/prompt/test_prompt_service.py
````python
#!/usr/bin/env python
"""Tests for metagit prompt emission service."""
⋮----
def _sample_config() -> MetagitConfig
⋮----
def test_catalog_lists_instructions_kind() -> None
⋮----
kinds = [entry.kind for entry in list_catalog()]
⋮----
def test_emit_instructions_workspace() -> None
⋮----
result = PromptService().emit(
⋮----
def test_emit_instructions_repo() -> None
⋮----
def test_emit_session_start_includes_manifest_when_requested() -> None
⋮----
def test_emit_repo_enrich_includes_detect_commands() -> None
⋮----
def test_kind_not_allowed_for_scope() -> None
````

## File: tests/core/web/__init__.py
````python

````

## File: tests/core/web/test_config_preview.py
````python
#!/usr/bin/env python
"""Tests for config YAML preview rendering."""
⋮----
def test_render_metagit_yaml_normalized() -> None
⋮----
config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
rendered = render_metagit_yaml(config, style="normalized")
⋮----
def test_render_appconfig_yaml_masks_secrets() -> None
⋮----
config = AppConfig.model_validate(
rendered = render_appconfig_yaml(
⋮----
def test_redact_secrets_nested() -> None
⋮----
payload = {"providers": {"gitlab": {"api_token": "glpat-secret"}}}
redacted = redact_secrets(payload)
````

## File: tests/core/web/test_graph_service.py
````python
#!/usr/bin/env python
"""Tests for workspace graph web view builder."""
⋮----
def test_build_view_includes_manual_and_structure(tmp_path: Path) -> None
⋮----
workspace_root = tmp_path / ".metagit"
⋮----
config = MetagitConfig(
view = WorkspaceGraphService().build_view(
⋮----
manual = [edge for edge in view.edges if edge.source == "manual"]
⋮----
structure = [edge for edge in view.edges if edge.source == "structure"]
````

## File: tests/core/web/test_job_store.py
````python
#!/usr/bin/env python
"""Unit tests for SyncJobStore."""
⋮----
def test_job_lifecycle_and_events() -> None
⋮----
store = SyncJobStore()
job_id = store.create_job()
⋮----
status = store.get(job_id)
⋮----
events = store.drain_events(job_id)
````

## File: tests/core/web/test_ops_handler.py
````python
#!/usr/bin/env python
"""HTTP tests for web ops routes (v3 API)."""
⋮----
manifest_lines = [
⋮----
server = build_web_server(
thread = threading.Thread(target=server.serve_forever, daemon=True)
⋮----
port = server.server_address[1]
base = f"http://127.0.0.1:{port}"
⋮----
def _post_json(url: str, payload: dict) -> tuple[int, dict]
⋮----
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
⋮----
raw = exc.read().decode("utf-8")
⋮----
def test_health_endpoint_returns_ok(tmp_path: Path) -> None
⋮----
def test_prune_preview_empty(tmp_path: Path) -> None
⋮----
def test_sync_dry_run_job_completes(tmp_path: Path) -> None
⋮----
job_id = created["job_id"]
⋮----
deadline = time.time() + 5.0
final_state = ""
⋮----
job_status = json.loads(resp.read().decode("utf-8"))
final_state = job_status["state"]
````

## File: tests/core/workspace/test_agent_instructions.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.workspace.agent_instructions
"""
⋮----
def test_file_level_instructions_without_workspace_block() -> None
⋮----
config = MetagitConfig(
result = AgentInstructionsResolver().resolve(config)
⋮----
def test_legacy_agent_prompt_alias_on_load() -> None
⋮----
config = MetagitConfig.model_validate(
⋮----
def test_compose_all_layers_including_repo() -> None
⋮----
project = config.workspace.projects[0]
repo = project.repos[0]
result = AgentInstructionsResolver().resolve(config, project=project, repo=repo)
````

## File: tests/core/workspace/test_context_models.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.workspace.context_models
"""
⋮----
def test_validate_env_key_accepts_metagit_style_keys() -> None
⋮----
def test_validate_env_key_rejects_lowercase() -> None
⋮----
def test_validate_env_value_rejects_bearer_token() -> None
⋮----
def test_project_session_rejects_long_agent_notes() -> None
⋮----
def test_project_session_caps_recent_repos() -> None
⋮----
session = ProjectSession(
````

## File: tests/core/workspace/test_dedupe_resolver.py
````python
#!/usr/bin/env python
"""Tests for per-project dedupe resolution."""
⋮----
def _config_with_projects() -> MetagitConfig
⋮----
def test_resolve_effective_dedupe_inherits_workspace_default() -> None
⋮----
workspace_dedupe = WorkspaceDedupeConfig(enabled=True)
⋮----
def test_resolve_effective_dedupe_project_disable_override() -> None
⋮----
project = WorkspaceProject(
⋮----
def test_resolve_effective_dedupe_project_enable_override() -> None
⋮----
workspace_dedupe = WorkspaceDedupeConfig(enabled=False)
⋮----
def test_resolve_effective_dedupe_for_project_by_name() -> None
⋮----
config = _config_with_projects()
⋮----
def test_resolve_dedupe_for_layout_without_project() -> None
⋮----
def test_workspace_project_rejects_unknown_dedupe_keys() -> None
````

## File: tests/core/workspace/test_hydrate.py
````python
#!/usr/bin/env python
"""Tests for symlink mount hydration."""
⋮----
class _DummyLogger
⋮----
def set_level(self, _: str) -> None
⋮----
def warning(self, _: str) -> None
⋮----
def debug(self, _: str) -> None
⋮----
def test_collect_file_copy_jobs_counts_nested_files(tmp_path: Path) -> None
⋮----
root = tmp_path / "src"
⋮----
jobs = collect_file_copy_jobs(root)
⋮----
def test_materialize_symlink_mount_replaces_link_with_directory(tmp_path: Path) -> None
⋮----
source = tmp_path / "canonical"
⋮----
mount = tmp_path / "project" / "repo"
⋮----
def test_project_sync_hydrate_after_deduped_symlink(tmp_path: Path) -> None
⋮----
source = tmp_path / "user-site"
⋮----
workspace_root = tmp_path / ".metagit"
dedupe = WorkspaceDedupeConfig(enabled=True, canonical_dir="_canonical")
manager = ProjectManager(workspace_root, _DummyLogger(), dedupe=dedupe)
repo = ProjectPath(name="site", path=str(source), sync=True, kind=ProjectKind.WEBSITE)
project = WorkspaceProject(name="local", repos=[repo])
⋮----
mount = workspace_root / "local" / "site"
````

## File: tests/core/workspace/test_layout_service.py
````python
#!/usr/bin/env python
"""Tests for workspace layout rename and move service."""
⋮----
def _setup_workspace(tmp_path: Path) -> tuple[Path, str, str]
⋮----
sync_root = tmp_path / "sync"
⋮----
manifest = {
config_path = tmp_path / ".metagit.yml"
⋮----
manager = MetagitConfigManager(str(config_path))
loaded = manager.load_config()
⋮----
def test_rename_project_moves_sync_folder(tmp_path: Path) -> None
⋮----
service = WorkspaceLayoutService()
result = service.rename_project(
⋮----
manager = MetagitConfigManager(config_path)
reloaded = manager.load_config()
⋮----
def test_rename_repo_moves_mount(tmp_path: Path) -> None
⋮----
result = service.rename_repo(
⋮----
def test_move_repo_between_projects(tmp_path: Path) -> None
⋮----
result = service.move_repo(
⋮----
beta = next(p for p in reloaded.workspace.projects if p.name == "beta")
⋮----
def test_dry_run_does_not_mutate(tmp_path: Path) -> None
````

## File: tests/scripts/test_prepush_gate_security.py
````python
#!/usr/bin/env python
⋮----
"""Tests for context-aware security steps in prepush-gate."""
⋮----
def _prepush_gate_module()
⋮----
path = Path(__file__).resolve().parents[2] / "scripts" / "prepush-gate.py"
spec = importlib.util.spec_from_file_location("prepush_gate", path)
⋮----
module = importlib.util.module_from_spec(spec)
⋮----
def test_security_scan_plan_full_when_unknown() -> None
⋮----
gate = _prepush_gate_module()
⋮----
def test_security_scan_plan_deps_triggers_sync() -> None
⋮----
def test_security_scan_plan_src_without_sync() -> None
⋮----
def test_security_scan_plan_skips_docs_only() -> None
````

## File: tests/test_config_example_generator.py
````python
#!/usr/bin/env python
"""Tests for MetagitConfig exemplar generation."""
⋮----
def test_render_yaml_includes_header_and_workspace() -> None
⋮----
generator = ConfigExampleGenerator(overrides=load_example_overrides())
rendered = generator.render_yaml(include_workspace=True, comment_style="line")
⋮----
def test_build_merges_overrides() -> None
⋮----
generator = ConfigExampleGenerator(overrides={"name": "override-name"})
payload = generator.build(include_workspace=False)
⋮----
def test_generated_payload_validates_when_overrides_used() -> None
⋮----
payload = generator.build(include_workspace=True)
config = MetagitConfig.model_validate(payload)
````

## File: tests/test_config_yaml_display.py
````python
#!/usr/bin/env python
"""Tests for config YAML display helpers."""
⋮----
def test_dump_config_dict_uses_literal_block_for_multiline() -> None
⋮----
rendered = dump_config_dict(
⋮----
rendered_unicode = dump_config_dict({"note": "status — ok"})
````

## File: tests/test_documentation_graph_models.py
````python
#!/usr/bin/env python
"""Tests for documentation sources and manual graph relationships."""
⋮----
def test_documentation_accepts_strings_and_dicts() -> None
⋮----
config = MetagitConfig(
⋮----
nodes = config.documentation_graph_nodes()
⋮----
def test_graph_relationships_and_export() -> None
⋮----
exported = config.graph_export_payload()
⋮----
def test_load_metagit_yml_documentation_block(tmp_path: Path) -> None
⋮----
manifest = {
path = tmp_path / ".metagit.yml"
⋮----
loaded = MetagitConfigManager(str(path)).load_config()
⋮----
def test_manual_graph_edges_in_dependency_map(tmp_path: Path) -> None
⋮----
workspace_root = tmp_path / ".metagit"
⋮----
result = CrossProjectDependencyService().map_dependencies(
manual = [edge for edge in result.edges if edge.type == "manual"]
````

## File: tests/test_project_manager_dedupe.py
````python
#!/usr/bin/env python
"""Integration tests for workspace dedupe sync layout."""
⋮----
class _DummyLogger
⋮----
def set_level(self, _: str) -> None
⋮----
def warning(self, _: str) -> None
⋮----
def debug(self, _: str) -> None
⋮----
source = tmp_path / "user-site"
⋮----
workspace_root = tmp_path / ".metagit"
dedupe = WorkspaceDedupeConfig(enabled=True, canonical_dir="_canonical")
manager = ProjectManager(workspace_root, _DummyLogger(), dedupe=dedupe)
⋮----
repo = ProjectPath(name="site", path=str(source), sync=True, kind=ProjectKind.WEBSITE)
project_a = WorkspaceProject(name="local", repos=[repo])
project_b = WorkspaceProject(
⋮----
mount_a = workspace_root / "local" / "site"
mount_b = workspace_root / "mirror" / "site"
canonical = workspace_root / "_canonical"
canonical_dirs = [path for path in canonical.iterdir() if path.is_dir() or path.is_symlink()]
⋮----
def _fake_clone(url: str, target: str, progress=None) -> None:  # noqa: ARG001
⋮----
url = "https://github.com/example/remote.git"
project_a = WorkspaceProject(
⋮----
mount_a = workspace_root / "p1" / "remote"
mount_b = workspace_root / "p2" / "remote"
````

## File: tests/test_project_manager_prune.py
````python
#!/usr/bin/env python
"""Tests for unmanaged sync directory listing and prune helpers."""
⋮----
class _DummyLogger
⋮----
def set_level(self, _: str) -> None
⋮----
def warning(self, _: str) -> None
⋮----
def debug(self, _: str) -> None
⋮----
def _config_one_repo() -> MetagitConfig
⋮----
def test_list_unmanaged_sync_directories_excludes_managed(tmp_path: Path) -> None
⋮----
workspace_root = tmp_path / ".metagit"
proj = workspace_root / "platform"
⋮----
mgr = ProjectManager(workspace_root, _DummyLogger())
unmanaged = mgr.list_unmanaged_sync_directories(
⋮----
hidden_on = mgr.list_unmanaged_sync_directories(
⋮----
hidden_off = mgr.list_unmanaged_sync_directories(
⋮----
"""Dot-directories should not appear in the fuzzy finder when ignore_hidden is true."""
⋮----
project_root = workspace_root / "platform"
⋮----
captured: dict = {}
⋮----
class _DummyFinder
⋮----
def __init__(self, config) -> None
⋮----
def run(self)
⋮----
_ = mgr.select_repo(
names = [item.name for item in captured["config"].items]
````

## File: tests/test_project_manager_select_repo.py
````python
#!/usr/bin/env python
"""
Unit tests for ProjectManager.select_repo behavior.
"""
⋮----
class _DummyLogger
⋮----
def set_level(self, _: str) -> None
⋮----
def warning(self, _: str) -> None
⋮----
def debug(self, _: str) -> None
⋮----
def _build_metagit_config() -> MetagitConfig
⋮----
def test_select_repo_respects_gitignore_and_sets_total_count(tmp_path, monkeypatch) -> None
⋮----
workspace_root = tmp_path / "workspace"
project_root = workspace_root / "proj-one"
⋮----
captured = {}
⋮----
class _DummyFinder
⋮----
def __init__(self, config) -> None
⋮----
def run(self) -> Optional[ProjectPath]
⋮----
manager = ProjectManager(workspace_root, _DummyLogger())
_ = manager.select_repo(_build_metagit_config(), "proj-one", show_preview=True)
⋮----
finder_config = captured["config"]
item_names = [item.name for item in finder_config.items]
⋮----
def test_select_repo_preview_contains_extended_metadata(tmp_path, monkeypatch) -> None
⋮----
target_item = next(
preview = target_item.description
````

## File: tests/test_project_source_models.py
````python
#!/usr/bin/env python
"""
Tests for source sync input model validation.
"""
⋮----
def test_source_spec_accepts_github_org() -> None
⋮----
spec = SourceSpec(provider=SourceProvider.GITHUB, org="metagit-ai")
⋮----
def test_source_spec_rejects_invalid_github_scope() -> None
⋮----
def test_source_spec_accepts_gitlab_group() -> None
⋮----
spec = SourceSpec(provider=SourceProvider.GITLAB, group="my-group/sub-group")
````

## File: tests/test_project_source_sync.py
````python
#!/usr/bin/env python
"""
Tests for source sync planner and applier.
"""
⋮----
def _service() -> SourceSyncService
⋮----
def test_plan_additive_adds_missing_repo() -> None
⋮----
service = _service()
spec = SourceSpec(provider=SourceProvider.GITHUB, org="metagit-ai")
project = WorkspaceProject(name="default", repos=[])
discovered = [
plan = service.plan(spec, project, discovered, SourceSyncMode.ADDITIVE)
⋮----
def test_plan_reconcile_removes_unmatched_provider_managed_repo() -> None
⋮----
project = WorkspaceProject(
⋮----
plan = service.plan(spec, project, discovered, SourceSyncMode.RECONCILE)
⋮----
def test_apply_plan_reconcile_preserves_protected_repo() -> None
⋮----
plan = service.plan(
updated = service.apply_plan(project, plan, SourceSyncMode.RECONCILE)
````

## File: tests/test_utils_fuzzyfinder.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.fuzzyfinder
"""
⋮----
def test_fuzzyfinder_basic()
⋮----
collection = ["apple", "banana", "grape", "apricot"]
results = list(fuzzyfinder.fuzzyfinder("ap", collection))
⋮----
def test_fuzzyfinder_empty()
⋮----
def test_fuzzyfinder_no_match()
⋮----
collection = ["cat", "dog"]
⋮----
def test_fuzzyfinder_app_search_not_capped_by_max_results()
⋮----
config = FuzzyFinderConfig(items=["a", "b", "c"], max_results=1)
app = FuzzyFinderApp(config)
results = app._search("")
````

## File: tests/test_workspace_dedupe.py
````python
#!/usr/bin/env python
"""Tests for workspace repository deduplication helpers."""
⋮----
def test_build_repo_identity_same_url_different_projects() -> None
⋮----
url = "https://github.com/example/org-repo.git"
left = ProjectPath(name="a", url=url)
right = ProjectPath(name="b", url=url + "/")
⋮----
def test_build_repo_identity_branch_suffix_differs() -> None
⋮----
base = ProjectPath(name="svc", url="https://github.com/example/svc.git")
branched = ProjectPath(
⋮----
def test_find_duplicate_identities_reports_existing() -> None
⋮----
shared = ProjectPath(
config = MetagitConfig(
incoming = ProjectPath(
matches = workspace_dedupe.find_duplicate_identities(config, incoming)
⋮----
def test_ensure_symlink_creates_and_repairs(tmp_path: Path) -> None
⋮----
target = tmp_path / "canonical"
⋮----
mount = tmp_path / "project" / "repo"
⋮----
broken = tmp_path / "broken-link"
⋮----
def test_list_orphan_canonical_dirs(tmp_path: Path) -> None
⋮----
dedupe = WorkspaceDedupeConfig(enabled=True, canonical_dir="_canonical")
workspace_root = tmp_path / ".metagit"
canonical_root = workspace_root / "_canonical"
⋮----
references = {"used-key": [("alpha", "repo")]}
orphans = workspace_dedupe.list_orphan_canonical_dirs(
````

## File: web/public/favicon.svg
````xml
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
````

## File: web/public/icons.svg
````xml
<svg xmlns="http://www.w3.org/2000/svg">
  <symbol id="bluesky-icon" viewBox="0 0 16 17">
    <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
    <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
  </symbol>
  <symbol id="discord-icon" viewBox="0 0 20 19">
    <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
  </symbol>
  <symbol id="documentation-icon" viewBox="0 0 21 20">
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
  </symbol>
  <symbol id="github-icon" viewBox="0 0 19 19">
    <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
  </symbol>
  <symbol id="social-icon" viewBox="0 0 20 20">
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
  </symbol>
  <symbol id="x-icon" viewBox="0 0 19 19">
    <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
  </symbol>
</svg>
````

## File: web/src/assets/react.svg
````xml
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
````

## File: web/src/assets/vite.svg
````xml
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="29.961" y="-17.13" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-13.43" y="-22.082" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="34.321" y="-37.644" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="38.847" y="-10.552" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="22.45" y="-1.645" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="32.919" y="11.36" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter></defs></svg>
````

## File: web/src/components/ConfigPreview.module.css
````css
.previewPanel {
⋮----
.header {
⋮----
.title {
⋮----
.controls {
⋮----
.select {
⋮----
.badge {
⋮----
.badgeInvalid {
⋮----
.codeWrap {
⋮----
.code {
⋮----
.state {
⋮----
.errors {
````

## File: web/src/components/ConfigPreview.tsx
````typescript
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import type { ConfigOperation, ConfigPreviewStyle } from '../api/client'
import { fetchConfigPreview, type ConfigTarget } from '../pages/configQueries'
import styles from './ConfigPreview.module.css'
⋮----
interface ConfigPreviewProps {
  target: ConfigTarget
  pendingOps: ConfigOperation[]
}
````

## File: web/src/components/FieldEditor.module.css
````css
.panel {
⋮----
.empty {
⋮----
.header {
⋮----
.title {
⋮----
.path {
⋮----
.meta {
⋮----
.badge {
⋮----
.description {
⋮----
.hint {
⋮----
.field {
⋮----
.label {
⋮----
.input,
⋮----
.input:focus,
⋮----
.input:disabled,
⋮----
.actions {
⋮----
.button {
⋮----
.button:hover:not(:disabled) {
⋮----
.button:disabled {
⋮----
.buttonPrimary {
⋮----
.buttonPrimary:hover:not(:disabled) {
⋮----
.errors {
⋮----
.errors li {
⋮----
.errors li:first-child {
⋮----
.errors li:last-child {
⋮----
.status {
````

## File: web/src/components/GraphDiagram.module.css
````css
.wrap {
⋮----
.legend {
⋮----
.legendItem {
⋮----
.legendSwatch {
⋮----
.canvasScroll {
⋮----
.canvas {
⋮----
.nodeProject {
⋮----
.nodeRepo {
⋮----
.nodeLabel {
⋮----
.edgeLabel {
⋮----
.empty {
````

## File: web/src/components/GraphDiagram.tsx
````typescript
import { useMemo } from 'react'
import type { GraphViewEdge, GraphViewNode } from '../api/client'
import styles from './GraphDiagram.module.css'
⋮----
export interface GraphDiagramProps {
  nodes: GraphViewNode[]
  edges: GraphViewEdge[]
  manualEdgeCount: number
  inferredEdgeCount: number
  structureEdgeCount: number
}
⋮----
interface LayoutNode {
  node: GraphViewNode
  x: number
  y: number
  width: number
  height: number
}
⋮----
function edgeStroke(source: GraphViewEdge['source'], type: string): string
⋮----
function computeLayout(nodes: GraphViewNode[]): Map<string, LayoutNode>
⋮----
function center(layout: LayoutNode):
````

## File: web/src/components/Layout.module.css
````css
.shell {
⋮----
.header {
⋮----
.title {
⋮----
.nav {
⋮----
.navLink {
⋮----
.navLink:hover {
⋮----
.navLinkActive {
⋮----
.themeToggle {
⋮----
.themeToggle:hover {
⋮----
.themeIcon {
⋮----
.main {
````

## File: web/src/components/OpsPanel.module.css
````css
.panel {
⋮----
.heading {
⋮----
.section {
⋮----
.sectionTitle {
⋮----
.field {
⋮----
.label {
⋮----
.select {
⋮----
.button {
⋮----
.button:hover:not(:disabled) {
⋮----
.button:disabled {
⋮----
.buttonPrimary {
⋮----
.buttonPrimary:hover:not(:disabled) {
⋮----
.buttonDanger {
⋮----
.buttonDanger:hover:not(:disabled) {
⋮----
.hint {
⋮----
.candidateList {
⋮----
.candidateList li {
⋮----
.candidateList li:last-child {
⋮----
.checkboxRow {
⋮----
.status {
⋮----
.statusError {
⋮----
.divider {
⋮----
.overlay {
⋮----
.modal {
⋮----
.modalTitle {
⋮----
.summaryGrid {
⋮----
.summaryChip {
⋮----
.summaryChip strong {
⋮----
.recommendations {
⋮----
.recommendations li {
⋮----
.severityCritical {
⋮----
.severityWarning {
⋮----
.severityInfo {
⋮----
.repoTable {
⋮----
.repoTable th,
⋮----
.repoTable th {
⋮----
.modalActions {
````

## File: web/src/components/OpsPanel.tsx
````typescript
import { useMemo, useState } from 'react'
import {
  ApiError,
  postHealth,
  postPrune,
  postPrunePreview,
  type PruneCandidate,
  type WorkspaceHealthResult,
  type WorkspaceProjectEntry,
} from '../api/client'
import styles from './OpsPanel.module.css'
⋮----
export interface OpsPanelProps {
  projects: WorkspaceProjectEntry[]
  onWorkspaceRefresh?: () => void
}
⋮----
const runHealth = async () =>
⋮----
const runPrunePreview = async () =>
⋮----
const runPruneExecute = async () =>
⋮----
onClick=
⋮----
````

## File: web/src/components/RepoTable.module.css
````css
.tableWrap {
⋮----
.table {
⋮----
.table th {
⋮----
.table td {
⋮----
.projectRow td {
⋮----
.projectHeader {
⋮----
.expandButton {
⋮----
.expandButton:hover {
⋮----
.projectMeta {
⋮----
.repoName {
⋮----
.path {
⋮----
.badge {
⋮----
.badgeSynced {
⋮----
.badgeMissing {
⋮----
[data-theme='dark'] .badgeMissing {
⋮----
.actions {
⋮----
.button {
⋮----
.button:hover {
⋮----
.buttonPrimary {
⋮----
.buttonPrimary:hover {
⋮----
.empty {
````

## File: web/src/components/RepoTable.tsx
````typescript
import { useMemo, useState } from 'react'
import type { WorkspaceProjectEntry, WorkspaceRepoIndexRow } from '../api/client'
import { repoSelector, type StatusFilter } from '../pages/workspaceQueries'
import styles from './RepoTable.module.css'
⋮----
export interface RepoTableProps {
  projects: WorkspaceProjectEntry[]
  reposIndex: WorkspaceRepoIndexRow[]
  statusFilter: StatusFilter
  search: string
  onSync: (repos: string[], title: string) => void
}
⋮----
interface ProjectGroup {
  project: WorkspaceProjectEntry
  repos: WorkspaceRepoIndexRow[]
}
⋮----
function matchesFilter(
  row: WorkspaceRepoIndexRow,
  statusFilter: StatusFilter,
): boolean
⋮----
function matchesSearch(row: WorkspaceRepoIndexRow, search: string): boolean
⋮----
const toggleProject = (name: string) =>
⋮----
interface ProjectSectionProps {
  project: WorkspaceProjectEntry
  repos: WorkspaceRepoIndexRow[]
  collapsed: boolean
  onToggle: () => void
  onSync: (repos: string[], title: string) => void
  onSyncAll: () => void
}
⋮----
onSync(
````

## File: web/src/components/SyncDialog.module.css
````css
.overlay {
⋮----
.dialog {
⋮----
.title {
⋮----
.subtitle {
⋮----
.field {
⋮----
.label {
⋮----
.select {
⋮----
.checkboxRow {
⋮----
.actions {
⋮----
.button {
⋮----
.button:hover:not(:disabled) {
⋮----
.button:disabled {
⋮----
.buttonPrimary {
⋮----
.buttonPrimary:hover:not(:disabled) {
⋮----
.status {
⋮----
.statusError {
⋮----
.summary {
⋮----
.summary li {
````

## File: web/src/components/SyncDialog.tsx
````typescript
import { useCallback, useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
  ApiError,
  getSyncJob,
  postSync,
  type SyncJobRequest,
} from '../api/client'
import { workspaceQueryKey } from '../pages/workspaceQueries'
import styles from './SyncDialog.module.css'
⋮----
export interface SyncDialogProps {
  open: boolean
  title: string
  repos: string[]
  onClose: () => void
}
⋮----
type Phase = 'idle' | 'running' | 'done' | 'error'
⋮----
const poll = async () =>
⋮----
const handleSubmit = async () =>
⋮----

⋮----
setMode(event.target.value as SyncJobRequest['mode'])
````

## File: web/src/pages/graphQueries.ts
````typescript
import { getWorkspaceGraph, type WorkspaceGraphView } from '../api/client'
⋮----
export const graphQueryKey = (includeInferred: boolean, includeStructure: boolean)
⋮----
export async function fetchWorkspaceGraph(
  includeInferred: boolean,
  includeStructure: boolean,
): Promise<WorkspaceGraphView>
````

## File: web/src/pages/workspaceQueries.ts
````typescript
import { getWorkspace, type WorkspaceData } from '../api/client'
⋮----
export async function fetchWorkspace(): Promise<WorkspaceData>
⋮----
export type StatusFilter = 'all' | 'synced' | 'missing'
⋮----
export function repoSelector(projectName: string, repoName: string): string
````

## File: web/src/theme/ThemeProvider.tsx
````typescript
import { useEffect, type ReactNode } from 'react'
import { useThemeStore } from './useThemeStore'
⋮----
interface ThemeProviderProps {
  children: ReactNode
}
⋮----
export default function ThemeProvider(
⋮----
const onChange = ()
````

## File: web/src/theme/useThemeStore.ts
````typescript
import { create } from 'zustand'
⋮----
export type ThemeMode = 'light' | 'dark' | 'system'
⋮----
function readStoredMode(): ThemeMode
⋮----
function resolveTheme(mode: ThemeMode): 'light' | 'dark'
⋮----
function applyTheme(mode: ThemeMode): void
⋮----
interface ThemeState {
  mode: ThemeMode
  resolved: 'light' | 'dark'
  setMode: (mode: ThemeMode) => void
  toggleResolved: () => void
  init: () => void
  syncSystemTheme: () => void
}
````

## File: web/.gitignore
````
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
````

## File: web/eslint.config.js
````javascript

````

## File: web/index.html
````html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>web</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
````

## File: web/package.json
````json
{
  "name": "web",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@tanstack/react-query": "^5.100.11",
    "react": "^19.2.6",
    "react-dom": "^19.2.6",
    "react-router-dom": "^7.15.1",
    "zustand": "^5.0.13"
  },
  "devDependencies": {
    "@eslint/js": "^10.0.1",
    "@types/node": "^24.12.3",
    "@types/react": "^19.2.14",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^6.0.1",
    "eslint": "^10.3.0",
    "eslint-plugin-react-hooks": "^7.1.1",
    "eslint-plugin-react-refresh": "^0.5.2",
    "globals": "^17.6.0",
    "typescript": "~6.0.2",
    "typescript-eslint": "^8.59.2",
    "vite": "^8.0.12"
  }
}
````

## File: web/README.md
````markdown
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      // Other configs...

      // Remove tseslint.configs.recommended and replace with this
      tseslint.configs.recommendedTypeChecked,
      // Alternatively, use this for stricter rules
      tseslint.configs.strictTypeChecked,
      // Optionally, add this for stylistic rules
      tseslint.configs.stylisticTypeChecked,

      // Other configs...
    ],
    languageOptions: {
      parserOptions: {
        project: ['./tsconfig.node.json', './tsconfig.app.json'],
        tsconfigRootDir: import.meta.dirname,
      },
      // other options...
    },
  },
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      // Other configs...
      // Enable lint rules for React
      reactX.configs['recommended-typescript'],
      // Enable lint rules for React DOM
      reactDom.configs.recommended,
    ],
    languageOptions: {
      parserOptions: {
        project: ['./tsconfig.node.json', './tsconfig.app.json'],
        tsconfigRootDir: import.meta.dirname,
      },
      // other options...
    },
  },
])
```
````

## File: web/tsconfig.app.json
````json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "es2023",
    "lib": ["ES2023", "DOM"],
    "module": "esnext",
    "types": ["vite/client"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}
````

## File: web/tsconfig.json
````json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}
````

## File: web/tsconfig.node.json
````json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "es2023",
    "lib": ["ES2023"],
    "module": "esnext",
    "types": ["node"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,

    /* Linting */
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["vite.config.ts"]
}
````

## File: web/vite.config.ts
````typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
⋮----
// https://vite.dev/config/
````

## File: .env.example
````
# Git Provider API Tokens
GITHUB_TOKEN="ghp_your_github_token_here"
GITHUB_URL="https://github.company.com/api/v3"
GITLAB_TOKEN="glpat_your_gitlab_token_here"
GITLAB_URL="https://gitlab.company.com/api/v4"

# Agent-optimized mode: no fuzzy finder, prompts, or editor (true/false)
# METAGIT_AGENT_MODE=false

METAGIT_LLM="openrouter"
METAGIT_LLM_MODEL="qwen/qwen2.5-vl-72b-instruct:free"
METAGIT_LLM_TOKEN=""

# OPENAI_API_KEY=""
# OPENROUTER_API_KEY=""
# OPENAI_MODEL_NAME="openrouter/nousresearch/deephermes-3-mistral-24b-preview:free"
# OPENAI_API_BASE="https://openrouter.ai/api/v1"
# OPENAI_PROXY_MODELS="openrouter/nousresearch/deephermes-3-mistral-24b-preview:free"

# GitLab Personal Access Token for repository metrics
#GITLAB_TOKEN="glpat_your_gitlab_token_here"
# Custom API URLs (for self-hosted instances)
#GITHUB_URL="https://github.company.com/api/v3"
#GITLAB_URL="https://gitlab.company.com/api/v4"
````

## File: .gitsecrets.lock
````
{
  "version": "1.0",
  "secrets": {},
  "secretfile": {
    "filename": "Secretfile.yml",
    "hash": "34ce5cc41aaab6f6832b541070a1aa8405b5f2262356b2e20caa49ba00c61209",
    "synced_at": "2026-05-06T16:59:13.263343+00:00",
    "var_files": [],
    "variables_hash": "cd258ea8b5adce1d02de944cab545b051be7a2d1da5f593790747636fa5f37da",
    "sync_identity": {
      "client": "cli",
      "secretzero_version": "0.13.3",
      "os_user": "zacharyloeber",
      "os_uid": 501,
      "os_euid": 501,
      "hostname": "Zacharys-MBP.home.loeber.live",
      "host_fqdn": "Zacharys-MBP.home.loeber.live",
      "platform": "macOS-26.3.1-arm64-arm-64bit",
      "git_user_name": "Zachary Loeber",
      "git_user_email": "zloeber@gmail.com",
      "git_commit_sha": "cafd6da"
    },
    "manifest_spec_version": "1"
  },
  "metadata": {}
}
````

## File: .python-version
````
3.12
````

## File: configure.sh
````bash
#!/usr/bin/env bash

# install mise if not already installed to local path
if ! command -v ~/.local/bin/mise &>/dev/null; then
    echo "mise is not installed. Would you like to install it now? (y/N)"
    read -r response
    if [[ "$response" =~ ^[Yy]$ ]]; then
        curl https://mise.run | sh
        echo ""
        echo "Load mise into your path (add to your ~/.zshrc profile to always be available): "
        echo '   echo 'eval "$(~/.local/bin/mise activate zsh)"' >> ~/.zshrc'
        # Load mise into the current script shell instead of zsh
        eval "$(~/.local/bin/mise activate bash)"
    else
        echo "Skipping mise installation."
        exit 1
    fi
else
    eval "$(~/.local/bin/mise activate bash)"
fi

mise install -y
uv sync
````

## File: LICENSE.md
````markdown
MIT License

Copyright (c) 2025 Zachary Loeber

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
````

## File: Secretfile.yml
````yaml
variables:
  github_org: metagit-ai
  github_repo: metagit-cli

providers:
  github:
    kind: github
    auth:
      kind: token
      config:
        token: ${METAGIT_GITHUB_TOKEN}

secrets:
  # ── Release token with write access to contents & PRs ──────────────
  - name: release_token
    kind: static
    config:
      default: ${PAT_TOKEN}
    targets:
      - provider: github
        kind: github_secret
        config:
          owner: ${github_org}
          repo: ${github_repo}
          secret_name: PAT_TOKEN

metadata:
  project: metagit-cli
  owner: metagit-ai
  description: PyPI API tokens for metagit-cli package publishing
  environments:
  - testpypi
  - production
````

## File: .cursor/skills/metagit-cli/SKILL.md
````markdown
---
name: metagit-cli
description: CLI-only shortcuts for metagit agents — workspace catalog, discovery, prompts, sync, layout, and config. Use instead of MCP or HTTP API when operating from a shell or agent_mode session.
---

# Metagit CLI (agent shortcuts)

Use this skill when an agent should drive metagit **only through the `metagit` command**. Do not call MCP tools or `metagit api` from workflows covered here unless the user explicitly asks.

Set non-interactive defaults when automating:

```bash
export METAGIT_AGENT_MODE=true
```

Global flags (most commands):

- `-c path/to/metagit.config.yaml` — app config (default `metagit.config.yaml`)
- Workspace manifest: `--definition` / `-c` on catalog commands (default `.metagit.yml`)

---

## Manifest editing fast map (`.metagit.yml`)

Use this table first when changing a workspace manifest from the CLI. Prefer **catalog commands** for projects/repos; use **`config patch`** for everything else in the schema (documentation, graph, dedupe overrides, nested fields).

| Task | Command |
|------|---------|
| **Inspect** manifest on disk | `metagit config show -c .metagit.yml` |
| **Inspect** normalized model | `metagit config show -c .metagit.yml --normalized` |
| **Inspect** as JSON (agents) | `metagit config show -c .metagit.yml --json` |
| **Browse** fields / paths | `metagit config tree -c .metagit.yml` or `… --json` |
| **Validate** after edits | `metagit config validate -c .metagit.yml` |
| **Dry-run** schema change | `metagit config preview -c .metagit.yml --file ops.json` |
| **Apply** schema change | `metagit config patch -c .metagit.yml --file ops.json --save` |
| **Set one field** | `metagit config patch -c .metagit.yml --op set --path <path> --value <v> --save` |
| **Enable** optional block | `metagit config patch … --op enable --path <path> --save` |
| **Add list item** | `metagit config patch … --op append --path <list.path> --save` then `set` on `[index].field` |
| **Remove list item** | `metagit config patch … --op remove --path <list.path>[index] --save` |

### Catalog shortcuts (projects & repos)

| Task | Command |
|------|---------|
| List projects | `metagit workspace project list -c .metagit.yml --json` |
| List repos (all / one project) | `metagit workspace repo list -c .metagit.yml --json` / `… --project <p> --json` |
| Add project | `metagit workspace project add -c .metagit.yml --name <p> --json` |
| Remove project | `metagit workspace project remove -c .metagit.yml --name <p> --json` |
| Rename project (dry-run) | `metagit workspace project rename -c .metagit.yml --name <old> --new-name <new> --dry-run --json` |
| Add repo | `metagit workspace repo add -c .metagit.yml --project <p> --name <r> --url <url> --json` |
| Remove repo (manifest only) | `metagit workspace repo remove -c .metagit.yml --project <p> --name <r> --json` |
| Rename / move repo (dry-run) | `metagit workspace repo rename …` / `metagit workspace repo move … --dry-run --json` |
| Search before adding | `metagit search "<name>" -c .metagit.yml --json` |

Active project context (optional): `metagit project repo add --name <r> --url <url>` after `metagit project select`.

### Schema patch examples (`ops.json`)

Paths use dot/bracket notation (same as web Config Studio). `--value` accepts JSON for objects.

```json
{
  "operations": [
    { "op": "set", "path": "name", "value": "my-workspace" },
    { "op": "enable", "path": "documentation" },
    { "op": "append", "path": "documentation" },
    { "op": "set", "path": "documentation[0]", "value": { "kind": "markdown", "path": "AGENTS.md" } },
    { "op": "enable", "path": "graph" },
    { "op": "append", "path": "graph.relationships" },
    {
      "op": "set",
      "path": "graph.relationships[0]",
      "value": {
        "id": "api-depends-infra",
        "from": { "project": "platform", "repo": "api" },
        "to": { "project": "infra", "repo": "terraform" },
        "type": "depends_on"
      }
    },
    { "op": "set", "path": "workspace.projects[0].dedupe.enabled", "value": false }
  ]
}
```

```bash
metagit config preview -c .metagit.yml --file ops.json
metagit config patch -c .metagit.yml --file ops.json --save
metagit config validate -c .metagit.yml
```

### App config vs manifest

| File | Scope | Edit via |
|------|-------|----------|
| `.metagit.yml` | Workspace manifest (projects, repos, docs, graph) | `metagit config …` + `metagit workspace …` |
| `metagit.config.yaml` | Tooling (paths, dedupe default, providers, profiles) | `metagit appconfig …` |

```bash
metagit appconfig show --format minimal-yaml
metagit appconfig patch --op set --path workspace.dedupe.enabled --value false --save
metagit config providers --show
```

### After every manifest edit

1. `metagit config validate -c .metagit.yml`
2. `metagit workspace list -c .metagit.yml --json` (sanity-check catalog)
3. If repos changed on disk: `metagit project sync` or `metagit project sync --hydrate`

### Export manual graph to GitNexus (Cypher)

| Task | Command |
|------|---------|
| Full export bundle (JSON) | `metagit config graph export -c .metagit.yml --json` |
| Raw Cypher script | `metagit config graph export -c .metagit.yml --format cypher -o graph.cypher` |
| MCP tool_calls only | `metagit config graph export -c .metagit.yml --format tool-calls` |
| Manual edges only | `metagit config graph export -c .metagit.yml --manual-only --format tool-calls` |
| MCP from agent | `metagit_export_workspace_graph_cypher` |

Ingest workflow: run `schema_statements` once via `gitnexus_cypher`, then each statement in `tool_calls` (or pipe `--format cypher`). Overlay tables: `MetagitEntity`, `MetagitLink`.

---

## Prompt commands (all kinds)

List built-in prompt kinds:

```bash
metagit prompt list
metagit prompt list --json
```

Emit prompts (`--text-only` for paste into agent context; `--json` for structured output; `--no-instructions` to omit manifest layers):

| Scope | Command | Default kind |
|-------|---------|--------------|
| Workspace | `metagit prompt workspace -c <definition> -k <kind>` | `instructions` |
| Project | `metagit prompt project -p <project> -c <definition> -k <kind>` | `instructions` |
| Repo | `metagit prompt repo -p <project> -n <repo> -c <definition> -k <kind>` | `instructions` |

### Prompt kinds by scope

| Kind | Workspace | Project | Repo | Purpose |
|------|:---------:|:-------:|:----:|---------|
| `instructions` | yes | yes | yes | Composed `agent_instructions` from manifest layers |
| `session-start` | yes | — | — | Session bootstrap checklist |
| `catalog-edit` | yes | yes | — | Search-before-create; catalog registration |
| `health-preflight` | yes | yes | — | Pre-work workspace/repo status pass |
| `sync-safe` | yes | yes | yes | Guarded sync rules |
| `subagent-handoff` | — | yes | yes | Delegate single-repo work |
| `layout-change` | yes | yes | yes | Rename/move dry-run workflow |
| `repo-enrich` | — | — | yes | **Discover + merge** workspace repo entry |

### Prompt shortcuts (copy-paste)

```bash
# Session bootstrap
metagit prompt workspace -k session-start --text-only -c .metagit.yml

# Composed instructions at each level
metagit prompt workspace -k instructions --text-only -c .metagit.yml
metagit prompt project -p default -k instructions --text-only -c .metagit.yml
metagit prompt repo -p default -n my-api -k instructions --text-only -c .metagit.yml

# Repo catalog enrichment (detect + merge manifest entry)
metagit prompt repo -p default -n my-api -k repo-enrich --text-only -c .metagit.yml

# Catalog registration discipline
metagit prompt workspace -k catalog-edit --text-only -c .metagit.yml

# Safe sync reminder
metagit prompt repo -p default -n my-api -k sync-safe --text-only -c .metagit.yml

# Subagent handoff
metagit prompt repo -p default -n my-api -k subagent-handoff --text-only -c .metagit.yml
```

---

## Repo enrich workflow (`repo-enrich`)

Run the prompt, then execute its steps:

```bash
metagit prompt repo -p <project> -n <repo> -k repo-enrich --text-only -c .metagit.yml
```

Typical discovery chain on the checkout:

```bash
cd "$(metagit search '<repo>' -c .metagit.yml --path-only)"
metagit detect repository -p . -o json
metagit detect repo -p . -o yaml
metagit detect repo_map -p . -o json
```

Provider metadata (dry-run):

```bash
metagit project source sync --provider github --org <org> --mode discover --no-apply
```

After merging fields into `workspace.projects[].repos[]`:

```bash
metagit config validate -c .metagit.yml
metagit workspace repo list --project <project> --json
```

---

## Workspace and catalog

Per-project dedupe override in `.metagit.yml` (overrides `workspace.dedupe.enabled` in `metagit.config.yaml` for that project only):

```yaml
workspace:
  projects:
    - name: local
      dedupe:
        enabled: false
      repos: []
```

```bash
metagit appconfig show --format json
metagit config info -c .metagit.yml
metagit config show -c .metagit.yml
metagit config validate -c .metagit.yml

metagit workspace list -c .metagit.yml --json
metagit workspace project list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --project <name> --json

metagit workspace project add --name <name> --json
metagit workspace repo add --project <name> --name <repo> --url <url> --json
metagit workspace project remove --name <name> --json
metagit workspace repo remove --project <name> --name <repo> --json
```

Search managed repos (always before creating entries):

```bash
metagit search "<query>" -c .metagit.yml --json
metagit search "<query>" -c .metagit.yml --path-only
metagit search "<query>" -c .metagit.yml --tag tier=1 --project <name>
```

---

## Project operations

```bash
metagit project list --config .metagit.yml --all --json
metagit project add --name <name> --json
metagit project remove --name <name> --json
metagit project rename --name <old> --new-name <new> --dry-run --json
metagit project select
metagit project sync
metagit project sync --hydrate   # symlink mounts → full directory copies (per-file progress)

metagit project repo list --json
metagit project repo add --project <name> --name <repo> --url <url>
metagit project repo remove --name <repo> --json
metagit project repo rename --name <old> --new-name <new> --dry-run --json
metagit project repo move --name <repo> --to-project <other> --dry-run --json
metagit project repo prune --project <name> --dry-run

metagit project source sync --provider github --org <org> --mode discover --no-apply
metagit project source sync --provider github --org <org> --mode additive --apply
```

Layout (manifest + disk; always dry-run first):

```bash
metagit workspace project rename --name <old> --new-name <new> --dry-run --json
metagit workspace repo rename --project <p> --name <old> --new-name <new> --dry-run --json
metagit workspace repo move --project <p> --name <repo> --to-project <other> --dry-run --json
```

---

## Discovery and local metadata

```bash
metagit detect project -p <path> -o yaml
metagit detect repo -p <path> -o yaml
metagit detect repo_map -p <path> -o json
metagit detect repository -p <path> -o json
metagit detect repository -p <path> -o metagit
# --save only with operator approval (blocked in agent_mode)
```

Bootstrap new trees:

```bash
metagit init --kind application
metagit init --kind umbrella --template hermes-orchestrator
```

---

## Selection and scope

```bash
metagit workspace select --project <name>
metagit project select
metagit project repo select
```

---

## Config and appconfig (reference)

See **Manifest editing fast map** above for day-to-day manifest work. Additional commands:

```bash
metagit config info -c .metagit.yml
metagit config example
metagit config schema
metagit appconfig validate
metagit appconfig get --name config.workspace.path
metagit appconfig tree --json
metagit appconfig preview --file ops.json
metagit appconfig patch --file ops.json --save
metagit config providers --show
```

---

## Records, skills, version

```bash
metagit record search "<query>"
metagit skills list
metagit skills show metagit-cli
metagit skills install --skill metagit-cli
metagit version
metagit info
```

---

## Agent habits

1. **Search before create** — `metagit search` then catalog add.
2. **Validate after manifest edits** — `metagit config validate`.
3. **Emit prompts instead of rewriting playbooks** — `metagit prompt … --text-only`.
4. **Enrich stale repo entries** — `metagit prompt repo … -k repo-enrich` then detect + merge.
5. **Dry-run layout** — always `--dry-run --json` before apply.
6. **Prefer `METAGIT_AGENT_MODE=true`** in CI and agent loops to skip fuzzy finder and confirm dialogs.

## Related bundled skills

Use topic skills when you need deeper playbooks (some mention MCP): `metagit-projects`, `metagit-workspace-scope`, `metagit-workspace-sync`, `metagit-config-refresh`. This skill is the **CLI-only** index and prompt reference.
````

## File: .cursor/skills/metagit-control-center/scripts/control-cycle.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-infra}"

"$(dirname "$0")/../../metagit-gating/scripts/gate-status.zsh" "$ROOT"

if [[ -n "$QUERY" ]]; then
  "$(dirname "$0")/../../metagit-upstream-scan/scripts/upstream-scan.zsh" "$ROOT" "$QUERY" "$PRESET" 20
else
  echo "status=ok\tmessage=no-query-provided"
fi
````

## File: .cursor/skills/metagit-control-center/SKILL.md
````markdown
---
name: metagit-control-center
description: Use when running metagit as an MCP control center for multi-repo awareness, guarded sync, and operational knowledge across ongoing agent tasks.
---

# Metagit Control Center Skill

Use this skill when an agent should actively coordinate repository context and task execution across a workspace.

## Purpose

Provide a repeatable control-center workflow where Metagit MCP guides awareness, synchronization, and operational continuity over multiple related repositories.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for control-center cycles:
- `./scripts/control-cycle.zsh [root_path] ["query"] [preset]`

Wrapper behavior:
- runs gating status first
- optionally runs upstream discovery for blocker queries
- emits compact, machine-readable lines

## Core Workflows

### 1) Session Initialization
- Validate active workspace gate.
- Read `metagit://workspace/config` and `metagit://workspace/repos/status`.
- Call `metagit_project_context_switch` when the objective is tied to a workspace project.
- Run `metagit_workspace_health_check` or read `metagit://workspace/health` for maintenance signals.
- Identify stale repos and unresolved blockers from prior activity.

### 2) Active Task Support
- For each coding objective, map impacted repos.
- Use workspace search and upstream hints before broad exploration.
- Sync only repos that are required by the active objective.

### 3) Guarded Synchronization
- Default to `fetch` for visibility.
- Use `pull` or `clone` only with explicit permission and rationale.
- Track sync outcomes in operations log resource.

### 4) Operational Memory
- Before switching projects: `metagit_session_update` (notes + recent repos), optional `metagit_workspace_state_snapshot`.
- After returning: `metagit_workspace_state_restore` when a snapshot was taken (metadata only; git tree is unchanged).

Maintain bounded local records of:
- sync actions
- issue signatures searched
- candidate upstream repos identified
- unresolved dependencies and follow-ups

## Decision Guidelines

- Use metagit search first when blocker appears external to current repo.
- Prefer deterministic evidence over speculative jumps.
- Keep operations minimal and auditable.

## Output Contract

For each control-center cycle, provide:
- current objective
- repositories examined
- actions taken (or intentionally deferred)
- next recommended step

## Safety Rules

- Never mutate repositories without explicit authorization.
- Never broaden scope beyond configured workspace boundaries.
- Always preserve a clear audit trail of control actions.
````

## File: .cursor/skills/metagit-gitnexus/SKILL.md
````markdown
---
name: metagit-gitnexus
description: Run gitnexus analysis for a target workspace and selected project repositories before graph-dependent tasks. Use when index staleness is detected or cross-repo graph results are needed.
---

# Running GitNexus Analysis

Use this skill whenever GitNexus context is stale or missing for target repositories.

## Local Wrapper (Use First)

- `zsh ./skills/metagit-gitnexus/scripts/analyze-targets.zsh <workspace_root> [project_name]`

## Workflow

1. Analyze the current repository where the command is run.
2. Resolve target project repositories from `.metagit.yml`.
3. Run `npx gitnexus analyze` in each local repository path found.
4. Report per-repository success/failure and next actions.

## Manual workspace graph (`.metagit.yml` → Cypher)

Export manifest `graph.relationships` for GitNexus overlay ingest:

```bash
metagit config graph export -c .metagit.yml --format tool-calls
# or MCP: metagit_export_workspace_graph_cypher
```

Run returned `gitnexus_cypher` tool calls against the umbrella repo index (`--gitnexus-repo` when names differ). Schema DDL (`MetagitEntity` / `MetagitLink`) runs once per index.

## Output Contract

Return:
- analyzed repositories
- failures and reasons
- whether graph queries are safe to run now

## Safety

- Skip repositories that do not exist locally.
- Do not mutate repo content; analysis should be read-only indexing.
````

## File: .cursor/skills/metagit-repo-impact/SKILL.md
````markdown
---
name: metagit-repo-impact
description: Plan repository change impact before edits by combining metagit workspace context and graph-based dependency analysis. Use when a change may affect multiple repositories.
---

# Planning Repo Impact

Use this skill before risky or cross-repo modifications.

## Workflow

1. Identify target symbols/files for change.
2. Use metagit workspace context to bound repository scope.
3. Use graph impact tooling to estimate blast radius.
4. Produce a change plan with test and rollback focus.

## Commands

- `metagit workspace select --project <name>`
- MCP `metagit_cross_project_dependencies` with `source_project` and `dependency_types` before large cross-project edits
- MCP `metagit_project_context_switch` to bound scope to one workspace project
- `npx gitnexus analyze` on affected repos when `graph_status` is `stale` or `missing`
- `npx gitnexus query` / `gitnexus impact` for symbol-level analysis after indexes are fresh

## Output Contract

Return:
- impacted repositories and interfaces
- highest-risk change points
- minimum validation plan

## Safety

- Do not execute mutating steps in this planning phase.
````

## File: .cursor/skills/metagit-workspace-scope/SKILL.md
````markdown
---
name: metagit-workspace-scope
description: Discover active metagit workspace scope, project boundaries, and repository status. Use when an agent starts work in a multi-repo workspace and needs fast, scoped context before editing.
---

# Discovering Workspace Scope

Use this skill at session start for workspace-aware tasks.

## Workflow

1. Run workspace gate and status checks first.
2. Read configured projects and repository status from metagit resources/tools.
3. Build a compact map of active project names, repo names, and sync state.
4. Report a bounded scope for the current objective.

## Commands

- `zsh ./skills/metagit-gating/scripts/gate-status.zsh [root_path]`
- `metagit workspace select --project <name>` (interactive repo picker)
- MCP `metagit_project_context_switch` with `project_name` (structured context + session restore)
- MCP `metagit_workspace_state_snapshot` before leaving a project for a long switch

## Output Contract

Return:
- active/inactive workspace state and reason
- candidate project to operate on
- top repositories to inspect first

## Safety

- Keep operations read-only in this step.
- Do not sync or mutate repositories without explicit request.
````

## File: .cursor/skills/metagit-workspace-sync/SKILL.md
````markdown
---
name: metagit-workspace-sync
description: Sync workspace repositories safely using metagit with scoped fetch, pull, or clone actions. Use when repository content must be refreshed for implementation.
---

# Syncing Workspace Repositories

Use this skill when repository state must be updated.

## Workflow

1. Confirm active workspace and target project.
2. Start with read-only status and fetch where possible.
3. Use pull/clone only when required for the current objective.
4. Summarize which repositories changed and what remains stale.

## Commands

- `metagit project sync --project <name>`
- MCP `metagit_workspace_sync` for batch fetch/pull/clone (`repos`, `only_if`, `dry_run`, `max_parallel`)
- MCP `metagit_repo_sync` for a single repository (requires `allow_mutation` for pull/clone)
- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- repositories synced
- sync mode used (fetch/pull/clone)
- failures and retry guidance

## Safety

- Limit sync to repos defined in `.metagit.yml`.
- Avoid broad synchronization if only a subset is needed.
````

## File: .github/workflows/semantic-release.yaml
````yaml
name: Semantic Release
run-name: Semantic Release - ${{ github.ref_name }}

on:
  push:
    branches:
      - main
  workflow_dispatch:

env:
  PROJECT_NAME: metagit-cli

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: write

jobs:
  semantic-release:
    name: Generate Semantic Release Tag
    runs-on: ubuntu-latest
    environment:
      name: production

    steps:
      - name: Checkout code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          token: ${{ secrets.PAT_TOKEN }}

      - name: Set up Git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

      - name: Get previous tag
        id: prev_tag
        run: |
          PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
          echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT

      - name: Parse commits and determine version bump
        id: version
        run: |
          PREV_TAG=${{ steps.prev_tag.outputs.tag }}
          PREV_VERSION="${PREV_TAG#v}"

          if git rev-parse "${{ steps.prev_tag.outputs.tag }}" >/dev/null 2>&1; then
            COMMITS=$(git log "${{ steps.prev_tag.outputs.tag }}..HEAD" --pretty=format:%s)
          else
            COMMITS=$(git log --pretty=format:%s)
          fi

          echo "Commits since last release:"
          echo "$COMMITS"

          MAJOR_BUMP=0
          MINOR_BUMP=0
          PATCH_BUMP=0

          echo "$COMMITS" | while IFS= read -r line; do
            if [[ "$line" =~ ^BREAKING\ CHANGE|^feat\!: ]]; then
              MAJOR_BUMP=1
            elif [[ "$line" =~ ^feat: ]]; then
              MINOR_BUMP=1
            elif [[ "$line" =~ ^fix:|^refactor:|^perf: ]]; then
              PATCH_BUMP=1
            fi
          done

          if [[ "$COMMITS" =~ BREAKING\ CHANGE|feat\!: ]]; then
            MAJOR_BUMP=1
          elif [[ "$COMMITS" =~ feat: ]]; then
            MINOR_BUMP=1
          elif [[ "$COMMITS" =~ fix:|refactor:|perf: ]]; then
            PATCH_BUMP=1
          fi

          IFS='.' read -r MAJOR MINOR PATCH <<< "$PREV_VERSION"
          MAJOR=${MAJOR:-0}
          MINOR=${MINOR:-0}
          PATCH=${PATCH:-0}

          if [[ $MAJOR_BUMP -eq 1 ]]; then
            MAJOR=$((MAJOR + 1))
            MINOR=0
            PATCH=0
          elif [[ $MINOR_BUMP -eq 1 ]]; then
            MINOR=$((MINOR + 1))
            PATCH=0
          elif [[ $PATCH_BUMP -eq 1 ]] || git rev-parse "${{ steps.prev_tag.outputs.tag }}" >/dev/null 2>&1 && [[ "$PREV_TAG" != "0.0.0" ]]; then
            PATCH=$((PATCH + 1))
          else
            if [[ "$PREV_TAG" == "0.0.0" ]] && [[ $PATCH_BUMP -eq 0 ]] && [[ $MINOR_BUMP -eq 0 ]] && [[ $MAJOR_BUMP -eq 0 ]]; then
              PATCH=1
            fi
          fi

          NEW_VERSION="$MAJOR.$MINOR.$PATCH"
          echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
          echo "New version: $NEW_VERSION"

      - name: Check if tag already exists
        id: check_tag
        run: |
          if git rev-parse "refs/tags/${{ steps.version.outputs.new_version }}" >/dev/null 2>&1; then
            echo "tag_exists=true" >> $GITHUB_OUTPUT
            echo "Tag ${{ steps.version.outputs.new_version }} already exists"
          else
            echo "tag_exists=false" >> $GITHUB_OUTPUT
          fi

      - name: Create and push tag
        if: steps.check_tag.outputs.tag_exists == 'false'
        run: |
          git tag -a "${{ steps.version.outputs.new_version }}" -m "Release ${{ steps.version.outputs.new_version }}"
          git push origin "${{ steps.version.outputs.new_version }}"
          echo "Created and pushed tag: ${{ steps.version.outputs.new_version }}"

      - name: Create Release Notes
        if: steps.check_tag.outputs.tag_exists == 'false'
        id: release_notes
        run: |
          PREV_TAG=${{ steps.prev_tag.outputs.tag }}
          NEW_VERSION="${{ steps.version.outputs.new_version }}"

          if git rev-parse "$PREV_TAG" >/dev/null 2>&1; then
            CHANGELOG=$(git log "$PREV_TAG..HEAD" --pretty=format:"- %s (%h)")
          else
            CHANGELOG=$(git log --pretty=format:"- %s (%h)")
          fi

          echo "changelog<<EOF" >> $GITHUB_OUTPUT
          echo "$CHANGELOG" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      - name: Create GitHub Release
        if: steps.check_tag.outputs.tag_exists == 'false'
        uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
        with:
          tag_name: ${{ steps.version.outputs.new_version }}
          name: Release ${{ steps.version.outputs.new_version }}
          body: |
            ## What's Changed

            ${{ steps.release_notes.outputs.changelog }}

            **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.tag }}...${{ steps.version.outputs.new_version }}
          draft: false
          prerelease: false

      - name: Log Output
        run: |
          echo "Previous tag: ${{ steps.prev_tag.outputs.tag }}"
          echo "New version: ${{ steps.version.outputs.new_version }}"
          echo "Tag exists: ${{ steps.check_tag.outputs.tag_exists }}"
````

## File: .github/workflows/test.yaml
````yaml
name: Test

on: [push, pull_request]

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

env:
  PROJECT_PATH: src/metagit

permissions:
  contents: read

jobs:
  test:
    name: Test - ${{ matrix.os }} - ${{ matrix.python-version }} - ${{ github.ref_name }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        python-version: ["3.13"]
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
          cache-dependency-path: setup.py

      - name: Set up environment
        run: |
          pip install uv
          uv venv
          uv pip install -e ".[test]"

      - name: Unit Tests
        run: |
          uv run pytest --maxfail=1 --disable-warnings -v tests

      - name: Linting
        if: matrix.os != 'windows-latest'
        run: |
          uv run ruff check ${{ env.PROJECT_PATH }}
          uv run ruff format --check ${{ env.PROJECT_PATH }}
      # - name: Type Checking
      #   if: matrix.os != 'windows-latest'
      #   run: |
      #     mypy metagit
      - name: Build Check
        run: |
          uv sync
          uv build
````

## File: .mex/context/architecture.md
````markdown
---
name: architecture
description: How the major pieces of this project connect and flow. Load when working on system design, integrations, or understanding how components interact.
triggers:
  - "architecture"
  - "system design"
  - "how does X connect to Y"
  - "integration"
  - "flow"
edges:
  - target: context/stack.md
    condition: when specific technology details are needed
  - target: context/decisions.md
    condition: when understanding why the architecture is structured this way
  - target: context/conventions.md
    condition: when implementing changes across CLI, core services, and tests
  - target: context/mcp-runtime.md
    condition: when task scope includes MCP tools, resources, stdio runtime, or sampling
last_updated: 2026-05-12
---

# Architecture

## System Overview
User runs `metagit` CLI command via `src/metagit/cli/main.py` and command modules under `src/metagit/cli/commands/`.
CLI command loads app config (`metagit.config.yaml`) through `metagit.core.appconfig` and initializes `UnifiedLogger`.
Command handlers call core managers/services in `src/metagit/core/*` (config manager, detection manager, workspace/project helpers, record managers).
For project metadata, `.metagit.yml` is loaded/validated via `MetagitConfigManager` and Pydantic models in `metagit.core.config.models`.
For MCP mode, `metagit mcp serve` enters `MetagitMcpRuntime`, evaluates workspace gate state, exposes tools/resources, and dispatches calls to MCP services (including `metagit_repo_search` for managed-repo-only lookup via `ManagedRepoSearchService`).
For a **local** JSON HTTP surface (development and agents), `metagit api serve` serves `src/metagit/core/api/server.py` endpoints backed by the same search service — not a hosted production API.
Testing flow is pytest-driven from `tests/` with focused unit tests per core module and integration checks for cross-module behavior.

## Key Components
- **CLI command layer (`src/metagit/cli/commands/*.py`)** — routes user actions (`config`, `detect`, `project`, `record`, `workspace`, `mcp`, `search`/`find`, `api`), depends on Click context + core managers.
- **Config subsystem (`metagit.core.config.*`)** — loads/creates/saves `.metagit.yml` and validates schema via Pydantic models; foundational for workspace and MCP gating behavior.
- **Detection subsystem (`metagit.core.detect.*`)** — infers repository metadata (language/framework/dependencies) and feeds generated config/context output.
- **Record subsystem (`metagit.core.record.*`)** — manages normalized records and conversions; used for storage/search flows beyond raw config files.
- **MCP runtime (`metagit.core.mcp.*`)** — stdio JSON-RPC server for tools/resources with state-aware gating, workspace path search/index, managed-repo search (`metagit_repo_search`), upstream hints, repo ops, and bootstrap sampling flow.
- **Managed repo search (`metagit.core.project.search_service`, `search_models`)** — ranks `.metagit.yml` workspace repos with tags/status; shared by CLI, MCP, and the local HTTP API.
- **Local HTTP API (`metagit.core.api.server`)** — optional `ThreadingHTTPServer` with read-only JSON routes for the same managed-repo search and resolve semantics.

## External Dependencies
- **Git providers (GitHub/GitLab APIs via provider modules)** — used for metadata/provider operations; provider wiring is optional/config-driven.
- **Git repository access (`GitPython`)** — used for repo introspection and sync-like operations (`inspect`, `fetch`, `pull`, `clone`) in project/MCP flows.
- **YAML/Pydantic stack (`PyYAML` + `pydantic`)** — enforces `.metagit.yml` and app config shape; all config entry points depend on this validation boundary.
- **MCP client host (Cursor/other MCP-compatible runtime)** — provides stdio transport and optional sampling capability (`sampling/createMessage`) for bootstrap generation.

## What Does NOT Exist Here
- No production-grade multi-tenant HTTP deployment; the bundled HTTP server is for local use alongside the CLI and MCP.
- No relational application database backing core runtime flows; state is file/workspace/repo driven.
- No centralized remote orchestration control plane for enterprise-wide repo execution in this repository.
- No full SBOM pipeline; project focuses on situational awareness metadata, not exhaustive dependency inventory.
````

## File: .mex/AGENTS.md
````markdown
---
name: agents
description: Always-loaded project anchor. Read this first. Contains project identity, non-negotiables, commands, and pointer to ROUTER.md for full context.
last_updated: 2026-05-05
---

# Metagit CLI

## What This Is
Metagit CLI manages and validates repository/workspace metadata (`.metagit.yml`) and exposes that context to humans and agents, including via MCP runtime tooling.

## Non-Negotiables
- Never commit secrets, tokens, or credential material into repo-tracked files.
- Keep `.metagit.yml` / config model compatibility intact; do not bypass validation paths.
- Keep mutating repo operations explicitly guarded (especially MCP sync modes).
- Put business logic in `src/metagit/core/*`; keep CLI command handlers thin.
- Run lint + relevant tests before claiming a change is complete.
- Default commit messages to patch semantics (`fix:`). Use `feat:` only for additive backward-compatible changes, and use major/breaking prefixes only when schema/config compatibility is intentionally broken.

## Commands
- Setup: `./configure.sh && task install`
- Lint: `task lint`
- Format: `task format`
- Tests: `task test`
- Build: `task build`
- MCP runtime: `uv run metagit mcp serve --status-once`

## Scaffold Growth
After every task: if no pattern exists for the task type you just completed, create one. If a pattern or context file is now out of date, update it. The scaffold grows from real work, not just setup. See the GROW step in `ROUTER.md` for details.

## Navigation
At the start of every session, read `ROUTER.md` before doing anything else.
For full project context, patterns, and task guidance — everything is there.
````

## File: .mex/SETUP.md
````markdown
# Setup — Populate This Scaffold

This file contains the prompts to populate the scaffold. It is NOT the dev environment setup — for that, see `context/setup.md` after population.

This scaffold is currently empty. Follow the steps below to populate it for your project.

## Recommended: Use setup.sh

```bash
.mex/setup.sh
```

The script handles everything automatically:
1. Detects your project state (existing codebase, fresh project, or partial)
2. Asks which AI tool you use and copies the right config file
3. Pre-scans your codebase with `mex init` to build a structured brief (~5-8k tokens vs ~50k from AI exploration)
4. Builds and runs the population prompt — or prints it for manual paste

If you want to populate manually instead, use the prompts below.

## Detecting Your State

**Existing codebase?** Follow Option A.
**Fresh project, nothing built yet?** Follow Option B.
**Partially built?** Follow Option A — the agent will flag empty slots it cannot fill yet.

---

## Option A — Existing Codebase

Paste the following prompt into your agent:

---

**SETUP PROMPT — copy everything between the lines:**

```
You are going to populate an AI context scaffold for this project.
The scaffold lives in the root of this repository.

Read the following files in order before doing anything else:
1. ROUTER.md — understand the scaffold structure
2. context/architecture.md — read the annotation comments to understand what belongs there
3. context/stack.md — same
4. context/conventions.md — same
5. context/decisions.md — same
6. context/setup.md — same

Then explore this codebase:
- Read the main entry point(s)
- Read the folder structure
- Read 2-3 representative files from each major layer
- Read any existing README or documentation

PASS 1 — Populate knowledge files:

Populate each context/ file by replacing the annotation comments
with real content from this codebase. Follow the annotation instructions exactly.
For each slot:
- Use the actual names, patterns, and structures from this codebase
- Do not use generic examples
- Do not leave any slot empty — if you cannot determine the answer,
  write "[TO DETERMINE]" and explain what information is needed
- Keep length within the guidance given in each annotation

Then assess: does this project have domains complex enough that cramming
them into architecture.md would make it too long or too shallow?
If yes, create additional domain-specific context files in context/.
Examples: a project with a complex auth system gets context/auth.md.
A data pipeline gets context/ingestion.md. A project with Stripe gets
context/payments.md. Use the same YAML frontmatter format (name,
description, triggers, edges, last_updated). Only create these for
domains that have real depth — not for simple integrations that fit
in a few lines of architecture.md.

After populating context/ files, update ROUTER.md:
- Fill in the Current Project State section based on what you found
- Add rows to the routing table for any domain-specific context files you created

Update AGENTS.md:
- Fill in the project name, one-line description, non-negotiables, and commands

PASS 2 — Generate starter patterns:

Read patterns/README.md for the format and categories.

Generate 3-5 starter patterns for the most common and most dangerous task
types in this project. Focus on:
- The 1-2 tasks a developer does most often (e.g., add endpoint, add component)
- The 1-2 integrations with the most non-obvious gotchas
- 1 debug pattern for the most common failure boundary

Each pattern should be specific to this project — real file paths, real gotchas,
real verify steps derived from the code you read in Pass 1.
Use the format in patterns/README.md. Name descriptively (e.g., add-endpoint.md).

Do NOT try to generate a pattern for every possible task type. The scaffold
grows incrementally — the behavioural contract (step 5: GROW) will create
new patterns from real work as the project evolves. Setup just seeds the most
critical ones.

After generating patterns, update patterns/INDEX.md with a row for each
pattern file you created. For multi-section patterns, add one row per task
section using anchor links (see INDEX.md annotation for format).

PASS 3 — Wire the web:

Re-read every file you just wrote (context/ files, pattern files, ROUTER.md).
For each file, add or update the `edges` array in the YAML frontmatter.
Each edge should point to another scaffold file that is meaningfully related,
with a `condition` explaining when an agent should follow that edge.

Rules for edges:
- Every context/ file should have at least 2 edges
- Every pattern file should have at least 1 edge (usually to the relevant context file)
- Edges should be bidirectional where it makes sense (if A links to B, consider B linking to A)
- Use relative paths (e.g., context/stack.md, patterns/add-endpoint.md)
- Pattern files can edge to other patterns (e.g., debug pattern → related task pattern)

Important: only write content derived from the codebase.
Do not include system-injected text (dates, reminders, etc.)
in any scaffold file.

When done, confirm which files were populated and flag any slots
you could not fill with confidence.
```

---

## Option B — Fresh Project

Paste the following prompt into your agent:

---

**SETUP PROMPT — copy everything between the lines:**

```
You are going to populate an AI context scaffold for a project that
is just starting. Nothing is built yet.

Read the following files in order before doing anything else:
1. ROUTER.md — understand the scaffold structure
2. All files in context/ — read the annotation comments in each

Then ask me the following questions one section at a time.
Wait for my answer before moving to the next section:

1. What does this project do? (one sentence)
2. What are the hard rules — things that must never happen in this codebase?
3. What is the tech stack? (language, framework, database, key libraries)
4. Why did you choose this stack over alternatives?
5. How will the major pieces connect? Describe the flow of a typical request/action.
6. What patterns do you want to enforce from day one?
7. What are you deliberately NOT building or using?

After I answer, populate the context/ files based on my answers.
For any slot you cannot fill yet, write "[TO BE DETERMINED]" and note
what needs to be decided before it can be filled.

Then assess: based on my answers, does this project have domains complex
enough that cramming them into architecture.md would make it too long
or too shallow? If yes, create additional domain-specific context files
in context/. Examples: a project with a complex auth system gets
context/auth.md. A data pipeline gets context/ingestion.md. A project
with Stripe gets context/payments.md. Use the same YAML frontmatter
format (name, description, triggers, edges, last_updated). Only create
these for domains that have real depth — not for simple integrations
that fit in a few lines of architecture.md. For fresh projects, mark
domain-specific unknowns with "[TO BE DETERMINED — populate after first
implementation]".

Update ROUTER.md current state to reflect that this is a new project.
Add rows to the routing table for any domain-specific context files you created.
Update AGENTS.md with the project name, description, non-negotiables, and commands.

Read patterns/README.md for the format and categories.

Generate 2-3 starter patterns for the most obvious task types you can
anticipate for this stack. Focus on the tasks a developer will do first.
Mark unknowns with "[VERIFY AFTER FIRST IMPLEMENTATION]".

Do NOT try to anticipate every possible pattern. The scaffold grows
incrementally — the behavioural contract (step 5: GROW) will create
new patterns from real work as the project evolves. Setup just seeds
the most critical ones.

After generating patterns, update patterns/INDEX.md with a row for each
pattern file you created.

PASS 3 — Wire the web:

Re-read every file you just wrote (context/ files, pattern files, ROUTER.md).
For each file, add or update the `edges` array in the YAML frontmatter.
Each edge should point to another scaffold file that is meaningfully related,
with a `condition` explaining when an agent should follow that edge.

Rules for edges:
- Every context/ file should have at least 2 edges
- Every pattern file should have at least 1 edge
- Edges should be bidirectional where it makes sense
- Use relative paths (e.g., context/stack.md, patterns/add-endpoint.md)

Important: only write content derived from your answers.
Do not include system-injected text (dates, reminders, etc.)
in any scaffold file.
```

---

## After Setup

**Verify** by starting a fresh session and asking your agent:
"Read `.mex/ROUTER.md` and tell me what you now know about this project."

A well-populated scaffold should give the agent enough to:
- Describe the architecture without looking at code
- Name the non-negotiable conventions
- Know which files to load for any given task type
- Know which patterns exist for common task types

## Keeping It Fresh

Once the scaffold is populated, use these to keep it aligned with your codebase:

- **`mex check`** — detect drift (zero tokens, zero AI)
- **`.mex/sync.sh`** — interactive drift check + targeted or full resync
- **`mex watch`** — auto drift score after every commit

## Commit Prefix Policy

When committing scaffold updates, default to `fix:` (patch intent). Use `feat:` only for additive backward-compatible updates. Use breaking markers (`type(scope)!:` or `BREAKING CHANGE:`) only for intentional schema/config compatibility breaks.
````

## File: .mex/SYNC.md
````markdown
# Sync — Realign This Scaffold

## Recommended: Use sync.sh

```bash
.mex/sync.sh
```

The script runs drift detection first, shows you exactly what's wrong, then offers:
1. **Targeted sync** — AI fixes only the flagged files (fastest, cheapest)
2. **Full resync** — AI re-reads everything and updates all scaffold files
3. **Prompt export** — shows the prompts for manual paste
4. **Exit** — fix it yourself

## Quick Check

```bash
mex check              # full drift report
mex check --quiet      # one-liner: "drift score 85/100 (1 error)"
mex sync --dry-run     # preview targeted fix prompts
```

## Manual Resync

If you prefer to paste a prompt manually, or don't have the CLI built:

---

**SYNC PROMPT — copy everything between the lines:**

```
You are going to resync the AI context scaffold for this project.
The scaffold may be out of date — the codebase has changed since it was last populated.

First, read all files in context/ to understand the current scaffold state.
Then explore what has changed in the codebase since the scaffold was last updated.
Check the last_updated dates in the YAML frontmatter of each file.

For each context/ file:
1. Compare the current scaffold content to the actual codebase
2. Identify what has changed, been added, or been removed
3. Update the file to reflect the current state

Critical rules for updating:
- Use surgical, targeted edits — NOT full file rewrites. Read the existing content,
  identify what changed, and update only those sections.
- PRESERVE YAML frontmatter structure. Never delete or rewrite the entire frontmatter block.
  Edit individual fields only. The edges, triggers, name, and description fields must
  survive every sync. If you need to update edges, add or remove individual entries —
  do not replace the entire array.
- In context/decisions.md: NEVER delete existing decisions.
  If a decision has changed, mark the old entry as "Superseded by [new decision title]"
  and add the new decision above it with today's date.
- In all other files: update content to reflect current reality
- Update last_updated in the YAML frontmatter of every file you change
- After updating each file, update ROUTER.md Current Project State
- Commit-message semantics: default to `fix:` for patch-level updates; use `feat:` only for additive backward-compatible changes; use breaking markers only when intentionally breaking schema/config compatibility.

When done, report:
- Which files were updated and what changed
- Any decisions that were superseded
- Any slots that could not be filled with confidence
```
````

## File: docs/install.md
````markdown
# Installation and usage guide

This guide covers practical ways to install and run Metagit locally, plus how to enable project skills for AI-agent workflows.

## Requirements

- Python 3.12+
- `uv`
- `git`

Optional:

- Docker (for container usage)
- `gitleaks` (for local security checks in QA workflows)

## Option 1: global CLI install with uv (recommended)

Install Metagit globally as a tool:

```bash
uv tool install metagit-cli
```

Upgrade later:

```bash
uv tool install -U metagit-cli
```

Verify:

```bash
metagit version
metagit --help
```

## Option 2: install from source (local development)

Clone and bootstrap:

```bash
git clone https://github.com/metagit-ai/metagit-cli.git
cd metagit-cli
uv sync
```

Install as an editable local package:

```bash
uv pip install -e .
```

Build a wheel and install that wheel:

```bash
task build
uv tool install dist/metagit-*-py3-none-any.whl
```

## Option 3: container usage

Run via container image:

```bash
docker pull ghcr.io/metagit-ai/metagit-cli:latest
docker run --rm ghcr.io/metagit-ai/metagit-cli:latest --help
```

## First local use

In a target Git repository:

```bash
metagit init --list-templates          # bundled profiles (application, umbrella, hermes-orchestrator, …)
metagit init ./my-coordinator --template hermes-orchestrator --create
metagit init --target ../hermes-control-plane --template hermes-orchestrator --no-prompt \
  --answers-file examples/hermes-orchestrator/answers.example.yml
metagit init --kind service --minimal  # any ProjectKind without a bundled template
```

This creates `.metagit.yml` and updates `.gitignore`.

Useful first checks:

```bash
metagit config validate
metagit info
```

## Local MCP runtime usage

Start MCP runtime in current workspace:

```bash
metagit mcp serve
```

Run one-shot status snapshot:

```bash
metagit mcp serve --status-once
```

Pin a specific workspace root:

```bash
metagit mcp serve --root /path/to/workspace
```

## AI-agent skill setup for this project

If you cloned this repository and want skills available for local agent discovery:

```bash
task skills:sync
task generate:schema
```

One-shot combined:

```bash
task skills:sync generate:schema
```

This syncs project skills into `.cursor/skills/` and regenerates config schemas.

## Session closeout checks

Before push or hand-off:

```bash
task skills:sync generate:schema
task qa:prepush
```

## Troubleshooting

- If `metagit` fails after tool install, reinstall:
  - `uv tool install -U --reinstall metagit-cli`
- If command not found, ensure `uv` tool bin path is on `PATH`.
- If provider-backed features fail, configure API tokens in app config or environment:
  - `METAGIT_GITHUB_API_TOKEN`
  - `METAGIT_GITLAB_API_TOKEN`
````

## File: examples/enhanced_fuzzyfinder_demo.py
````python
#!/usr/bin/env python
"""
Example demonstrating the enhanced FuzzyFinder features:
- Optional item opacity
- Vertical scrolling for large lists
- Enhanced navigation (Page Up/Down, Home/End)
"""
⋮----
# Add the metagit package to the path
⋮----
def main()
⋮----
"""Demonstrate enhanced FuzzyFinder features."""
⋮----
# Create a larger dataset to show scrolling
programming_languages = [
⋮----
# Configure with new features
config = FuzzyFinderConfig(
⋮----
max_results=25,  # Show many results to demonstrate scrolling
⋮----
item_opacity=0.8,  # 80% opacity for subtle transparency
highlight_color="bold white bg:#2563eb",  # Nice blue highlight
⋮----
# Create and run the fuzzy finder
finder = FuzzyFinder(config)
⋮----
result = finder.run()
````

## File: examples/fuzzyfinder_simple_colors.py
````python
#!/usr/bin/env python
"""
Simple example showing how to use custom colors in FuzzyFinder.
This demonstrates coloring items based on their type or priority.
"""
⋮----
# Add the metagit package to the path
⋮----
def main()
⋮----
"""Demonstrate custom colors with a simple task list."""
⋮----
# Create a list of tasks with different types
tasks = [
⋮----
# Define colors based on task type (prefix)
task_colors = {
⋮----
"bug_fix_login": "bold red",  # Critical bugs in red
"bug_fix_payment": "bold red",  # Critical bugs in red
"feature_dashboard": "green",  # Features in green
"feature_notifications": "green",  # Features in green
"docs_api_reference": "blue",  # Documentation in blue
"docs_user_guide": "blue",  # Documentation in blue
"test_user_flow": "cyan",  # Tests in cyan
"test_integration": "cyan",  # Tests in cyan
⋮----
# Configure FuzzyFinder with custom colors
config = FuzzyFinderConfig(
⋮----
# Run the finder
finder = FuzzyFinder(config)
⋮----
result = finder.run()
⋮----
task_type = result.split("_")[0]
````

## File: examples/get_git_files.py
````python
#!/usr/bin/env python
"""
List git files in a repository.
"""
⋮----
# Add the metagit package to the path
⋮----
def main()
⋮----
"""List all git files in the current repository."""
⋮----
# Get the current working directory
repo_path = Path.cwd()
⋮----
# List git files
git_files = list_git_files(repo_path)
````

## File: scripts/prepush-gate.py
````python
#!/usr/bin/env python3
"""Cross-agent pre-push quality gate runner."""
⋮----
def run_step(name: str, cmd: list[str], logs_dir: Path, shell: bool = False) -> bool
⋮----
log_path = logs_dir / f"{name}.log"
⋮----
completed = subprocess.run(
⋮----
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
⋮----
def resolve_pytest_cmd() -> list[str]
⋮----
SECURITY_DEP_FILES = frozenset({"pyproject.toml", "uv.lock"})
SECURITY_SRC_PREFIX = "src/"
⋮----
def _git_lines(args: list[str]) -> list[str] | None
⋮----
def changed_paths_for_security() -> set[str] | None
⋮----
"""Collect changed paths; None means run the full security pipeline."""
chunks: list[str] = []
⋮----
lines = _git_lines(spec)
⋮----
upstream = _git_lines(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
⋮----
branch_diff = _git_lines(["diff", "--name-only", f"{upstream[0]}...HEAD"])
⋮----
def security_scan_plan(changed: set[str] | None) -> tuple[bool, bool, bool]
⋮----
"""Return (sync_deps, pip_audit, bandit) for context-aware security."""
⋮----
deps = any(path in SECURITY_DEP_FILES for path in changed)
src = any(path.startswith(SECURITY_SRC_PREFIX) for path in changed)
⋮----
def run_security_scan(logs_dir: Path) -> bool
⋮----
"""Run pip-audit/bandit when lockfile or src/ changed; skip for docs-only diffs."""
changed = changed_paths_for_security()
⋮----
failed = False
⋮----
def main() -> int
⋮----
parser = argparse.ArgumentParser()
⋮----
args = parser.parse_args()
⋮----
logs_dir = Path(".metagit")
round_idx = 1
⋮----
security_ok = run_step(
⋮----
failed = True
````

## File: skills/metagit-control-center/scripts/control-cycle.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-infra}"

"$(dirname "$0")/../../metagit-gating/scripts/gate-status.zsh" "$ROOT"

if [[ -n "$QUERY" ]]; then
  "$(dirname "$0")/../../metagit-upstream-scan/scripts/upstream-scan.zsh" "$ROOT" "$QUERY" "$PRESET" 20
else
  echo "status=ok\tmessage=no-query-provided"
fi
````

## File: skills/metagit-control-center/SKILL.md
````markdown
---
name: metagit-control-center
description: Use when running metagit as an MCP control center for multi-repo awareness, guarded sync, and operational knowledge across ongoing agent tasks.
---

# Metagit Control Center Skill

Use this skill when an agent should actively coordinate repository context and task execution across a workspace.

## Purpose

Provide a repeatable control-center workflow where Metagit MCP guides awareness, synchronization, and operational continuity over multiple related repositories.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for control-center cycles:
- `./scripts/control-cycle.zsh [root_path] ["query"] [preset]`

Wrapper behavior:
- runs gating status first
- optionally runs upstream discovery for blocker queries
- emits compact, machine-readable lines

## Core Workflows

### 1) Session Initialization
- Validate active workspace gate.
- Read `metagit://workspace/config` and `metagit://workspace/repos/status`.
- Call `metagit_project_context_switch` when the objective is tied to a workspace project.
- Run `metagit_workspace_health_check` or read `metagit://workspace/health` for maintenance signals.
- Identify stale repos and unresolved blockers from prior activity.

### 2) Active Task Support
- For each coding objective, map impacted repos.
- Use workspace search and upstream hints before broad exploration.
- Sync only repos that are required by the active objective.

### 3) Guarded Synchronization
- Default to `fetch` for visibility.
- Use `pull` or `clone` only with explicit permission and rationale.
- Track sync outcomes in operations log resource.

### 4) Operational Memory
- Before switching projects: `metagit_session_update` (notes + recent repos), optional `metagit_workspace_state_snapshot`.
- After returning: `metagit_workspace_state_restore` when a snapshot was taken (metadata only; git tree is unchanged).

Maintain bounded local records of:
- sync actions
- issue signatures searched
- candidate upstream repos identified
- unresolved dependencies and follow-ups

## Decision Guidelines

- Use metagit search first when blocker appears external to current repo.
- Prefer deterministic evidence over speculative jumps.
- Keep operations minimal and auditable.

## Output Contract

For each control-center cycle, provide:
- current objective
- repositories examined
- actions taken (or intentionally deferred)
- next recommended step

## Safety Rules

- Never mutate repositories without explicit authorization.
- Never broaden scope beyond configured workspace boundaries.
- Always preserve a clear audit trail of control actions.
````

## File: skills/metagit-gitnexus/SKILL.md
````markdown
---
name: metagit-gitnexus
description: Run gitnexus analysis for a target workspace and selected project repositories before graph-dependent tasks. Use when index staleness is detected or cross-repo graph results are needed.
---

# Running GitNexus Analysis

Use this skill whenever GitNexus context is stale or missing for target repositories.

## Local Wrapper (Use First)

- `zsh ./skills/metagit-gitnexus/scripts/analyze-targets.zsh <workspace_root> [project_name]`

## Workflow

1. Analyze the current repository where the command is run.
2. Resolve target project repositories from `.metagit.yml`.
3. Run `npx gitnexus analyze` in each local repository path found.
4. Report per-repository success/failure and next actions.

## Manual workspace graph (`.metagit.yml` → Cypher)

Export manifest `graph.relationships` for GitNexus overlay ingest:

```bash
metagit config graph export -c .metagit.yml --format tool-calls
# or MCP: metagit_export_workspace_graph_cypher
```

Run returned `gitnexus_cypher` tool calls against the umbrella repo index (`--gitnexus-repo` when names differ). Schema DDL (`MetagitEntity` / `MetagitLink`) runs once per index.

## Output Contract

Return:
- analyzed repositories
- failures and reasons
- whether graph queries are safe to run now

## Safety

- Skip repositories that do not exist locally.
- Do not mutate repo content; analysis should be read-only indexing.
````

## File: skills/metagit-repo-impact/SKILL.md
````markdown
---
name: metagit-repo-impact
description: Plan repository change impact before edits by combining metagit workspace context and graph-based dependency analysis. Use when a change may affect multiple repositories.
---

# Planning Repo Impact

Use this skill before risky or cross-repo modifications.

## Workflow

1. Identify target symbols/files for change.
2. Use metagit workspace context to bound repository scope.
3. Use graph impact tooling to estimate blast radius.
4. Produce a change plan with test and rollback focus.

## Commands

- `metagit workspace select --project <name>`
- MCP `metagit_cross_project_dependencies` with `source_project` and `dependency_types` before large cross-project edits
- MCP `metagit_project_context_switch` to bound scope to one workspace project
- `npx gitnexus analyze` on affected repos when `graph_status` is `stale` or `missing`
- `npx gitnexus query` / `gitnexus impact` for symbol-level analysis after indexes are fresh

## Output Contract

Return:
- impacted repositories and interfaces
- highest-risk change points
- minimum validation plan

## Safety

- Do not execute mutating steps in this planning phase.
````

## File: skills/metagit-workspace-scope/SKILL.md
````markdown
---
name: metagit-workspace-scope
description: Discover active metagit workspace scope, project boundaries, and repository status. Use when an agent starts work in a multi-repo workspace and needs fast, scoped context before editing.
---

# Discovering Workspace Scope

Use this skill at session start for workspace-aware tasks.

## Workflow

1. Run workspace gate and status checks first.
2. Read configured projects and repository status from metagit resources/tools.
3. Build a compact map of active project names, repo names, and sync state.
4. Report a bounded scope for the current objective.

## Commands

- `zsh ./skills/metagit-gating/scripts/gate-status.zsh [root_path]`
- `metagit workspace select --project <name>` (interactive repo picker)
- MCP `metagit_project_context_switch` with `project_name` (structured context + session restore)
- MCP `metagit_workspace_state_snapshot` before leaving a project for a long switch

## Output Contract

Return:
- active/inactive workspace state and reason
- candidate project to operate on
- top repositories to inspect first

## Safety

- Keep operations read-only in this step.
- Do not sync or mutate repositories without explicit request.
````

## File: skills/metagit-workspace-sync/SKILL.md
````markdown
---
name: metagit-workspace-sync
description: Sync workspace repositories safely using metagit with scoped fetch, pull, or clone actions. Use when repository content must be refreshed for implementation.
---

# Syncing Workspace Repositories

Use this skill when repository state must be updated.

## Workflow

1. Confirm active workspace and target project.
2. Start with read-only status and fetch where possible.
3. Use pull/clone only when required for the current objective.
4. Summarize which repositories changed and what remains stale.

## Commands

- `metagit project sync --project <name>`
- MCP `metagit_workspace_sync` for batch fetch/pull/clone (`repos`, `only_if`, `dry_run`, `max_parallel`)
- MCP `metagit_repo_sync` for a single repository (requires `allow_mutation` for pull/clone)
- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- repositories synced
- sync mode used (fetch/pull/clone)
- failures and retry guidance

## Safety

- Limit sync to repos defined in `.metagit.yml`.
- Avoid broad synchronization if only a subset is needed.
````

## File: src/metagit/cli/commands/mcp.py
````python
#!/usr/bin/env python
"""
MCP command group for Metagit CLI.
"""
⋮----
@click.group()
def mcp() -> None
⋮----
"""Metagit MCP server commands."""
⋮----
@click.pass_context
def serve(ctx: click.Context, root: Optional[str], status_once: bool) -> None
⋮----
"""Start MCP runtime over stdio."""
runtime = MetagitMcpRuntime(root=root)
⋮----
snapshot = runtime.status_snapshot()
⋮----
logger = ctx.obj.get("logger") if ctx.obj else None
⋮----
"""Install metagit MCP server entry into supported agent configs."""
logger = ctx.obj["logger"] if ctx.obj else None
selected_targets = resolve_targets(
⋮----
results = install_mcp_for_targets(
````

## File: src/metagit/cli/commands/search.py
````python
#!/usr/bin/env python
"""
Managed workspace repository search (`.metagit.yml` corpus only).
"""
⋮----
def _parse_tag_filters(tag_values: tuple[str, ...]) -> dict[str, str] | None
⋮----
"""Parse repeated `--tag key=value` into a tag filter dict."""
⋮----
parsed: dict[str, str] = {}
⋮----
"""Search managed repositories declared in `.metagit.yml` (alias: `metagit find`)."""
_ = ctx
manager = MetagitConfigManager(definition_path)
config = manager.load_config()
⋮----
workspace_root = str(Path(definition_path).resolve().parent)
tag_filters = _parse_tag_filters(tag_values)
service = ManagedRepoSearchService()
status_filter = list(status_values) if status_values else None
result = service.search(
⋮----
resolved = service.resolve_one(
````

## File: src/metagit/cli/commands/skills.py
````python
#!/usr/bin/env python
"""
Skills command group for bundled skill management.
"""
⋮----
@click.group(name="skills", invoke_without_command=True)
@click.pass_context
def skills(ctx: click.Context) -> None
⋮----
"""Bundled skill management commands."""
⋮----
@skills.command("list")
@click.pass_context
def skills_list(ctx: click.Context) -> None
⋮----
"""List bundled skills available for install."""
logger = ctx.obj["logger"]
bundled = list_bundled_skills()
⋮----
@skills.command("show")
@click.argument("skill_name", required=False)
@click.pass_context
def skills_show(ctx: click.Context, skill_name: str | None) -> None
⋮----
"""Show a bundled skill document."""
⋮----
skill_names = list_bundled_skills()
⋮----
content = skill_markdown(skill_name)
⋮----
"""Install bundled skills into supported agent targets."""
⋮----
selected_targets = resolve_targets(
⋮----
selected_skills = resolve_skill_names(list(skills) if skills else None)
⋮----
message = str(exc)
⋮----
results = install_skills_for_targets(
⋮----
line = f"[{result.target}] {result.details} -> {result.path}"
````

## File: src/metagit/cli/json_output.py
````python
#!/usr/bin/env python
"""
Shared JSON output helpers for agentic CLI use.
"""
⋮----
def emit_json(payload: BaseModel | dict[str, Any]) -> None
⋮----
"""Print a JSON document to stdout."""
⋮----
data: dict[str, Any] = payload.model_dump(mode="json")
⋮----
data = payload
⋮----
"""Emit mutation result and exit non-zero when ok is false."""
⋮----
ok = bool(getattr(result, "ok", True))
error = getattr(result, "error", None)
⋮----
entity = getattr(result, "entity", "entity")
operation = getattr(result, "operation", "updated")
name = getattr(result, "repo_name", None) or getattr(
⋮----
"""Emit layout mutation result; show plan summary on dry-run."""
⋮----
data = getattr(result, "data", None) or {}
````

## File: src/metagit/core/config/example_generator.py
````python
#!/usr/bin/env python
"""
Generate a documented YAML exemplar for MetagitConfig with field descriptions as comments.
"""
⋮----
def load_example_overrides() -> dict[str, Any]
⋮----
"""Load optional nested overrides for the generated exemplar."""
override_path = Path(DATA_PATH) / "config-example-overrides.yml"
⋮----
loaded = metagit_yaml.safe_load(handle)
⋮----
def deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]
⋮----
"""Recursively merge overrides into base (overrides win)."""
merged = dict(base)
⋮----
class ConfigExampleGenerator
⋮----
"""Build a representative MetagitConfig-shaped dict for documentation."""
⋮----
def __init__(self, overrides: dict[str, Any] | None = None) -> None
⋮----
def build(self, *, include_workspace: bool = True) -> dict[str, Any]
⋮----
"""Return a nested dict suitable for YAML serialization."""
payload = self._sample_model(MetagitConfig)
⋮----
"""Render YAML with per-field description comments."""
payload = self.build(include_workspace=include_workspace)
header = (
⋮----
lines = header.splitlines(keepends=True)
⋮----
"""Render one mapping using model field order and descriptions."""
pad = "  " * indent
lines: list[str] = []
fields = model.model_fields
⋮----
field_info = fields[key]
description = field_info.description
⋮----
annotation = field_info.annotation
nested_model = self._nested_model_for_annotation(annotation)
⋮----
def _render_key_value(self, key: str, value: Any, *, indent: int) -> list[str]
⋮----
lines = [f"{pad}{key}:\n"]
⋮----
def _format_scalar(self, value: Any) -> str
⋮----
escaped = str(value).replace("\n", "\\n")
⋮----
def _sample_model(self, model: type[BaseModel]) -> dict[str, Any]
⋮----
payload: dict[str, Any] = {}
⋮----
inner = self._sample_annotation(field_info.annotation, name)
⋮----
def _sample_annotation(self, annotation: Any, field_name: str) -> Any
⋮----
origin = get_origin(annotation)
⋮----
args = [arg for arg in get_args(annotation) if arg is not type(None)]
⋮----
inner_args = get_args(annotation)
inner = inner_args[0] if inner_args else Any
sampled = self._sample_annotation(inner, field_name)
⋮----
nested = self._nested_model_for_annotation(annotation)
⋮----
nested = self._nested_model_for_annotation(arg)
⋮----
args = get_args(annotation)
⋮----
def _scalar_for_name(self, field_name: str) -> Any
⋮----
lowered = field_name.lower()
````

## File: src/metagit/core/detect/detectors/docker.py
````python
class DockerDetector
⋮----
name = "DockerDetector"
⋮----
def should_run(self, ctx: ProjectScanContext) -> bool
⋮----
def run(self, ctx: ProjectScanContext) -> Optional[DiscoveryResult]
⋮----
dockerfiles = [p for p in ctx.all_files if "dockerfile" in p.name.lower()]
composefiles = [
⋮----
containers = []
⋮----
base_image = None
exposed_ports = []
entrypoint = None
cmd = None
⋮----
line = line.strip()
⋮----
base_image = line.split(None, 1)[1]
⋮----
entrypoint = line[len("ENTRYPOINT") :].strip()
⋮----
cmd = line[len("CMD") :].strip()
⋮----
continue  # corrupt file
⋮----
services = []
⋮----
data = yaml.safe_load(f)
````

## File: src/metagit/core/detect/detectors/python.py
````python
class PythonDetector
⋮----
name = "PythonDetector"
⋮----
def should_run(self, ctx: ProjectScanContext) -> bool
⋮----
indicators = ["pyproject.toml", "requirements.txt", "setup.py"]
⋮----
def run(self, ctx: ProjectScanContext) -> Optional[DiscoveryResult]
⋮----
dependencies = self._get_dependencies(ctx.root_path)
⋮----
def _get_dependencies(self, root: Path) -> list[str]
⋮----
deps = []
⋮----
pyproject = os.path.join(root, "pyproject.toml")
⋮----
data = tomli.load(f)
# Handle both Poetry and PEP 621
⋮----
reqs = root / "requirements.txt"
⋮----
setup = root / "setup.py"
⋮----
)  # You could parse with `ast`, but keep it simple here
````

## File: src/metagit/core/detect/detectors/terraform.py
````python
def classify_module_source(source: str) -> str
⋮----
source = source.strip()
⋮----
class TerraformDetector
⋮----
name = "TerraformDetector"
⋮----
def should_run(self, ctx: ProjectScanContext) -> bool
⋮----
def run(self, ctx: ProjectScanContext) -> Optional[DiscoveryResult]
⋮----
provider_set = set()
module_sources = []
backend_type = None
⋮----
parsed = hcl2.load(f)
⋮----
# Providers
⋮----
# Modules
⋮----
source = attrs.get("source")
⋮----
# Terraform block (for backend)
⋮----
backend = block.get("backend")
⋮----
backend_type = next(iter(backend.keys()), None)
````

## File: src/metagit/core/mcp/services/bootstrap_sampling.py
````python
#!/usr/bin/env python
"""
Sampling-assisted bootstrap service for `.metagit.yml`.
"""
⋮----
class BootstrapSamplingService
⋮----
"""Generate `.metagit.yml` using sampling when available, otherwise fallback."""
⋮----
"""Generate config draft and return write guidance."""
⋮----
errors: list[str] = []
⋮----
prompt = self._build_prompt(context=context, validation_errors=errors)
draft_yaml = self._sampler({"prompt": prompt})
validation = self._validate_yaml(draft_yaml=draft_yaml)
⋮----
errors = [validation["error"]]
⋮----
errors = "\n".join(validation_errors or [])
⋮----
def _validate_yaml(self, draft_yaml: str) -> dict[str, str | bool]
⋮----
loaded = yaml.safe_load(draft_yaml)
````

## File: src/metagit/core/mcp/services/cross_project_dependencies.py
````python
#!/usr/bin/env python
"""
Map cross-project dependencies from workspace configuration and import hints.
"""
⋮----
class CrossProjectDependencyService
⋮----
"""Build a dependency graph across workspace projects."""
⋮----
_valid_types = {
⋮----
"""Return dependency nodes and edges reachable from a source project."""
⋮----
project_names = {project.name for project in config.workspace.projects}
⋮----
selected_types = self._normalize_types(dependency_types=dependency_types)
rows = self._index.build_index(config=config, workspace_root=workspace_root)
⋮----
edges = self._collect_edges(
⋮----
source_id = f"project:{source_project}"
⋮----
graph_status = self._registry.summarize_for_paths(
⋮----
impact = self._build_impact_summary(
⋮----
def _normalize_types(self, dependency_types: Optional[list[str]]) -> set[str]
⋮----
"""Normalize requested dependency type filters."""
⋮----
selected = {item.lower() for item in dependency_types}
unknown = selected - self._valid_types
⋮----
selected = selected & self._valid_types
⋮----
"""Build project and repo nodes from workspace index rows."""
nodes: list[DependencyNode] = []
id_to_node: dict[str, DependencyNode] = {}
path_to_id: dict[str, str] = {}
project_names = {row["project_name"] for row in rows}
⋮----
node_id = f"project:{project_name}"
node = DependencyNode(
⋮----
repo_path = str(row.get("repo_path", ""))
node_id = f"repo:{row['project_name']}/{row['repo_name']}"
⋮----
"""Collect dependency edges from all enabled collectors."""
edges: list[DependencyEdge] = []
project_names = {
⋮----
"""Edges from top-level graph.relationships in .metagit.yml."""
⋮----
from_id = resolve_graph_endpoint_id(
to_id = resolve_graph_endpoint_id(
⋮----
evidence = ["manifest graph.relationships"]
⋮----
"""Edges from explicit refs and root-level dependency declarations."""
⋮----
repo_by_name: dict[str, list[dict[str, Any]]] = {}
⋮----
from_id = f"repo:{row['project_name']}/{row['repo_name']}"
tags = row.get("tags") or {}
⋮----
ref_target = self._ref_target(repo=repo, project_names=project_names)
⋮----
ref_target = self._ref_target(repo=dep, project_names=project_names)
⋮----
matched = repo_by_name.get(dep.name, [])
⋮----
"""Edges from shared URLs and configured path references."""
⋮----
by_url: dict[str, list[dict[str, Any]]] = {}
⋮----
url = row.get("url")
⋮----
normalized = self._normalize_url(str(url))
⋮----
configured_paths: dict[str, list[dict[str, Any]]] = {}
⋮----
configured = row.get("configured_path")
⋮----
"""Edges from manifest import hints between repositories."""
⋮----
hints = self._import_scanner.scan_repo(
⋮----
to_id = hint.get("to_id")
⋮----
"""Keep nodes and edges within N project hops from the source."""
adjacency: dict[str, set[str]] = {}
⋮----
visited = {source_id}
queue: deque[tuple[str, int]] = deque([(source_id, 0)])
⋮----
source_project = source_id.split(":", 1)[1]
⋮----
filtered_edges = [
filtered_nodes = [node for node in nodes if node.id in visited]
⋮----
"""Summarize risk and affected projects."""
affected_projects = sorted(
affected_repos = sorted(
notes: list[str] = []
stale_count = sum(1 for status in graph_status.values() if status == "stale")
missing_count = sum(
⋮----
risk = "low"
⋮----
risk = "high"
⋮----
risk = "medium"
⋮----
def _ref_target(self, repo: ProjectPath, project_names: set[str]) -> Optional[str]
⋮----
"""Resolve a ProjectPath ref to a workspace project name."""
⋮----
def _edge_is_internal(self, edge: DependencyEdge, project_names: set[str]) -> bool
⋮----
"""Return whether an edge stays inside configured workspace projects."""
⋮----
project = node_id.split(":", 1)[1]
⋮----
def _normalize_url(self, url: str) -> str
⋮----
"""Normalize repository URLs for comparison."""
cleaned = url.strip().lower().rstrip("/")
⋮----
cleaned = cleaned[:-4]
parsed = urlparse(cleaned)
⋮----
def _dedupe_edges(self, edges: list[DependencyEdge]) -> list[DependencyEdge]
⋮----
"""Remove duplicate edges while preserving evidence."""
merged: dict[tuple[str, str, str], DependencyEdge] = {}
⋮----
key = (edge.from_id, edge.to_id, edge.type)
⋮----
existing = merged[key]
combined = list(dict.fromkeys(existing.evidence + edge.evidence))
````

## File: src/metagit/core/mcp/services/project_context.py
````python
#!/usr/bin/env python
"""
Project context switching and environment export for MCP tools.
"""
⋮----
_INSPECT_LIMIT = 20
_EXPORTABLE_VARIABLE_KINDS = {
⋮----
class ProjectContextService
⋮----
"""Switch active workspace project and build agent context bundles."""
⋮----
"""Switch to a workspace project and return a context bundle."""
project = self._find_project(config=config, project_name=project_name)
⋮----
store = SessionStore(workspace_root=workspace_root)
prior_meta = store.get_workspace_meta()
⋮----
prior_session = store.get_project_session(
⋮----
bundle = self._build_bundle(
⋮----
"""Return context for active or named project without changing active project."""
⋮----
meta = store.get_workspace_meta()
target = project_name or meta.active_project
⋮----
project = self._find_project(config=config, project_name=target)
⋮----
"""Build a project context bundle without updating active project."""
⋮----
rows = self._index.build_index(config=config, workspace_root=workspace_root)
project_rows = [row for row in rows if row["project_name"] == project_name]
inspect_truncated = len(project_rows) > _INSPECT_LIMIT
inspect_rows = project_rows[:_INSPECT_LIMIT]
⋮----
repo_contexts: list[ProjectRepoContext] = []
⋮----
session_state = ProjectContextSession(restored=False)
⋮----
saved = store.get_project_session(project_name=project_name)
session_state = ProjectContextSession(
⋮----
env_bundle = (
⋮----
suggested = self._resolve_suggested_cwd(
focus_repo_entry = None
focus_repo_name: Optional[str] = None
⋮----
focus_row = self._match_repo_target(rows=project_rows, target=primary_repo)
⋮----
focus_repo_name = str(focus_row.get("repo_name", "")) or None
focus_repo_entry = self._instructions.find_repo(
⋮----
focus_row = self._match_repo_target(rows=project_rows, target=suggested)
⋮----
instructions = self._instructions.resolve(
⋮----
"""Return sorted env export keys for a project without switching context."""
⋮----
"""Persist session fields for a project."""
⋮----
session = store.update_project_session(
⋮----
"""Locate workspace project by name."""
⋮----
"""Build repo context with optional git inspect."""
exists = bool(row.get("exists"))
repo_name = str(row.get("repo_name", ""))
repo_entry = self._instructions.find_repo(
branch: Optional[str] = None
dirty: Optional[bool] = None
inspect_error: Optional[str] = None
⋮----
inspected = inspect_repo_state(repo_path=str(row["repo_path"]))
⋮----
branch = str(inspected["branch"]) if inspected.get("branch") else None
dirty = (
⋮----
inspect_error = str(inspected.get("error", "inspect failed"))
⋮----
"""Build safe environment exports and hints."""
exports: dict[str, str] = {
hints: list[str] = []
⋮----
"""Pick a suggested working directory for agents."""
⋮----
match = self._match_repo_target(rows=project_rows, target=primary_repo)
⋮----
"""Match repo by name or resolved path."""
normalized = target.strip()
````

## File: src/metagit/core/mcp/services/repo_ops.py
````python
#!/usr/bin/env python
"""
Repository inspection and guarded synchronization operations.
"""
⋮----
class RepoOperationsService
⋮----
"""Provide safe repo inspect/sync operations for MCP tools."""
⋮----
def inspect(self, repo_path: str) -> dict[str, str | bool]
⋮----
"""Inspect repository branch and dirty state."""
⋮----
repo = Repo(repo_path)
⋮----
"""Synchronize repository with mutation guardrails."""
normalized_mode = mode.lower()
⋮----
origin = repo.remote(name="origin")
````

## File: src/metagit/core/mcp/services/session_store.py
````python
#!/usr/bin/env python
"""
Persist workspace and per-project session state under .metagit/sessions/.
"""
⋮----
_PROJECT_FILE_PATTERN = re.compile(r"^[\w.-]+$")
⋮----
class SessionStore
⋮----
"""Read and write session JSON under the workspace .metagit directory."""
⋮----
def __init__(self, workspace_root: str) -> None
⋮----
@property
    def sessions_dir(self) -> Path
⋮----
"""Return the sessions directory path."""
⋮----
def ensure_dirs(self) -> None
⋮----
"""Create session directories with restrictive permissions when possible."""
⋮----
def get_workspace_meta(self) -> WorkspaceSessionMeta
⋮----
"""Load workspace session metadata or return defaults."""
payload = self._read_json(path=self._workspace_meta_path)
⋮----
def save_workspace_meta(self, meta: WorkspaceSessionMeta) -> None
⋮----
"""Persist workspace session metadata."""
⋮----
def set_active_project(self, project_name: str) -> WorkspaceSessionMeta
⋮----
"""Set active project on workspace metadata."""
meta = self.get_workspace_meta()
⋮----
def get_project_session(self, project_name: str) -> ProjectSession
⋮----
"""Load a project session or return an empty session."""
path = self._project_session_path(project_name=project_name)
payload = self._read_json(path=path)
⋮----
session = ProjectSession.model_validate(payload)
⋮----
def save_project_session(self, session: ProjectSession) -> None
⋮----
"""Persist a project session."""
⋮----
path = self._project_session_path(project_name=session.project_name)
⋮----
"""Merge updates into a project session."""
session = self.get_project_session(project_name=project_name)
⋮----
merged = dict(session.env_overrides)
⋮----
def rename_project_session(self, from_name: str, to_name: str) -> bool
⋮----
"""
        Rename a per-project session file when a workspace project is renamed.

        Returns True when a session file was migrated.
        """
old_path = self._project_session_path(project_name=from_name)
new_path = self._project_session_path(project_name=to_name)
⋮----
session = self.get_project_session(project_name=to_name)
⋮----
def link_snapshot(self, snapshot_id: str, project_name: Optional[str]) -> None
⋮----
"""Record snapshot id on workspace and optional project session."""
⋮----
def _project_session_path(self, project_name: str) -> Path
⋮----
"""Resolve sanitized per-project session file path."""
⋮----
def _read_json(self, path: Path) -> Optional[dict]
⋮----
"""Read JSON object from path."""
⋮----
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
⋮----
def _write_json(self, path: Path, payload: dict) -> None
⋮----
"""Write JSON object to path."""
````

## File: src/metagit/core/mcp/services/upstream_hints.py
````python
#!/usr/bin/env python
"""
Upstream issue hint ranking service.
"""
⋮----
class UpstreamHintService
⋮----
"""Rank repositories likely to contain upstream root causes."""
⋮----
_category_terms: dict[str, list[str]] = {
⋮----
"""Return ranked repository candidates for the blocker description."""
blocker_lower = blocker.lower()
results: list[dict[str, Any]] = []
⋮----
score = self._score_repo(blocker_lower=blocker_lower, repo=repo)
⋮----
def _score_repo(self, blocker_lower: str, repo: dict[str, Any]) -> float
⋮----
score = 0.0
name = str(repo.get("repo_name", "")).lower()
project = str(repo.get("project_name", "")).lower()
path = str(repo.get("repo_path", "")).lower()
⋮----
def _rationale(self, blocker_lower: str, repo: dict[str, Any]) -> str
⋮----
name = str(repo.get("repo_name", "unknown"))
````

## File: src/metagit/core/project/search_service.py
````python
#!/usr/bin/env python
"""
Search and resolve managed workspace repositories from `.metagit.yml` only.
"""
⋮----
class ManagedRepoSearchService
⋮----
"""Match queries against configured workspace repos using WorkspaceIndexService rows."""
⋮----
def __init__(self) -> None
⋮----
"""Return ranked matches for a free-text query and optional filters."""
rows = self._index.build_index(config=config, workspace_root=workspace_root)
status_filter = set(status) if status else None
matches: list[ManagedRepoMatch] = []
⋮----
ordered = self._sort_matches(matches=matches, sort=sort)
⋮----
"""Return a single best match or a structured not_found / ambiguous error."""
result = self.search(
⋮----
status = ManagedRepoStatus(
⋮----
"""Return whether an index row satisfies structural filters."""
⋮----
row_url = row.get("url")
⋮----
row_sync = bool(row.get("sync"))
⋮----
row_tags = row.get("tags") or {}
⋮----
"""Sort matches by score, project, or repository name."""
normalized = sort.lower()
⋮----
reasons: list[str] = []
q = query.strip()
⋮----
name = row.get("repo_name") or ""
⋮----
score = 0
q_lower = q.lower()
name_lower = name.lower()
⋮----
url = row.get("url") or ""
⋮----
project_name = row.get("project_name") or ""
````

## File: src/metagit/core/project/source_sync.py
````python
#!/usr/bin/env python
"""
Provider-backed recursive repository discovery and workspace planning.
"""
⋮----
class SourceSyncService
⋮----
"""Discovers repositories from providers and builds/apply sync plans."""
⋮----
def __init__(self, app_config: AppConfig, logger: UnifiedLogger)
⋮----
def discover(self, spec: SourceSpec) -> Union[List[DiscoveredRepo], Exception]
⋮----
discovered_project_paths = [self._to_project_path(repo) for repo in discovered]
discovered_by_url = {
existing_by_url = {
⋮----
plan = SourceSyncPlan(discovered_count=len(discovered))
⋮----
existing = existing_by_url.get(url_key)
⋮----
url_key = self._normalized_url(repo.url)
⋮----
repos: List[ProjectPath] = list(project.repos)
repo_index: Dict[str, int] = {}
⋮----
url_key = self._normalized_url(candidate.url)
⋮----
remove_keys = set()
⋮----
repos = [
⋮----
provider_cfg = self._app_config.providers.github
⋮----
session = requests.Session()
⋮----
scope_kind = "orgs" if spec.org else "users"
scope_value = spec.org if spec.org else spec.user
endpoint = f"{provider_cfg.base_url}/{scope_kind}/{scope_value}/repos"
⋮----
discovered: List[DiscoveredRepo] = []
page = 1
⋮----
response = session.get(
⋮----
items = response.json()
⋮----
candidate = DiscoveredRepo(
⋮----
provider_cfg = self._app_config.providers.gitlab
⋮----
group_ref = requests.utils.quote(spec.group or "", safe="")
endpoint = f"{provider_cfg.base_url}/groups/{group_ref}/projects"
⋮----
def _include_candidate(self, spec: SourceSpec, candidate: DiscoveredRepo) -> bool
⋮----
def _to_project_path(self, repo: DiscoveredRepo) -> ProjectPath
⋮----
def _needs_update(self, current: ProjectPath, incoming: ProjectPath) -> bool
⋮----
tracked_fields: List[Tuple[Optional[str], Optional[str]]] = [
⋮----
def _normalized_url(self, url: Optional[object]) -> str
````

## File: src/metagit/core/skills/__init__.py
````python
#!/usr/bin/env python
"""
Skills installation helpers.
"""
⋮----
__all__ = [
````

## File: src/metagit/core/web/ops_handler.py
````python
#!/usr/bin/env python
"""HTTP handlers for workspace ops routes (health, prune, sync)."""
⋮----
JsonResponder = Callable[[int, dict[str, Any]], None]
⋮----
_SYNC_JOB_PATH = re.compile(
_SYNC_EVENTS_PATH = re.compile(
⋮----
_JOB_STORE = SyncJobStore()
⋮----
class OpsWebHandler
⋮----
"""Route workspace health, prune, and sync operations for the web HTTP API."""
⋮----
"""Dispatch JSON ops routes; return True when handled."""
parsed_path = path if path.startswith("/") else f"/{path}"
⋮----
status_match = _SYNC_JOB_PATH.match(parsed_path)
⋮----
def sync_events_job_id(self, method: str, path: str) -> str | None
⋮----
"""Return job id when path is a sync SSE events route."""
⋮----
events_match = _SYNC_EVENTS_PATH.match(parsed_path)
⋮----
def stream_sync_events(self, job_id: str, stream: BinaryIO) -> None
⋮----
"""Write server-sent events for a sync job until it finishes."""
⋮----
def _post_health(self, body: bytes, respond: JsonResponder) -> None
⋮----
config = self._load_metagit(respond)
⋮----
app_config = self._load_appconfig(respond)
⋮----
payload = self._parse_body(body, respond, required=False) or {}
project_raw = payload.get("project")
project_name = str(project_raw).strip() if project_raw else None
⋮----
project_name = None
dedupe = (
result = self._health.check(
⋮----
def _get_graph(self, query: str, respond: JsonResponder) -> None
⋮----
params = parse_qs(query.lstrip("?"))
include_inferred = (
include_structure = (
view = self._graph.build_view(
⋮----
def _post_prune_preview(self, body: bytes, respond: JsonResponder) -> None
⋮----
payload = self._parse_body(body, respond, required=True)
⋮----
project = str(payload.get("project", "")).strip()
⋮----
include_hidden = bool(payload.get("include_hidden", False))
ignore_hidden = (
⋮----
project_manager = project_manager_from_app(
⋮----
candidates = project_manager.list_unmanaged_sync_directories(
⋮----
def _post_prune(self, body: bytes, respond: JsonResponder) -> None
⋮----
raw_paths = payload.get("paths")
⋮----
dry_run = bool(payload.get("dry_run", False))
force = bool(payload.get("force", False))
⋮----
project_sync = (Path(self._workspace_root) / project).resolve()
resolved_paths: list[Path] = []
⋮----
candidate = Path(str(item)).expanduser()
resolved = (
⋮----
removed: list[str] = []
errors: list[dict[str, str]] = []
⋮----
def _post_sync(self, body: bytes, respond: JsonResponder) -> None
⋮----
request = SyncJobRequest.model_validate(payload)
⋮----
job_id = self._job_store.create_job()
thread = threading.Thread(
⋮----
status = self._job_store.get(job_id)
⋮----
def _get_sync_status(self, job_id: str, respond: JsonResponder) -> None
⋮----
def _stream_sync_events(self, job_id: str, stream: BinaryIO) -> None
⋮----
events = self._job_store.drain_events(job_id)
⋮----
payload = json.dumps(event, separators=(",", ":"))
⋮----
payload = json.dumps(
⋮----
rows = self._index.build_index(
⋮----
payload = self._sync.sync_many(
⋮----
error = str(payload.get("error", "sync failed"))
⋮----
summary = payload.get("summary")
results = payload.get("results")
⋮----
def _load_metagit(self, respond: JsonResponder) -> MetagitConfig | None
⋮----
manager = MetagitConfigManager(self._config_path)
loaded = manager.load_config()
⋮----
def _load_appconfig(self, respond: JsonResponder) -> AppConfig | None
⋮----
loaded = load_appconfig(self._appconfig_path)
⋮----
parsed = json.loads(body.decode("utf-8"))
````

## File: src/metagit/core/workspace/catalog_models.py
````python
#!/usr/bin/env python
"""
Pydantic models for workspace catalog list and mutation results.
"""
⋮----
class CatalogError(BaseModel)
⋮----
"""Structured catalog operation error."""
⋮----
kind: str
message: str
⋮----
class CatalogResult(BaseModel)
⋮----
"""Uniform envelope for agentic CLI, MCP, and API responses."""
⋮----
ok: bool = True
error: Optional[CatalogError] = None
data: Optional[dict[str, Any]] = None
⋮----
class WorkspaceSummary(BaseModel)
⋮----
"""Workspace section of a manifest with project roll-up."""
⋮----
definition_path: str
workspace_root: str
file_name: str
file_description: Optional[str] = None
file_agent_instructions: Optional[str] = None
workspace: Optional[Workspace] = None
project_count: int = 0
repo_count: int = 0
⋮----
class ProjectListEntry(BaseModel)
⋮----
"""Project row for list operations."""
⋮----
name: str
description: Optional[str] = None
agent_instructions: Optional[str] = None
dedupe_enabled: Optional[bool] = Field(
⋮----
class RepoListEntry(BaseModel)
⋮----
"""Repository row for list operations."""
⋮----
project_name: str
repo: ProjectPath
configured_path: Optional[str] = None
repo_path: Optional[str] = None
exists: Optional[bool] = None
status: Optional[str] = None
⋮----
class CatalogMutationResult(BaseModel)
⋮----
"""Result of add/remove mutations."""
⋮----
entity: Literal["project", "repo"] = "project"
operation: Literal["add", "remove"] = "add"
project_name: str = ""
repo_name: Optional[str] = None
config_path: str = ""
````

## File: src/metagit/core/workspace/context_models.py
````python
#!/usr/bin/env python
"""
Pydantic models for workspace project context and snapshot persistence.
"""
⋮----
_ENV_KEY_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{0,63}$")
_SECRET_VALUE_PATTERNS = (
_MAX_AGENT_NOTES = 4096
_MAX_RECENT_REPOS = 10
⋮----
def utc_now_iso() -> str
⋮----
"""Return current UTC timestamp in ISO8601 format."""
⋮----
def validate_env_key(key: str) -> str
⋮----
"""Validate environment variable key naming."""
⋮----
def validate_env_value(value: str) -> str
⋮----
"""Reject values that look like secrets."""
⋮----
class WorkspaceSessionMeta(BaseModel)
⋮----
"""Workspace-level session metadata."""
⋮----
active_project: Optional[str] = None
last_switch_at: Optional[str] = None
last_snapshot_id: Optional[str] = None
⋮----
class ProjectSession(BaseModel)
⋮----
"""Per-project persisted session state."""
⋮----
project_name: str
updated_at: str = Field(default_factory=utc_now_iso)
recent_repos: list[str] = Field(default_factory=list)
primary_repo_path: Optional[str] = None
agent_notes: Optional[str] = None
env_overrides: dict[str, str] = Field(default_factory=dict)
⋮----
@field_validator("agent_notes")
@classmethod
    def validate_agent_notes(cls, value: Optional[str]) -> Optional[str]
⋮----
"""Bound agent notes length."""
⋮----
@field_validator("recent_repos")
@classmethod
    def validate_recent_repos(cls, value: list[str]) -> list[str]
⋮----
"""Cap recent repo list length."""
⋮----
@field_validator("env_overrides")
@classmethod
    def validate_env_overrides(cls, value: dict[str, str]) -> dict[str, str]
⋮----
"""Validate non-secret environment overrides."""
validated: dict[str, str] = {}
⋮----
class ProjectRepoContext(BaseModel)
⋮----
"""Repository row included in a project context bundle."""
⋮----
repo_name: str
repo_path: str
configured_path: Optional[str] = None
exists: bool = False
branch: Optional[str] = None
dirty: Optional[bool] = None
tags: dict[str, str] = Field(default_factory=dict)
agent_instructions: Optional[str] = None
inspect_error: Optional[str] = None
⋮----
class ProjectContextEnv(BaseModel)
⋮----
"""Environment exports and hints for agents."""
⋮----
export: dict[str, str] = Field(default_factory=dict)
hints: list[str] = Field(default_factory=list)
⋮----
class ProjectContextSession(BaseModel)
⋮----
"""Session slice returned with a context bundle."""
⋮----
restored: bool = False
⋮----
class ProjectContextBundle(BaseModel)
⋮----
"""Result of switching or showing project context."""
⋮----
ok: bool = True
error: Optional[str] = None
project_name: str = ""
workspace_root: str = ""
project_description: Optional[str] = None
⋮----
instruction_layers: list[AgentInstructionLayer] = Field(default_factory=list)
effective_agent_instructions: str = ""
focus_repo_name: Optional[str] = None
repos: list[ProjectRepoContext] = Field(default_factory=list)
env: ProjectContextEnv = Field(default_factory=ProjectContextEnv)
session: ProjectContextSession = Field(default_factory=ProjectContextSession)
suggested_cwd: Optional[str] = None
inspect_truncated: bool = False
⋮----
class SnapshotRepoState(BaseModel)
⋮----
"""Git state for one repository in a snapshot."""
⋮----
dirty: bool = False
ahead: Optional[int] = None
behind: Optional[int] = None
uncommitted_count: Optional[int] = None
⋮----
class WorkspaceSnapshot(BaseModel)
⋮----
"""Immutable workspace state manifest."""
⋮----
snapshot_id: str
created_at: str = Field(default_factory=utc_now_iso)
⋮----
label: Optional[str] = None
repos: list[SnapshotRepoState] = Field(default_factory=list)
env_key_names: list[str] = Field(default_factory=list)
session_ref: Optional[str] = None
⋮----
class WorkspaceSnapshotRestoreResult(BaseModel)
⋮----
"""Result of restoring a workspace snapshot."""
⋮----
snapshot_id: str = ""
context: Optional[ProjectContextBundle] = None
notes: list[str] = Field(default_factory=list)
````

## File: src/metagit/core/workspace/dependency_models.py
````python
#!/usr/bin/env python
"""
Pydantic models for cross-project dependency graph results.
"""
⋮----
DependencyEdgeType = Literal[
⋮----
class DependencyNode(BaseModel)
⋮----
"""Node in a workspace dependency graph."""
⋮----
id: str
kind: Literal["project", "repo"]
label: str
project_name: Optional[str] = None
repo_path: Optional[str] = None
gitnexus_indexed: Optional[bool] = None
gitnexus_status: Optional[str] = None
⋮----
class DependencyEdge(BaseModel)
⋮----
"""Directed dependency edge with evidence."""
⋮----
from_id: str
to_id: str
type: DependencyEdgeType
evidence: list[str] = Field(default_factory=list)
⋮----
class ImpactSummary(BaseModel)
⋮----
"""High-level impact assessment for dependency exploration."""
⋮----
risk: Literal["low", "medium", "high"] = "low"
affected_projects: list[str] = Field(default_factory=list)
affected_repos: list[str] = Field(default_factory=list)
edge_count: int = 0
notes: list[str] = Field(default_factory=list)
⋮----
class CrossProjectDependencyResult(BaseModel)
⋮----
"""Result of cross-project dependency mapping."""
⋮----
ok: bool = True
error: Optional[str] = None
source_project: str = ""
dependency_types: list[str] = Field(default_factory=list)
depth: int = 1
graph_status: dict[str, str] = Field(default_factory=dict)
nodes: list[DependencyNode] = Field(default_factory=list)
edges: list[DependencyEdge] = Field(default_factory=list)
impact_summary: ImpactSummary = Field(default_factory=ImpactSummary)
````

## File: src/metagit/data/skills/metagit-control-center/scripts/control-cycle.zsh
````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-infra}"

"$(dirname "$0")/../../metagit-gating/scripts/gate-status.zsh" "$ROOT"

if [[ -n "$QUERY" ]]; then
  "$(dirname "$0")/../../metagit-upstream-scan/scripts/upstream-scan.zsh" "$ROOT" "$QUERY" "$PRESET" 20
else
  echo "status=ok\tmessage=no-query-provided"
fi
````

## File: src/metagit/data/skills/metagit-control-center/SKILL.md
````markdown
---
name: metagit-control-center
description: Use when running metagit as an MCP control center for multi-repo awareness, guarded sync, and operational knowledge across ongoing agent tasks.
---

# Metagit Control Center Skill

Use this skill when an agent should actively coordinate repository context and task execution across a workspace.

## Purpose

Provide a repeatable control-center workflow where Metagit MCP guides awareness, synchronization, and operational continuity over multiple related repositories.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for control-center cycles:
- `./scripts/control-cycle.zsh [root_path] ["query"] [preset]`

Wrapper behavior:
- runs gating status first
- optionally runs upstream discovery for blocker queries
- emits compact, machine-readable lines

## Core Workflows

### 1) Session Initialization
- Validate active workspace gate.
- Read `metagit://workspace/config` and `metagit://workspace/repos/status`.
- Call `metagit_project_context_switch` when the objective is tied to a workspace project.
- Run `metagit_workspace_health_check` or read `metagit://workspace/health` for maintenance signals.
- Identify stale repos and unresolved blockers from prior activity.

### 2) Active Task Support
- For each coding objective, map impacted repos.
- Use workspace search and upstream hints before broad exploration.
- Sync only repos that are required by the active objective.

### 3) Guarded Synchronization
- Default to `fetch` for visibility.
- Use `pull` or `clone` only with explicit permission and rationale.
- Track sync outcomes in operations log resource.

### 4) Operational Memory
- Before switching projects: `metagit_session_update` (notes + recent repos), optional `metagit_workspace_state_snapshot`.
- After returning: `metagit_workspace_state_restore` when a snapshot was taken (metadata only; git tree is unchanged).

Maintain bounded local records of:
- sync actions
- issue signatures searched
- candidate upstream repos identified
- unresolved dependencies and follow-ups

## Decision Guidelines

- Use metagit search first when blocker appears external to current repo.
- Prefer deterministic evidence over speculative jumps.
- Keep operations minimal and auditable.

## Output Contract

For each control-center cycle, provide:
- current objective
- repositories examined
- actions taken (or intentionally deferred)
- next recommended step

## Safety Rules

- Never mutate repositories without explicit authorization.
- Never broaden scope beyond configured workspace boundaries.
- Always preserve a clear audit trail of control actions.
````

## File: src/metagit/data/skills/metagit-gitnexus/SKILL.md
````markdown
---
name: metagit-gitnexus
description: Run gitnexus analysis for a target workspace and selected project repositories before graph-dependent tasks. Use when index staleness is detected or cross-repo graph results are needed.
---

# Running GitNexus Analysis

Use this skill whenever GitNexus context is stale or missing for target repositories.

## Local Wrapper (Use First)

- `zsh ./skills/metagit-gitnexus/scripts/analyze-targets.zsh <workspace_root> [project_name]`

## Workflow

1. Analyze the current repository where the command is run.
2. Resolve target project repositories from `.metagit.yml`.
3. Run `npx gitnexus analyze` in each local repository path found.
4. Report per-repository success/failure and next actions.

## Manual workspace graph (`.metagit.yml` → Cypher)

Export manifest `graph.relationships` for GitNexus overlay ingest:

```bash
metagit config graph export -c .metagit.yml --format tool-calls
# or MCP: metagit_export_workspace_graph_cypher
```

Run returned `gitnexus_cypher` tool calls against the umbrella repo index (`--gitnexus-repo` when names differ). Schema DDL (`MetagitEntity` / `MetagitLink`) runs once per index.

## Output Contract

Return:
- analyzed repositories
- failures and reasons
- whether graph queries are safe to run now

## Safety

- Skip repositories that do not exist locally.
- Do not mutate repo content; analysis should be read-only indexing.
````

## File: src/metagit/data/skills/metagit-projects/SKILL.md
````markdown
---
name: metagit-projects
description: Ongoing workspace and project management for OpenClaw and Hermes agents. Use when starting work, organizing repos, or before creating a new project folder so existing metagit projects are reused instead of duplicated.
---

# Ongoing Project Management

Use this skill when the user starts new work, reorganizes repositories, or asks you to create a project folder. Metagit is the source of truth for what already exists in the workspace.

## Concepts

Metagit uses a three-level hierarchy (see project terminology docs):

| Level | Meaning |
|-------|---------|
| **Workspace** | Root folder where projects are synced (from app config `workspace.path`, often `./.metagit/`). Holds many projects. |
| **Project** | Named group of one or more Git repositories. Multi-repo products are one project; unrelated repos can also share a workspace under different project names. |
| **Repo** | A single Git repository entry under `workspace.projects[].repos` in `.metagit.yml`. |

A **project** is not always “one product.” It is whatever grouping helps the user and agents reason about related (or intentionally grouped) repositories. A workspace may contain unrelated projects side by side (for example `default`, `client-a`, `experiments`).

The umbrella `.metagit.yml` (workspace definition, often `kind: umbrella`) lives in a coordinating repository or central config checkout. Application repos may have their own `.metagit.yml` for metadata mode.

## Mandatory: check before creating folders

**Never** create a new project directory or clone into the workspace until you have checked metagit for an existing match.

1. **Locate the workspace definition**
   - Prefer the user’s umbrella `.metagit.yml` if known.
   - Otherwise use `.metagit.yml` in the current repo with `--definition /path/to/.metagit.yml`.

2. **List configured projects and repo counts**
   ```bash
   metagit config info --config-path /path/to/.metagit.yml
   metagit project list --config /path/to/.metagit.yml --project default
   ```
   Repeat `--project` for each project name returned by `config info`.

3. **Search managed repos by name, URL fragment, or tag**
   ```bash
   metagit search "<proposed-name-or-url>" --definition /path/to/.metagit.yml
   metagit search "<name>" --definition /path/to/.metagit.yml --json
   ```

4. **Inspect on disk** (workspace path from app config, default `./.metagit/`)
   - Expected layout: `{workspace.path}/{project_name}/{repo_name}/`
   - If the directory already exists, **reuse it**; do not create a parallel tree.

5. **Decide**
   - **Match found** → use existing project/repo; run `metagit project sync` only if the user wants checkouts refreshed.
   - **No match** → proceed with registration steps below (still add to workspace; do not leave orphan folders).

## Registering new work in the workspace

### New repository in an existing project

From the directory containing the workspace `.metagit.yml` (or pass `--config`):

```bash
metagit project repo add --project <project_name> --prompt
# or non-interactive:
metagit project repo add --project <project_name> --name <repo> --url <git-url>
metagit config validate --config-path .metagit.yml
metagit project sync --project <project_name>
```

In the new application repo (if applicable):

```bash
cd /path/to/new/repo
metagit init
metagit detect repo --force   # optional: enrich .metagit.yml
```

### New project group (new `workspace.projects[]` entry)

There is no separate `project create` CLI today. Add a project block to `.metagit.yml`:

```yaml
workspace:
  projects:
    - name: my-new-project
      description: Short purpose for agents and humans
      repos: []
```

Then validate, add repos, and sync:

```bash
metagit config validate --config-path .metagit.yml
metagit project repo add --project my-new-project --prompt
metagit project sync --project my-new-project
```

Choose a **distinct project name**; avoid duplicating an existing `workspace.projects[].name`.

### New umbrella workspace

When bootstrapping a workspace coordinator repo:

```bash
metagit init --kind umbrella
metagit project repo add --project default --prompt
metagit project sync
```

## Ongoing session habits

At the start of sustained work:

1. Run **metagit-workspace-scope** (or `metagit mcp serve --status-once` when MCP is available).
2. Confirm **active project** matches the user’s intent (`metagit workspace select --project <name>` when switching).
3. Use **`metagit search`** before assuming a repo is missing or lives elsewhere.
4. For multi-repo tasks, prefer **metagit-control-center** or **metagit-multi-repo** over ad-hoc cloning.

When the user names a target folder:

- Resolve it against managed config first.
- If unmanaged but present on disk under the project sync folder, report it and offer to add via `metagit project repo add` rather than recreating.

## OpenClaw and Hermes setup

Install bundled skills (including this one) for agent hosts:

```bash
metagit skills list
metagit skills install --scope user --target openclaw --target hermes
metagit mcp install --scope user --target openclaw --target hermes
```

Use `--scope project` when installing into a specific umbrella repository checkout.

## Output contract

After project-management actions, report:

- workspace definition path used
- whether the target was **existing** or **newly registered**
- project name and repo name(s) affected
- sync status if `project sync` was run
- recommended next command (`workspace select`, `project select`, or `detect`)

## Rename and move (layout)

Use layout commands when reorganizing project or repo names. **Always dry-run first.**

```bash
metagit workspace project rename old-name new-name --dry-run --json
metagit workspace repo rename -p <project> old-repo new-repo --dry-run --json
metagit workspace repo move -p <from> -n <repo> --to-project <to> --dry-run --json
```

MCP: `metagit_workspace_project_rename`, `metagit_workspace_repo_rename`, `metagit_workspace_repo_move`.

- Renames update `.metagit.yml` and synced folders under `workspace.path` when they exist.
- Moves relocate repo mounts between projects (dedupe mode relinks symlinks to canonical checkouts).
- Use `--manifest-only` to change the manifest without touching disk.
- Run `metagit config validate` after any layout change.

## Safety

- Do not clone, delete, or overwrite sync directories without explicit user approval.
- Do not edit `.metagit.yml` without validating afterward (`metagit config validate`).
- Prefer reusing configured repos over creating duplicate checkouts.
- Keep unrelated experiments in separate `workspace.projects` entries when the user wants clear boundaries.
````

## File: src/metagit/data/skills/metagit-repo-impact/SKILL.md
````markdown
---
name: metagit-repo-impact
description: Plan repository change impact before edits by combining metagit workspace context and graph-based dependency analysis. Use when a change may affect multiple repositories.
---

# Planning Repo Impact

Use this skill before risky or cross-repo modifications.

## Workflow

1. Identify target symbols/files for change.
2. Use metagit workspace context to bound repository scope.
3. Use graph impact tooling to estimate blast radius.
4. Produce a change plan with test and rollback focus.

## Commands

- `metagit workspace select --project <name>`
- MCP `metagit_cross_project_dependencies` with `source_project` and `dependency_types` before large cross-project edits
- MCP `metagit_project_context_switch` to bound scope to one workspace project
- `npx gitnexus analyze` on affected repos when `graph_status` is `stale` or `missing`
- `npx gitnexus query` / `gitnexus impact` for symbol-level analysis after indexes are fresh

## Output Contract

Return:
- impacted repositories and interfaces
- highest-risk change points
- minimum validation plan

## Safety

- Do not execute mutating steps in this planning phase.
````

## File: src/metagit/data/skills/metagit-workspace-scope/SKILL.md
````markdown
---
name: metagit-workspace-scope
description: Discover active metagit workspace scope, project boundaries, and repository status. Use when an agent starts work in a multi-repo workspace and needs fast, scoped context before editing.
---

# Discovering Workspace Scope

Use this skill at session start for workspace-aware tasks.

## Workflow

1. Run workspace gate and status checks first.
2. Read configured projects and repository status from metagit resources/tools.
3. Build a compact map of active project names, repo names, and sync state.
4. Report a bounded scope for the current objective.

## Commands

- `zsh ./skills/metagit-gating/scripts/gate-status.zsh [root_path]`
- `metagit workspace select --project <name>` (interactive repo picker)
- MCP `metagit_project_context_switch` with `project_name` (structured context + session restore)
- MCP `metagit_workspace_state_snapshot` before leaving a project for a long switch

## Output Contract

Return:
- active/inactive workspace state and reason
- candidate project to operate on
- top repositories to inspect first

## Safety

- Keep operations read-only in this step.
- Do not sync or mutate repositories without explicit request.
````

## File: src/metagit/data/skills/metagit-workspace-sync/SKILL.md
````markdown
---
name: metagit-workspace-sync
description: Sync workspace repositories safely using metagit with scoped fetch, pull, or clone actions. Use when repository content must be refreshed for implementation.
---

# Syncing Workspace Repositories

Use this skill when repository state must be updated.

## Workflow

1. Confirm active workspace and target project.
2. Start with read-only status and fetch where possible.
3. Use pull/clone only when required for the current objective.
4. Summarize which repositories changed and what remains stale.

## Commands

- `metagit project sync --project <name>`
- MCP `metagit_workspace_sync` for batch fetch/pull/clone (`repos`, `only_if`, `dry_run`, `max_parallel`)
- MCP `metagit_repo_sync` for a single repository (requires `allow_mutation` for pull/clone)
- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- repositories synced
- sync mode used (fetch/pull/clone)
- failures and retry guidance

## Safety

- Limit sync to repos defined in `.metagit.yml`.
- Avoid broad synchronization if only a subset is needed.
````

## File: src/metagit/data/config-example-overrides.yml
````yaml
# Merged last when generating docs/reference/metagit-config.full-example.yml
name: example-umbrella
description: Example umbrella workspace for documentation (not for production).
kind: umbrella
agent_instructions: |
  Hermes controller example: search the workspace before creating folders or clones.
  Use metagit_workspace_status and metagit_workspace_health_check at session start.
url: https://github.com/example-org/hermes-control-plane.git
workspace:
  description: Portfolio of git repos and local publish paths managed by metagit.
  agent_instructions: |
    Validate after manifest edits (metagit config validate). Default sync is fetch-only;
  projects:
    - name: portfolio
      description: Git-backed applications and services.
      agent_instructions: |
        Delegate single-repo work to subagents with effective_agent_instructions.
      repos:
        - name: example-service
          description: Sample git-backed service.
          url: https://github.com/example-org/example-service.git
          sync: true
          agent_instructions: |
            Run tests before opening PRs; keep tags updated in this manifest.
    - name: local
      description: Local-only paths for static sites (no git remotes).
      dedupe:
        enabled: false
      agent_instructions: |
        Do not git clone or pull repos in this project; paths are symlinks to user dirs.
        Dedupe is disabled for this project (overrides workspace.dedupe in metagit.config.yaml).
      repos:
        - name: example-site
          description: Sample static site published from a local folder.
          path: ~/Sites/example-site
          sync: true
          kind: website
          agent_instructions: |
            Document build and publish steps here; update path if the site moves.
````

## File: tests/cli/commands/test_init.py
````python
#!/usr/bin/env python
"""
CLI tests for metagit init command.
"""
⋮----
def test_resolve_project_metadata_non_git_directory(tmp_path: Path) -> None
⋮----
project_dir = tmp_path / "my-project"
⋮----
def test_resolve_project_metadata_git_repo_without_remote(tmp_path: Path) -> None
⋮----
project_dir = tmp_path / "local-repo"
⋮----
def test_init_succeeds_outside_git_repository() -> None
⋮----
runner = CliRunner()
⋮----
result = runner.invoke(
⋮----
config_path = Path(".metagit.yml")
⋮----
loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
⋮----
def test_init_list_templates() -> None
⋮----
result = runner.invoke(cli, ["init", "--list-templates"])
⋮----
def test_init_hermes_template_no_prompt() -> None
⋮----
answers = {
answers_path = Path("answers.yml")
⋮----
loaded = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8"))
⋮----
def test_resolve_target_dir_create(tmp_path: Path) -> None
⋮----
target = tmp_path / "new-coordinator"
resolved = resolve_target_dir(str(target), create=True)
⋮----
def test_init_writes_to_target_folder() -> None
⋮----
target = Path("coordinator")
⋮----
def test_init_target_option_overrides_positional(tmp_path: Path) -> None
⋮----
chosen = tmp_path / "chosen"
ignored = tmp_path / "ignored"
⋮----
def test_init_minimal_service_kind() -> None
````

## File: tests/cli/commands/test_mcp.py
````python
#!/usr/bin/env python
"""
CLI tests for metagit mcp commands.
"""
⋮----
def test_mcp_serve_accepts_root_option(tmp_path) -> None
⋮----
runner = CliRunner()
⋮----
result = runner.invoke(
⋮----
def test_mcp_install_project_target_updates_config() -> None
⋮----
config_data = json.loads(Path(".opencode/mcp.json").read_text(encoding="utf-8"))
````

## File: tests/cli/commands/test_project_repo.py
````python
#!/usr/bin/env python
"""CLI tests for metagit project repo prune."""
⋮----
def test_project_repo_prune_dry_run_lists_unmanaged(tmp_path: Path) -> None
⋮----
runner = CliRunner()
workspace = tmp_path / ".metagit"
platform = workspace / "platform"
⋮----
metagit_yml = tmp_path / ".metagit.yml"
⋮----
app_cfg = tmp_path / "metagit.config.yaml"
⋮----
result = runner.invoke(
⋮----
def test_project_repo_prune_force_removes_without_prompt(tmp_path: Path) -> None
⋮----
leftover = platform / "leftover"
````

## File: tests/cli/commands/test_skills.py
````python
#!/usr/bin/env python
"""
CLI tests for metagit skills commands.
"""
⋮----
def test_skills_list_displays_bundled_skills() -> None
⋮----
runner = CliRunner()
result = runner.invoke(cli, ["skills", "list"])
⋮----
def test_skills_install_project_target_creates_skill_directory() -> None
⋮----
result = runner.invoke(
⋮----
destination = Path(".opencode/skills")
⋮----
def test_skills_install_single_skill_only() -> None
⋮----
installed = [
⋮----
def test_skills_install_dry_run_does_not_write_files() -> None
⋮----
def test_skills_install_unknown_skill_fails() -> None
````

## File: tests/core/mcp/services/test_project_context.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.project_context
"""
⋮----
def _write_multi_project_workspace(tmp_path: Path) -> str
⋮----
alpha_repo = tmp_path / "alpha" / "repo-one"
⋮----
beta_repo = tmp_path / "beta" / "repo-two"
⋮----
def _load_config(tmp_path: Path) -> MetagitConfig
⋮----
manager = MetagitConfigManager(config_path=tmp_path / ".metagit.yml")
loaded = manager.load_config()
⋮----
def test_switch_sets_active_project_and_returns_repos(tmp_path: Path) -> None
⋮----
root = _write_multi_project_workspace(tmp_path)
config = _load_config(tmp_path)
service = ProjectContextService()
⋮----
bundle = service.switch(
⋮----
meta = SessionStore(workspace_root=root).get_workspace_meta()
⋮----
def test_switch_unknown_project_returns_error(tmp_path: Path) -> None
⋮----
def test_env_export_includes_metagit_and_config_variables(tmp_path: Path) -> None
⋮----
def test_update_session_persists_notes(tmp_path: Path) -> None
⋮----
result = service.update_session(
⋮----
session = SessionStore(workspace_root=root).get_project_session(project_name="alpha")
⋮----
def test_env_export_skips_sensitive_variable_ref(tmp_path: Path) -> None
⋮----
config = MetagitConfig(
````

## File: tests/core/workspace/test_catalog_service.py
````python
#!/usr/bin/env python
"""Tests for workspace catalog list and mutation service."""
⋮----
def _write_manifest(tmp_path: Path, projects: list[dict] | None = None) -> tuple[Path, str]
⋮----
manifest = {
config_path = tmp_path / ".metagit.yml"
⋮----
manager = MetagitConfigManager(str(config_path))
loaded = manager.load_config()
⋮----
def test_list_projects_and_repos(tmp_path: Path) -> None
⋮----
service = WorkspaceCatalogService()
projects = service.list_projects(config)
⋮----
repos = service.list_repos(config, str(tmp_path), project_name="platform")
⋮----
workspace = service.list_workspace(config, config_path, str(tmp_path))
⋮----
def test_add_and_remove_project(tmp_path: Path) -> None
⋮----
added = service.add_project(config, config_path, name="infra")
⋮----
manager = MetagitConfigManager(config_path)
reloaded = manager.load_config()
⋮----
removed = service.remove_project(reloaded, config_path, name="infra")
⋮----
def test_add_and_remove_repo(tmp_path: Path) -> None
⋮----
built = service.build_repo_from_fields(
⋮----
added = service.add_repo(
⋮----
removed = service.remove_repo(
⋮----
def test_add_repo_rejects_duplicate_identity(tmp_path: Path) -> None
⋮----
result = service.add_repo(
````

## File: tests/integration/test_mcp_workspace_flow.py
````python
#!/usr/bin/env python
"""
Integration tests for MCP workspace activation flow.
"""
⋮----
def test_end_to_end_workspace_activation_and_discovery(tmp_path: Path) -> None
⋮----
runner = CliRunner()
workspace_root = tmp_path / "workspace"
⋮----
inactive_result = runner.invoke(
⋮----
active_result = runner.invoke(
⋮----
runtime = MetagitMcpRuntime(root=str(workspace_root))
tools_response = runtime._handle_request(
⋮----
tool_names = [item["name"] for item in tools_response["result"]["tools"]]
````

## File: tests/test_appconfig_display.py
````python
#!/usr/bin/env python
"""Tests for appconfig show display and agent_mode."""
⋮----
def test_appconfig_show_includes_dedupe_and_agent_mode() -> None
⋮----
config = AppConfig()
payload = build_appconfig_payload(config, config_path="/tmp/metagit.config.yaml")
⋮----
def test_metagit_agent_mode_env_overrides_config(monkeypatch) -> None
⋮----
config = AppConfig(agent_mode=False)
config = AppConfig._override_from_environment(config)
⋮----
def test_render_appconfig_show_json() -> None
⋮----
rendered = render_appconfig_show(
````

## File: tests/test_project_search_service.py
````python
#!/usr/bin/env python
"""
Unit tests for ManagedRepoSearchService.
"""
⋮----
def _config(tmp_path: Path) -> MetagitConfig
⋮----
workspace_root = tmp_path / "workspace"
app_repo = workspace_root / "platform" / "abacus-app"
module_repo = workspace_root / "shared" / "abacus-module"
⋮----
def test_search_prioritizes_exact_repo_name(tmp_path: Path) -> None
⋮----
service = ManagedRepoSearchService()
result = service.search(
⋮----
def test_search_can_filter_by_tag(tmp_path: Path) -> None
⋮----
def test_search_filters_by_status_and_has_url(tmp_path: Path) -> None
⋮----
missing_repo = tmp_path / "workspace" / "platform" / "missing-app"
⋮----
config = _config(tmp_path)
⋮----
def test_search_sorts_by_project_name(tmp_path: Path) -> None
⋮----
project_names = [match.project_name for match in result.matches]
⋮----
def test_resolve_one_returns_ambiguous_match(tmp_path: Path) -> None
⋮----
resolved = service.resolve_one(
````

## File: tests/test_skills_installer.py
````python
#!/usr/bin/env python
"""
Unit tests for skills installer helpers.
"""
⋮----
def test_resolve_targets_respects_disable() -> None
⋮----
targets = resolve_targets(
⋮----
def test_autodetect_targets_project_scope(monkeypatch, tmp_path) -> None
⋮----
project_root = tmp_path / "project"
⋮----
detected = autodetect_targets(mode="skills", scope="project")
⋮----
def test_resolve_skill_names_rejects_unknown() -> None
⋮----
def test_install_skills_for_targets_single_skill(monkeypatch, tmp_path) -> None
⋮----
results = install_skills_for_targets(
destination = project_root / ".opencode" / "skills"
⋮----
installed = [p.name for p in destination.iterdir() if p.is_dir()]
⋮----
def test_install_skills_dry_run_writes_nothing(monkeypatch, tmp_path) -> None
````

## File: tests/test_workspace_index_service.py
````python
#!/usr/bin/env python
"""
Unit tests for WorkspaceIndexService managed repo rows (tags, status).
"""
⋮----
def test_build_index_synced_git_repo_with_tags_and_paths(tmp_path: Path) -> None
⋮----
workspace_root = tmp_path / "workspace"
⋮----
repo_dir = workspace_root / "svc-auth"
⋮----
config = MetagitConfig(
service = WorkspaceIndexService()
rows = service.build_index(config=config, workspace_root=str(workspace_root))
⋮----
row = rows[0]
⋮----
def test_build_index_url_only_repo_uses_project_mount_path(tmp_path: Path) -> None
⋮----
workspace_root = tmp_path / ".metagit"
⋮----
repo_dir = workspace_root / "platform" / "svc-auth"
⋮----
def test_build_index_missing_path_is_configured_missing(tmp_path: Path) -> None
````

## File: web/src/components/Layout.tsx
````typescript
import { NavLink, Outlet } from 'react-router-dom'
import { useThemeStore } from '../theme/useThemeStore'
import styles from './Layout.module.css'
⋮----
const navLinkClass = (
````

## File: web/src/components/SchemaTree.module.css
````css
.tree {
⋮----
.nested {
⋮----
.row {
⋮----
.row:hover {
⋮----
.rowSelected {
⋮----
.rowDisabled {
⋮----
.checkbox {
⋮----
.checkboxPlaceholder {
⋮----
.label {
⋮----
.key {
⋮----
.type {
⋮----
.required {
⋮----
.state {
⋮----
.error {
⋮----
.expandBtn {
⋮----
.expandBtn:hover {
⋮----
.listBtn {
⋮----
.listBtn:hover {
⋮----
.listBtnDanger {
⋮----
.listBtnDanger:hover {
⋮----
.count {
````

## File: web/src/components/SchemaTree.tsx
````typescript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useState } from 'react'
import type { ConfigOperation, SchemaFieldNode } from '../api/client'
import {
  configTreeQueryKey,
  fetchConfigTree,
  patchConfigTree,
  type ConfigTarget,
} from '../pages/configQueries'
import styles from './SchemaTree.module.css'
⋮----
interface SchemaTreeProps {
  target: ConfigTarget
  selectedPath: string | null
  onSelect: (node: SchemaFieldNode) => void
  onOperationApplied?: (op: ConfigOperation) => void
}
⋮----
function isOptionalToggleable(node: SchemaFieldNode): boolean
⋮----
function displayType(node: SchemaFieldNode): string
⋮----
function isListItemNode(node: SchemaFieldNode): boolean
⋮----
interface TreeNodesProps {
  nodes: SchemaFieldNode[]
  selectedPath: string | null
  onSelect: (node: SchemaFieldNode) => void
  onToggle: (node: SchemaFieldNode, checked: boolean) => void
  onAppend: (node: SchemaFieldNode) => void
  onRemove: (node: SchemaFieldNode) => void
  mutationPending: boolean
}
⋮----
onClick=
⋮----
event.stopPropagation()
onToggle(node, event.target.checked)
⋮----
onAppend(node)
⋮----
onRemove(node)
⋮----
setExpanded((value)
````

## File: web/src/pages/AppconfigPage.tsx
````typescript
import ConfigPage from './ConfigPage'
⋮----
export default function AppconfigPage()
````

## File: web/src/pages/ConfigPage.module.css
````css
.page {
⋮----
.header {
⋮----
.title {
⋮----
.subtitle {
⋮----
.layout {
⋮----
.layout > :last-child {
⋮----
.treePanel {
⋮----
.treeHeading {
````

## File: web/src/pages/ConfigPage.tsx
````typescript
import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import type { ConfigOperation, SchemaFieldNode } from '../api/client'
import ConfigPreview from '../components/ConfigPreview'
import FieldEditor from '../components/FieldEditor'
import SchemaTree from '../components/SchemaTree'
import {
  configTreeQueryKey,
  fetchConfigTree,
  type ConfigTarget,
} from './configQueries'
import styles from './ConfigPage.module.css'
⋮----
interface ConfigPageProps {
  target: ConfigTarget
  title: string
}
⋮----
function findNodeByPath(
  root: SchemaFieldNode | undefined,
  path: string | null,
): SchemaFieldNode | null
⋮----
function mergePendingOp(
  pending: ConfigOperation[],
  op: ConfigOperation,
): ConfigOperation[]
⋮----
export default function ConfigPage(
````

## File: web/src/pages/configQueries.ts
````typescript
import {
  getAppconfigTree,
  getMetagitConfigTree,
  patchAppconfig,
  patchMetagitConfig,
  postConfigPreview,
  type ConfigOperation,
  type ConfigPreviewResponse,
  type ConfigPreviewStyle,
  type ConfigTreeResponse,
} from '../api/client'
⋮----
export type ConfigTarget = 'metagit' | 'appconfig'
⋮----
export const configTreeQueryKey = (target: ConfigTarget)
⋮----
export function fetchConfigTree(target: ConfigTarget): Promise<ConfigTreeResponse>
⋮----
export function patchConfigTree(
  target: ConfigTarget,
  ops: ConfigOperation[],
  save: boolean,
): Promise<ConfigTreeResponse>
⋮----
export function fetchConfigPreview(
  target: ConfigTarget,
  style: ConfigPreviewStyle,
  operations: ConfigOperation[],
): Promise<ConfigPreviewResponse>
````

## File: web/src/pages/MetagitConfigPage.tsx
````typescript
import ConfigPage from './ConfigPage'
⋮----
export default function MetagitConfigPage()
````

## File: web/src/pages/WorkspacePage.module.css
````css
.page {
⋮----
.header {
⋮----
.title {
⋮----
.subtitle {
⋮----
.chips {
⋮----
.chip {
⋮----
.chip strong {
⋮----
.toolbar {
⋮----
.tabs {
⋮----
.tab {
⋮----
.tab:hover {
⋮----
.tabActive {
⋮----
.search {
⋮----
.search:focus {
⋮----
.layout {
⋮----
.loading,
⋮----
.loading {
⋮----
.error {
⋮----
.graphFilters {
⋮----
.checkLabel {
⋮----
.graphPanel {
````

## File: web/src/theme/tokens.css
````css
:root {
⋮----
:root,
⋮----
[data-theme='dark'] {
⋮----
:root:not([data-theme='light']) {
````

## File: web/src/App.css
````css
/* Route-level styles live in page/component CSS modules. */
````

## File: web/src/App.tsx
````typescript
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import Layout from './components/Layout'
import ThemeProvider from './theme/ThemeProvider'
import AppconfigPage from './pages/AppconfigPage'
import MetagitConfigPage from './pages/MetagitConfigPage'
import WorkspacePage from './pages/WorkspacePage'
````

## File: web/src/index.css
````css
* {
⋮----
html {
⋮----
body {
⋮----
#root {
⋮----
button,
````

## File: web/src/main.tsx
````typescript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
⋮----
import App from './App.tsx'
````

## File: .metagit.example.yml
````yaml
name: metagit-cli
description: Metagit-ai multi-repo management tool
url: https://github.com/metagit-ai/metagit-cli
kind: cli
documentation:
  - https://metagit-ai.github.io/metagit-cli/
license:
  kind: Apache-2.0
  file: LICENSE
maintainers:
  - name: Zachary Loeber
    email: zloeber@gmail.com
    role: author
branch_strategy: githubflow
taskers:
  - kind: Taskfile
paths:
  - name: metagit-cli
    description: The main CLI application
    kind: cli
    path: src/metagit
    language: python
    language_version: "3.13"
    package_manager: uv
    frameworks:
      - click
      - pydantic
      - loguru
cicd:
  platform: GitHub
  pipelines:
    - name: test
      ref: .github/workflows/test.yaml
    - name: release
      ref: .github/workflows/release.yaml
    - name: docker
      ref: .github/workflows/docker.yaml
    - name: docs
      ref: .github/workflows/docs.yaml
artifacts:
  - type: docker
    definition: Dockerfile
    location: ghcr.io/metagit-ai/metagit-cli
    version_strategy: semver
  - type: python_package
    definition: pyproject.toml
    location: https://pypi.org/project/metagit-cli/
    version_strategy: semver
deployment:
  strategy: pipeline
observability:
  logging_provider: console
  monitoring_providers:
    - sentry
secrets_management:
  - github_secrets
secrets:
  - name: GITHUB_TOKEN
    kind: access_token
    ref: ${{ secrets.GITHUB_TOKEN }}
  - name: PYPI_API_TOKEN
    kind: api_key
    ref: ${{ secrets.PYPI_API_TOKEN }}
````

## File: AGENTS.md
````markdown
<!-- gitnexus:start -->
# GitNexus — Code Intelligence

This project is indexed by GitNexus as **metagit-cli** (3672 symbols, 5447 relationships, 73 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.

> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

## Always Do

- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.

## Repo quality gate

- **`task qa:prepush`** from the repo root must run successfully before you report implementation work as complete whenever this session modified files in the tree (see `scripts/prepush-gate.py`). Fix failures and re-run. Skip only when the session made no edits or the user waived the gate in this thread.

## Never Do

- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.

## Resources

| Resource | Use for |
|----------|---------|
| `gitnexus://repo/metagit-cli/context` | Codebase overview, check index freshness |
| `gitnexus://repo/metagit-cli/clusters` | All functional areas |
| `gitnexus://repo/metagit-cli/processes` | All execution flows |
| `gitnexus://repo/metagit-cli/process/{name}` | Step-by-step execution trace |

## CLI

| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
| Work in the Examples area (87 symbols) | `.claude/skills/generated/examples/SKILL.md` |
| Work in the Mcp area (40 symbols) | `.claude/skills/generated/mcp/SKILL.md` |
| Work in the Commands area (38 symbols) | `.claude/skills/generated/commands/SKILL.md` |
| Work in the Record area (26 symbols) | `.claude/skills/generated/record/SKILL.md` |
| Work in the Services area (20 symbols) | `.claude/skills/generated/services/SKILL.md` |
| Work in the Providers area (16 symbols) | `.claude/skills/generated/providers/SKILL.md` |
| Work in the Cluster_45 area (11 symbols) | `.claude/skills/generated/cluster-45/SKILL.md` |
| Work in the Cluster_53 area (11 symbols) | `.claude/skills/generated/cluster-53/SKILL.md` |
| Work in the Cluster_41 area (8 symbols) | `.claude/skills/generated/cluster-41/SKILL.md` |
| Work in the Detect area (8 symbols) | `.claude/skills/generated/detect/SKILL.md` |
| Work in the Gitcache area (8 symbols) | `.claude/skills/generated/gitcache/SKILL.md` |
| Work in the Detectors area (7 symbols) | `.claude/skills/generated/detectors/SKILL.md` |
| Work in the Config area (6 symbols) | `.claude/skills/generated/config/SKILL.md` |
| Work in the Project area (6 symbols) | `.claude/skills/generated/project/SKILL.md` |
| Work in the Tests area (6 symbols) | `.claude/skills/generated/tests/SKILL.md` |
| Work in the Appconfig area (5 symbols) | `.claude/skills/generated/appconfig/SKILL.md` |
| Work in the Cluster_58 area (5 symbols) | `.claude/skills/generated/cluster-58/SKILL.md` |
| Work in the Cli area (4 symbols) | `.claude/skills/generated/cli/SKILL.md` |
| Work in the Cluster_52 area (4 symbols) | `.claude/skills/generated/cluster-52/SKILL.md` |

<!-- gitnexus:end -->
````

## File: .cursor/skills/metagit-release-audit/SKILL.md
````markdown
---
name: metagit-release-audit
description: Mandatory before calling work complete when the session changed repo files. Runs format, lint, tests, integration tests, context-aware pip-audit/bandit, and optional gitleaks via task qa:prepush. Use before push, release, or hand-off.
---

# Auditing Release Readiness

Use this skill **whenever** your session added or edited tracked files in this repository and you are about to hand off or say the task is done—not only “release” workflows. Read-only Q&A with no writes can skip it.

## Workflow

1. Run the pre-push quality gate.
2. Capture failing stage logs and iterate fixes.
3. Re-run until all required checks pass.
4. Return a short readiness summary.

## Commands

- `task qa:prepush`
- `task qa:prepush:loop -- 3` (optional bounded retry loop)

## Output Contract

Return:
- pass/fail status by stage (including `security_sync` / `security_audit` / `security_bandit` when triggered)
- unresolved blockers (if any)
- push readiness recommendation

Security in the gate is context-aware: lockfile changes run `uv sync --frozen --all-extras` + `pip-audit` + `bandit`; `src/` changes run `pip-audit` + `bandit`; docs-only diffs skip security. Use `task security:scan` for a full manual run.

## Safety

- Do not claim readiness unless checks are actually green.
````

## File: .mex/context/mcp-runtime.md
````markdown
---
name: mcp-runtime
description: Metagit MCP runtime architecture, tool/resource dispatch, gating, and sampling behavior.
triggers:
  - "mcp runtime"
  - "tools/list"
  - "tools/call"
  - "resources/read"
  - "sampling"
  - "stdio framing"
edges:
  - target: context/architecture.md
    condition: when MCP changes affect broader CLI/core system flow
  - target: context/conventions.md
    condition: when implementing or reviewing runtime/service code patterns
  - target: context/stack.md
    condition: when protocol/library/runtime version constraints matter
  - target: patterns/add-mcp-tool.md
    condition: when adding a new MCP tool or changing tool schemas
  - target: patterns/debug-mcp-runtime.md
    condition: when MCP message loop, framing, or tool dispatch fails
last_updated: 2026-05-15
---

# MCP Runtime

## Overview
- Entry command is `metagit mcp serve` from `src/metagit/cli/commands/mcp.py`.
- Runtime implementation lives in `src/metagit/core/mcp/runtime.py`.
- Runtime uses stdio JSON-RPC with MCP framing (`Content-Length` header + body).
- Gate state is resolved from workspace root + `.metagit.yml` validation before exposing tools.

## Active Runtime Services
- `WorkspaceRootResolver` + `WorkspaceGate` + `ToolRegistry` for state-aware tool visibility.
- Workspace services: index, path-based workspace search (`metagit_workspace_search`), GitNexus semantic query fan-out (`metagit_workspace_semantic_search`), upstream hints.
- `ManagedRepoSearchService` for `metagit_repo_search` (managed `.metagit.yml` repos only, with tags/status).
- Repo ops service for inspect/sync with mutation guardrails.
- Project context service (`metagit_project_context_switch`, `metagit_session_update`) with session store under `.metagit/sessions/`.
- Workspace snapshot service (`metagit_workspace_state_snapshot`, `metagit_workspace_state_restore`) under `.metagit/snapshots/`.
- Workspace search uses ripgrep when `rg` is on PATH; `metagit_workspace_sync` batches guarded fetch/pull/clone across index rows.
- `metagit_cross_project_dependencies` combines config-declared edges, manifest import hints, shared URL/path detection, and GitNexus per-repo index status.
- Phase 3: `metagit_workspace_health_check` (branch-age / integration staleness when enabled), `metagit_workspace_discover`, `metagit_project_template_apply`, resources `metagit://workspace/health` and `metagit://workspace/context`.
- Resource publisher for config/repo-status/ops-log resources.
- Bootstrap sampling service with fallback and optional client sampling flow.

## Protocol Notes
- Supported methods: `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `ping`.
- Invalid tool arguments normalize to JSON-RPC error `-32602` with `data.kind=invalid_arguments`.
- Runtime tracks client sampling capability from initialize params and can call `sampling/createMessage`.

## Guardrails
- Inactive gate state only exposes safe baseline tools.
- Mutating sync operations require explicit opt-in arguments/flags.
- Tool schemas are explicitly published in `tools/list` and should stay aligned with dispatcher validation.
````

## File: docs/reference/metagit-config.full-example.yml
````yaml
# NON-PRODUCTION EXEMPLAR — generated by `metagit config example`.
# Field descriptions come from Pydantic models; edit before use.

# Project name
name: example-umbrella
...
# Project description
description: Example umbrella workspace for documentation (not for production).
# Optional instructions for controller agents; applies with or without a workspace block
agent_instructions: 'Hermes controller example: search the workspace before creating folders or clones.\nUse
  metagit_workspace_status and metagit_workspace_health_check at session start.\n'
# Project URL
url: https://github.com/example-org/hermes-control-plane.git
...
# Project kind. This is used to determine the type of project and the best way to manage it.
kind: umbrella
# Documentation sources: bare strings (path or URL) or objects with kind, path/url, tags, and metadata for knowledge-graph ingestion
documentation:
  -
    # Source type (markdown, web, confluence, sharepoint, wiki, api, other)
    kind: example-value
...
    # Repo-relative or absolute path to a documentation file
    path: example-value
...
    # Remote documentation URL
    url: https://example.com/org/repo
...
    # Human-readable title
    title: example-value
...
    # Short summary for indexing and graph nodes
    description: Example text for documentation; replace before production use.
    # Flat metadata tags for filtering and graph edges
    tags:
      example: documentation
    # Extensible key-value payload for downstream graph ingestors
    metadata:
      source: metagit-config-example
...
# Manual cross-repo relationships and graph metadata for exports and GitNexus-style dependency maps
graph:
  # Manually entered cross-repo or cross-project edges
  relationships:
    -
      # Stable identifier for exports and graph merges
      id: example-value
...
      # Relationship source
      from_endpoint:
        # Workspace project name
        project: example-value
...
        # Repository name under the project
        repo: example-value
...
        # Optional file or directory path within the repo
        path: example-value
...
      # Relationship target
      to:
        # Workspace project name
        project: example-value
...
        # Repository name under the project
        repo: example-value
...
        # Optional file or directory path within the repo
        path: example-value
...
      # Relationship type (depends_on, documents, consumes, owns, related, …)
      type: example-value
...
      # Short label for graph UIs
      label: example-value
...
      # Longer explanation for agents and exports
      description: Example text for documentation; replace before production use.
      # Flat tags for filtering graph exports
      tags:
        example: documentation
      # Extensible payload for GitNexus or other graph ingestors
      metadata:
        source: metagit-config-example
...
  # Graph-level metadata for export pipelines
  metadata:
    source: metagit-config-example
...
# License information
license:
  # License type
  kind: None
  # License file path
  file: example
# Project maintainers
maintainers:
  -
    # Maintainer name
    name: example-name
...
    # Maintainer email
    email: maintainer@example.com
...
    # Maintainer role
    role: example
# Branch strategy used by the project.
branch_strategy: trunk
# Task management tools employed by the project.
taskers:
  -
    # Tasker type
    kind: Taskfile
# Branch naming patterns used by the project.
branch_naming:
  -
    # Branch strategy
    kind: trunk
    # Branch naming pattern
    pattern: example
# Generated artifacts from the project.
artifacts:
  -
    # Artifact type
    type: docker
    # Artifact definition
    definition: example
    # Artifact location
    location: example
    # Version strategy
    version_strategy: semver
# Secrets management tools employed by the project.
secrets_management:
  - example-value
...
# Secret definitions
secrets:
  -
    # Secret name
    name: example-name
...
    # Secret type
    kind: remote_jwt
    # Secret reference
    ref: example
# Variable definitions
variables:
  -
    # Variable name
    name: example-name
...
    # Variable type
    kind: string
    # Variable reference
    ref: example
# CI/CD configuration
cicd:
  # CI/CD platform
  platform: GitHub
  # List of pipelines
  pipelines:
    -
      # Pipeline name
      name: example-name
...
      # Pipeline reference
      ref: example
      # Pipeline variables
      variables:
        - example-value
...
# Deployment configuration
deployment:
  # Deployment strategy
  strategy: blue/green
  # Deployment environments
  environments:
    -
      # Environment name
      name: example-name
...
      # Environment URL
      url: https://example.com/org/repo
...
  # Infrastructure configuration
  infrastructure:
    # Provisioning tool
    provisioning_tool: Terraform
    # Hosting platform
    hosting: EC2
# Observability configuration
observability:
  # Logging provider
  logging_provider: console
  # Monitoring providers
  monitoring_providers:
    - prometheus
  # Alerting channels
  alerting_channels:
    -
      # Alerting channel name
      name: example-name
...
      # Alerting channel type
      type: slack
      # Alerting channel URL
      url: https://example.com/org/repo
...
  # Monitoring dashboards
  dashboards:
    -
      # Dashboard name
      name: example-name
...
      # Dashboard tool
      tool: example-value
...
      # Dashboard URL
      url: https://example.com/org/repo
...
# Important local project paths. In a monorepo, this would include any sub-projects typically found being built in the CICD pipelines.
paths:
  -
    # Friendly name for the path or project
    name: example-name
...
    # Short description of the path or project
    description: Example text for documentation; replace before production use.
    # Project kind
    kind: monorepo
    # Reference in the current project for the target project, used in dependencies
    ref: example
    # Project path
    path: example-value
...
    # Project branches
    branches:
      - example-value
...
    # Project URL
    url: https://example.com/org/repo
...
    # Sync setting
    sync: true
    # Programming language
    language: python
    # Language version
    language_version: 3.12
    # Package manager used by the project
    package_manager: uv
    # Frameworks used by the project
    frameworks:
      - example-value
...
    # Provider used to discover this repository
    source_provider: example-value
...
    # Source namespace identifier (org/user/group)
    source_namespace: example-value
...
    # Provider-native repository identifier
    source_repo_id: example-value
...
    # Flat metadata tags for managed repo search and filtering
    tags:
      example: documentation
    # If true, reconcile mode must not remove this repository automatically
    protected: false
    # Optional instructions for subagents operating in this repo or path
    agent_instructions: Example text for documentation; replace before production use.
# Additional project dependencies not found in the paths or components lists. These include docker images, helm charts, or terraform modules.
dependencies:
  -
    # Friendly name for the path or project
    name: example-name
...
    # Short description of the path or project
    description: Example text for documentation; replace before production use.
    # Project kind
    kind: monorepo
    # Reference in the current project for the target project, used in dependencies
    ref: example
    # Project path
    path: example-value
...
    # Project branches
    branches:
      - example-value
...
    # Project URL
    url: https://example.com/org/repo
...
    # Sync setting
    sync: true
    # Programming language
    language: python
    # Language version
    language_version: 3.12
    # Package manager used by the project
    package_manager: uv
    # Frameworks used by the project
    frameworks:
      - example-value
...
    # Provider used to discover this repository
    source_provider: example-value
...
    # Source namespace identifier (org/user/group)
    source_namespace: example-value
...
    # Provider-native repository identifier
    source_repo_id: example-value
...
    # Flat metadata tags for managed repo search and filtering
    tags:
      example: documentation
    # If true, reconcile mode must not remove this repository automatically
    protected: false
    # Optional instructions for subagents operating in this repo or path
    agent_instructions: Example text for documentation; replace before production use.
# Additional project component paths that may be useful in other projects.
components:
  -
    # Friendly name for the path or project
    name: example-name
...
    # Short description of the path or project
    description: Example text for documentation; replace before production use.
    # Project kind
    kind: monorepo
    # Reference in the current project for the target project, used in dependencies
    ref: example
    # Project path
    path: example-value
...
    # Project branches
    branches:
      - example-value
...
    # Project URL
    url: https://example.com/org/repo
...
    # Sync setting
    sync: true
    # Programming language
    language: python
    # Language version
    language_version: 3.12
    # Package manager used by the project
    package_manager: uv
    # Frameworks used by the project
    frameworks:
      - example-value
...
    # Provider used to discover this repository
    source_provider: example-value
...
    # Source namespace identifier (org/user/group)
    source_namespace: example-value
...
    # Provider-native repository identifier
    source_repo_id: example-value
...
    # Flat metadata tags for managed repo search and filtering
    tags:
      example: documentation
    # If true, reconcile mode must not remove this repository automatically
    protected: false
    # Optional instructions for subagents operating in this repo or path
    agent_instructions: Example text for documentation; replace before production use.
# Workspaces are a collection of projects that are related to each other. They are used to group projects together for a specific purpose. These are manually defined by the user. The internal workspace name is reservice
workspace:
  # Human-readable description of this workspace
  description: Portfolio of git repos and local publish paths managed by metagit.
  # Optional instructions for agents working in this workspace
  agent_instructions: Validate after manifest edits (metagit config validate). Default sync is fetch-only;\n
...
  # Workspace projects
  projects:
    -
      # Workspace project name
      name: portfolio
      # Human-readable description of this workspace project
      description: Git-backed applications and services.
...
      # Optional instructions for agents working in this workspace project
      agent_instructions: Delegate single-repo work to subagents with effective_agent_instructions.\n
...
      # Repository list
      repos:
        -
          # Friendly name for the path or project
          name: example-service
...
          # Short description of the path or project
          description: Sample git-backed service.
...
          # Project URL
          url: https://github.com/example-org/example-service.git
...
          # Sync setting
          sync: true
          # Optional instructions for subagents operating in this repo or path
          agent_instructions: Run tests before opening PRs; keep tags updated in this manifest.\n
    -
      # Workspace project name
      name: local
      # Human-readable description of this workspace project
      description: Local-only paths for static sites (no git remotes).
...
      # Optional override of app-config workspace.dedupe for this project (currently supports enabled only)
      dedupe:
        # When set, overrides workspace.dedupe.enabled from metagit.config.yaml for sync and layout under this project only
        enabled: false
      # Optional instructions for agents working in this workspace project
      agent_instructions: Do not git clone or pull repos in this project; paths are symlinks to user dirs.\nDedupe is disabled for this project (overrides workspace.dedupe in metagit.config.yaml).\n
      # Repository list
      repos:
        -
          # Friendly name for the path or project
          name: example-site
...
          # Short description of the path or project
          description: Sample static site published from a local folder.
          # Project path
          path: ~/Sites/example-site
...
          # Sync setting
          sync: true
          # Project kind
          kind: website
          # Optional instructions for subagents operating in this repo or path
          agent_instructions: Document build and publish steps here; update path if the site moves.\n
````

## File: examples/fuzzyfinder_custom_colors_demo.py
````python
#!/usr/bin/env python
"""
Example demonstrating custom colors per list item in FuzzyFinder.
This shows different ways to assign colors to individual items, including:
1. FuzzyFinderTarget objects with built-in color/opacity properties
2. Custom color mapping via custom_colors configuration
3. Mixed approaches and fallback behavior
"""
⋮----
# Add the metagit package to the path
⋮----
def test_fuzzyfindertarget_with_colors()
⋮----
"""Test FuzzyFinderTarget objects with built-in color and opacity properties."""
⋮----
# Create FuzzyFinderTarget objects with individual colors and opacity
items = [
⋮----
color="bold white bg:#cc0000",  # Red background for critical
⋮----
color="bold yellow",  # Yellow for high priority
⋮----
color="green",  # Green for normal priority
⋮----
color="#0088cc",  # Blue hex color for docs
⋮----
color="cyan",  # Cyan for testing
⋮----
color="magenta bg:#001122",  # Custom background
⋮----
# Note: custom_colors is defined but will be overridden by FuzzyFinderTarget properties
fallback_colors = {
⋮----
"Critical Bug Fix": "red",  # This won't be used - object has its own color
"Feature Development": "blue",  # This won't be used either
⋮----
config = FuzzyFinderConfig(
⋮----
custom_colors=fallback_colors,  # These are fallbacks, won't be used
item_opacity=0.5,  # This is fallback opacity, objects have their own
⋮----
finder = FuzzyFinder(config)
⋮----
result = finder.run()
⋮----
def test_mixed_object_types()
⋮----
"""Test mixing FuzzyFinderTarget objects with regular objects."""
⋮----
# Mix FuzzyFinderTarget objects with regular objects
class SimpleTask
⋮----
def __init__(self, name, priority)
⋮----
# FuzzyFinderTarget with built-in colors
⋮----
# Regular objects that will use custom_colors mapping
⋮----
# Custom colors for regular objects (by name)
color_mapping = {
⋮----
item_opacity=0.6,  # Fallback opacity for regular objects
⋮----
def test_priority_demonstration()
⋮----
"""Demonstrate the priority system: FuzzyFinderTarget properties override custom_colors."""
⋮----
# Create items where custom_colors would conflict with object properties
⋮----
color="green",  # Object says GREEN
⋮----
color="blue",  # Object says BLUE
⋮----
# No color/opacity set - will use fallbacks
⋮----
# Custom colors that would conflict with object properties
conflicting_colors = {
⋮----
"Override Test": "red",  # Object overrides this with GREEN
"Another Override": "yellow",  # Object overrides this with BLUE
"No Color Set": "purple",  # This will be used (no object color)
⋮----
item_opacity=0.5,  # Fallback opacity
⋮----
def test_string_items_with_colors()
⋮----
"""Test custom colors with string items."""
⋮----
# Create items with different priorities/types
⋮----
# Define custom colors for different item types
color_map = {
⋮----
def test_object_items_with_colors()
⋮----
"""Test custom colors with object items."""
⋮----
# Create task objects
class Task
⋮----
def __init__(self, name, priority, category)
⋮----
def __str__(self)
⋮----
tasks = [
⋮----
# Color by priority
priority_colors = {
⋮----
color_field="priority",  # Use priority field for color mapping
⋮----
def test_mixed_color_formats()
⋮----
"""Test different color format specifications."""
⋮----
# Demonstrate different color formats
color_formats = {
⋮----
"hex_color": "#ff6600",  # Hex color
"named_color": "cyan",  # Named color
"background_only": "bg:#ffff00",  # Background only
"text_and_bg": "blue bg:#ffffcc",  # Text and background
"rich_markup": "bold magenta",  # Rich markup
⋮----
def test_preview_with_colors()
⋮----
"""Test custom colors with preview enabled."""
⋮----
class ColoredItem
⋮----
def __init__(self, name, color_type, description)
⋮----
ui_colors = {
⋮----
def test_fuzzyfindertarget_with_preview()
⋮----
"""Test FuzzyFinderTarget objects with preview functionality."""
⋮----
# Create FuzzyFinderTarget objects for UI components with rich descriptions
⋮----
def main()
⋮----
"""Run all custom color demonstrations."""
⋮----
# New tests showcasing FuzzyFinderTarget objects
⋮----
# Legacy tests for custom_colors mapping
````

## File: skills/metagit-release-audit/SKILL.md
````markdown
---
name: metagit-release-audit
description: Mandatory before calling work complete when the session changed repo files. Runs format, lint, tests, integration tests, context-aware pip-audit/bandit, and optional gitleaks via task qa:prepush. Use before push, release, or hand-off.
---

# Auditing Release Readiness

Use this skill **whenever** your session added or edited tracked files in this repository and you are about to hand off or say the task is done—not only “release” workflows. Read-only Q&A with no writes can skip it.

## Workflow

1. Run the pre-push quality gate.
2. Capture failing stage logs and iterate fixes.
3. Re-run until all required checks pass.
4. Return a short readiness summary.

## Commands

- `task qa:prepush`
- `task qa:prepush:loop -- 3` (optional bounded retry loop)

## Output Contract

Return:
- pass/fail status by stage (including `security_sync` / `security_audit` / `security_bandit` when triggered)
- unresolved blockers (if any)
- push readiness recommendation

Security in the gate is context-aware: lockfile changes run `uv sync --frozen --all-extras` + `pip-audit` + `bandit`; `src/` changes run `pip-audit` + `bandit`; docs-only diffs skip security. Use `task security:scan` for a full manual run.

## Safety

- Do not claim readiness unless checks are actually green.
````

## File: src/metagit/cli/commands/init.py
````python
#!/usr/bin/env python
"""
Init subcommand

Creates a validated `.metagit.yml` from bundled templates (copier-style prompts or
answers files) or from a minimal kind profile aligned with the current schema.
"""
⋮----
def _resolve_project_metadata(directory: Path) -> Tuple[str, Optional[str]]
⋮----
"""
    Resolve project name and optional remote URL from a directory.

    Works inside a Git repository or any ordinary folder.
    """
name = directory.name
url: Optional[str] = None
⋮----
git_repo = Repo(directory)
name = Path(git_repo.working_dir).name
⋮----
remote_url = git_repo.remotes[0].url
url = remote_url if remote_url else None
⋮----
def _kind_choice() -> list[str]
⋮----
"""
    Resolve and validate the directory to initialize.

    Args:
        target: Path string (relative, absolute, or ~).
        create: When true, create the directory tree if missing.

    Returns:
        Absolute resolved target path.
    """
path = Path(target).expanduser().resolve()
⋮----
"""
    Initialize metagit with a validated manifest and optional companion files.

    TARGET is the folder to write into (default: current directory). Use --target
    to pass a path when you prefer a flag over the positional argument.
    """
logger: UnifiedLogger = ctx.obj["logger"]
app_config: AppConfig = ctx.obj["config"]
service = InitService()
target_path = resolve_target_dir(
⋮----
rows = service.list_templates()
⋮----
overrides: dict[str, str] = {}
⋮----
template_id = service.resolve_template_id(template, kind if not minimal else None)
manifest = service.registry.load_manifest(template_id)
use_bundled = manifest is not None and not minimal
⋮----
agent_mode = bool(ctx.obj.get("agent_mode", False))
⋮----
no_prompt = True
⋮----
result = service.initialize(
effective_kind = manifest.kind
⋮----
desc = description or f"{kind} project managed by metagit."
result = service.initialize_minimal(
effective_kind = kind
⋮----
def _sanitize_workspace_path(workspace_path: str) -> str
⋮----
"""Sanitize workspace path for .gitignore."""
⋮----
sanitized = workspace_path[2:]
⋮----
sanitized = workspace_path
⋮----
"""Update .gitignore file to include workspace path."""
target_path = _sanitize_workspace_path(workspace_path)
⋮----
lines = handle.readlines()
````

## File: src/metagit/cli/commands/workspace.py
````python
"""
Workspace subcommand
"""
⋮----
def _catalog_ctx(ctx: click.Context) -> tuple[MetagitConfig, str, str]
⋮----
local_config: MetagitConfig = ctx.obj["local_config"]
config_path: str = ctx.obj["config_path"]
app_config: AppConfig = ctx.obj["config"]
workspace_root = str(Path(app_config.workspace.path).expanduser().resolve())
⋮----
def _layout_ctx(ctx: click.Context) -> tuple[MetagitConfig, str, str, AppConfig]
⋮----
@click.pass_context
def workspace(ctx: click.Context, config_path: str) -> None
⋮----
"""Workspace subcommands"""
⋮----
logger = ctx.obj["logger"]
⋮----
config_manager = MetagitConfigManager(config_path)
local_config = config_manager.load_config()
⋮----
@click.pass_context
def workspace_list(ctx: click.Context, as_json: bool, no_index: bool) -> None
⋮----
"""List workspace manifest summary, projects, and repository index."""
⋮----
service = WorkspaceCatalogService()
result = service.list_workspace(
⋮----
summary = (result.data or {}).get("summary", {})
⋮----
@workspace.group("project")
@click.pass_context
def workspace_project(_ctx: click.Context) -> None
⋮----
"""Manage workspace projects in the manifest."""
⋮----
@click.pass_context
def workspace_project_list(ctx: click.Context, as_json: bool) -> None
⋮----
"""List projects defined in the workspace manifest."""
⋮----
result = WorkspaceCatalogService().list_projects(local_config)
⋮----
"""Add a project to the workspace manifest."""
⋮----
result = WorkspaceCatalogService().add_project(
⋮----
@click.pass_context
def workspace_project_remove(ctx: click.Context, name: str, as_json: bool) -> None
⋮----
"""Remove a project (and its repos) from the workspace manifest."""
⋮----
result = WorkspaceCatalogService().remove_project(
⋮----
"""Rename a workspace project (manifest and sync folder when present)."""
⋮----
dedupe = resolve_dedupe_for_layout(
result = WorkspaceLayoutService().rename_project(
⋮----
@workspace.group("repo")
@click.pass_context
def workspace_repo(_ctx: click.Context) -> None
⋮----
"""Manage repository entries in the workspace manifest."""
⋮----
"""List repositories in the workspace manifest."""
⋮----
result = WorkspaceCatalogService().list_repos(
⋮----
repo = row.get("repo", {})
⋮----
"""Add a repository entry to a workspace project (manifest only)."""
⋮----
built = service.build_repo_from_fields(
⋮----
result = service.add_repo(
⋮----
"""Remove a repository entry from the workspace manifest (does not delete files)."""
⋮----
result = WorkspaceCatalogService().remove_repo(
⋮----
"""Rename a repository entry and its sync mount when present."""
⋮----
result = WorkspaceLayoutService().rename_repo(
⋮----
"""Move a repository entry to another workspace project."""
⋮----
result = WorkspaceLayoutService().move_repo(
⋮----
@click.pass_context
def workspace_select(ctx: click.Context, project: str = None) -> None
⋮----
"""Select project repo to work on"""
⋮----
project = app_config.workspace.default_project
````

## File: src/metagit/core/api/server.py
````python
#!/usr/bin/env python
"""
Minimal local JSON HTTP API for managed workspace repository search.
"""
⋮----
def _parse_tag_filters_from_query(tag_values: list[str]) -> dict[str, str] | None
⋮----
"""Parse repeated `tag=key=value` style query pairs into a tag filter dict."""
⋮----
parsed: dict[str, str] = {}
⋮----
"""Return the first query value for a key, or default if missing or empty."""
values = params.get(key)
⋮----
first = values[0]
⋮----
def build_server(root: str, host: str, port: int) -> ThreadingHTTPServer
⋮----
"""Build a threading HTTP server rooted at a workspace directory."""
root_resolved = str(Path(root).resolve())
config_path = os.path.join(root_resolved, ".metagit.yml")
service = ManagedRepoSearchService()
catalog = CatalogApiHandler(
layout = LayoutApiHandler(
⋮----
class ReusableThreadingHTTPServer(ThreadingHTTPServer)
⋮----
allow_reuse_address = True
⋮----
class Handler(BaseHTTPRequestHandler)
⋮----
def log_message(self, format: str, *args: Any) -> None
⋮----
_ = (format, args)
⋮----
def do_GET(self) -> None
⋮----
parsed = urlparse(self.path)
params = parse_qs(parsed.query, keep_blank_values=True)
⋮----
manager = MetagitConfigManager(config_path)
loaded = manager.load_config()
⋮----
config = loaded
⋮----
limit_raw = _first(params, "limit", "10") or "10"
⋮----
limit_val = int(limit_raw)
⋮----
limit_val = 10
limit_val = max(1, min(limit_val, 500))
project_raw = _first(params, "project")
project_filter = (
result = service.search(
⋮----
resolved = service.resolve_one(
⋮----
code = 200
⋮----
code = 409
⋮----
code = 404
⋮----
def do_POST(self) -> None
⋮----
length = int(self.headers.get("Content-Length", "0") or "0")
body = self.rfile.read(length) if length > 0 else b""
⋮----
def do_DELETE(self) -> None
⋮----
def _json(self, status: int, payload: dict[str, Any]) -> None
⋮----
body = json.dumps(payload).encode("utf-8")
````

## File: src/metagit/core/appconfig/__init__.py
````python
#!/usr/bin/env python
⋮----
__all__ = [
⋮----
def load_config(config_path: str) -> Union[AppConfig, Exception]
⋮----
"""
    Load and validate the YAML configuration file.
    """
⋮----
config_file = Path(config_path)
⋮----
config_data = yaml.safe_load(file)
⋮----
config = AppConfig(**config_data["config"])
⋮----
def save_config(config_path: str, config: AppConfig) -> Union[None, Exception]
⋮----
"""
    Save the AppConfig object to a YAML file.
    """
⋮----
config_dict = {"config": config.model_dump(exclude_none=True, mode="json")}
⋮----
"""Set appconfig values"""
⋮----
logger = UnifiedLogger(
⋮----
config_path = name.split(".")
current_level = appconfig
⋮----
current_level = getattr(current_level, element)
⋮----
last_element = config_path[-1]
⋮----
field_type = type(getattr(current_level, last_element))
⋮----
converted_value = True
⋮----
converted_value = False
⋮----
converted_value = field_type(value)
⋮----
"""Retrieve appconfig values"""
⋮----
# Map LOG_LEVELS[3] (which is logging.INFO) to the string 'INFO'
⋮----
appconfig_dict = appconfig.model_dump(
output_value = {"config": appconfig_dict}
⋮----
element_value = output_value[element]
⋮----
output_value = element_value.__dict__
⋮----
output_value = element_value
⋮----
output_value = list(output_value.keys())
⋮----
output_result = []
⋮----
output_value = output_result
⋮----
base_yaml.Dumper.ignore_aliases = lambda *args: True  # noqa: ARG005
````

## File: src/metagit/core/config/models.py
````python
#!/usr/bin/env python
"""
Pydantic models for .metagit.yml configuration file.

This module defines the data models used to parse and validate
the .metagit.yml configuration file structure.
"""
⋮----
class LicenseKind(str, Enum)
⋮----
"""Enumeration of license kinds."""
⋮----
NONE = "None"
MIT = "MIT"
APACHE_2_0 = "Apache-2.0"
GPL_3_0 = "GPL-3.0"
BSD_3_CLAUSE = "BSD-3-Clause"
PROPRIETARY = "proprietary"
CUSTOM = "custom"
UNKNOWN = "unknown"
⋮----
class BranchStrategy(str, Enum)
⋮----
"""Enumeration of branch strategies."""
⋮----
TRUNK = "trunk"
GITFLOW = "gitflow"
GITHUBFLOW = "githubflow"
GITLABFLOW = "gitlabflow"
FORK = "fork"
NONE = "none"
⋮----
class TaskerKind(str, Enum)
⋮----
"""Enumeration of tasker kinds."""
⋮----
TASKFILE = "Taskfile"
MAKEFILE = "Makefile"
JEST = "Jest"
NPM = "NPM"
ATMOS = "Atmos"
⋮----
MISE_TASKS = "mise_tasks"
⋮----
class ArtifactType(str, Enum)
⋮----
"""Enumeration of artifact types."""
⋮----
DOCKER = "docker"
GITHUB_RELEASE = "github_release"
HELM_CHART = "helm_chart"
NPM_PACKAGE = "npm_package"
STATIC_WEBSITE = "static_website"
PYTHON_PACKAGE = "python_package"
NODE_PACKAGE = "node_package"
RUBY_PACKAGE = "ruby_package"
JAVA_PACKAGE = "java_package"
C_PACKAGE = "c_package"
CPP_PACKAGE = "cpp_package"
CSHARP_PACKAGE = "csharp_package"
GO_PACKAGE = "go_package"
RUST_PACKAGE = "rust_package"
PHP_PACKAGE = "php_package"
DOTNET_PACKAGE = "dotnet_package"
ELIXIR_PACKAGE = "elixir_package"
HASKELL_PACKAGE = "haskell_package"
⋮----
OTHER = "other"
PLUGIN = "plugin"
TEMPLATE = "template"
CONFIG = "config"
BINARY = "binary"
ARCHIVE = "archive"
⋮----
class VersionStrategy(str, Enum)
⋮----
"""Enumeration of version strategies."""
⋮----
SEMVER = "semver"
⋮----
class SecretKind(str, Enum)
⋮----
"""Enumeration of secret kinds."""
⋮----
REMOTE_JWT = "remote_jwt"
REMOTE_API_KEY = "remote_api_key"
GENERATED_STRING = "generated_string"
⋮----
DYNAMIC = "dynamic"
PRIVATE_KEY = "private_key"
PUBLIC_KEY = "public_key"
SECRET_KEY = "secret_key"
API_KEY = "api_key"
ACCESS_TOKEN = "access_token"
REFRESH_TOKEN = "refresh_token"
PASSWORD = "password"
DATABASE_PASSWORD = "database_password"
⋮----
class VariableKind(str, Enum)
⋮----
"""Enumeration of variable kinds."""
⋮----
STRING = "string"
INTEGER = "integer"
BOOLEAN = "boolean"
⋮----
class CICDPlatform(str, Enum)
⋮----
"""Enumeration of CI/CD platforms."""
⋮----
GITHUB = "GitHub"
GITLAB = "GitLab"
CIRCLECI = "CircleCI"
JENKINS = "Jenkins"
JX = "jx"
TEKTON = "tekton"
⋮----
class DeploymentStrategy(str, Enum)
⋮----
"""Enumeration of deployment strategies."""
⋮----
BLUE_GREEN = "blue/green"
ROLLING = "rolling"
MANUAL = "manual"
GITOPS = "gitops"
PIPELINE = "pipeline"
⋮----
class ProvisioningTool(str, Enum)
⋮----
"""Enumeration of provisioning tools."""
⋮----
TERRAFORM = "Terraform"
CLOUDFORMATION = "CloudFormation"
CDKTF = "CDKTF"
AWS_CDK = "AWS CDK"
BICEP = "Bicep"
⋮----
class Hosting(str, Enum)
⋮----
"""Enumeration of hosting options."""
⋮----
EC2 = "EC2"
VMWARE = "VMware"
ORACLE = "Oracle"
KUBERNETES = "Kubernetes"
VERCEL = "Vercel"
ECS = "ECS"
AWS_LAMBDA = "AWS Lambda"
AWS_FARGATE = "AWS Fargate"
AWS_EKS = "AWS EKS"
AWS_ECS = "AWS ECS"
AWS_ECS_FARGATE = "AWS ECS Fargate"
AWS_ECS_FARGATE_SPOT = "AWS ECS Fargate Spot"
AWS_ECS_FARGATE_SPOT_SPOT = "AWS ECS Fargate Spot Spot"
ELASTIC_BEANSTALK = "Elastic Beanstalk"
AZURE_APP_SERVICE = "Azure App Service"
AZURE_FUNCTIONS = "Azure Functions"
AZURE_CONTAINER_INSTANCES = "Azure Container Instances"
AZURE_CONTAINER_APPS = "Azure Container Apps"
AZURE_CONTAINER_APPS_ENVIRONMENT = "Azure Container Apps Environment"
AZURE_CONTAINER_APPS_ENVIRONMENT_SERVICE = (
AZURE_CONTAINER_APPS_ENVIRONMENT_SERVICE_SERVICE = (
⋮----
class LoggingProvider(str, Enum)
⋮----
"""Enumeration of logging providers."""
⋮----
CONSOLE = "console"
CLOUDWATCH = "cloudwatch"
ELK = "elk"
SENTRY = "sentry"
⋮----
class MonitoringProvider(str, Enum)
⋮----
"""Enumeration of monitoring providers."""
⋮----
PROMETHEUS = "prometheus"
DATADOG = "datadog"
GRAFANA = "grafana"
⋮----
class AlertingChannelType(str, Enum)
⋮----
"""Enumeration of alerting channel types."""
⋮----
SLACK = "slack"
TEAMS = "teams"
EMAIL = "email"
SMS = "sms"
WEBHOOK = "webhook"
⋮----
class ComponentKind(str, Enum)
⋮----
"""Enumeration of component kinds."""
⋮----
ENTRY_POINT = "entry_point"
⋮----
class DependencyKind(str, Enum)
⋮----
"""Enumeration of dependency kinds."""
⋮----
DOCKER_IMAGE = "docker_image"
REPOSITORY = "repository"
⋮----
class Maintainer(BaseModel)
⋮----
"""Model for project maintainer information."""
⋮----
name: str = Field(..., description="Maintainer name")
email: str = Field(..., description="Maintainer email")
role: str = Field(..., description="Maintainer role")
⋮----
class Config
⋮----
"""Pydantic configuration."""
⋮----
use_enum_values = True
extra = "forbid"
⋮----
class License(BaseModel)
⋮----
"""Model for project license information."""
⋮----
kind: LicenseKind = Field(..., description="License type")
file: str = Field(default="", description="License file path")
⋮----
class Tasker(BaseModel)
⋮----
"""Model for task management tools."""
⋮----
kind: TaskerKind = Field(..., description="Tasker type")
⋮----
class BranchNaming(BaseModel)
⋮----
"""Model for branch naming patterns."""
⋮----
kind: BranchStrategy = Field(..., description="Branch strategy")
pattern: str = Field(..., description="Branch naming pattern")
⋮----
class Branch(BaseModel)
⋮----
"""Model for branch information."""
⋮----
name: str = Field(..., description="Branch name")
environment: Optional[str] = Field(None, description="Environment for this branch")
⋮----
class Artifact(BaseModel)
⋮----
"""Model for generated artifacts."""
⋮----
type: ArtifactType = Field(..., description="Artifact type")
definition: str = Field(..., description="Artifact definition")
location: Union[HttpUrl, str] = Field(..., description="Artifact location")
version_strategy: VersionStrategy = Field(..., description="Version strategy")
⋮----
@field_serializer("location")
    def serialize_location(self, location: Union[HttpUrl, str], _info: Any) -> str
⋮----
"""Serialize location to string."""
⋮----
class Secret(BaseModel)
⋮----
"""Model for secret definitions."""
⋮----
name: str = Field(..., description="Secret name")
kind: SecretKind = Field(..., description="Secret type")
ref: str = Field(..., description="Secret reference")
⋮----
class Variable(BaseModel)
⋮----
"""Model for variable definitions."""
⋮----
name: str = Field(..., description="Variable name")
kind: VariableKind = Field(..., description="Variable type")
ref: str = Field(..., description="Variable reference")
⋮----
class Pipeline(BaseModel)
⋮----
"""Model for CI/CD pipeline."""
⋮----
name: str = Field(..., description="Pipeline name")
ref: str = Field(..., description="Pipeline reference")
variables: Optional[List[str]] = Field(None, description="Pipeline variables")
⋮----
@field_validator("variables", mode="before")
    def validate_variables(cls, v: Any) -> Any
⋮----
"""Validate variables field."""
⋮----
class CICD(BaseModel)
⋮----
"""Model for CI/CD configuration."""
⋮----
platform: CICDPlatform = Field(..., description="CI/CD platform")
pipelines: List[Pipeline] = Field(..., description="List of pipelines")
⋮----
class Environment(BaseModel)
⋮----
"""Model for deployment environment."""
⋮----
name: str = Field(..., description="Environment name")
url: Optional[HttpUrl] = Field(None, description="Environment URL")
⋮----
@field_serializer("url")
    def serialize_url(self, url: Optional[HttpUrl], _info: Any) -> Optional[str]
⋮----
"""Serialize URL to string."""
⋮----
class Infrastructure(BaseModel)
⋮----
"""Model for infrastructure configuration."""
⋮----
provisioning_tool: ProvisioningTool = Field(..., description="Provisioning tool")
hosting: Hosting = Field(..., description="Hosting platform")
⋮----
class Deployment(BaseModel)
⋮----
"""Model for deployment configuration."""
⋮----
strategy: DeploymentStrategy = Field(..., description="Deployment strategy")
environments: Optional[List[Environment]] = Field(
infrastructure: Optional[Infrastructure] = Field(
⋮----
class AlertingChannel(BaseModel)
⋮----
"""Model for alerting channel."""
⋮----
name: str = Field(..., description="Alerting channel name")
type: AlertingChannelType = Field(..., description="Alerting channel type")
url: Union[HttpUrl, str] = Field(..., description="Alerting channel URL")
⋮----
@field_serializer("url")
    def serialize_url(self, url: Union[HttpUrl, str], _info: Any) -> str
⋮----
class Dashboard(BaseModel)
⋮----
"""Model for monitoring dashboard."""
⋮----
name: str = Field(..., description="Dashboard name")
tool: str = Field(..., description="Dashboard tool")
url: HttpUrl = Field(..., description="Dashboard URL")
⋮----
@field_serializer("url")
    def serialize_url(self, url: HttpUrl, _info: Any) -> str
⋮----
class Observability(BaseModel)
⋮----
"""Model for observability configuration."""
⋮----
logging_provider: Optional[LoggingProvider] = Field(
monitoring_providers: Optional[List[MonitoringProvider]] = Field(
alerting_channels: Optional[List[AlertingChannel]] = Field(
dashboards: Optional[List[Dashboard]] = Field(
⋮----
class Visibility(str, Enum)
⋮----
"""Enumeration of repository visibility types."""
⋮----
PUBLIC = "public"
PRIVATE = "private"
INTERNAL = "internal"
⋮----
class ProjectType(str, Enum)
⋮----
"""Enumeration of project types."""
⋮----
APPLICATION = "application"
LIBRARY = "library"
MICROSERVICE = "microservice"
CLI = "cli"
IAC = "iac"
⋮----
DATA_SCIENCE = "data-science"
⋮----
DOCS = "docs"
TEST = "test"
⋮----
class ProjectDomain(str, Enum)
⋮----
"""Enumeration of project domains."""
⋮----
WEB = "web"
MOBILE = "mobile"
DEVOPS = "devops"
ML = "ml"
DATABASE = "database"
SECURITY = "security"
FINANCE = "finance"
GAMING = "gaming"
IOT = "iot"
AGENT = "agent"
⋮----
DOCUMENTATION = "documentation"
⋮----
class BuildTool(str, Enum)
⋮----
"""Enumeration of build tools."""
⋮----
MAKE = "make"
CMAKE = "cmake"
BAZEL = "bazel"
⋮----
class LicenseType(str, Enum)
⋮----
"""Enumeration of license types."""
⋮----
class Owner(BaseModel)
⋮----
"""Model for repository owner information."""
⋮----
org: str = Field(..., description="Organization name")
team: str = Field(..., description="Team name")
contact: str = Field(..., description="Contact email")
⋮----
class Language(BaseModel)
⋮----
"""Model for project language information."""
⋮----
primary: str = Field(..., description="Primary programming language")
secondary: Optional[List[str]] = Field(
⋮----
class Project(BaseModel)
⋮----
"""Model for project information."""
⋮----
description: Optional[str] = Field(
agent_instructions: Optional[str] = Field(
type: ProjectType = Field(..., description="Project type")
domain: ProjectDomain = Field(..., description="Project domain")
language: Language = Field(..., description="Language information")
framework: Optional[List[str]] = Field(None, description="Frameworks used")
package_managers: Optional[List[str]] = Field(
build_tool: Optional[BuildTool] = Field(None, description="Build tool used")
deploy_targets: Optional[List[str]] = Field(None, description="Deployment targets")
⋮----
class RepoMetadata(BaseModel)
⋮----
"""Model for repository metadata."""
⋮----
tags: Optional[List[str]] = Field(None, description="Repository tags")
created_at: Optional[datetime] = Field(None, description="Repository creation date")
last_commit_at: Optional[datetime] = Field(None, description="Last commit date")
default_branch: Optional[str] = Field(None, description="Default branch name")
topics: Optional[List[str]] = Field(None, description="Repository topics")
forked_from: Optional[Union[HttpUrl, str]] = Field(
archived: Optional[bool] = Field(
template: Optional[bool] = Field(
has_ci: Optional[bool] = Field(False, description="Whether repository has CI/CD")
has_tests: Optional[bool] = Field(False, description="Whether repository has tests")
has_docs: Optional[bool] = Field(
has_docker: Optional[bool] = Field(
has_iac: Optional[bool] = Field(
⋮----
"""Serialize forked_from to string."""
⋮----
class CommitFrequency(str, Enum)
⋮----
"""Enumeration of commit frequency types."""
⋮----
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY = "monthly"
⋮----
class PullRequests(BaseModel)
⋮----
"""Model for pull request metrics."""
⋮----
open: int = Field(..., description="Number of open pull requests")
merged_last_30d: int = Field(
⋮----
class Metrics(BaseModel)
⋮----
"""Model for repository metrics."""
⋮----
stars: int = Field(..., description="Number of stars")
forks: int = Field(..., description="Number of forks")
open_issues: int = Field(..., description="Number of open issues")
pull_requests: PullRequests = Field(..., description="Pull request metrics")
contributors: int = Field(..., description="Number of contributors")
commit_frequency: CommitFrequency = Field(..., description="Commit frequency")
⋮----
# New configuration models for AppConfig
class MetagitConfig(BaseModel)
⋮----
"""Main model for .metagit.yml configuration file."""
⋮----
name: str = Field(..., description="Project name")
⋮----
url: Optional[Union[HttpUrl, GitUrl]] = Field(None, description="Project URL")
kind: Optional[ProjectKind] = Field(
documentation: Optional[List[DocumentationSource]] = Field(
graph: Optional[WorkspaceGraph] = Field(
license: Optional[License] = Field(None, description="License information")
maintainers: Optional[List[Maintainer]] = Field(
branch_strategy: Optional[BranchStrategy] = Field(
taskers: Optional[List[Tasker]] = Field(
branch_naming: Optional[List[BranchNaming]] = Field(
artifacts: Optional[List[Artifact]] = Field(
secrets_management: Optional[List[str]] = Field(
secrets: Optional[List[Secret]] = Field(None, description="Secret definitions")
variables: Optional[List[Variable]] = Field(
cicd: Optional[CICD] = Field(None, description="CI/CD configuration")
deployment: Optional[Deployment] = Field(
observability: Optional[Observability] = Field(
paths: Optional[List[ProjectPath]] = Field(
dependencies: Optional[List[ProjectPath]] = Field(
components: Optional[List[ProjectPath]] = Field(
workspace: Optional[Workspace] = Field(
⋮----
@field_validator("documentation", mode="before")
@classmethod
    def _coerce_documentation(cls, value: object) -> object
⋮----
"""Accept strings or dicts in YAML documentation lists."""
⋮----
def documentation_graph_nodes(self) -> list[dict[str, Any]]
⋮----
"""Export documentation entries for knowledge-graph ingestors."""
⋮----
def graph_export_payload(self) -> dict[str, Any]
⋮----
"""Export manual graph relationships and metadata for external tools."""
⋮----
relationships = []
⋮----
@property
    def local_workspace_project(self) -> WorkspaceProject
⋮----
"""Get the local workspace project configuration."""
# Combine paths and dependencies into a single list of repos
repos = []
⋮----
validate_assignment = True
⋮----
class TenantConfig(AppConfig)
⋮----
"""Model for tenant configuration that inherits from AppConfig to include all Boundary settings."""
⋮----
# Tenant-specific fields (in addition to all AppConfig fields)
enabled: bool = Field(default=False, description="Whether multi-tenancy is enabled")
default_tenant: str = Field(default="default", description="Default tenant ID")
tenant_header: str = Field(default="X-Tenant-ID", description="Tenant header name")
tenant_required: bool = Field(
allowed_tenants: List[str] = Field(
⋮----
@classmethod
    def _override_from_environment(cls, config: "TenantConfig") -> "TenantConfig"
⋮----
"""
        Override configuration with environment variables, including tenant-specific ones.

        Args:
            config: TenantConfig to override

        Returns:
            Updated TenantConfig
        """
# Call parent method first
config = super()._override_from_environment(config)
⋮----
# Tenant-specific environment variables
⋮----
@classmethod
    def load(cls, config_path: str = None) -> Union["TenantConfig", Exception]
⋮----
"""
        Load TenantConfig from file.

        Args:
            config_path: Path to configuration file (optional)

        Returns:
            TenantConfig object or Exception
        """
⋮----
config_path = os.path.join(
⋮----
config_file = Path(config_path)
⋮----
config_data = yaml.safe_load(f)
⋮----
config = cls(**config_data["config"])
⋮----
config = cls(**config_data)
⋮----
# Override with environment variables
config = cls._override_from_environment(config)
````

## File: src/metagit/core/detect/manager.py
````python
#!/usr/bin/env python3
⋮----
# from metagit.core.detect.detectors.terraform import TerraformModuleDiscovery
⋮----
class DetectionManager(MetagitRecord, LoggingModel)
⋮----
"""
    Single entrypoint for performing detection analysis of a target git project or git project path.

    This class inherits from MetagitRecord and includes all RepositoryAnalysis functionality.
    Existing metagitconfig data is loaded first if a config file exists in the project.
    """
⋮----
# Detection-specific configuration
detection_config: DetectionManagerConfig = Field(
⋮----
# Internal tracking
analysis_completed: bool = Field(
⋮----
@property
    def project_path(self) -> str
⋮----
"""Get the project path."""
⋮----
@project_path.setter
    def project_path(self, value: str) -> None
⋮----
"""Set the project path."""
⋮----
"""
        Create a DetectionManager from a local path.

        Args:
            path: Path to the git repository or project directory
            logger: Logger instance to use
            config: Detection configuration

        Returns:
            DetectionManager instance or Exception
        """
logger = logger or UnifiedLogger(LoggerConfig()).get_logger()
⋮----
# Load existing metagitconfig if it exists
existing_config = cls._load_existing_config(path)
⋮----
# Create base MetagitRecord data
record_data = {
⋮----
# Merge with existing config if found
⋮----
# Create DetectionManager instance
manager = cls(
⋮----
"""
        Create a DetectionManager from a git URL (clones the repository).

        Args:
            url: Git repository URL
            temp_dir: Temporary directory for cloning
            logger: Logger instance to use
            config: Detection configuration

        Returns:
            DetectionManager instance or Exception
        """
⋮----
normalized_url = normalize_git_url(url)
⋮----
# Create temporary directory if not provided
⋮----
temp_dir = tempfile.mkdtemp(prefix="metagit_")
⋮----
# Clone the repository
⋮----
_ = Repo.clone_from(normalized_url, temp_dir)
⋮----
# Load existing metagitconfig if it exists in the cloned repo
existing_config = cls._load_existing_config(temp_dir)
⋮----
@staticmethod
    def _load_existing_config(path: str) -> Optional[MetagitConfig]
⋮----
"""Load existing metagitconfig if it exists in the project."""
config_paths = [
⋮----
config_data = yaml.safe_load(f)
⋮----
def run_all(self) -> Union[None, Exception]
⋮----
"""
        Run all enabled analysis methods.

        Returns:
            None if successful, Exception if failed
        """
⋮----
# Check if this is a git repository
⋮----
_ = Repo(self.path)
⋮----
language_result = self._detect_languages()
⋮----
# Run project type detection
type_result = self._detect_project_type()
⋮----
# Run branch analysis if enabled
⋮----
# Run CI/CD analysis if enabled
⋮----
# Run directory summary analysis if enabled
⋮----
# Run directory details analysis if enabled
⋮----
file_lookup = FileExtensionLookup()
⋮----
# Analyze files
⋮----
# Detect metrics
⋮----
# Update MetagitRecord fields
⋮----
def run_specific(self, method_name: str) -> Union[None, Exception]
⋮----
"""
        Run a specific analysis method.

        Args:
            method_name: Name of the method to run

        Returns:
            None if successful, Exception if failed
        """
⋮----
result = self._detect_languages()
⋮----
result = self._detect_project_type()
⋮----
result = GitBranchAnalysis.from_repo(self.path, self.logger)
⋮----
result = self._ci_config_analysis()
⋮----
result = directory_summary(self.path)
⋮----
result = directory_details(self.path, file_lookup)
⋮----
def _extract_metadata(self) -> None
⋮----
"""Extract basic repository metadata."""
⋮----
# Extract name from path if not set
⋮----
# Try to extract description from README files
readme_files = ["README.md", "README.txt", "README.rst", "README"]
⋮----
readme_path = Path(self.path) / readme_file
⋮----
content = f.read()
# Extract first line as description
lines = content.split("\n")
⋮----
line = line.strip()
⋮----
self.description = line[:200]  # Limit to 200 chars
⋮----
def _detect_languages(self) -> Union[LanguageDetection, Exception]
⋮----
"""Detect programming languages in the repository."""
⋮----
# This is a simplified language detection
# In a real implementation, you would use more sophisticated detection
detected_languages = []
frameworks = []
package_managers = []
build_tools = []
⋮----
# Check for common file extensions
⋮----
# Check for framework and tool indicators
⋮----
package_managers = list(set(package_managers))
⋮----
# Determine primary language (most common)
⋮----
primary = max(set(detected_languages), key=detected_languages.count)
secondary = list(set(detected_languages) - {primary})
⋮----
primary = "Unknown"
secondary = []
⋮----
def _detect_project_type(self) -> Union[ProjectTypeDetection, Exception]
⋮----
"""Detect project type and domain."""
⋮----
# This is a simplified project type detection
⋮----
indicators = []
project_type = "other"
domain = "other"
confidence = 0.5
⋮----
# Check for common project indicators
⋮----
project_type = "application"
confidence = 0.7
⋮----
confidence = 0.8
⋮----
confidence = 0.6
⋮----
domain = "documentation"
⋮----
def _analyze_files(self) -> None
⋮----
"""Analyze files in the repository."""
⋮----
# Check for various file types
⋮----
# Categorize detected files
⋮----
def _detect_metrics(self) -> None
⋮----
"""Detect repository metrics."""
⋮----
repo = Repo(self.path)
⋮----
# Create metrics object
⋮----
stars=0,  # Would be fetched from provider API
forks=0,  # Would be fetched from provider API
open_issues=0,  # Would be fetched from provider API
⋮----
# Create metadata object
⋮----
def _update_metagit_record(self) -> None
⋮----
"""Update MetagitRecord fields with analysis results."""
⋮----
# Update language information
⋮----
# Update project type information
⋮----
# Update branch information
⋮----
# Convert BranchInfo to Branch objects
⋮----
# Update detection timestamp
⋮----
def summary(self) -> Union[str, Exception]
⋮----
"""
        Generate a summary of the repository analysis.

        Returns:
            Summary string or Exception
        """
⋮----
lines = [f"Repository Analysis for: {self.name or self.path}"]
⋮----
# Language detection
⋮----
# Project type detection
⋮----
# Branch analysis
⋮----
# CI/CD analysis
⋮----
# Directory analysis
⋮----
# File analysis
⋮----
# Metrics
⋮----
def to_yaml(self) -> Union[str, Exception]
⋮----
"""
        Convert DetectionManager to YAML string.

        Returns:
            YAML string or Exception
        """
⋮----
data = self.model_dump(exclude_none=True, exclude_defaults=True)
⋮----
# Handle complex objects that can't be serialized directly
def convert_objects(obj)
⋮----
# Convert nested objects
⋮----
def to_json(self) -> Union[str, Exception]
⋮----
"""
        Convert DetectionManager to JSON string.

        Returns:
            JSON string or Exception
        """
⋮----
"""
        Analyze CI/CD configuration in the repository.

        Args:
            repo_path: Path to the repository

        Returns:
            CIConfigAnalysis object or Exception
        """
⋮----
repo_path = self.path
⋮----
repo_path_obj = Path(repo_path)
⋮----
analysis = CIConfigAnalysis()
⋮----
# Check for common CI/CD configuration files
ci_files = self.detection_config.data_ci_file_source
⋮----
full_path = os.path.join(repo_path_obj, file_path)
⋮----
# Read configuration content
⋮----
# Count pipelines (basic heuristic)
⋮----
# Simple pipeline counting based on common patterns
pipeline_indicators = ["job:", "stage:", "pipeline:", "workflow:"]
⋮----
"""
        Analyze the git repository at the given path and return branch information and a strategy guess.
        Uses GitPython for all git operations.

        Args:
            repo_path: Path to the repository

        Returns:
            GitBranchAnalysis object or Exception

        Notes:
          - Should look to replace this with a more sophisticated analysis
          - Should replace GitPython with a more lightweight library
        """
⋮----
repo = Repo(repo_path)
⋮----
# Get local branches
local_branches = [
⋮----
if branch.name != "HEAD"  # Exclude HEAD branch
⋮----
# Get remote branches
remote_branches = []
⋮----
# Remove remote name prefix (e.g., 'origin/')
branch_name = ref.name.split("/", 1)[1] if "/" in ref.name else ref.name
# Exclude HEAD branch from remote branches
⋮----
# Combine and deduplicate branches (prefer local if name overlaps)
all_branches_dict = {b.name: b for b in remote_branches}
⋮----
all_branches = list(all_branches_dict.values())
⋮----
# Analyze branching strategy
strategy_guess = self._analyze_branching_strategy(all_branches)
⋮----
def _analyze_branching_strategy(self, branches: List[BranchInfo]) -> BranchStrategy
⋮----
"""Analyze the branching strategy based on branch names and patterns.

        Args:
            branches: List of BranchInfo objects

        Returns:
            BranchStrategy enum value

        Notes:
          - Should look to replace this with a more sophisticated analysis
          - Should replace GitPython with a more lightweight library
          - Consider custom branch names via appconfig for analysis of additional strategies
        """
⋮----
# Only remote branches matter
remote_branch_names = [b.name for b in branches if b.is_remote]
# local_branch_names = [b.name for b in branches if not b.is_remote]
⋮----
# Check for Git Flow patterns
⋮----
# Check for GitHub Flow patterns
⋮----
if len(remote_branch_names) <= 2:  # main/master + feature branches
⋮----
# Check for GitLab Flow patterns
⋮----
# Check for Trunk-Based Development
⋮----
# Check for Release Branching
⋮----
# def cleanup(self) -> None:
#     """Clean up temporary files if this was a cloned repository."""
#     if self.is_cloned and self.temp_dir and os.path.exists(self.temp_dir):
#         try:
#             shutil.rmtree(self.temp_dir)
#             self.logger.debug(f"Cleaned up temporary directory: {self.temp_dir}")
#         except Exception as e:
#             self.logger.warning(f"Failed to clean up temporary directory: {e}")
⋮----
# class ProjectDiscovery(Protocol):
#     def discover(self, metadata: ProjectMetadata) -> None:
#         """Mutates the metadata object by adding discovered dependencies"""
#         ...
⋮----
# def run_discovery(root_dir: Path, logger: UnifiedLogger) -> ProjectMetadata:
#     """Create a map of files and folders in a repository for further analysis."""
⋮----
#     try:
#         summary = directory_summary(root_dir, file_lookup=FileExtensionLookup())
#     except Exception as e:
#         logger.error(f"Error creating directory summary at {root_dir}: {e}")
#         return Exception(f"Error creating directory summary at {root_dir}: {e}")
⋮----
#         details = directory_details(target_path=root_dir, file_lookup=FileExtensionLookup())
⋮----
#         logger.error(f"Error creating directory details at {root_dir}: {e}")
#         return Exception(f"Error creating directory details at {root_dir}: {e}")
⋮----
#     metadata = ProjectMetadata(project_root=root_dir, directory_summary=summary, directory_details=details)
⋮----
#     detectors = [TerraformModuleDiscovery()]  # Add more detectors here
⋮----
#     for detector in detectors:
#         detector.discover(metadata)
⋮----
#     return metadata
⋮----
class ProjectDetection
⋮----
"""
    Class to manage project detection and analysis.
    This class is responsible for running various detection methods on a project.
    """
⋮----
def __init__(self, logger: Optional[UnifiedLogger] = None)
⋮----
# Dynamically register all detectors found in src/metagit/core/detect/detectors/
⋮----
def _load_detectors(self) -> None
⋮----
"""
        Load all detector modules dynamically from the detectors package.
        This method uses pkgutil to find all modules in the detectors package and registers
        all classes that are subclasses of Detector.
        """
⋮----
module = importlib.import_module(
# Register all classes that are subclasses of Detector
⋮----
attr = getattr(module, attr_name)
⋮----
def run(self, path: str) -> Union[List[DiscoveryResult], Exception]
⋮----
"""
        Run all registered detectors on the specified path.

        Args:
            path (str): The path to the project directory.

        Returns:
            Union[List[DiscoveryResult], Exception]: A list of discovery results if detection is successful, otherwise an exception.
        """
⋮----
path_files = list_git_files(path)
⋮----
scan_context = ProjectScanContext(root_path=Path(path), all_files=path_files)
results = []
⋮----
result = detector.run(scan_context)
⋮----
def all_files(self, path: str) -> Union[List[Path], Exception]
⋮----
"""
        Get all files in the specified path.
        This method uses the list_git_files utility to enumerate all files in the project directory rapidly.

        Args:
            path (str): The path to the project directory.

        Returns:
            List[Path]: A list of all file paths in the project directory.
        """
files = list_git_files(path)
file_list = [f.as_posix() for f in files]
````

## File: src/metagit/core/detect/models.py
````python
#!/usr/bin/env python3
"""
Combined models for the detect module.

This module contains all the Pydantic models used in the detection system,
including language detection, project type detection, branch analysis, CI/CD analysis,
and detection manager configuration.
"""
⋮----
class LanguageDetection(BaseModel)
⋮----
"""Model for language detection results."""
⋮----
primary: str = Field(default="Unknown", description="Primary programming language")
secondary: List[str] = Field(
frameworks: List[str] = Field(
package_managers: List[str] = Field(
build_tools: List[str] = Field(
⋮----
class Config
⋮----
use_enum_values = True
extra = "forbid"
⋮----
class ProjectTypeDetection(BaseModel)
⋮----
"""Model for project type detection results."""
⋮----
type: ProjectType = Field(
domain: ProjectDomain = Field(
confidence: float = Field(default=0.0, description="Confidence score (0.0 to 1.0)")
indicators: List[str] = Field(
⋮----
class BranchInfo(BaseModel)
⋮----
"""Model for branch information."""
⋮----
name: str = Field(..., description="Branch name")
is_remote: bool = Field(
⋮----
class BranchStrategy(str, Enum)
⋮----
"""Model for branch strategy."""
⋮----
GIT_FLOW = "Git Flow"
GITHUB_FLOW = "GitHub Flow"
GITLAB_FLOW = "GitLab Flow"
TRUNK_BASED_DEVELOPMENT = "Trunk-Based Development"
RELEASE_BRANCHING = "Release Branching"
UNKNOWN = "Unknown"
OTHER = "Other"
⋮----
class GitBranchAnalysis(LoggingModel)
⋮----
"""Model for Git branch analysis results."""
⋮----
branches: List[BranchInfo] = Field(
strategy_guess: Optional[BranchStrategy] = Field(
⋮----
# @classmethod
# def from_repo(
#     cls, repo_path: str = ".", logger: Optional[UnifiedLogger] = None
# ) -> Union["GitBranchAnalysis", Exception]:
#     """
#     Analyze the git repository at the given path and return branch information and a strategy guess.
#     Uses GitPython for all git operations.
⋮----
#     logger = logger or UnifiedLogger().get_logger()
⋮----
#     try:
#         repo = Repo(repo_path)
#     except (InvalidGitRepositoryError, NoSuchPathError) as e:
#         logger.exception(f"Invalid git repository at '{repo_path}': {e}")
#         return ValueError(f"Invalid git repository at '{repo_path}': {e}")
⋮----
#     # Get local branches
#     local_branches = [
#         BranchInfo(name=branch.name, is_remote=False)
#         for branch in repo.branches
#         if branch.name != "HEAD"  # Exclude HEAD branch
#     ]
#     logger.debug(f"Found {len(local_branches)} local branches")
⋮----
#     # Get remote branches
#     remote_branches = []
#     for remote in repo.remotes:
#         for ref in remote.refs:
#             # Remove remote name prefix (e.g., 'origin/')
#             branch_name = ref.name.split("/", 1)[1] if "/" in ref.name else ref.name
#             # Exclude HEAD branch from remote branches
#             if branch_name != "HEAD":
#                 remote_branches.append(BranchInfo(name=branch_name, is_remote=True))
#     logger.debug(f"Found {len(remote_branches)} remote branches")
⋮----
#     # Combine and deduplicate branches (prefer local if name overlaps)
#     all_branches_dict = {b.name: b for b in remote_branches}
#     all_branches_dict.update({b.name: b for b in local_branches})
#     all_branches = list(all_branches_dict.values())
⋮----
#     # Analyze branching strategy
#     strategy_guess = cls._analyze_branching_strategy(all_branches, logger)
⋮----
#     return cls(branches=all_branches, strategy_guess=strategy_guess)
⋮----
# @staticmethod
# def _analyze_branching_strategy(
#     branches: List[BranchInfo], logger: UnifiedLogger
# ) -> str:
#     """Analyze the branching strategy based on branch names and patterns."""
#     branch_names = [b.name for b in branches]
#     local_branches = [b.name for b in branches if not b.is_remote]
⋮----
#     logger.debug(f"Analyzing branching strategy for branches: {branch_names}")
⋮----
#     # Check for Git Flow patterns
#     if any(name in branch_names for name in ["develop", "master", "main"]):
#         if "develop" in branch_names:
#             return "Git Flow"
⋮----
#     # Check for GitHub Flow patterns
#     if "main" in branch_names or "master" in branch_names:
#         if len(local_branches) <= 2:  # main/master + feature branches
#             return "GitHub Flow"
⋮----
#     # Check for GitLab Flow patterns
#     if any(name in branch_names for name in ["staging", "production"]):
#         return "GitLab Flow"
⋮----
#     # Check for Trunk-Based Development
#     if len(local_branches) <= 1:
#         return "Trunk-Based Development"
⋮----
#     # Check for Release Branching
#     if any(name.startswith("release/") for name in branch_names):
#         return "Release Branching"
⋮----
#     return "Unknown"
⋮----
class CIConfigAnalysis(LoggingModel)
⋮----
"""Model for CI/CD configuration analysis results."""
⋮----
detected_tool: Optional[str] = Field(None, description="Detected CI/CD tool")
ci_config_path: Optional[str] = Field(
config_content: Optional[str] = Field(
pipeline_count: int = Field(default=0, description="Number of detected pipelines")
triggers: Optional[List[str]] = Field(default=[], description="Detected triggers")
⋮----
# ) -> Union["CIConfigAnalysis", Exception]:
⋮----
#     Analyze CI/CD configuration in the repository.
⋮----
#     Args:
#         repo_path: Path to the repository
#         logger: Logger instance
⋮----
#     Returns:
#         CIConfigAnalysis object or Exception
⋮----
#         repo_path_obj = Path(repo_path)
#         analysis = cls()
⋮----
#         # Check for common CI/CD configuration files
#         ci_files = {
#             ".github/workflows/": "GitHub Actions",
#             ".gitlab-ci.yml": "GitLab CI",
#             ".circleci/config.yml": "CircleCI",
#             "Jenkinsfile": "Jenkins",
#             ".travis.yml": "Travis CI",
#             "azure-pipelines.yml": "Azure DevOps",
#             "bitbucket-pipelines.yml": "Bitbucket Pipelines",
#         }
⋮----
#         for file_path, tool_name in ci_files.items():
#             full_path = os.path.join(repo_path_obj, file_path)
#             if full_path.exists():
#                 analysis.detected_tool = tool_name
#                 analysis.ci_config_path = str(full_path)
⋮----
#                 # Read configuration content
#                 if full_path.is_dir():
#                     for file in full_path.iterdir():
#                         if file.is_file():
#                             with open(file, "r", encoding="utf-8") as f:
#                                 analysis.config_content = f.read()
#                 else:
#                     try:
#                         with open(full_path, "r", encoding="utf-8") as f:
#                             analysis.config_content = f.read()
#                     except Exception as e:
#                         logger.warning(
#                             f"Could not read CI config file {full_path}: {e}"
#                         )
⋮----
#                 logger.debug(f"Detected CI/CD tool: {tool_name}")
#                 break
⋮----
#         # Count pipelines (basic heuristic)
#         if analysis.config_content:
#             # Simple pipeline counting based on common patterns
#             pipeline_indicators = ["job:", "stage:", "pipeline:", "workflow:"]
#             analysis.pipeline_count = sum(
#                 1
#                 for indicator in pipeline_indicators
#                 if indicator in analysis.config_content
#             )
⋮----
#         return analysis
⋮----
#     except Exception as e:
#         logger.exception(f"CI/CD analysis failed: {e}")
#         return e
⋮----
class DetectionManagerConfig(BaseModel)
⋮----
"""
    Configuration for DetectionManager specifying which analysis methods are enabled.
    """
⋮----
branch_analysis_enabled: bool = Field(
ci_config_analysis_enabled: bool = Field(
directory_summary_enabled: bool = Field(
directory_details_enabled: bool = Field(
# Future analysis methods
commit_analysis_enabled: bool = Field(
tag_analysis_enabled: bool = Field(
data_file_type_source: Optional[str] = Field(
data_ci_file_source: Optional[str] = Field(
data_cd_file_source: Optional[str] = Field(
data_package_manager_source: Optional[str] = Field(
⋮----
@classmethod
    def all_enabled(cls) -> "DetectionManagerConfig"
⋮----
"""Create a configuration with all analysis methods enabled."""
⋮----
@classmethod
    def minimal(cls) -> "DetectionManagerConfig"
⋮----
"""Create a configuration with only essential analysis methods enabled."""
⋮----
def get_enabled_methods(self) -> list[str]
⋮----
"""Get a list of enabled analysis method names."""
enabled = []
⋮----
class DiscoveryResult(BaseModel)
⋮----
name: str
description: Optional[str] = None
tags: List[str] = []
confidence: float = 1.0
data: dict[str, Any] = {}  # detector-specific structured data
⋮----
class ProjectScanContext(BaseModel)
⋮----
root_path: Path
all_files: List[Path]
⋮----
@runtime_checkable
class Detector(Protocol)
⋮----
def should_run(self, ctx: ProjectScanContext) -> bool: ...
⋮----
def run(self, ctx: ProjectScanContext) -> Optional[DiscoveryResult]: ...
````

## File: src/metagit/core/mcp/services/workspace_health.py
````python
#!/usr/bin/env python
"""
Workspace integrity and maintenance health checks.
"""
⋮----
class WorkspaceHealthService
⋮----
"""Validate workspace integrity and emit maintenance recommendations."""
⋮----
"""Run selected health checks across workspace repositories."""
gate_status = self._gate.evaluate(root_path=workspace_root)
rows = self._index.build_index(config=config, workspace_root=workspace_root)
⋮----
rows = [row for row in rows if row["project_name"] == project_name]
⋮----
recommendations: list[HealthRecommendation] = []
repo_rows: list[RepoHealthRow] = []
gitnexus_map: dict[str, str] = {}
⋮----
paths = [str(row["repo_path"]) for row in rows if row.get("exists")]
gitnexus_map = self._registry.summarize_for_paths(repo_paths=paths)
⋮----
missing_count = 0
dirty_count = 0
behind_count = 0
stale_gn_count = 0
stale_head_warn_count = 0
stale_head_critical_count = 0
integration_stale_count = 0
⋮----
repo_path = str(row.get("repo_path", ""))
inspect_git = (
inspected = inspect_repo_state(repo_path=repo_path) if inspect_git else {}
gn_status = gitnexus_map.get(repo_path) if check_gitnexus else None
head_raw = inspected.get("head_commit_age_days")
head_age_days = (
merge_raw = inspected.get("merge_base_age_days")
merge_age_days = (
ahead_raw = inspected.get("ahead")
behind_raw = inspected.get("behind")
⋮----
warn_td = min(branch_head_warning_days, branch_head_critical_days)
crit_td = max(branch_head_warning_days, branch_head_critical_days)
⋮----
behind = inspected.get("behind")
⋮----
summary = {
critical = sum(1 for item in recommendations if item.severity == "critical")
⋮----
"""Warn when multiple repos share the same configured URL."""
by_url: dict[str, list[dict[str, Any]]] = {}
⋮----
url = row.get("url")
⋮----
warnings: list[HealthRecommendation] = []
⋮----
names = ", ".join(
action = "review_config"
message = f"Multiple repos share URL {url}: {names}"
⋮----
action = "resync_canonical"
message = (
⋮----
"""Recommend repair when a configured repo path is a broken symlink."""
⋮----
repo_path = Path(str(row.get("repo_path", "")))
⋮----
"""Warn about canonical directories not referenced in the manifest."""
references = workspace_dedupe.list_canonical_references(
orphans = workspace_dedupe.list_orphan_canonical_dirs(
⋮----
"""Sort recommendations by severity."""
order = {"critical": 0, "warning": 1, "info": 2}
````

## File: src/metagit/core/mcp/resources.py
````python
#!/usr/bin/env python
"""
Resource publishing for Metagit MCP runtime.
"""
⋮----
class ResourcePublisher
⋮----
"""Serve MCP resources for workspace config and status views."""
⋮----
def __init__(self, ops_log: OperationsLogService) -> None
⋮----
"""Return a resource payload for known URIs."""
⋮----
meta = SessionStore(workspace_root=workspace_root).get_workspace_meta()
session = (
````

## File: src/metagit/core/project/models.py
````python
#!/usr/bin/env python
"""
Pydantic models for project configuration.
"""
⋮----
class GitUrl(str)
⋮----
"""Custom type for Git repository URLs."""
⋮----
GIT_URL_REGEX = re.compile(
⋮----
@classmethod
    def validate(cls, value: str, _: Any) -> "GitUrl"
⋮----
class ProjectKind(str, Enum)
⋮----
"""Enumeration of project kinds."""
⋮----
MONOREPO = "monorepo"
UMBRELLA = "umbrella"
APPLICATION = "application"
GITOPS = "gitops"
INFRASTRUCTURE = "infrastructure"
SERVICE = "service"
LIBRARY = "library"
WEBSITE = "website"
OTHER = "other"
DOCKER_IMAGE = "docker_image"
REPOSITORY = "repository"
CLI = "cli"
⋮----
class ProjectPath(BaseModel)
⋮----
"""Model for project path, dependency, component, or workspace project information."""
⋮----
name: str = Field(..., description="Friendly name for the path or project")
description: Optional[str] = Field(
kind: Optional[ProjectKind] = Field(None, description="Project kind")
ref: Optional[str] = Field(
path: Optional[str] = Field(None, description="Project path")
branches: Optional[List[str]] = Field(None, description="Project branches")
url: Optional[Union[HttpUrl, GitUrl]] = Field(None, description="Project URL")
sync: Optional[bool] = Field(None, description="Sync setting")
language: Optional[str] = Field(None, description="Programming language")
language_version: Optional[Union[str, float, int]] = Field(
package_manager: Optional[str] = Field(
frameworks: Optional[List[str]] = Field(
source_provider: Optional[str] = Field(
source_namespace: Optional[str] = Field(
source_repo_id: Optional[str] = Field(
tags: dict[str, str] = Field(
protected: Optional[bool] = Field(
agent_instructions: Optional[str] = Field(
⋮----
@field_validator("language_version", mode="before")
    def validate_language_version(cls, v: Any) -> Optional[str]
⋮----
"""Serialize the URL to a string."""
⋮----
class Config
⋮----
"""Pydantic configuration."""
⋮----
use_enum_values = True
extra = "forbid"
````

## File: src/metagit/core/skills/installer.py
````python
#!/usr/bin/env python
"""
Installer utilities for bundled skills and MCP config updates.
"""
⋮----
InstallScope = Literal["project", "user"]
InstallMode = Literal["skills", "mcp"]
⋮----
SUPPORTED_TARGETS = [
⋮----
class TargetPaths(BaseModel)
⋮----
"""Paths for target deployment in each scope."""
⋮----
project_skills_path: str = Field(
user_skills_path: str = Field(..., description="User-global skills destination")
project_mcp_path: str = Field(..., description="Project-local MCP config path")
user_mcp_path: str = Field(..., description="User-global MCP config path")
⋮----
class InstallResult(BaseModel)
⋮----
"""Summary for a single target installation."""
⋮----
target: str
mode: InstallMode
scope: InstallScope
applied: bool
path: str
details: str
dry_run: bool = Field(
⋮----
TARGET_PATHS: Dict[str, TargetPaths] = {
⋮----
def bundled_skills_root() -> Path
⋮----
"""Resolve bundled skill source path."""
⋮----
def list_bundled_skills() -> List[str]
⋮----
"""Return bundled skill names."""
skills_root = bundled_skills_root()
⋮----
def skill_markdown(skill_name: str) -> Optional[str]
⋮----
"""Load SKILL.md content for a bundled skill."""
skill_file = bundled_skills_root() / skill_name / "SKILL.md"
⋮----
def resolve_skill_names(skill_names: Optional[List[str]]) -> List[str]
⋮----
"""Validate and resolve bundled skill names for install."""
bundled = list_bundled_skills()
⋮----
unknown = sorted({name for name in skill_names if name not in bundled})
⋮----
available = ", ".join(bundled) if bundled else "(none)"
⋮----
"""Resolve install targets by explicit include/exclude or auto-detection."""
disabled = set(disable_targets)
⋮----
detected = autodetect_targets(mode=mode, scope=scope)
⋮----
def autodetect_targets(mode: InstallMode, scope: InstallScope) -> List[str]
⋮----
"""Detect target applications by existing config/directories."""
resolved: List[str] = []
⋮----
target_paths = TARGET_PATHS[target]
⋮----
candidate = _expand_target_path(
⋮----
"""Build a human-readable install summary line."""
verb = "Would install" if dry_run else "Installed"
⋮----
names = ", ".join(installed_names)
⋮----
"""Install bundled skills for selected targets."""
source_root = bundled_skills_root()
results: List[InstallResult] = []
⋮----
selected_skills = resolve_skill_names(skill_names)
⋮----
destination = _expand_target_path(
⋮----
installed_names: List[str] = []
⋮----
source_skill = source_root / skill_name
⋮----
dest_skill = destination / skill_name
⋮----
"""Install/update MCP server configuration for selected targets."""
⋮----
config_path = _expand_target_path(
⋮----
config_data = _read_json_with_comments(config_path)
⋮----
config_data = {}
mcp_servers = config_data.get("mcpServers")
⋮----
mcp_servers = {}
⋮----
def _read_json_with_comments(path: Path) -> Dict[str, object]
⋮----
"""Parse JSON or JSONC-style files."""
⋮----
content = path.read_text(encoding="utf-8").strip()
⋮----
no_line_comments = re.sub(r"^\s*//.*$", "", content, flags=re.MULTILINE)
no_block_comments = re.sub(r"/\*.*?\*/", "", no_line_comments, flags=re.DOTALL)
⋮----
def _expand_target_path(path_value: str) -> Path
⋮----
expanded = Path(os.path.expanduser(path_value))
````

## File: src/metagit/core/utils/files.py
````python
#! /usr/bin/env python3
"""
File reader tool for the detect flow
"""
⋮----
def directory_tree(paths: List[Path], show_files: bool = False) -> str
⋮----
"""
    Generate a tree diagram of directory structure from a list of paths.

    Args:
        paths: List of Path objects representing files and directories
        show_files: Whether to display individual files in each directory

    Returns:
        String representation of the directory tree
    """
⋮----
# Build a tree structure from the paths
tree = {}
⋮----
# Normalize all paths
normalized_paths = [path.resolve() for path in paths]
⋮----
# Build tree structure
⋮----
parts = path.parts
current = tree
⋮----
current = current[part]
⋮----
# Generate tree string representation
⋮----
lines = []
keys = sorted(tree_dict.keys())
⋮----
is_last_item = i == len(keys) - 1
connector = "└── " if is_last_item else "├── "
⋮----
if tree_dict[key]:  # Has children
extension = "    " if is_last_item else "│   "
child_lines = _build_tree(
⋮----
def is_binary_file(file_path: str) -> bool
⋮----
"""
    Check if a file is binary by examining its content.

    Args:
        file_path: Path to the file to check

    Returns:
        True if the file is binary, False if it's text
    """
⋮----
# Read first 1024 bytes to check for binary content
chunk = f.read(1024)
⋮----
# Check for null bytes (common in binary files)
⋮----
# Check if the chunk contains mostly printable ASCII characters
text_chars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x7F)))
⋮----
# If we can't read the file, assume it's not binary
⋮----
def get_file_size(file_path: str) -> int
⋮----
"""
    Get the size of a file in bytes.

    Args:
        file_path: Path to the file

    Returns:
        File size in bytes, 0 if file doesn't exist
    """
⋮----
def list_files(directory_path: str) -> List[str]
⋮----
"""
    List all files in a directory recursively.

    Args:
        directory_path: Path to the directory

    Returns:
        List of file paths
    """
⋮----
files = []
⋮----
def list_git_files(directory_path: str) -> List[Path]
⋮----
"""
    List all files in a Git repository.

    Args:
        directory_path: Path to the Git repository
    Returns:
        List of file paths in the repository
    """
⋮----
repo = Repo(directory_path)
⋮----
values = repo.git.ls_files(
⋮----
def read_file_lines(file_path: str) -> List[str]
⋮----
"""
    Read all lines from a file.

    Args:
        file_path: Path to the file

    Returns:
        List of lines (without newline characters)
    """
⋮----
def write_file_lines(file_path: str, lines: List[str]) -> bool
⋮----
"""
    Write lines to a file.

    Args:
        file_path: Path to the file
        lines: List of lines to write

    Returns:
        True if successful, False otherwise
    """
⋮----
def copy_file(source_path: str, dest_path: str) -> bool
⋮----
"""
    Copy a file from source to destination.

    Args:
        source_path: Path to source file
        dest_path: Path to destination file

    Returns:
        True if successful, False otherwise
    """
⋮----
def remove_file(file_path: str) -> bool
⋮----
"""
    Remove a file.

    Args:
        file_path: Path to the file to remove

    Returns:
        True if successful, False otherwise
    """
⋮----
def make_dir(dir_path: str) -> bool
⋮----
"""
    Create a directory.

    Args:
        dir_path: Path to the directory to create

    Returns:
        True if successful, False otherwise
    """
⋮----
def remove_dir(dir_path: str) -> bool
⋮----
"""
    Remove a directory and its contents.

    Args:
        dir_path: Path to the directory to remove

    Returns:
        True if successful, False otherwise
    """
⋮----
class FileTypeInfo(BaseModel)
⋮----
kind: str
type: str
⋮----
class FileTypeWithPercent(BaseModel)
⋮----
percent: float
⋮----
class DirectoryDetails(BaseModel)
⋮----
path: str
num_files: int
file_types: Dict[str, List[FileTypeWithPercent]]
subpaths: List["DirectoryDetails"]
⋮----
class FileExtensionLookup
⋮----
# Parse JSON data
⋮----
data = json.loads(f.read())
⋮----
# Create extension to info mapping for O(1) lookup
⋮----
# Handle the JSON structure which has data wrapped in "extensions" key
⋮----
items = data["extensions"]
⋮----
items = data
⋮----
kind = item.get("kind", "")
file_type = item.get("type", "")
extensions = item.get("extensions", [])
⋮----
# Store each extension with its corresponding info
info = FileTypeInfo(kind=kind, type=file_type)
⋮----
# Normalize extension (lowercase, ensure leading dot)
ext = ext.lower()
⋮----
ext = f".{ext}"
⋮----
def get_file_info(self, filename: str) -> Optional[FileTypeInfo]
⋮----
"""
        Look up file type information based on file extension.

        Args:
            filename: File name or path to check

        Returns:
            FileTypeInfo tuple containing name and type, or None if not found
        """
# Extract extension and normalize
⋮----
def parse_gitignore(ignore_file: Path) -> Set[str]
⋮----
"""
    Parse .gitignore files.

    Args:
        directory_path: Path to the current directory to check for .gitignore
        base_path: Base directory path for the analysis (root of the tree)

    Returns:
        Set of patterns to ignore (combined from all .gitignore files in the path)
    """
ignore_patterns = set()
⋮----
line = line.strip()
# Skip empty lines and comments
⋮----
# Remove trailing slash from patterns
line = line.rstrip("/")
⋮----
def should_ignore_path(path: Path, ignore_patterns: Set[str], base_path: Path) -> bool
⋮----
"""
    Check if a path should be ignored based on ignored patterns.

    Args:
        path: Path to check
        ignore_patterns: Set of patterns from .gitignore files
        base_path: Base directory path for relative pattern matching

    Returns:
        True if the path should be ignored, False otherwise
    """
⋮----
# Get relative path from base directory
⋮----
relative_path = path.relative_to(base_path)
⋮----
# Path is not relative to base, use the path name
relative_path = Path(path.name)
⋮----
relative_str = str(relative_path)
⋮----
# Check each pattern
⋮----
# Handle file patterns
⋮----
"""
    Recursively walks a directory and builds detailed metadata structure using FileExtensionLookup.

    Args:
        target_path: Path to the target directory to analyze
        file_lookup: Single instance of FileExtensionLookup for file type information
        ignore_patterns: Set of patterns to ignore (applied to all subdirectories)

    Returns:
        DirectoryDetails: NamedTuple containing directory structure and detailed file statistics grouped by category
    """
path = Path(target_path)
ignore_file = os.path.join(path, ".gitignore")
ignore_patterns = ignore_patterns or set()
ignore_patterns = ignore_patterns.union(parse_gitignore(ignore_file))
⋮----
# Initialize data structures
file_type_counts: Dict[str, Dict[str, int]] = {
subpaths: List[DirectoryDetails] = []
num_files = 0
⋮----
# Process directory contents
⋮----
# Always ignore .git folders
⋮----
# Check if item should be ignored based on ignore_patterns
⋮----
# Recursively process subdirectory with the same ignore_patterns
sub_metadata = directory_details(
⋮----
# Count file and get detailed type information
⋮----
file_info = file_lookup.get_file_info(item.name)
⋮----
# Group by type category and count by kind
category = file_info.type
kind = file_info.kind
⋮----
# Convert counts to percentages based on total files in directory
file_types_by_category: Dict[str, List[FileTypeWithPercent]] = {}
⋮----
if num_files > 0:  # Only calculate percentages if there are files
⋮----
if kinds:  # Only include categories that have files
⋮----
final_path = path.resolve() if resolve_path else path
⋮----
class FileType(BaseModel)
⋮----
count: int
⋮----
class DirectorySummary(BaseModel)
⋮----
file_types: List[FileType]
subpaths: List["DirectorySummary"]
⋮----
"""
    Recursively walks a directory and builds a metadata structure for a directory summary.
    This is a simplified version of directory_details that only returns the file types and counts.
    This will adhere to .gitignore files.

    Args:
        target_path: Path to the target directory to analyze
        ignore_patterns: Set of patterns to ignore (applied to all subdirectories)

    Returns:
        DirectorySummary: Pydantic model containing directory structure and file statistics
    """
⋮----
file_types: Dict[str, int] = {}
subpaths: List[DirectorySummary] = []
⋮----
sub_metadata = directory_summary(str(item), ignore_patterns, resolve_path)
⋮----
# Count file and type
⋮----
file_ext = (
⋮----
)  # Only the extension without the dot, or full name if no extension
⋮----
# Convert file types to list of FileType models
file_types_list = [
````

## File: src/metagit/core/web/config_handler.py
````python
#!/usr/bin/env python
"""HTTP handlers for metagit and appconfig schema tree routes (v3 API)."""
⋮----
JsonResponder = Callable[[int, dict[str, Any]], None]
⋮----
ConfigTarget = Literal["metagit", "appconfig"]
ValidateTarget = Literal["metagit", "appconfig", "both"]
⋮----
class ConfigWebHandler
⋮----
"""Route config tree and patch operations for the local web HTTP API."""
⋮----
"""Dispatch config routes; return True when handled."""
parsed_path = urlparse(path).path
⋮----
loaded = config
errors = list(validation_errors or [])
⋮----
loaded_result = self._load_metagit(respond)
⋮----
loaded = loaded_result
response = self._tree_response(
⋮----
loaded_result = self._load_appconfig(respond)
⋮----
def _patch_metagit(self, body: bytes, respond: JsonResponder) -> None
⋮----
loaded = self._load_metagit(respond)
⋮----
patch = self._parse_patch(body, respond)
⋮----
saved = False
⋮----
manager = MetagitConfigManager(self._metagit_config_path)
save_result = manager.save_config(updated)
⋮----
saved = True
⋮----
def _patch_appconfig(self, body: bytes, respond: JsonResponder) -> None
⋮----
loaded = self._load_appconfig(respond)
⋮----
save_result = save_appconfig(self._appconfig_path, updated)
⋮----
def _validate_configs(self, body: bytes, respond: JsonResponder) -> None
⋮----
payload = self._parse_body(body, respond, required=False) or {}
target_raw = payload.get("target", "both")
⋮----
target: ValidateTarget = target_raw
results: list[dict[str, Any]] = []
targets: list[ConfigTarget] = (
⋮----
errors = self._validation_errors_for_metagit()
⋮----
errors = self._validation_errors_for_appconfig()
⋮----
def _validation_errors_for_metagit(self) -> list[dict[str, str]]
⋮----
loaded = manager.load_config()
⋮----
def _validation_errors_for_appconfig(self) -> list[dict[str, str]]
⋮----
loaded = load_appconfig(self._appconfig_path)
⋮----
request = self._parse_preview_request(query, body, respond)
⋮----
config = loaded
validation_errors: list[dict[str, str]] = []
draft = bool(request.operations)
⋮----
yaml_text = read_disk_text(self._metagit_config_path)
⋮----
yaml_text = render_metagit_yaml(config, style=request.style)
response = ConfigPreviewResponse(
⋮----
yaml_text = read_disk_text(self._appconfig_path)
⋮----
yaml_text = render_appconfig_yaml(
⋮----
params = parse_qs(query, keep_blank_values=True)
style_raw = (params.get("style") or ["normalized"])[0]
⋮----
style: PreviewStyle = style_raw
⋮----
payload = self._parse_body(body, respond, required=False)
⋮----
parsed = ConfigPreviewRequest.model_validate(
⋮----
tree = self._schema.build_tree(
⋮----
def _load_metagit(self, respond: JsonResponder) -> MetagitConfig | None
⋮----
def _load_appconfig(self, respond: JsonResponder) -> AppConfig | None
⋮----
payload = self._parse_body(body, respond, required=True)
⋮----
parsed = json.loads(body.decode("utf-8"))
⋮----
@staticmethod
    def _format_error_path(loc: tuple[Any, ...]) -> str
⋮----
parts: list[str] = []
````

## File: src/metagit/core/web/models.py
````python
#!/usr/bin/env python
"""Pydantic models for metagit web API."""
⋮----
class ConfigOpKind(str, Enum)
⋮----
ENABLE = "enable"
DISABLE = "disable"
SET = "set"
APPEND = "append"
REMOVE = "remove"
⋮----
class ConfigOperation(BaseModel)
⋮----
op: ConfigOpKind
path: str
value: Any | None = None
⋮----
class ConfigPatchRequest(BaseModel)
⋮----
save: bool = False
operations: list[ConfigOperation] = Field(default_factory=list)
⋮----
class SchemaFieldNode(BaseModel)
⋮----
key: str
type: str
type_label: str | None = None
description: str | None = None
required: bool = False
enabled: bool = False
editable: bool = True
sensitive: bool = False
default_value: Any | None = None
⋮----
enum_options: list[str] = Field(default_factory=list)
item_count: int | None = None
can_append: bool = False
children: list["SchemaFieldNode"] = Field(default_factory=list)
⋮----
class ConfigTreeResponse(BaseModel)
⋮----
ok: bool
target: Literal["metagit", "appconfig"]
config_path: str
tree: SchemaFieldNode
validation_errors: list[dict[str, str]] = Field(default_factory=list)
saved: bool = False
⋮----
class ConfigPreviewRequest(BaseModel)
⋮----
style: Literal["normalized", "minimal", "disk"] = "normalized"
⋮----
class ConfigPreviewResponse(BaseModel)
⋮----
style: Literal["normalized", "minimal", "disk"]
yaml: str
draft: bool = False
⋮----
class SyncJobRequest(BaseModel)
⋮----
repos: list[str] | None = None
mode: Literal["fetch", "pull", "clone"] = "fetch"
dry_run: bool = False
allow_mutation: bool = True
max_parallel: int = 4
⋮----
class SyncJobStatus(BaseModel)
⋮----
job_id: str
state: Literal["pending", "running", "completed", "failed"]
summary: dict[str, Any] = Field(default_factory=dict)
results: list[dict[str, Any]] = Field(default_factory=list)
error: str | None = None
````

## File: src/metagit/core/web/server.py
````python
#!/usr/bin/env python
"""Local HTTP server for the metagit web UI."""
⋮----
def _resolve_workspace_root(root: str, workspace_path: str) -> str
⋮----
"""Resolve workspace sync root from appconfig path (relative to manifest root)."""
path = Path(workspace_path).expanduser()
⋮----
path = (Path(root) / path).resolve()
⋮----
path = path.resolve()
⋮----
"""Build a threading HTTP server for web UI routes."""
root_resolved = str(Path(root).resolve())
config_path = os.path.join(root_resolved, ".metagit.yml")
appconfig_resolved = str(Path(appconfig_path).resolve())
app_config = load_appconfig(appconfig_resolved)
⋮----
workspace_root = _resolve_workspace_root(
static_handler = StaticWebHandler()
catalog_handler = CatalogApiHandler(
layout_handler = LayoutApiHandler(
config_handler = ConfigWebHandler(
ops_handler = OpsWebHandler(
⋮----
class ReusableThreadingHTTPServer(ThreadingHTTPServer)
⋮----
allow_reuse_address = True
⋮----
class Handler(BaseHTTPRequestHandler)
⋮----
def log_message(self, format: str, *args: Any) -> None
⋮----
_ = (format, args)
⋮----
def do_GET(self) -> None
⋮----
def do_PATCH(self) -> None
⋮----
def do_POST(self) -> None
⋮----
def do_DELETE(self) -> None
⋮----
def _dispatch(self, method: str) -> None
⋮----
parsed = urlparse(self.path)
events_job_id = ops_handler.sync_events_job_id(method, parsed.path)
⋮----
length = int(self.headers.get("Content-Length", "0") or "0")
body = self.rfile.read(length) if length > 0 else b""
⋮----
def _json(self, status: int, payload: dict[str, Any]) -> None
⋮----
body = json.dumps(payload).encode("utf-8")
````

## File: src/metagit/core/workspace/catalog_service.py
````python
#!/usr/bin/env python
"""
List and mutate workspace projects and repositories in `.metagit.yml`.
"""
⋮----
class WorkspaceCatalogService
⋮----
"""CRUD-style catalog operations for workspace manifests."""
⋮----
"""Return workspace summary, projects, and optional index rows."""
summary = self._workspace_summary(
projects = self.list_projects(config=config).data or {}
payload: dict[str, Any] = {
⋮----
def list_projects(self, config: MetagitConfig) -> CatalogResult
⋮----
"""List workspace projects defined in the manifest."""
⋮----
entries = [
⋮----
"""List configured repositories, optionally scoped to one project."""
⋮----
index_rows: list[dict[str, Any]] = []
⋮----
index_rows = self._index.build_index(
index_by_key = {
repos: list[dict[str, Any]] = []
⋮----
row = index_by_key.get((project.name, repo.name), {})
entry = RepoListEntry(
⋮----
"""Add a workspace project (group) to the manifest."""
trimmed = name.strip()
⋮----
save_err = self._save(config=config, config_path=config_path)
⋮----
"""Remove a workspace project from the manifest (repos are removed with it)."""
⋮----
before = len(config.workspace.projects)
⋮----
"""Add a repository entry under a workspace project."""
project = self._find_project(config=config, project_name=project_name)
⋮----
duplicates = find_duplicate_identities(config, repo)
⋮----
locations = ", ".join(f"{proj}/{name}" for proj, name in duplicates)
⋮----
"""Remove a repository entry from a workspace project (manifest only)."""
⋮----
before = len(project.repos)
⋮----
"""Construct a ProjectPath from API/MCP/CLI fields."""
trimmed_name = name.strip()
⋮----
kind_val: ProjectKind | None = None
⋮----
kind_val = ProjectKind(kind)
⋮----
project_count = len(config.workspace.projects) if config.workspace else 0
repo_count = 0
⋮----
repo_count = sum(
⋮----
def _save(self, config: MetagitConfig, config_path: str) -> Optional[Exception]
⋮----
manager = MetagitConfigManager(metagit_config=config)
result = manager.save_config(config, Path(config_path))
````

## File: src/metagit/core/workspace/models.py
````python
#!/usr/bin/env python
"""
Pydantic models for .metagit.yml workspace configuration.
"""
⋮----
class ProjectDedupeOverride(BaseModel)
⋮----
"""Per-project override of app-config ``workspace.dedupe``."""
⋮----
enabled: Optional[bool] = Field(
⋮----
class Config
⋮----
"""Pydantic configuration."""
⋮----
extra = "forbid"
⋮----
class WorkspaceProject(BaseModel)
⋮----
"""Model for workspace project."""
⋮----
name: str = Field(..., description="Workspace project name")
description: Optional[str] = Field(
agent_instructions: Optional[str] = Field(
dedupe: Optional[ProjectDedupeOverride] = Field(
repos: List[ProjectPath] = Field(..., description="Repository list")
⋮----
@field_validator("repos", mode="before")
    def validate_repos(cls, v: Any) -> Any
⋮----
"""Handle YAML anchors and complex repo structures."""
⋮----
# Flatten any nested lists that might come from YAML anchors
flattened: List[Any] = []
⋮----
use_enum_values = True
⋮----
class Workspace(BaseModel)
⋮----
"""Model for workspace configuration."""
⋮----
projects: List[WorkspaceProject] = Field(..., description="Workspace projects")
````

## File: src/metagit/data/skills/metagit-release-audit/SKILL.md
````markdown
---
name: metagit-release-audit
description: Mandatory before calling work complete when the session changed repo files. Runs format, lint, tests, integration tests, context-aware pip-audit/bandit, and optional gitleaks via task qa:prepush. Use before push, release, or hand-off.
---

# Auditing Release Readiness

Use this skill **whenever** your session added or edited tracked files in this repository and you are about to hand off or say the task is done—not only "release" workflows. Read-only Q&A with no writes can skip it.

## Workflow

1. Run the pre-push quality gate.
2. Capture failing stage logs and iterate fixes.
3. Re-run until all required checks pass.
4. Return a short readiness summary.

## Commands

- `task qa:prepush`
- `task qa:prepush:loop -- 3` (optional bounded retry loop)

## Output Contract

Return:
- pass/fail status by stage (including `security_sync` / `security_audit` / `security_bandit` when triggered)
- unresolved blockers (if any)
- push readiness recommendation

Security in the gate is context-aware: lockfile changes run `uv sync --frozen --all-extras` + `pip-audit` + `bandit`; `src/` changes run `pip-audit` + `bandit`; docs-only diffs skip security. Use `task security:scan` for a full manual run.

## Safety

- Do not claim readiness unless checks are actually green.
````

## File: src/metagit/data/metagit.config.yaml
````yaml
config:
  description: "Configuration for metagit CLI"
  agent_mode: false
  # Reserved for future use
  api_url: ""
  # Reserved for future use
  api_version: ""
  # Reserved for future use
  api_key: ""
  # Reserved for future use
  cicd_file_data: data/cicd-files.json
  file_type_data: data/file-types.json
  package_manager_data: data/package-managers.json
  default_profile: default

  llm:
    provider: "openrouter"
    provider_model: "gpt-4o-mini"
    embedder: "ollama"
    embedder_model: "nomic-embed-text"
    api_key: ""

  workspace:
    # Default workspace path, should be added to .gitignore
    path: ./.metagit
    default_project: default
    ui_ignore_hidden: true
    dedupe:
      enabled: false
      canonical_dir: _canonical

  profiles:
    # Profiles are used to define multiple configurations for different organizations
    - name: default
    # Boundaries are used to define the boundaries of the organization
    # They are used to determine which repositories are part of the organization
    # and which are not. This is important for auto-detection of internal and external repositories.
    # Any artifact, repository, or registry that is not part of the defined boundaries is considered external.
      boundaries:
      - name: github
        values: []
      - name: jfrog
        values: []
      - name: gitlab
        values: []
      - name: bitbucket
        values: []
      - name: azure_devops
        values: []
      - name: dockerhub
        values: []
      - name: domain
        values:
          - localhost
          - "127.0.0.1"
          - "0.0.0.0"
          - 192.168.*
          - 10.0.*
          - 172.16.*
````

## File: tests/core/mcp/services/test_workspace_search.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_search
"""
⋮----
def test_workspace_search_returns_scoped_hits(tmp_path: Path) -> None
⋮----
repo_path = tmp_path / "repo-1"
⋮----
tf_file = repo_path / "main.tf"
⋮----
service = WorkspaceSearchService()
⋮----
results = service.search(
⋮----
def test_filter_repo_paths_supports_project_repo_selector() -> None
⋮----
rows = [
paths = service.filter_repo_paths(rows, repos=["alpha/repo-one"])
⋮----
def test_workspace_search_includes_context_when_rg_available(tmp_path: Path) -> None
⋮----
sample = repo_path / "sample.txt"
⋮----
context = results[0]["context_before"] + results[0]["context_after"]
⋮----
def test_discover_files_returns_categorized_entries(tmp_path: Path) -> None
⋮----
payload = service.discover_files(
````

## File: tests/core/mcp/test_runtime.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.runtime
"""
⋮----
def test_initialize_request_returns_capabilities(tmp_path: Path) -> None
⋮----
runtime = MetagitMcpRuntime(root=str(tmp_path))
⋮----
response = runtime._handle_request(
⋮----
def test_tools_list_returns_inactive_tools_without_config(tmp_path: Path) -> None
⋮----
names = [item["name"] for item in response["result"]["tools"]]
⋮----
workspace_status_tool = next(
⋮----
def test_tools_call_workspace_status_returns_text_payload(tmp_path: Path) -> None
⋮----
payload = json.loads(response["result"]["content"][0]["text"])
⋮----
def test_resources_read_ops_log_returns_json_content(tmp_path: Path) -> None
⋮----
def test_tools_call_invalid_arguments_returns_mcp_invalid_params(tmp_path: Path) -> None
⋮----
def test_tools_call_workspace_semantic_search_requires_query(tmp_path: Path) -> None
⋮----
def test_initialize_can_enable_sampling_capability(tmp_path: Path) -> None
⋮----
def test_bootstrap_uses_sampling_when_client_supports_it(tmp_path: Path) -> None
⋮----
runtime._request_client_sampling = lambda context: {  # type: ignore[method-assign]
⋮----
def test_tools_list_includes_repo_search_for_active_workspace(tmp_path: Path) -> None
⋮----
def test_tools_call_repo_search_returns_matches(tmp_path: Path) -> None
⋮----
repo_dir = tmp_path / "platform" / "abacus-app"
⋮----
def test_tools_list_includes_project_context_tools(tmp_path: Path) -> None
⋮----
def test_tools_call_project_context_switch_unknown_project(tmp_path: Path) -> None
⋮----
def test_tools_call_cross_project_dependencies(tmp_path: Path) -> None
````

## File: tests/core/mcp/test_tool_registry.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.tool_registry
"""
⋮----
def test_inactive_registry_exposes_only_safe_tools() -> None
⋮----
registry = ToolRegistry()
status = WorkspaceStatus(
⋮----
tools = registry.list_tools(status=status)
⋮----
def test_active_registry_exposes_full_toolset() -> None
````

## File: tests/core/web/test_config_handler.py
````python
#!/usr/bin/env python
"""HTTP tests for web config tree routes (v3 API)."""
⋮----
server = build_web_server(
thread = threading.Thread(target=server.serve_forever, daemon=True)
⋮----
port = server.server_address[1]
base = f"http://127.0.0.1:{port}"
⋮----
def _patch_json(base: str, target: str, body: dict) -> tuple[int, dict]
⋮----
patch_body = json.dumps(body).encode("utf-8")
patch_req = urllib.request.Request(
⋮----
raw = exc.read().decode("utf-8")
⋮----
def test_get_metagit_config_tree(tmp_path: Path) -> None
⋮----
payload = json.loads(
⋮----
name_node = next(
⋮----
def test_patch_metagit_set_name_without_save(tmp_path: Path) -> None
⋮----
patch_body = json.dumps(
⋮----
patched = json.loads(
⋮----
on_disk = (tmp_path / ".metagit.yml").read_text(encoding="utf-8")
⋮----
def test_patch_metagit_set_name_with_save(tmp_path: Path) -> None
⋮----
kind_node = next(
⋮----
def test_get_metagit_preview_normalized(tmp_path: Path) -> None
⋮----
def test_post_metagit_preview_draft_operations(tmp_path: Path) -> None
⋮----
body = json.dumps(
req = urllib.request.Request(
payload = json.loads(urllib.request.urlopen(req, timeout=5).read().decode("utf-8"))
````

## File: tests/test_config_models.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.config.models
"""
⋮----
def test_license_kind_enum()
⋮----
def test_branch_strategy_enum()
⋮----
def test_license_model()
⋮----
lic = models.License(kind=models.LicenseKind.MIT, file="LICENSE")
⋮----
def test_branch_naming_model()
⋮----
bn = models.BranchNaming(kind=models.BranchStrategy.TRUNK, pattern="main/*")
⋮----
def test_artifact_model()
⋮----
art = models.Artifact(
⋮----
def test_secret_model()
⋮----
sec = models.Secret(
⋮----
def test_variable_model()
⋮----
var = models.Variable(
⋮----
def test_pipeline_and_cicd()
⋮----
pipe = models.Pipeline(
cicd = models.CICD(platform=models.CICDPlatform.GITHUB, pipelines=[pipe])
⋮----
def test_environment_and_deployment()
⋮----
env = models.Environment(name="prod", url="http://prod.example.com")
infra = models.Infrastructure(
dep = models.Deployment(
⋮----
def test_observability()
⋮----
alert = models.AlertingChannel(
dash = models.Dashboard(name="main", tool="grafana", url="http://grafana.com")
obs = models.Observability(
⋮----
def test_project_and_metadata()
⋮----
lang = models.Language(primary="python", secondary=["js"])
proj = models.Project(
⋮----
meta = models.RepoMetadata(tags=["tag1"], created_at=datetime.now())
⋮----
def test_metrics_and_pull_requests()
⋮----
pr = models.PullRequests(open=2, merged_last_30d=5)
metrics = models.Metrics(
⋮----
def test_metagit_config_minimal()
⋮----
cfg = models.MetagitConfig(name="proj")
⋮----
# Test serialization
⋮----
def test_workspace_description_and_agent_instructions()
⋮----
"""Workspace and workspace projects accept optional description and agent_instructions."""
repo = ProjectPath(
ws = models.Workspace(
⋮----
def test_workspace_project_dedupe_override() -> None
⋮----
"""Workspace projects accept optional dedupe.enabled override."""
project = models.WorkspaceProject(
⋮----
class TestMetagitConfig
⋮----
"""Test MetagitConfig class."""
⋮----
def test_metagit_config_basic(self)
⋮----
"""Test basic MetagitConfig creation."""
config = models.MetagitConfig(
⋮----
def test_metagit_config_with_optional_fields(self)
⋮----
"""Test MetagitConfig with optional fields."""
⋮----
def test_metagit_config_validation_error(self)
⋮----
"""Test MetagitConfig validation error."""
⋮----
models.MetagitConfig()  # Missing required name field
⋮----
def test_metagit_config_does_not_have_detection_attributes(self)
⋮----
"""Test that MetagitConfig does not have detection-specific attributes."""
config = models.MetagitConfig(name="test-project")
⋮----
# These attributes should not exist on MetagitConfig
````

## File: web/src/components/FieldEditor.tsx
````typescript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import type { ConfigOperation, SchemaFieldNode } from '../api/client'
import {
  configTreeQueryKey,
  fetchConfigTree,
  patchConfigTree,
  type ConfigTarget,
} from '../pages/configQueries'
import styles from './FieldEditor.module.css'
⋮----
interface FieldEditorProps {
  target: ConfigTarget
  node: SchemaFieldNode | null
  pendingOps: ConfigOperation[]
  onPendingChange: (ops: ConfigOperation[]) => void
}
⋮----
function scalarTypes(): Set<string>
⋮----
function formatValidationErrors(
  errors: Array<Record<string, string>>,
): Array<
⋮----
function isMaskedSensitiveValue(node: SchemaFieldNode): boolean
⋮----
function shouldSkipSensitiveSet(node: SchemaFieldNode, draft: string): boolean
⋮----
function normalizeDraftValue(node: SchemaFieldNode): unknown
⋮----
function parseDraftValue(node: SchemaFieldNode, raw: string): unknown
⋮----
const queueSetOp = (save: boolean) =>
⋮----
const saveAllPending = () =>
⋮----
const revert = () =>
````

## File: web/src/pages/WorkspacePage.tsx
````typescript
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import GraphDiagram from '../components/GraphDiagram'
import OpsPanel from '../components/OpsPanel'
import RepoTable from '../components/RepoTable'
import SyncDialog from '../components/SyncDialog'
import { fetchWorkspaceGraph, graphQueryKey } from './graphQueries'
import {
  fetchWorkspace,
  workspaceQueryKey,
  type StatusFilter,
} from './workspaceQueries'
import styles from './WorkspacePage.module.css'
⋮----
type WorkspaceView = 'repos' | 'graph'
⋮----
interface SyncTarget {
  repos: string[]
  title: string
}
⋮----

⋮----
void refetch()
````

## File: .cursorrules
````
---
name: agents
description: Always-loaded project anchor. Read this first. Contains project identity, non-negotiables, commands, and pointer to ROUTER.md for full context.
last_updated: 2026-05-15
---

# [Project Name]

## What This Is
<!-- One sentence. What does this project do?
     Length: 1 sentence maximum.
     Not a tagline — a factual description of what the software does.
     Example: "A REST API for managing inventory across multiple warehouse locations." -->

## Non-Negotiables
<!-- Hard rules the agent must never violate. Not preferences — rules.
     These are the things that, if broken, cause real damage to the codebase.
     Length: 3-7 items maximum. More than 7 means the list has not been prioritised.
     Example:
     - Never write database queries outside of the repository layer
     - Never commit secrets or API keys
     - Always handle errors explicitly — no silent failures -->

## Commands
<!-- The exact commands needed to work on this project.
     Include: run dev server, run tests, run linter, build.
     Use the actual commands from this codebase — not placeholders.
     Example:
     - Dev: `npm run dev`
     - Test: `npm test`
     - Lint: `npm run lint`
     - Build: `npm run build` -->

## After Every Task
After completing any task: update `.mex/ROUTER.md` project state and any `.mex/` files that are now out of date. If no pattern existed for the task you just completed, create one in `.mex/patterns/`.

**Before you tell the user the work is done** — if this conversation introduced or edited project files under this repo — run **`task qa:prepush`** from the repository root (`scripts/prepush-gate.py` via `scripts/prepush-gate.zsh`). Fix failures and re-run until green. Only skip when the task was strictly read-only (no writes) or the user explicitly waived the QA gate in this thread.

## Commands (minimum)
- QA gate before hand-off / “work complete”: `task qa:prepush` (format, lint, tests, integration tests, context-aware pip-audit/bandit; optional `gitleaks` when installed)
- Full unit tests only: `task test`

## Navigation
At the start of every session, read `.mex/ROUTER.md` before doing anything else.
For full project context, patterns, and task guidance — everything is there.
````

## File: .metagit.yml
````yaml
documentation:
  # Strings are allowed and will be treated as a markdown file in the root of the project
  - README.md
  - CHANGELOG.md
  - CONTRIBUTING.md
  - CODE_OF_CONDUCT.md
  - SECURITY.md
  # URLs are allowed and will be treated as a web page.
  - https://metagit-ai.github.io/metagit-cli/
  # All of these are duplicates of the strings above, but they are here to demonstrate 
  # the different types of documentation that can be used.
  - kind: markdown
    path: README.md
  - kind: markdown
    path: CHANGELOG.md
  - kind: markdown
    path: CONTRIBUTING.md
  - kind: markdown
    path: CODE_OF_CONDUCT.md
  - kind: markdown
    path: SECURITY.md
  - kind: web
    url: https://metagit-ai.github.io/metagit-cli/
  # Explicitly defined documentation types.
  - kind: confluence
    url: https://confluence.example.com/display/METAGIT/Metagit+Documentation
    tags:
      - playbook
      - tutorial
  - kind: sharepoint
    url: https://sharepoint.example.com/display/METAGIT/Metagit+DevOps
    tags:
      - devops
  - kind: web
    url: https://hub.docker.com/_/python
    tags:
      - docker
      - python

artifacts:
  - definition: pyproject.toml
    location: dist/
    type: python_package
    version_strategy: semver
  - definition: Dockerfile
    location: .
    type: docker
    version_strategy: semver

branch_strategy: trunk

cicd:
  pipelines:
    - name: Lint and Test
      ref: .github/workflows/lint-and-test.yaml
      variables: []
    - name: Docs
      ref: .github/workflows/docs.yaml
      variables: []
    - name: PR and Push
      ref: .github/workflows/pr-and-push.yaml
      variables: []
    - name: Publish CLI
      ref: .github/workflows/publish-cli.yaml
      variables: []
  platform: GitHub
dependencies:
  - description:
      Ubuntu is a Linux distribution based on Debian and composed mostly
      of free and open-source software.
    kind: docker_image
    name: ubuntu_latest
    ref: ./.github/workflows/lint-and-test.yaml
  - kind: docker_image
    name: python:3.12-slim
    ref: ./Dockerfile
    url: https://hub.docker.com/_/python
description:
  Metagit is situational awareness for developers and agents. It can make
  a sprawling multi-repo project feel more like a monorepo or be used as a standalone
  tool to provide concise information on the project's used languages/frameworks,
  artifacts, upstream dependencies, and more.
license:
  file: LICENSE.md
  kind: BSD-3-Clause
maintainers:
  - email: zloeber@gmail.com
    name: Zachary Loeber
    role: Contributor
name: metagit-cli
observability: {}
  # dashboards:
  #   - name: OpenSearch Dashboards
  #     tool: OpenSearch
  #     url: http://localhost:5601/
  # logging_provider: elk
  # monitoring_providers:
  #   - grafana
paths:
  - description: The main project path
    kind: application
    language: python
    language_version: "3.13"
    name: metagit
    package_manager: uv
    frameworks:
      - CrewAI
      - Click
    path: ./metagit
taskers:
  - kind: Taskfile
workspace:
  projects: []
    # - name: default
    #   description: Default project for repositories that don't fit into other projects. Includes most IaC deployments.
````

## File: .github/workflows/release.yaml
````yaml
name: Publish to PyPI, TestPyPI, and GitHub

on:
  push:
  workflow_dispatch:
  
env:
  PROJECT_NAME: metagit-cli

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  build:
    name: Build distribution
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.13"

      - name: Set up uv
        uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

      - name: Install dependencies
        run: |
          uv sync --frozen

      - name: Build package
        run: |
          uv build
      
      - name: Store the distribution packages
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: python-package-distributions
          path: dist/


  publish-to-testpypi:
    name: Publish to PyPI (TEST)
    needs:
    - build
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/')
    environment:
      name: testpypi
      url: https://test.pypi.org/p/${{ env.PROJECT_NAME }}
    permissions:
      id-token: write
    steps:
    - name: Download all the dists
      uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
      with:
        name: python-package-distributions
        path: dist/
    - name: Publish
      uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
      with:
        repository-url: https://test.pypi.org/legacy/

  publish-to-pypi:
    name: Publish to PyPI (PROD)
    runs-on: ubuntu-latest
    # only publish to PyPI on tag pushes
    if: startsWith(github.ref, 'refs/tags/')
    needs: 
      - build
      - publish-to-testpypi
    permissions:
      id-token: write
    environment:
      name: production
      url: https://pypi.org/p/${{ env.PROJECT_NAME }}
    steps:
      - name: Download all the dists
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: python-package-distributions
          path: dist/

      - name: Publish
        uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0

  create-release:
    name: Create GitHub Release
    permissions:
      contents: write
    runs-on: ubuntu-latest
    needs: publish-to-pypi
    if: github.ref_type == 'tag'
    steps:
      - name: Create Release
        uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
        with:
          files: dist/*
          make_latest: true
          generate_release_notes: true
````

## File: .mex/patterns/metagit-web-api.md
````markdown
# Metagit local web API (`metagit web serve`)

## Models

- Pydantic shapes for the localhost web UI live in `src/metagit/core/web/models.py`.
- Extend that module for new request/response types; keep CLI and HTTP handlers thin and delegate validation to these models.

## Server wiring

- `build_web_server(root, appconfig_path, host, port)` in `src/metagit/core/web/server.py` — `ThreadingHTTPServer` + `BaseHTTPRequestHandler`, same JSON helper pattern as `metagit.core.api.server`.
- Static assets: `StaticWebHandler` (`src/metagit/core/web/static_handler.py`) serves `DATA_PATH/web/`; SPA fallback for non-API GET paths.
- GET dispatch order: static (non-API) → v2 catalog → v2 layout → v3 config → v3 ops → 404 JSON for unknown `/v*`.
- POST/DELETE/PATCH: layout, catalog, config, ops (no static).
- Config routes live in `ConfigWebHandler` (`src/metagit/core/web/config_handler.py`): GET/PATCH `/v3/config/{metagit|appconfig}[/tree]`, POST `/v3/config/validate`.
- Ops routes live in `OpsWebHandler` (`src/metagit/core/web/ops_handler.py`): POST `/v3/ops/health`, `/v3/ops/prune/preview`, `/v3/ops/prune`, `/v3/ops/sync`; GET `/v3/ops/sync/{job_id}`; GET `/v3/ops/sync/{job_id}/events` (SSE). Module-level `SyncJobStore` tracks async sync jobs.

## When adding routes later

- Extend `ConfigWebHandler` / `OpsWebHandler` or add sibling handlers; register in `server.py` `Handler._dispatch` (SSE: match path before JSON dispatch).
- Run `task qa:prepush` before hand-off.
````

## File: docs/reference/metagit-config.md
````markdown
# Metagit configuration exemplar

The file [metagit-config.full-example.yml](metagit-config.full-example.yml) is a **generated,
non-production** sample of `.metagit.yml` with representative values and Pydantic field
descriptions as comments.

## Regenerate

From the repository root:

```bash
task generate:schema
```

That runs `metagit config schema`, `metagit appconfig schema`, and `metagit config example`.

To emit only the YAML exemplar:

```bash
metagit config example --output docs/reference/metagit-config.full-example.yml
```

Overrides merged from `src/metagit/data/config-example-overrides.yml` keep the workspace and
Hermes-oriented examples readable.

## Machine-readable schema

JSON Schema for editors and CI:

- [schemas/metagit_config.schema.json](../../schemas/metagit_config.schema.json)
- [schemas/metagit_appconfig.schema.json](../../schemas/metagit_appconfig.schema.json)

## Validate your manifest

```bash
metagit config validate --config-path .metagit.yml
```

## Schema-backed editing (CLI)

The same operation model as the web Config Studio is available on the CLI:

| Command | Purpose |
|---------|---------|
| `metagit config tree` | Browse fields, types, and paths |
| `metagit config preview` | Apply draft ops and print YAML (no save) |
| `metagit config patch --save` | Apply ops and write `.metagit.yml` when valid |
| `metagit appconfig tree` | App config field tree |
| `metagit appconfig preview` | Draft preview (secrets redacted) |
| `metagit appconfig patch --save` | Apply ops to `metagit.config.yaml` |

Operations: `enable`, `disable`, `set`, `append`, `remove`. Paths use dot/bracket notation
(e.g. `workspace.projects[0].name`, `documentation[0].path`).

```bash
# Single field
metagit config patch --op set --path name --value my-workspace --save

# Batch from JSON (same shape as web PATCH body)
metagit config patch --file ops.json --save
```

`ops.json` may be `{"operations": [...]}` or a bare array of operation objects.

Do not deploy the generated exemplar verbatim; copy sections you need and replace placeholders.

## Documentation sources

The top-level `documentation` list accepts **bare strings** or **objects**:

- A string without `http(s)://` is treated as `kind: markdown` with `path` set to that string.
- A URL string becomes `kind: web`.
- Objects support `kind`, `path`, `url`, `title`, `description`, `tags` (list or map), and `metadata` (map) for knowledge-graph ingestion.

Use `MetagitConfig.documentation_graph_nodes()` (or export from your tooling) to emit normalized node payloads.

## Manual graph relationships

The optional top-level `graph` block declares cross-repo edges that are not inferred from imports or URLs:

```yaml
graph:
  metadata:
    source: manual
  relationships:
    - id: platform-api-uses-infra-tf
      from:
        project: platform
        repo: api
      to:
        project: infra
        repo: terraform-modules
      type: depends_on
      label: API stack depends on shared modules
      tags:
        layer: platform
```

These edges are merged into cross-project dependency maps (`type: manual`) and available via `MetagitConfig.graph_export_payload()` for GitNexus-style exports. Request dependency type `manual` when calling `metagit_cross_project_dependencies` to focus on manifest-declared edges.

### Export to GitNexus (Cypher)

Export manual relationships (and optional structure/documentation nodes) as Cypher for `gitnexus_cypher` MCP tool calls:

```bash
metagit config graph export -c .metagit.yml --format json
metagit config graph export -c .metagit.yml --format cypher --output workspace-graph.cypher
metagit config graph export -c .metagit.yml --format tool-calls --manual-only
```

The exporter creates overlay tables `MetagitEntity` and `MetagitLink` (run `schema_statements` once per target GitNexus index), then `MERGE`/`CREATE` workspace nodes and manual edges. MCP: `metagit_export_workspace_graph_cypher`.

Pass `--gitnexus-repo <name>` when the umbrella workspace is indexed under a different GitNexus repo name than the manifest `name` field.
````

## File: docs/reference/metagit-web.md
````markdown
# Metagit Web

Bundled SPA + local HTTP handler that ships with the **`metagit-cli`** Python package.

## Purpose

Metagit Web is a developer-focused browser UI for workspace awareness and maintenance
while you work in a `.metagit.yml` umbrella. It sits next to `metagit` CLI commands and
the local HTTP handlers (for example `/v2` workspace catalog/layout and `/v3` config
and ops endpoints). Use it when you prefer point-and-click inspection and edits over
printing JSON in the terminal.

## `metagit web serve`

From a directory containing a valid `.metagit.yml` (or pointing at one with `--root`):

```bash
metagit web serve [OPTIONS]
```

| Flag | Default | Meaning |
|------|---------|---------|
| `--root` | `.` | Workspace directory that contains `.metagit.yml`. Resolved to an absolute path for logging and API handlers. |
| `--appconfig` | *(inherit)* | Overrides the usual Metagit app-config file path (`metagit` passes this from CLI context); required if `ctx.obj` is missing — run via installed `metagit` CLI, or pass `--appconfig` explicitly. |
| `--host` | `127.0.0.1` | Bind address for the bundled HTTP server. |
| `--port` | `8787` | TCP port for the bundled HTTP server (OS may probe if busy). |
| `--open` / `--no-open` | `no-open` | When `--open` is set, the default browser opens the UI URL once the server starts. |
| `--status-once` | *(off)* | Bind once, print a single `web_state=ready host=… port=… url=…` line, then exit immediately (agents and scripts use this for startup checks without leaving a process running). |

The server serves:

- Static assets packaged under `src/metagit/data/web/` from the SPA build.
- API routes delegated to workspace config, catalog, ops, and related handlers (`/v2`, `/v3`, etc.). See `.mex/patterns/metagit-web-api.md` in this repo for handler/model patterns.

Typical foreground run:

```bash
cd /path/to/workspace
metagit web serve
```

Browse to the printed URL (default root is `/`).

## UI tour

### Config Studio

**Config Studio** is the paired Metagit and application configuration editors accessed from the top navigation:

- **Metagit config** (`/config/metagit`): schema-backed tree navigation and field edits for `.metagit.yml` semantics (PATCH flows against `/v3` config APIs).
- **App config** (`/config/appconfig`): same interaction model against application configuration persisted via Metagit's app-config path.

These screens share TanStack Query data loading and theme styling with the shell.

Each config editor includes a **YAML preview** panel with three render modes:

| Mode | Meaning |
|------|---------|
| **Normalized** | Full config from the validated Pydantic model (same serializer as `metagit config show --normalized`). |
| **Minimal** | Non-default fields only (`exclude_defaults`), useful for seeing what differs from schema defaults. |
| **On disk** | Raw file contents as stored on disk (no draft overlay). |

When you **Apply** edits without saving, pending operations are merged into the preview and a **Draft** badge appears. Validation errors from draft operations surface above the YAML block. App-config previews redact sensitive tokens (`***` + last four characters).

API: `POST /v3/config/metagit/preview` and `POST /v3/config/appconfig/preview` with `{ "style": "normalized", "operations": [...] }`.

CLI parity (same operation model):

```bash
metagit config tree
metagit config preview --file ops.json
metagit config patch --file ops.json --save
metagit appconfig patch --op set --path workspace.dedupe.enabled --value false --save
```

See [metagit-config.md](metagit-config.md#schema-backed-editing-cli) for operation shapes and path examples.

### Workspace Console

The **Workspace Console** is **Workspace** in the chrome (`/workspace`): catalog-level context (projects/repos index, search/filter) plus the **workspace operations** side panel (health/prune/sync style actions routed through `/v3/ops`). This is meant for situational awareness and lightweight maintenance; destructive actions remain gated as in the CLI and API.

Use the **Repositories | Graph** toggle on the workspace toolbar:

- **Repositories** — filterable table of projects and repos (synced / missing) with per-repo sync actions.
- **Graph** — SVG diagram of workspace relationships: manual edges from `.metagit.yml` `graph.relationships`, optional inferred cross-project dependencies, and project → repo structure edges. Checkboxes control inferred and structure layers.

Graph data is loaded from `GET /v3/ops/graph`:

| Query param | Default | Meaning |
|-------------|---------|---------|
| `include_inferred` | `true` | Include edges inferred from cross-project dependency analysis. |
| `include_structure` | `true` | Include project → repo containment edges. |

Response shape: `{ ok, nodes[], edges[], manual_edge_count, inferred_edge_count, structure_edge_count }`. Each node has `id`, `label`, `kind` (`project` \| `repo`). Each edge has `from_id`, `to_id`, `type`, optional `label`, and `source` (`manual` \| `inferred` \| `structure`).

## Frontend development workflow

Prerequisites under `web/`: npm (Node toolchain). Prefer the Task targets from the repo root.

### Hot reload (`task web:dev`)

```bash
task web:dev
```

Runs `npm run dev` (Vite) with a dev-server proxy:

- Paths under `/v2` and `/v3` proxy to **`http://127.0.0.1:8787`** (`web/vite.config.ts`).

Therefore the usual workflow is:

1. In one shell: **`metagit web serve`** (or `--port`/`--host` overrides if you customize Vite targets).
2. In another shell: **`task web:dev`** and open Vite's origin (shown in npm output).

The browser talks to Vite while API calls traverse the proxy to Python.

### Production-like bundle (`task web:build`)

```bash
task web:build
```

This runs `npm ci || npm install` under `web/`, then `npm run build`, emitting into
`src/metagit/data/web/` (`emptyOutDir: true`). Commit those generated assets whenever
shipping UI fixes so `metagit web serve` picks them up without a local Node install.

Continuous integration hooks in `task qa:prepush`/`scripts/prepush-gate.py` focus on Python
tests and lint; they do **not** run **`task web:build`**. If you alter source under `web/`,
run **`task web:build`** manually before tagging or merging UI changes unless your team
delegates builds to CI elsewhere.

## Security

- **`--host` defaults to localhost** (`127.0.0.1`): the bundled server expects local-only use.
- **No authentication layer in Web v1** — anyone who can reach the TCP port can call the APIs the UI exposes. Do not expose an open `--host`/`--port` endpoint on shared networks until an auth model lands.
- Treat `metagit web serve` like any other localhost admin utility: firewall exposure is your responsibility once you bind `0.0.0.0` or comparable.
````

## File: docs/development.md
````markdown
# Metagit Development Guide

Upon making changes run the following to validate everything before submitting a PR

```bash
task format lint:fix test
```

## MCP Development Notes

- Use `metagit mcp serve` to start the MCP stdio runtime.
- Use `--root <path>` to test workspace gating against a specific folder.
- Use `--status-once` for quick diagnostics without starting the message loop.
- MCP gating states:
  - `inactive_missing_config` when `.metagit.yml` is not present
  - `inactive_invalid_config` when `.metagit.yml` fails validation
  - `active` when `.metagit.yml` loads successfully

## Provider Source Sync

Use source sync to discover repositories from GitHub/GitLab and plan/apply workspace updates:

- Discover-only: `metagit project source sync --provider github --org <org> --mode discover`
- Additive apply: `metagit project source sync --provider github --org <org> --mode additive --apply`
- Reconcile apply: `metagit project source sync --provider gitlab --group <group> --mode reconcile --apply --yes`

## Semantic Release Tags

`release-please` runs on merges to `main` and uses conventional commit prefixes to prepare/version releases.

- It opens/updates a release PR with computed next version + changelog.
- When that PR is merged, it creates the semantic tag (`vX.Y.Z`) and GitHub release.

- `fix:` -> patch release (`X.Y.Z+1`) **default for most updates**
- `feat:` -> minor release (`X.Y+1.0`)
- `type(scope)!:` or `BREAKING CHANGE:` -> major release (`X+1.0.0`)

### Commit Prefix Guidance

Use patch semantics first (`fix:`) unless schema/config compatibility is intentionally broken.

- Use `fix:` for normal maintenance and safe behavior changes.
- Use `feat:` only for additive, backward-compatible functionality.
- Use `!` / `BREAKING CHANGE` when changing `.metagit.yml` or app config schema in a non-backward-compatible way.
````

## File: skills/metagit-cli/SKILL.md
````markdown
---
name: metagit-cli
description: CLI-only shortcuts for metagit agents — workspace catalog, discovery, prompts, sync, layout, and config. Use instead of MCP or HTTP API when operating from a shell or agent_mode session.
---

# Metagit CLI (agent shortcuts)

Use this skill when an agent should drive metagit **only through the `metagit` command**. Do not call MCP tools or `metagit api` from workflows covered here unless the user explicitly asks.

Set non-interactive defaults when automating:

```bash
export METAGIT_AGENT_MODE=true
```

Global flags (most commands):

- `-c path/to/metagit.config.yaml` — app config (default `metagit.config.yaml`)
- Workspace manifest: `--definition` / `-c` on catalog commands (default `.metagit.yml`)

---

## Manifest editing fast map (`.metagit.yml`)

Use this table first when changing a workspace manifest from the CLI. Prefer **catalog commands** for projects/repos; use **`config patch`** for everything else in the schema (documentation, graph, dedupe overrides, nested fields).

| Task | Command |
|------|---------|
| **Inspect** manifest on disk | `metagit config show -c .metagit.yml` |
| **Inspect** normalized model | `metagit config show -c .metagit.yml --normalized` |
| **Inspect** as JSON (agents) | `metagit config show -c .metagit.yml --json` |
| **Browse** fields / paths | `metagit config tree -c .metagit.yml` or `… --json` |
| **Validate** after edits | `metagit config validate -c .metagit.yml` |
| **Dry-run** schema change | `metagit config preview -c .metagit.yml --file ops.json` |
| **Apply** schema change | `metagit config patch -c .metagit.yml --file ops.json --save` |
| **Set one field** | `metagit config patch -c .metagit.yml --op set --path <path> --value <v> --save` |
| **Enable** optional block | `metagit config patch … --op enable --path <path> --save` |
| **Add list item** | `metagit config patch … --op append --path <list.path> --save` then `set` on `[index].field` |
| **Remove list item** | `metagit config patch … --op remove --path <list.path>[index] --save` |

### Catalog shortcuts (projects & repos)

| Task | Command |
|------|---------|
| List projects | `metagit workspace project list -c .metagit.yml --json` |
| List repos (all / one project) | `metagit workspace repo list -c .metagit.yml --json` / `… --project <p> --json` |
| Add project | `metagit workspace project add -c .metagit.yml --name <p> --json` |
| Remove project | `metagit workspace project remove -c .metagit.yml --name <p> --json` |
| Rename project (dry-run) | `metagit workspace project rename -c .metagit.yml --name <old> --new-name <new> --dry-run --json` |
| Add repo | `metagit workspace repo add -c .metagit.yml --project <p> --name <r> --url <url> --json` |
| Remove repo (manifest only) | `metagit workspace repo remove -c .metagit.yml --project <p> --name <r> --json` |
| Rename / move repo (dry-run) | `metagit workspace repo rename …` / `metagit workspace repo move … --dry-run --json` |
| Search before adding | `metagit search "<name>" -c .metagit.yml --json` |

Active project context (optional): `metagit project repo add --name <r> --url <url>` after `metagit project select`.

### Schema patch examples (`ops.json`)

Paths use dot/bracket notation (same as web Config Studio). `--value` accepts JSON for objects.

```json
{
  "operations": [
    { "op": "set", "path": "name", "value": "my-workspace" },
    { "op": "enable", "path": "documentation" },
    { "op": "append", "path": "documentation" },
    { "op": "set", "path": "documentation[0]", "value": { "kind": "markdown", "path": "AGENTS.md" } },
    { "op": "enable", "path": "graph" },
    { "op": "append", "path": "graph.relationships" },
    {
      "op": "set",
      "path": "graph.relationships[0]",
      "value": {
        "id": "api-depends-infra",
        "from": { "project": "platform", "repo": "api" },
        "to": { "project": "infra", "repo": "terraform" },
        "type": "depends_on"
      }
    },
    { "op": "set", "path": "workspace.projects[0].dedupe.enabled", "value": false }
  ]
}
```

```bash
metagit config preview -c .metagit.yml --file ops.json
metagit config patch -c .metagit.yml --file ops.json --save
metagit config validate -c .metagit.yml
```

### App config vs manifest

| File | Scope | Edit via |
|------|-------|----------|
| `.metagit.yml` | Workspace manifest (projects, repos, docs, graph) | `metagit config …` + `metagit workspace …` |
| `metagit.config.yaml` | Tooling (paths, dedupe default, providers, profiles) | `metagit appconfig …` |

```bash
metagit appconfig show --format minimal-yaml
metagit appconfig patch --op set --path workspace.dedupe.enabled --value false --save
metagit config providers --show
```

### After every manifest edit

1. `metagit config validate -c .metagit.yml`
2. `metagit workspace list -c .metagit.yml --json` (sanity-check catalog)
3. If repos changed on disk: `metagit project sync` or `metagit project sync --hydrate`

### Export manual graph to GitNexus (Cypher)

| Task | Command |
|------|---------|
| Full export bundle (JSON) | `metagit config graph export -c .metagit.yml --json` |
| Raw Cypher script | `metagit config graph export -c .metagit.yml --format cypher -o graph.cypher` |
| MCP tool_calls only | `metagit config graph export -c .metagit.yml --format tool-calls` |
| Manual edges only | `metagit config graph export -c .metagit.yml --manual-only --format tool-calls` |
| MCP from agent | `metagit_export_workspace_graph_cypher` |

Ingest workflow: run `schema_statements` once via `gitnexus_cypher`, then each statement in `tool_calls` (or pipe `--format cypher`). Overlay tables: `MetagitEntity`, `MetagitLink`.

---

## Prompt commands (all kinds)

List built-in prompt kinds:

```bash
metagit prompt list
metagit prompt list --json
```

Emit prompts (`--text-only` for paste into agent context; `--json` for structured output; `--no-instructions` to omit manifest layers):

| Scope | Command | Default kind |
|-------|---------|--------------|
| Workspace | `metagit prompt workspace -c <definition> -k <kind>` | `instructions` |
| Project | `metagit prompt project -p <project> -c <definition> -k <kind>` | `instructions` |
| Repo | `metagit prompt repo -p <project> -n <repo> -c <definition> -k <kind>` | `instructions` |

### Prompt kinds by scope

| Kind | Workspace | Project | Repo | Purpose |
|------|:---------:|:-------:|:----:|---------|
| `instructions` | yes | yes | yes | Composed `agent_instructions` from manifest layers |
| `session-start` | yes | — | — | Session bootstrap checklist |
| `catalog-edit` | yes | yes | — | Search-before-create; catalog registration |
| `health-preflight` | yes | yes | — | Pre-work workspace/repo status pass |
| `sync-safe` | yes | yes | yes | Guarded sync rules |
| `subagent-handoff` | — | yes | yes | Delegate single-repo work |
| `layout-change` | yes | yes | yes | Rename/move dry-run workflow |
| `repo-enrich` | — | — | yes | **Discover + merge** workspace repo entry |

### Prompt shortcuts (copy-paste)

```bash
# Session bootstrap
metagit prompt workspace -k session-start --text-only -c .metagit.yml

# Composed instructions at each level
metagit prompt workspace -k instructions --text-only -c .metagit.yml
metagit prompt project -p default -k instructions --text-only -c .metagit.yml
metagit prompt repo -p default -n my-api -k instructions --text-only -c .metagit.yml

# Repo catalog enrichment (detect + merge manifest entry)
metagit prompt repo -p default -n my-api -k repo-enrich --text-only -c .metagit.yml

# Catalog registration discipline
metagit prompt workspace -k catalog-edit --text-only -c .metagit.yml

# Safe sync reminder
metagit prompt repo -p default -n my-api -k sync-safe --text-only -c .metagit.yml

# Subagent handoff
metagit prompt repo -p default -n my-api -k subagent-handoff --text-only -c .metagit.yml
```

---

## Repo enrich workflow (`repo-enrich`)

Run the prompt, then execute its steps:

```bash
metagit prompt repo -p <project> -n <repo> -k repo-enrich --text-only -c .metagit.yml
```

Typical discovery chain on the checkout:

```bash
cd "$(metagit search '<repo>' -c .metagit.yml --path-only)"
metagit detect repository -p . -o json
metagit detect repo -p . -o yaml
metagit detect repo_map -p . -o json
```

Provider metadata (dry-run):

```bash
metagit project source sync --provider github --org <org> --mode discover --no-apply
```

After merging fields into `workspace.projects[].repos[]`:

```bash
metagit config validate -c .metagit.yml
metagit workspace repo list --project <project> --json
```

---

## Workspace and catalog

Per-project dedupe override in `.metagit.yml` (overrides `workspace.dedupe.enabled` in `metagit.config.yaml` for that project only):

```yaml
workspace:
  projects:
    - name: local
      dedupe:
        enabled: false
      repos: []
```

```bash
metagit appconfig show --format json
metagit config info -c .metagit.yml
metagit config show -c .metagit.yml
metagit config validate -c .metagit.yml

metagit workspace list -c .metagit.yml --json
metagit workspace project list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --project <name> --json

metagit workspace project add --name <name> --json
metagit workspace repo add --project <name> --name <repo> --url <url> --json
metagit workspace project remove --name <name> --json
metagit workspace repo remove --project <name> --name <repo> --json
```

Search managed repos (always before creating entries):

```bash
metagit search "<query>" -c .metagit.yml --json
metagit search "<query>" -c .metagit.yml --path-only
metagit search "<query>" -c .metagit.yml --tag tier=1 --project <name>
```

---

## Project operations

```bash
metagit project list --config .metagit.yml --all --json
metagit project add --name <name> --json
metagit project remove --name <name> --json
metagit project rename --name <old> --new-name <new> --dry-run --json
metagit project select
metagit project sync
metagit project sync --hydrate   # symlink mounts → full directory copies (per-file progress)

metagit project repo list --json
metagit project repo add --project <name> --name <repo> --url <url>
metagit project repo remove --name <repo> --json
metagit project repo rename --name <old> --new-name <new> --dry-run --json
metagit project repo move --name <repo> --to-project <other> --dry-run --json
metagit project repo prune --project <name> --dry-run

metagit project source sync --provider github --org <org> --mode discover --no-apply
metagit project source sync --provider github --org <org> --mode additive --apply
```

Layout (manifest + disk; always dry-run first):

```bash
metagit workspace project rename --name <old> --new-name <new> --dry-run --json
metagit workspace repo rename --project <p> --name <old> --new-name <new> --dry-run --json
metagit workspace repo move --project <p> --name <repo> --to-project <other> --dry-run --json
```

---

## Discovery and local metadata

```bash
metagit detect project -p <path> -o yaml
metagit detect repo -p <path> -o yaml
metagit detect repo_map -p <path> -o json
metagit detect repository -p <path> -o json
metagit detect repository -p <path> -o metagit
# --save only with operator approval (blocked in agent_mode)
```

Bootstrap new trees:

```bash
metagit init --kind application
metagit init --kind umbrella --template hermes-orchestrator
```

---

## Selection and scope

```bash
metagit workspace select --project <name>
metagit project select
metagit project repo select
```

---

## Config and appconfig (reference)

See **Manifest editing fast map** above for day-to-day manifest work. Additional commands:

```bash
metagit config info -c .metagit.yml
metagit config example
metagit config schema
metagit appconfig validate
metagit appconfig get --name config.workspace.path
metagit appconfig tree --json
metagit appconfig preview --file ops.json
metagit appconfig patch --file ops.json --save
metagit config providers --show
```

---

## Records, skills, version

```bash
metagit record search "<query>"
metagit skills list
metagit skills show metagit-cli
metagit skills install --skill metagit-cli
metagit version
metagit info
```

---

## Agent habits

1. **Search before create** — `metagit search` then catalog add.
2. **Validate after manifest edits** — `metagit config validate`.
3. **Emit prompts instead of rewriting playbooks** — `metagit prompt … --text-only`.
4. **Enrich stale repo entries** — `metagit prompt repo … -k repo-enrich` then detect + merge.
5. **Dry-run layout** — always `--dry-run --json` before apply.
6. **Prefer `METAGIT_AGENT_MODE=true`** in CI and agent loops to skip fuzzy finder and confirm dialogs.

## Related bundled skills

Use topic skills when you need deeper playbooks (some mention MCP): `metagit-projects`, `metagit-workspace-scope`, `metagit-workspace-sync`, `metagit-config-refresh`. This skill is the **CLI-only** index and prompt reference.
````

## File: src/metagit/cli/commands/appconfig.py
````python
"""
Appconfig subcommand
"""
⋮----
@click.group(name="appconfig", invoke_without_command=True)
@click.pass_context
def appconfig(ctx: click.Context) -> None
⋮----
"""Application configuration subcommands"""
# If no subcommand is provided, show help
⋮----
@appconfig.command("info")
@click.pass_context
def appconfig_info(ctx: click.Context) -> None
⋮----
"""
    Information about the application configuration.
    """
logger = ctx.obj.get("logger") or UnifiedLogger(LoggerConfig())
⋮----
@click.pass_context
def appconfig_show(ctx: click.Context, output_format: str) -> None
⋮----
"""Show the full active application configuration."""
logger = ctx.obj["logger"]
⋮----
config: AppConfig = ctx.obj["config"]
config_path: str = ctx.obj["config_path"]
minimal = output_format == "minimal-yaml"
⋮----
rendered = render_appconfig_show(
⋮----
"""Validate a configuration file"""
⋮----
config_path = os.path.join(DATA_PATH, "metagit.config.yaml")
⋮----
# Step 1: Load YAML
⋮----
config_data = yaml_load(f)
⋮----
# Step 2: Validate structure with Pydantic model
⋮----
_ = AppConfig(**config_data["config"])
⋮----
@click.option("--output", default="json", help="Output format (json/yaml)")
@click.pass_context
def appconfig_get(ctx: click.Context, name: str, show_keys: bool, output: str) -> None
⋮----
"""Display appconfig value"""
⋮----
config = ctx.obj["config"]
result = get_config(
⋮----
logger = ctx.obj.get("logger")
⋮----
@click.pass_context
def appconfig_create(ctx: click.Context, config_path: str = None) -> None
⋮----
"""Create default application config"""
⋮----
default_config = load_config(DEFAULT_CONFIG)
⋮----
output = base_yaml.dump(
⋮----
@appconfig.command("set")
@click.option("--name", help="Appconfig element to target")
@click.option("--value", help="Value to set")
@click.pass_context
def appconfig_set(ctx: click.Context, name: str, value: str) -> None
⋮----
"""Set a value in the application configuration."""
⋮----
config_path = ctx.obj["config_path"]
⋮----
updated_config = set_config(appconfig=config, name=name, value=value)
⋮----
save_result = save_config(config_path=config_path, config=updated_config)
⋮----
@click.pass_context
def appconfig_tree(ctx: click.Context, as_json: bool) -> None
⋮----
"""Show schema-backed field tree for metagit.config.yaml."""
⋮----
result = ConfigPatchService().build_tree("appconfig", config_path)
⋮----
"""Preview app config after draft operations (secrets redacted in output)."""
⋮----
operations = (
result = ConfigPatchService().preview(
⋮----
"""
    Apply schema operations to metagit.config.yaml (enable/disable/set/append/remove).

    Paths use AppConfig field names (e.g. workspace.dedupe.enabled), not the config: wrapper.
    """
⋮----
operations = resolve_operations(
result = ConfigPatchService().patch(
⋮----
@click.pass_context
def appconfig_schema(ctx: click.Context, output_path: str) -> None
⋮----
"""
    Generate a JSON schema for the AppConfig class and write it to a file.
    """
⋮----
schema = AppConfig.model_json_schema()
````

## File: src/metagit/cli/commands/detect.py
````python
"""
Detect subcommand
"""
⋮----
@click.group(name="detect", invoke_without_command=True, help="Detection subcommands")
@click.pass_context
def detect(ctx: click.Context) -> None
⋮----
"""Detection subcommands"""
# If no subcommand is provided, show help
⋮----
@click.pass_context
def detect_project(ctx: click.Context, path: str, output: str) -> None
⋮----
"""Perform project detection and analysis."""
logger = ctx.obj["logger"]
⋮----
path_files = list_git_files(path)
⋮----
detection = ProjectDetection(logger=logger)
⋮----
results = detection.run(path)
⋮----
detections = []
⋮----
summary = {
⋮----
# .model_dump(exclude_none=True, exclude_defaults=True)
full_result = {
⋮----
yaml_output = yaml.safe_dump(
⋮----
json_output = json.dumps(full_result, indent=2)
⋮----
@click.pass_context
def detect_repo_map(ctx: click.Context, path: str, output: str) -> None
⋮----
"""Create a map of files and folders in a repository for further analysis."""
⋮----
summary = directory_summary(path)
⋮----
details = directory_details(target_path=path, file_lookup=FileExtensionLookup())
⋮----
result = {
⋮----
json_output = json.dumps(result, indent=2)
⋮----
@click.pass_context
def detect_repo(ctx: click.Context, path: str, output: str) -> None
⋮----
"""Detect the codebase."""
⋮----
# Create DetectionManager with all analyses enabled
config = DetectionManagerConfig.all_enabled()
project = DetectionManager.from_path(path, logger, config)
⋮----
run_result = project.run_all()
⋮----
yaml_output = project.to_yaml()
⋮----
json_output = project.to_json()
⋮----
summary_output = project.summary()
⋮----
"""Comprehensive repository analysis and MetagitConfig generation using DetectionManager."""
⋮----
app_config = ctx.obj["config"]
⋮----
# Configure providers
⋮----
# Try to load AppConfig and configure providers
⋮----
# app_config = AppConfig.load()
⋮----
# Fall back to environment variables
⋮----
# Use environment variables only
⋮----
# Override with CLI options if provided
⋮----
# Clear existing providers and configure with CLI options
⋮----
github_provider = GitHubProvider(
⋮----
gitlab_provider = GitLabProvider(
⋮----
# Log configured providers
providers = registry.get_all_providers()
⋮----
provider_names = [p.get_name() for p in providers]
⋮----
# Default to current directory if no path or URL provided
path = os.getcwd()
⋮----
# if not output and not save:
#     output = "summary"
⋮----
detection_manager = DetectionManager.from_path(path, logger, config)
⋮----
detection_manager = DetectionManager.from_url(url, temp_dir, logger, config)
⋮----
# Run all analyses
run_result = detection_manager.run_all()
⋮----
result = None
⋮----
# Output all detection data including MetagitRecord fields
⋮----
result = detection_manager.model_dump(
⋮----
result = yaml.safe_dump(
⋮----
result = detection_manager.to_yaml()
⋮----
# Convert to MetagitConfig (remove detection-specific fields)
config_data = detection_manager.model_dump(
⋮----
result = detection_manager.summary()
⋮----
result = yaml.dump(
⋮----
result = json.dumps(
⋮----
# Save as MetagitConfig (not DetectionManager)
⋮----
# Clean up if this was a cloned repository
````

## File: src/metagit/core/mcp/services/workspace_search.py
````python
#!/usr/bin/env python
"""
Workspace-scoped search service using ripgrep with a bounded fallback scanner.
"""
⋮----
class WorkspaceSearchService
⋮----
"""Search configured repositories with bounded, text-based matching."""
⋮----
_preset_terms: dict[str, list[str]] = {
⋮----
_intent_globs: dict[str, list[str]] = {
⋮----
_default_exclude: list[str] = [
⋮----
_generated_exclude: list[str] = [
⋮----
_category_rules: list[tuple[str, list[str]]] = [
⋮----
"""Search across scoped repository paths and return bounded hits."""
⋮----
glob_paths = list(paths or [])
⋮----
glob_paths = list(dict.fromkeys(glob_paths + self._intent_globs[intent]))
⋮----
glob_paths = list(dict.fromkeys(glob_paths + self._intent_globs[preset]))
exclude_globs = list(dict.fromkeys((exclude or []) + self._default_exclude))
search_query = self._compose_query(query=query, preset=preset)
results: list[dict[str, Any]] = []
⋮----
root = Path(repo_path).expanduser().resolve()
⋮----
remaining = max_results - len(results)
⋮----
hits: list[dict[str, Any]] = []
⋮----
hits = self._search_with_rg(
⋮----
hits = self._search_fallback(
⋮----
"""Resolve repo path list from index rows and optional repo selectors."""
⋮----
selectors = {item.strip() for item in repos if item.strip()}
selected: list[str] = []
⋮----
repo_path = str(row.get("repo_path", ""))
repo_name = str(row.get("repo_name", ""))
project_name = str(row.get("project_name", ""))
keys = {repo_path, repo_name, project_name, f"{project_name}/{repo_name}"}
⋮----
def _compose_query(self, query: str, preset: Optional[str]) -> str
⋮----
"""Build ripgrep query text from user query and optional preset terms."""
terms = self._terms(query=query, preset=preset)
⋮----
"""Run ripgrep and parse JSON output into hit records."""
⋮----
cmd = [
⋮----
completed = subprocess.run(
⋮----
context_before: list[str] = []
context_after: list[str] = []
⋮----
payload = json.loads(line)
⋮----
message_type = payload.get("type")
data = payload.get("data", {})
⋮----
context_text = str(data.get("lines", {}).get("text", "")).rstrip("\n")
⋮----
path_text = str(data.get("path", {}).get("text", ""))
line_number = int(data.get("line_number", 0))
line_text = str(data.get("lines", {}).get("text", "")).rstrip("\n")
hit = {
⋮----
context_before = []
context_after = []
⋮----
"""Fallback scanner when ripgrep is unavailable."""
raw_terms = [term for term in self._terms(query=query, preset=preset) if term]
terms = [term.lower() for term in raw_terms]
⋮----
terms = [t.lower() for t in query.split() if t]
⋮----
lines = file_path.read_text(encoding="utf-8").splitlines()
⋮----
lower_line = line.lower()
⋮----
"""Discover files by intent or glob pattern across repositories."""
glob_paths: list[str] = []
⋮----
glob_paths = ["**/*"]
exclude_globs = list(self._default_exclude)
⋮----
files: list[dict[str, str]] = []
⋮----
discovered = self._list_files_with_rg(
⋮----
payload: dict[str, Any] = {
⋮----
"""List files in a repo root using ripgrep or directory walk."""
⋮----
cmd = ["rg", "--files", "--no-messages"]
⋮----
completed = None
⋮----
lines = [
⋮----
"""Fallback file listing without ripgrep."""
_ = exclude_globs
results: list[str] = []
⋮----
"""Group discovered files into coarse categories."""
categories: dict[str, list[dict[str, str]]] = {}
⋮----
category = self._category_for_path(file_path=item.get("file_path", ""))
⋮----
def _category_for_path(self, file_path: str) -> str
⋮----
"""Assign a category label for a file path."""
lower = file_path.lower()
⋮----
def _terms(self, query: str, preset: Optional[str]) -> list[str]
⋮----
query_terms = [term for term in query.split() if term]
⋮----
def _is_ignored(self, file_path: Path) -> bool
⋮----
name = file_path.name
````

## File: src/metagit/data/skills/metagit-cli/SKILL.md
````markdown
---
name: metagit-cli
description: CLI-only shortcuts for metagit agents — workspace catalog, discovery, prompts, sync, layout, and config. Use instead of MCP or HTTP API when operating from a shell or agent_mode session.
---

# Metagit CLI (agent shortcuts)

Use this skill when an agent should drive metagit **only through the `metagit` command**. Do not call MCP tools or `metagit api` from workflows covered here unless the user explicitly asks.

Set non-interactive defaults when automating:

```bash
export METAGIT_AGENT_MODE=true
```

Global flags (most commands):

- `-c path/to/metagit.config.yaml` — app config (default `metagit.config.yaml`)
- Workspace manifest: `--definition` / `-c` on catalog commands (default `.metagit.yml`)

---

## Manifest editing fast map (`.metagit.yml`)

Use this table first when changing a workspace manifest from the CLI. Prefer **catalog commands** for projects/repos; use **`config patch`** for everything else in the schema (documentation, graph, dedupe overrides, nested fields).

| Task | Command |
|------|---------|
| **Inspect** manifest on disk | `metagit config show -c .metagit.yml` |
| **Inspect** normalized model | `metagit config show -c .metagit.yml --normalized` |
| **Inspect** as JSON (agents) | `metagit config show -c .metagit.yml --json` |
| **Browse** fields / paths | `metagit config tree -c .metagit.yml` or `… --json` |
| **Validate** after edits | `metagit config validate -c .metagit.yml` |
| **Dry-run** schema change | `metagit config preview -c .metagit.yml --file ops.json` |
| **Apply** schema change | `metagit config patch -c .metagit.yml --file ops.json --save` |
| **Set one field** | `metagit config patch -c .metagit.yml --op set --path <path> --value <v> --save` |
| **Enable** optional block | `metagit config patch … --op enable --path <path> --save` |
| **Add list item** | `metagit config patch … --op append --path <list.path> --save` then `set` on `[index].field` |
| **Remove list item** | `metagit config patch … --op remove --path <list.path>[index] --save` |

### Catalog shortcuts (projects & repos)

| Task | Command |
|------|---------|
| List projects | `metagit workspace project list -c .metagit.yml --json` |
| List repos (all / one project) | `metagit workspace repo list -c .metagit.yml --json` / `… --project <p> --json` |
| Add project | `metagit workspace project add -c .metagit.yml --name <p> --json` |
| Remove project | `metagit workspace project remove -c .metagit.yml --name <p> --json` |
| Rename project (dry-run) | `metagit workspace project rename -c .metagit.yml --name <old> --new-name <new> --dry-run --json` |
| Add repo | `metagit workspace repo add -c .metagit.yml --project <p> --name <r> --url <url> --json` |
| Remove repo (manifest only) | `metagit workspace repo remove -c .metagit.yml --project <p> --name <r> --json` |
| Rename / move repo (dry-run) | `metagit workspace repo rename …` / `metagit workspace repo move … --dry-run --json` |
| Search before adding | `metagit search "<name>" -c .metagit.yml --json` |

Active project context (optional): `metagit project repo add --name <r> --url <url>` after `metagit project select`.

### Schema patch examples (`ops.json`)

Paths use dot/bracket notation (same as web Config Studio). `--value` accepts JSON for objects.

```json
{
  "operations": [
    { "op": "set", "path": "name", "value": "my-workspace" },
    { "op": "enable", "path": "documentation" },
    { "op": "append", "path": "documentation" },
    { "op": "set", "path": "documentation[0]", "value": { "kind": "markdown", "path": "AGENTS.md" } },
    { "op": "enable", "path": "graph" },
    { "op": "append", "path": "graph.relationships" },
    {
      "op": "set",
      "path": "graph.relationships[0]",
      "value": {
        "id": "api-depends-infra",
        "from": { "project": "platform", "repo": "api" },
        "to": { "project": "infra", "repo": "terraform" },
        "type": "depends_on"
      }
    },
    { "op": "set", "path": "workspace.projects[0].dedupe.enabled", "value": false }
  ]
}
```

```bash
metagit config preview -c .metagit.yml --file ops.json
metagit config patch -c .metagit.yml --file ops.json --save
metagit config validate -c .metagit.yml
```

### App config vs manifest

| File | Scope | Edit via |
|------|-------|----------|
| `.metagit.yml` | Workspace manifest (projects, repos, docs, graph) | `metagit config …` + `metagit workspace …` |
| `metagit.config.yaml` | Tooling (paths, dedupe default, providers, profiles) | `metagit appconfig …` |

```bash
metagit appconfig show --format minimal-yaml
metagit appconfig patch --op set --path workspace.dedupe.enabled --value false --save
metagit config providers --show
```

### After every manifest edit

1. `metagit config validate -c .metagit.yml`
2. `metagit workspace list -c .metagit.yml --json` (sanity-check catalog)
3. If repos changed on disk: `metagit project sync` or `metagit project sync --hydrate`

### Export manual graph to GitNexus (Cypher)

| Task | Command |
|------|---------|
| Full export bundle (JSON) | `metagit config graph export -c .metagit.yml --json` |
| Raw Cypher script | `metagit config graph export -c .metagit.yml --format cypher -o graph.cypher` |
| MCP tool_calls only | `metagit config graph export -c .metagit.yml --format tool-calls` |
| Manual edges only | `metagit config graph export -c .metagit.yml --manual-only --format tool-calls` |
| MCP from agent | `metagit_export_workspace_graph_cypher` |

Ingest workflow: run `schema_statements` once via `gitnexus_cypher`, then each statement in `tool_calls` (or pipe `--format cypher`). Overlay tables: `MetagitEntity`, `MetagitLink`.

---

## Prompt commands (all kinds)

List built-in prompt kinds:

```bash
metagit prompt list
metagit prompt list --json
```

Emit prompts (`--text-only` for paste into agent context; `--json` for structured output; `--no-instructions` to omit manifest layers):

| Scope | Command | Default kind |
|-------|---------|--------------|
| Workspace | `metagit prompt workspace -c <definition> -k <kind>` | `instructions` |
| Project | `metagit prompt project -p <project> -c <definition> -k <kind>` | `instructions` |
| Repo | `metagit prompt repo -p <project> -n <repo> -c <definition> -k <kind>` | `instructions` |

### Prompt kinds by scope

| Kind | Workspace | Project | Repo | Purpose |
|------|:---------:|:-------:|:----:|---------|
| `instructions` | yes | yes | yes | Composed `agent_instructions` from manifest layers |
| `session-start` | yes | — | — | Session bootstrap checklist |
| `catalog-edit` | yes | yes | — | Search-before-create; catalog registration |
| `health-preflight` | yes | yes | — | Pre-work workspace/repo status pass |
| `sync-safe` | yes | yes | yes | Guarded sync rules |
| `subagent-handoff` | — | yes | yes | Delegate single-repo work |
| `layout-change` | yes | yes | yes | Rename/move dry-run workflow |
| `repo-enrich` | — | — | yes | **Discover + merge** workspace repo entry |

### Prompt shortcuts (copy-paste)

```bash
# Session bootstrap
metagit prompt workspace -k session-start --text-only -c .metagit.yml

# Composed instructions at each level
metagit prompt workspace -k instructions --text-only -c .metagit.yml
metagit prompt project -p default -k instructions --text-only -c .metagit.yml
metagit prompt repo -p default -n my-api -k instructions --text-only -c .metagit.yml

# Repo catalog enrichment (detect + merge manifest entry)
metagit prompt repo -p default -n my-api -k repo-enrich --text-only -c .metagit.yml

# Catalog registration discipline
metagit prompt workspace -k catalog-edit --text-only -c .metagit.yml

# Safe sync reminder
metagit prompt repo -p default -n my-api -k sync-safe --text-only -c .metagit.yml

# Subagent handoff
metagit prompt repo -p default -n my-api -k subagent-handoff --text-only -c .metagit.yml
```

---

## Repo enrich workflow (`repo-enrich`)

Run the prompt, then execute its steps:

```bash
metagit prompt repo -p <project> -n <repo> -k repo-enrich --text-only -c .metagit.yml
```

Typical discovery chain on the checkout:

```bash
cd "$(metagit search '<repo>' -c .metagit.yml --path-only)"
metagit detect repository -p . -o json
metagit detect repo -p . -o yaml
metagit detect repo_map -p . -o json
```

Provider metadata (dry-run):

```bash
metagit project source sync --provider github --org <org> --mode discover --no-apply
```

After merging fields into `workspace.projects[].repos[]`:

```bash
metagit config validate -c .metagit.yml
metagit workspace repo list --project <project> --json
```

---

## Workspace and catalog

Per-project dedupe override in `.metagit.yml` (overrides `workspace.dedupe.enabled` in `metagit.config.yaml` for that project only):

```yaml
workspace:
  projects:
    - name: local
      dedupe:
        enabled: false
      repos: []
```

```bash
metagit appconfig show --format json
metagit config info -c .metagit.yml
metagit config show -c .metagit.yml
metagit config validate -c .metagit.yml

metagit workspace list -c .metagit.yml --json
metagit workspace project list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --project <name> --json

metagit workspace project add --name <name> --json
metagit workspace repo add --project <name> --name <repo> --url <url> --json
metagit workspace project remove --name <name> --json
metagit workspace repo remove --project <name> --name <repo> --json
```

Search managed repos (always before creating entries):

```bash
metagit search "<query>" -c .metagit.yml --json
metagit search "<query>" -c .metagit.yml --path-only
metagit search "<query>" -c .metagit.yml --tag tier=1 --project <name>
```

---

## Project operations

```bash
metagit project list --config .metagit.yml --all --json
metagit project add --name <name> --json
metagit project remove --name <name> --json
metagit project rename --name <old> --new-name <new> --dry-run --json
metagit project select
metagit project sync
metagit project sync --hydrate   # symlink mounts → full directory copies (per-file progress)

metagit project repo list --json
metagit project repo add --project <name> --name <repo> --url <url>
metagit project repo remove --name <repo> --json
metagit project repo rename --name <old> --new-name <new> --dry-run --json
metagit project repo move --name <repo> --to-project <other> --dry-run --json
metagit project repo prune --project <name> --dry-run

metagit project source sync --provider github --org <org> --mode discover --no-apply
metagit project source sync --provider github --org <org> --mode additive --apply
```

Layout (manifest + disk; always dry-run first):

```bash
metagit workspace project rename --name <old> --new-name <new> --dry-run --json
metagit workspace repo rename --project <p> --name <old> --new-name <new> --dry-run --json
metagit workspace repo move --project <p> --name <repo> --to-project <other> --dry-run --json
```

---

## Discovery and local metadata

```bash
metagit detect project -p <path> -o yaml
metagit detect repo -p <path> -o yaml
metagit detect repo_map -p <path> -o json
metagit detect repository -p <path> -o json
metagit detect repository -p <path> -o metagit
# --save only with operator approval (blocked in agent_mode)
```

Bootstrap new trees:

```bash
metagit init --kind application
metagit init --kind umbrella --template hermes-orchestrator
```

---

## Selection and scope

```bash
metagit workspace select --project <name>
metagit project select
metagit project repo select
```

---

## Config and appconfig (reference)

See **Manifest editing fast map** above for day-to-day manifest work. Additional commands:

```bash
metagit config info -c .metagit.yml
metagit config example
metagit config schema
metagit appconfig validate
metagit appconfig get --name config.workspace.path
metagit appconfig tree --json
metagit appconfig preview --file ops.json
metagit appconfig patch --file ops.json --save
metagit config providers --show
```

---

## Records, skills, version

```bash
metagit record search "<query>"
metagit skills list
metagit skills show metagit-cli
metagit skills install --skill metagit-cli
metagit version
metagit info
```

---

## Agent habits

1. **Search before create** — `metagit search` then catalog add.
2. **Validate after manifest edits** — `metagit config validate`.
3. **Emit prompts instead of rewriting playbooks** — `metagit prompt … --text-only`.
4. **Enrich stale repo entries** — `metagit prompt repo … -k repo-enrich` then detect + merge.
5. **Dry-run layout** — always `--dry-run --json` before apply.
6. **Prefer `METAGIT_AGENT_MODE=true`** in CI and agent loops to skip fuzzy finder and confirm dialogs.

## Related bundled skills

Use topic skills when you need deeper playbooks (some mention MCP): `metagit-projects`, `metagit-workspace-scope`, `metagit-workspace-sync`, `metagit-config-refresh`. This skill is the **CLI-only** index and prompt reference.
````

## File: src/metagit/data/skills/README.md
````markdown
# Bundled Metagit skills

Installed via `metagit skills install`. All names use the `metagit-` prefix.

- `metagit-cli` — CLI-only agent shortcuts (prompts, catalog, detect, sync; no MCP/API)
- `metagit-projects` — workspace project lifecycle
- `metagit-workspace-scope` — scope discovery
- `metagit-control-center` — multi-repo control center
- `metagit-workspace-sync` — guarded sync
- `metagit-config-refresh` / `metagit-bootstrap` — config refresh and bootstrap
- `metagit-gating` — MCP gate checks
- `metagit-upstream-scan` / `metagit-upstream-triage` — upstream discovery
- `metagit-repo-impact` / `metagit-multi-repo` — impact and implementation
- `metagit-gitnexus` — GitNexus analysis
- `metagit-release-audit` — release readiness

See [docs/skills.md](../../../../docs/skills.md) for install instructions.
````

## File: tests/core/web/test_schema_tree.py
````python
#!/usr/bin/env python
"""Unit tests for SchemaTreeService."""
⋮----
def test_build_metagit_tree_marks_present_fields() -> None
⋮----
service = SchemaTreeService()
config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
root = service.build_tree(config, MetagitConfig)
⋮----
name_node = service.find_node(root, "name")
kind_node = service.find_node(root, "kind")
⋮----
def test_enum_field_exposes_all_options() -> None
⋮----
def test_disable_optional_field_removes_key() -> None
⋮----
def test_enable_optional_field_adds_default() -> None
⋮----
def test_set_field_updates_value() -> None
⋮----
def test_appconfig_sensitive_field_masked_in_tree() -> None
⋮----
raw = {
config = AppConfig(**raw)
root = service.build_tree(config, AppConfig, mask_secrets=True)
token_node = service.find_node(root, "providers.github.api_token")
⋮----
def test_apply_operations_returns_original_instance_on_validation_error() -> None
⋮----
def test_sensitive_token_unchanged_after_masked_set() -> None
⋮----
def test_type_label_shows_model_name() -> None
⋮----
config = MetagitConfig.model_validate(
⋮----
workspace_node = service.find_node(root, "workspace")
⋮----
def test_enable_optional_list_defaults_empty() -> None
⋮----
def test_append_and_remove_list_items() -> None
⋮----
root = service.build_tree(appended, MetagitConfig)
artifacts_node = service.find_node(root, "artifacts")
````

## File: mise.toml
````toml
[env]
_.source = '.envrc'

[settings]
python.uv_venv_auto = true

[tools]
uv = "latest"
python = "3.12"
task = "latest"
npm = "latest"
gh = "latest"
gitleaks = "latest"
glow = "latest"
rg = "latest"
node = "latest"
"npm:repomix" = "latest"
````

## File: .cursor/rules/project-level.mdc
````
---
description: Metagit
globs: *
alwaysApply: true
---

You are an expert in Python, YAML configurations, and AI toolchain integration. Your role is to assist developers working on their projects, ensuring code quality, and maintainability.

## Code Style and Structure

- Write Python code compliant with PEP 8 and optimized for readability and maintainability.
- Use type hints for all function parameters and return types.
- Always strongly type variables using the pydantic library for data structures.
- Always use Protocol definitions for interface definitions.
- Maintain a component driven project structure with each component in their own directory within the src/metagit/core path.
- Avoid duplication by using clear, modular code organization.
- All file paths should be constructed using os.path.join() instead of string concatenation.
- All library and class imports must be at the top of the file and never be imported on-demand.
- Remove all unused imports from each python file after editing them.
- Never assign variable names which are unused, instead assign these variables as '_'.
- Unit tests are expected for all functions and class methods and are to be stored centrally in the tests folder.
- Combine if statements instead of nesting them where possible.
- Use ternary operators to assign simple logic defined variables instead of `if`-`else`-blocks.
- Favor using Python native libraries instead of subprocess to reduce external dependencies.
- Use 2 spaces for indentation.
- Private class members should be prefixed with an underscore (`_`).
- Use `isinstance()` for type comparisons.

## Naming Conventions

- Use snake_case for variables, functions, and filenames.
- Use PascalCase for class names.
- Prefix environment variables with provider name (e.g., `OLLAMA_`, `OPENAI_`).
- Use descriptive names for configuration files (e.g., `agents.yaml`, `tasks.yaml`).

## Environment and Configuration

- Use `python-dotenv` to manage environment variables.
- Maintain `.env.example` as a template for environment setup.
- Structure YAML files clearly and validate on load:
  - Use `yaml.safe_load` for security.
  - Include clear error messages for missing or invalid keys.

## Syntax and Formatting

- New Python files should always include `#!/usr/bin/env python` as the very first line.
- Format code with tools like Black and lint with Flake8.
- Follow Pythonic idioms and best practices.
- Use early returns in functions to simplify logic.
- Write clear docstrings for all classes and functions.

## Error Handling and Validation

- Validate environment variables at startup.
- Use try-except blocks with meaningful error messages.
- Never create bare exception statements.
- Be as explicit as possible when handling exceptions.
- Log errors appropriately using the UnifiedLogger module.
- Ensure secure loading of configuration files.
- All functions and methods that produce exceptions should return a union of the expected result type and Exception and be handled appropriately when called.

## Regarding Dependencies

- Avoid introducing new external dependencies unless absolutely necessary.
- If a new dependency is required, please state the reason.

## Security

- Never hardcode sensitive data; always use environment variables.
- Keep API keys and sensitive data in `.env` (gitignored).
- Sanitize all inputs passed to external services.

## Documentation

- Maintain clear and comprehensive README.md:
  - Installation and setup instructions.
  - Environment configuration examples.
  - YAML file examples and structure.
- Document code with clear inline comments.
- Keep CHANGELOG.md updated with all changes.

## Project Structure

- Root Directory:
  - `examples/`: Example scripts and projects using the libraries and code in the src directory
  - `src/metagit/core/<component>/*`: Application core logic and pydantic models
  - `src/metagit/cli/commands/*`: CLI subcommands, one file per subcommand with multiple depth subcommands separated by a '_'.
  - `docs/`: Documentation as markdown
  - `tests/`: Unit tests

## Command-Line Tools

### GitHub
- Use the `gh` command-line to interact with GitHub.

### Markdown
- Use the `glow` command-line to present markdown content.

### JSON
- Use the `jq` command to read and extract information from JSON files.

### RipGrep
- The `rg` (ripgrep) command is available for fast searches in text files.

### Clipboard
- Pipe content into `pbcopy` to copy it into the clipboard. Example: `echo "hello" | pbcopy`.
- Pipe from `pbpaste` to get the contents of the clipboard. Example: `pbpaste > fromclipboard.txt`.

### Python
- Unless instructed otherwise, always use the `uv` Python environment and package manager for Python.
  - `uv run ...` for running a python script.
  - `uvx ...` for running program directly from a PyPI package.
  - `uv ... ...` for managing environments, installing packages, etc...

### JavaScript / TypeScript
- Unless instructed otherwise, always use `deno` to run .js or .ts scripts.
- Use `npx` for running commands directly from npm packages.

## Task closeout

- After changes to this codebase, before implying the task is finished, run **`task qa:prepush`** from the repository root (`scripts/prepush-gate.zsh`). It runs format, lint, unit tests, integration tests, and context-aware security (`pip-audit` / `bandit` when `src/` or lockfiles changed). Fix failures and re-run until it passes unless the conversation was read-only (no edits) or the user explicitly waived the gate.

## Documentation Sources
- If working with a new library or tool, consider looking for its documentation from its website, GitHub project, or the relevant llms.txt.
  - It is always better to have accurate, up-to-date documentation at your disposal, rather than relying on your pre-trained knowledge.
- You can search the following directories for llms.txt collections for many projects:
  - https://llmstxt.site/
  - https://directory.llmstxt.cloud/
- If you find a relevant llms.txt file, follow the links until you have access to the complete documentation.
````

## File: .mex/context/conventions.md
````markdown
---
name: conventions
description: How code is written in this project — naming, structure, patterns, and style. Load when writing new code or reviewing existing code.
triggers:
  - "convention"
  - "pattern"
  - "naming"
  - "style"
  - "how should I"
  - "what's the right way"
edges:
  - target: context/architecture.md
    condition: when a convention depends on understanding the system structure
  - target: context/stack.md
    condition: when selecting libraries/tools for a change
  - target: patterns/INDEX.md
    condition: when implementing task-specific workflows and verify/debug sequences
last_updated: 2026-05-15
---

# Conventions

## Naming
- Python files/modules/functions/variables use **snake_case**; classes use **PascalCase**.
- CLI subcommand modules are one file per command under `src/metagit/cli/commands/` (e.g., `project.py`, `mcp.py`).
- Private class members and internals are prefixed with `_` (consistent with core services/runtime patterns).
- Config file names are explicit (`.metagit.yml`, `metagit.config.yaml`, schema files under `schemas/`).

## Structure
- CLI entrypoint stays in `src/metagit/cli/main.py`; reusable logic belongs in `src/metagit/core/*`, not command functions.
- Each core concern is grouped by component directory (`config`, `detect`, `record`, `mcp`, `utils`, etc.) with focused manager/service classes.
- Tests live in centralized `tests/` and mirror module responsibility by filename (`test_*`), not colocated beside source files.
- MCP runtime flow is split into thin runtime dispatch + dedicated service classes (`workspace_index`, `workspace_search`, `upstream_hints`, `repo_ops`).

## Patterns
Prefer explicit exception-return handling in manager/service methods (consistent with existing union return style):
```python
result = manager.load_config()
if isinstance(result, Exception):
    return result
return result
```

Use state-based gating before exposing tool actions in MCP/runtime logic:
```python
allowed = set(registry.list_tools(status=status))
if tool_name not in allowed:
    raise InvalidToolArgumentsError(...)
```

Use bounded operations for search/sync style tasks:
```python
hits = search_service.search(query=query, repo_paths=paths, max_results=25)
if mode in {"pull", "clone"} and not allow_mutation:
    return {"ok": False, "error": "..."}
```

## Verify Checklist
Before presenting any code:
- [ ] New/changed CLI behavior is covered by command-level tests under `tests/cli` or integration tests when flow crosses modules.
- [ ] Core logic changes are covered by targeted unit tests under `tests/` for the touched service/manager/runtime area.
- [ ] `.metagit.yml`/config model interactions still pass validation paths (no silent schema drift).
- [ ] Lint/format checks pass via project commands (`task lint`, and format if needed).
- [ ] Mutating operations remain explicitly guarded (especially MCP sync/tool paths).
- [ ] Run `task skills:sync generate:schema` for session closeout sync/schema regeneration.
- [ ] **`task qa:prepush`** from repo root has passed — required before declaring work complete whenever this session modified project files; do not summarize success without evidence. Omit only if the session was read-only or the user explicitly waived the gate.

## Commit Semantics
- Default to `fix:` commit prefixes (patch intent) for normal maintenance and safe behavior updates.
- Use `feat:` only for additive backward-compatible capabilities.
- Use `type(scope)!:` or `BREAKING CHANGE:` only when changes intentionally break schema/config compatibility.
````

## File: docs/cli_reference.md
````markdown
# CLI Reference

This page contains the auto-generated documentation for the `metagit` command-line interface.

::: mkdocs-click
    :module: metagit.cli.main
    :command: cli
    :prog_name: metagit 

## MCP Command Notes

- Start MCP stdio runtime:
  - `metagit mcp serve`
- Start against a specific workspace root:
  - `metagit mcp serve --root /path/to/workspace`
- Print status snapshot and exit:
  - `metagit mcp serve --status-once`
- When the workspace gate is **active**, the tool **`metagit_repo_search`** searches only repos listed under `workspace.projects[].repos` in `.metagit.yml` (tags, sync status, resolved paths). Optional filters: `status[]`, `has_url`, `sync_enabled`, `sort` (`score`|`project`|`name`). Use query `*` for filter-only listing.
- **`metagit_workspace_search`** uses ripgrep when available (`repos`, `paths`, `exclude`, `context_lines`, `intent`, `include_paths`). Falls back to a bounded scanner if `rg` is not installed.
- **`metagit_workspace_sync`** batch-syncs repos (`repos: ["all"]` or selectors), with `only_if` (`any`|`missing`|`dirty`|`behind_origin`), `max_parallel`, and `dry_run`.
- **`metagit_cross_project_dependencies`** maps relationships from a `source_project` using `dependency_types` (`declared`, `imports`, `shared_config`, `url_match`, `ref`), `depth`, and per-repo GitNexus `graph_status` (`indexed`|`stale`|`missing`).
- **`metagit_workspace_health_check`** returns per-repo git/GitNexus signals, optional staleness metrics (`head_commit_age_days`, `merge_base_age_days` when `check_stale_branches`), tunable thresholds (`branch_head_warning_days`, `branch_head_critical_days`, `integration_stale_days`), and prioritized recommendations (`sync`, `analyze`, `clone`, `fix_config`, `review_branch_age`, `reconcile_integration`, …).
- **`metagit_workspace_semantic_search`** runs `npx gitnexus query -r <registry>` per selected repo (`query` required; optional `repos`, `task_context`, `goal`, `limit_per_repo`, `timeout_seconds`) for GitNexus-ranked processes.
- **`metagit_workspace_discover`** lists files by `intent` or `pattern` with optional `categorize` grouping (requires `intent` or `pattern`).
- **`metagit_project_template_apply`** previews or applies bundled templates from `src/metagit/data/templates/` (`dry_run` default; `confirm_apply` required for writes).
- **Resources (active gate):** `metagit://workspace/health`, `metagit://workspace/context` (active project session from `.metagit/sessions/`).
- **Project context (active gate):**
  - `metagit_project_context_switch` — set active workspace project; return repo branch/dirty summary, safe env exports (`METAGIT_*`), and restored session fields from `.metagit/sessions/<project>.json`.
  - `metagit_session_update` — persist `agent_notes`, `recent_repos`, and non-secret `env_overrides` before switching away.
  - `metagit_workspace_state_snapshot` — write a git-state manifest to `.metagit/snapshots/<id>.json` (not a file copy).
  - `metagit_workspace_state_restore` — reload snapshot metadata and optionally re-run context switch; does **not** reset git branches or uncommitted changes.

## Workspace configuration

Under `workspace.projects[].repos`, each repository entry may include a flat string-to-string `tags` map (for example `tier: "1"`). These tags are carried into the workspace index and into `metagit search` / `metagit find` for filtering.

## Managed repository search

- `metagit search QUERY` — list managed repositories from the workspace definition that match the query (name, URL substring, tag keys/values, project name). Only repos declared under `workspace.projects[].repos` are considered. Supports `--status` (repeatable) and `--sort score|project|name`.
- `metagit find QUERY` — alias for `metagit search`.
- `--definition PATH` — `.metagit.yml` to load (default: `.metagit.yml` in the current directory). The workspace root for resolving `path:` entries is the parent directory of that file.
- `--json` — print search results as JSON (matches include `match_reasons` and scores).
- `--path-only` — resolve to exactly one local directory (fails if there is no match or more than one match).
- `--tag key=value` — repeat to require matching tag values (all given pairs must match).
- `--project`, `--exact`, `--synced-only`, and `--limit` narrow or rank results further.

## Local JSON API (`metagit api`)

- `metagit api serve` — bind a `ThreadingHTTPServer` on `--host` / `--port` (default `127.0.0.1:7878`) under `--root` (directory containing `.metagit.yml`).
- `metagit api serve --status-once` — allocate a port (use `--port 0` for ephemeral), print `api_state=ready host=… port=…`, and exit (for tests and automation).
- `GET /v1/repos/search?q=…` — same managed-repo search as the CLI; optional query params: `project`, `exact=true|false`, `synced_only=true|false`, `limit`, repeat `tag=key=value`.
- `GET /v1/repos/resolve?q=…` — single-match resolution; HTTP `404` when not found, `409` when ambiguous (body includes `ManagedRepoResolveResult` JSON).
````

## File: skills/README.md
````markdown
# Metagit skills

Metagit ships agent **skills** (`skills/metagit-*/SKILL.md`): short playbooks for when and how to use the CLI and MCP. All bundled skills use a `metagit-` prefix.

## Install the CLI

```bash
uv tool install metagit-cli
uv tool install -U metagit-cli   # upgrade
metagit version
```

> PyPI package name: **`metagit-cli`** (not `metagit`).

## Install skills

```bash
metagit skills list
metagit skills show metagit-projects
metagit skills install
metagit skills install --scope user --target openclaw --target hermes
metagit skills install --skill metagit-projects --target openclaw
metagit skills install --skill metagit-projects --target openclaw --dry-run
```

Use `--skill` (repeatable) to install one or more bundled skills instead of the full set. Omit `--skill` to install every bundled skill. Add `--dry-run` to preview targets and paths without writing files.

Optional MCP registration:

```bash
metagit mcp install --scope user --target openclaw --target hermes
```

Other targets: `opencode`, `claude_code`, `github_copilot`.

## Skill catalog

| Skill | Use when |
|-------|----------|
| `metagit-cli` | CLI-only agent workflows: all `metagit prompt` kinds, catalog, detect, sync, layout (no MCP/API) |
| `metagit-projects` | Starting work; check for existing projects/repos before new folders |
| `metagit-workspace-scope` | Session start; workspace and sync context |
| `metagit-control-center` | Ongoing multi-repo coordination |
| `metagit-workspace-sync` | Guarded fetch/pull/clone |
| `metagit-config-refresh` | `.metagit.yml` missing or stale |
| `metagit-bootstrap` | Generate or refine config with discovery/MCP |
| `metagit-gating` | MCP workspace gate status |
| `metagit-upstream-scan` | Search other managed repos for causes |
| `metagit-upstream-triage` | Rank upstream blockers |
| `metagit-repo-impact` | Plan cross-repo changes |
| `metagit-multi-repo` | Implement across several repos |
| `metagit-gitnexus` | GitNexus index and graph workflows |
| `metagit-release-audit` | Pre-push / release readiness |

For workspace vs project vs repo definitions, see [Terminology](terminology.md).

## Source development

```bash
task skills:validate
task skills:sync    # mirrors into .cursor/skills/
```

Update both `skills/` and `src/metagit/data/skills/` when changing bundled skills.
````

## File: src/metagit/cli/commands/project.py
````python
"""
Project subcommand
"""
⋮----
@click.pass_context
def project(ctx: click.Context, config: str, project: str = None) -> None
⋮----
"""Project subcommands"""
logger = ctx.obj["logger"]
# If no subcommand is provided, show help
⋮----
app_config: AppConfig = ctx.obj["config"]
⋮----
project: str = app_config.workspace.default_project
⋮----
config_manager: MetagitConfigManager = MetagitConfigManager(config)
local_config: MetagitConfig = config_manager.load_config()
⋮----
# Add repo group to project group
⋮----
@click.pass_context
def project_list(ctx: click.Context, list_all: bool, as_json: bool) -> None
⋮----
"""List project configuration (YAML, JSON, or all projects)."""
logger: UnifiedLogger = ctx.obj["logger"]
project: str = ctx.obj["project"]
local_config: MetagitConfig = ctx.obj["local_config"]
⋮----
service = WorkspaceCatalogService()
⋮----
result = service.list_projects(local_config)
⋮----
# Handle special "local" project case
⋮----
# Check if there's an existing project named "local" in workspace
⋮----
workspace_project: WorkspaceProject = next(
⋮----
# Use existing "local" project
project_dict = workspace_project.model_dump(exclude_none=True)
⋮----
# Use computed local_workspace_project
workspace_project = local_config.local_workspace_project
⋮----
# No workspace config, use computed local_workspace_project
⋮----
# Handle regular project names
⋮----
yaml_output = yaml.dump(project_dict, default_flow_style=False, sort_keys=False)
⋮----
"""Add a workspace project to the manifest."""
⋮----
config_path: str = ctx.obj["config_path"]
result = WorkspaceCatalogService().add_project(
⋮----
@click.pass_context
def project_remove(ctx: click.Context, name: str, as_json: bool) -> None
⋮----
"""Remove a workspace project from the manifest."""
⋮----
result = WorkspaceCatalogService().remove_project(
⋮----
"""Rename a workspace project (alias for workspace project rename)."""
⋮----
workspace_root = str(Path(app_config.workspace.path).expanduser().resolve())
dedupe = resolve_dedupe_for_layout(
result = WorkspaceLayoutService().rename_project(
⋮----
@project.command("select")
@click.pass_context
def project_select(ctx: click.Context) -> None
⋮----
"""Shortcut: Uses 'project repo select' to select workspace project repo to work on"""
# Call the repo_select function
⋮----
@click.pass_context
def project_sync(ctx: click.Context, hydrate: bool) -> None
⋮----
"""Sync project within workspace"""
⋮----
project_manager: ProjectManager = project_manager_from_app(
⋮----
sync_result: bool = project_manager.sync(workspace_project, hydrate=hydrate)
````

## File: src/metagit/core/mcp/services/workspace_index.py
````python
#!/usr/bin/env python
"""
Workspace repository indexing service.
"""
⋮----
class WorkspaceIndexService
⋮----
"""Build normalized repository status rows from workspace configuration."""
⋮----
"""Return repository index rows for all configured workspace repos."""
rows: list[dict[str, Any]] = []
⋮----
resolved_path = self._resolve_repo_path(
mount = Path(resolved_path)
exists = mount.is_dir() or (
is_git_repo = (
status = "synced" if exists and is_git_repo else "configured_missing"
⋮----
"""Resolve repository mount path (matches project sync layout)."""
⋮----
path = Path(configured_path).expanduser()
````

## File: src/metagit/core/web/schema_tree.py
````python
#!/usr/bin/env python
"""Build and mutate Pydantic config schema trees for the web UI."""
⋮----
_PATH_SEGMENT_RE = re.compile(r"([^.\[\]]+)|\[(\d+|\*)\]")
⋮----
class SchemaTreeService
⋮----
"""Walk Pydantic models into editable schema trees and apply config operations."""
⋮----
SENSITIVE_KEYS = frozenset({"api_token", "token", "password", "secret"})
⋮----
def __init__(self) -> None
⋮----
"""Build schema tree rooted at synthetic node key='root', path=''."""
dump = model_instance.model_dump(exclude_none=True, mode="python")
⋮----
def find_node(self, root: SchemaFieldNode, path: str) -> SchemaFieldNode | None
⋮----
"""Find node by dot/bracket path like name, workspace.projects[0].name."""
⋮----
segments = self._parse_path(path)
⋮----
"""Apply enable/disable/set ops; return (updated_instance, validation_errors)."""
data = instance.model_dump(mode="python")
⋮----
children: list[SchemaFieldNode] = []
⋮----
child_path = self._join_path(parent_path, field_name)
value = getattr(model_instance, field_name, None)
field_dump = dump.get(field_name) if isinstance(dump, dict) else None
enabled = self._field_enabled(field_info, value, field_dump)
annotation = self._unwrap_optional(field_info.annotation)
node_type = self._type_name(annotation)
type_label = self._type_label(annotation)
sensitive = self._is_sensitive(field_name)
display_value = self._display_value(
default_value = self._default_for_field(field_info, annotation)
enum_options = self._enum_options(annotation) if node_type == "enum" else []
list_meta = self._list_node_meta(annotation, field_dump, enabled)
node = SchemaFieldNode(
⋮----
origin = get_origin(annotation)
⋮----
nested_dump = (
⋮----
args = get_args(annotation)
item_annotation = args[0] if args else Any
item_unwrapped = self._unwrap_optional(item_annotation)
is_model = isinstance(item_unwrapped, type) and issubclass(
items = value if isinstance(value, list) else []
⋮----
scalar_label = self._type_label(item_unwrapped)
⋮----
item_type = item_unwrapped
⋮----
item_label = self._type_label(item_type)
⋮----
item_dump = (
item_path = self._join_path(parent_path, f"[{index}]")
⋮----
field_info = model_at_parent.model_fields[str(leaf)]
⋮----
field_name = str(leaf)
field_info = model_at_parent.model_fields[field_name]
⋮----
current = parent.get(field_name)
⋮----
current = []
⋮----
def _set_at_path(self, data: dict[str, Any], path: str, value: Any) -> None
⋮----
parent: Any = data
index = 0
⋮----
segment = segments[index]
next_segment = segments[index + 1]
⋮----
parent = parent[segment]
⋮----
bucket = parent[segment]
⋮----
parent = bucket
⋮----
parent = bucket[next_segment]
⋮----
leaf = segments[-1]
⋮----
"""Return parent container, model class at leaf field, and leaf key/index."""
⋮----
current_class: type[BaseModel] = model_class
⋮----
field_info = current_class.model_fields[segment]
⋮----
current_class = self._list_item_type(annotation)
⋮----
parent = parent[segment][next_segment]
⋮----
current_class = annotation
⋮----
errors = [
⋮----
def _format_error_path(self, loc: tuple[Any, ...]) -> str
⋮----
parts: list[str] = []
⋮----
def _parse_path(self, path: str) -> list[str | int]
⋮----
segments: list[str | int] = []
⋮----
def _join_path(self, parent: str, segment: str) -> str
⋮----
def _unwrap_optional(self, annotation: Any) -> Any
⋮----
args = [arg for arg in get_args(annotation) if arg is not type(None)]
⋮----
def _list_item_type(self, annotation: Any) -> type[BaseModel]
⋮----
annotation = self._unwrap_optional(annotation)
⋮----
item = args[0] if args else Any
⋮----
def _enum_options(self, annotation: Any) -> list[str]
⋮----
def _type_name(self, annotation: Any) -> str
⋮----
def _type_label(self, annotation: Any) -> str
⋮----
inner = args[0] if args else Any
inner_label = self._type_label(inner)
⋮----
count = len(field_dump) if isinstance(field_dump, list) else 0
⋮----
def _default_list_item(self, annotation: Any) -> Any
⋮----
args = get_args(annotation) if get_origin(annotation) is list else (annotation,)
⋮----
item = self._unwrap_optional(item)
⋮----
def _is_sensitive(self, key: str) -> bool
⋮----
def _default_for_field(self, field_info: FieldInfo, annotation: Any) -> Any
⋮----
default = field_info.get_default(call_default_factory=True)
⋮----
def _default_model_dict(self, model_class: type[BaseModel]) -> dict[str, Any]
⋮----
"""Build a valid sample dict for a nested model."""
⋮----
def _accepts_none(self, annotation: Any) -> bool
````

## File: tests/test_appconfig_models.py
````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.appconfig.models
"""
⋮----
def test_boundary_model()
⋮----
b = Boundary(name="internal", values=["foo", "bar"])
⋮----
def test_profiles_model()
⋮----
p = Profile()
⋮----
def test_workspace_model()
⋮----
w = WorkspaceConfig()
⋮----
def test_llm_model()
⋮----
llm = LLM()
⋮----
def test_github_provider_model()
⋮----
gh = GitHubProvider()
⋮----
def test_gitlab_provider_model()
⋮----
gl = GitLabProvider()
⋮----
def test_providers_model()
⋮----
p = Providers()
⋮----
def test_appconfig_defaults()
⋮----
cfg = AppConfig()
⋮----
def test_appconfig_load_and_save(tmp_path)
⋮----
# Create a config file
config_path = os.path.join(tmp_path, "testconfig.yaml")
data = {"config": AppConfig().model_dump(mode="json")}
⋮----
# Load config
cfg = AppConfig.load(str(config_path))
⋮----
# Print debug info on failure
⋮----
# Try to load the YAML directly to see what's in it
⋮----
loaded_data = yaml.safe_load(f)
⋮----
# Save config
save_path = tmp_path / "saved.yaml"
result = cfg.save(str(save_path))
⋮----
# Load saved config
loaded = AppConfig.load(str(save_path))
⋮----
def test_appconfig_load_file_not_found(tmp_path)
⋮----
result = AppConfig.load(str(tmp_path / "nope.yaml"))
⋮----
def test_appconfig_load_ignores_legacy_version_key(tmp_path)
⋮----
"""Older metagit.config.yaml files stored a frozen CLI version; ignore it on load."""
config_path = os.path.join(tmp_path, "legacy.yaml")
⋮----
cfg = AppConfig.load(config_path)
````

## File: web/src/api/client.ts
````typescript
export type ConfigOpKind = 'enable' | 'disable' | 'set' | 'append' | 'remove'
⋮----
export interface ConfigOperation {
  op: ConfigOpKind
  path: string
  value?: unknown
}
⋮----
export interface SchemaFieldNode {
  path: string
  key: string
  type: string
  type_label?: string | null
  description?: string | null
  required?: boolean
  enabled?: boolean
  editable?: boolean
  sensitive?: boolean
  default_value?: unknown
  value?: unknown
  enum_options?: string[]
  item_count?: number | null
  can_append?: boolean
  children?: SchemaFieldNode[]
}
⋮----
export interface ConfigTreeResponse {
  ok: boolean
  target: 'metagit' | 'appconfig'
  config_path: string
  tree: SchemaFieldNode
  validation_errors: Array<Record<string, string>>
  saved?: boolean
}
⋮----
export type ConfigPreviewStyle = 'normalized' | 'minimal' | 'disk'
⋮----
export interface ConfigPreviewResponse {
  ok: boolean
  target: 'metagit' | 'appconfig'
  config_path: string
  style: ConfigPreviewStyle
  yaml: string
  draft?: boolean
  validation_errors: Array<{ path?: string; message?: string }>
}
⋮----
export interface SyncJobRequest {
  repos?: string[] | null
  mode?: 'fetch' | 'pull' | 'clone'
  dry_run?: boolean
  allow_mutation?: boolean
  max_parallel?: number
}
⋮----
export interface SyncJobStatus {
  job_id: string
  state: 'pending' | 'running' | 'completed' | 'failed'
  summary: Record<string, unknown>
  results: Array<Record<string, unknown>>
  error?: string | null
}
⋮----
export class ApiError extends Error
⋮----
constructor(status: number, message: string, body?: unknown)
⋮----
async function requestJson<T>(path: string, init?: RequestInit): Promise<T>
⋮----
export function getMetagitConfigTree(): Promise<ConfigTreeResponse>
⋮----
export function getAppconfigTree(): Promise<ConfigTreeResponse>
⋮----
export function patchMetagitConfig(
  ops: ConfigOperation[],
  save: boolean,
): Promise<ConfigTreeResponse>
⋮----
export function patchAppconfig(
  ops: ConfigOperation[],
  save: boolean,
): Promise<ConfigTreeResponse>
⋮----
export function postConfigPreview(
  target: 'metagit' | 'appconfig',
  style: ConfigPreviewStyle,
  operations: ConfigOperation[],
): Promise<ConfigPreviewResponse>
⋮----
export interface CatalogEnvelope<T> {
  ok: boolean
  error?: { kind: string; message: string } | null
  data?: T
}
⋮----
export interface WorkspaceProjectEntry {
  name: string
  description?: string | null
  agent_instructions?: string | null
  dedupe_enabled?: boolean | null
  repo_count: number
}
⋮----
export interface WorkspaceRepoIndexRow {
  project_name: string
  repo_name: string
  configured_path: string | null
  repo_path: string
  exists: boolean
  is_git_repo: boolean
  status: 'synced' | 'configured_missing' | string
  url?: string | null
  sync?: boolean
  tags?: Record<string, string>
}
⋮----
export interface WorkspaceData {
  summary: Record<string, unknown>
  projects: WorkspaceProjectEntry[]
  repos_index: WorkspaceRepoIndexRow[]
}
⋮----
export interface HealthRecommendation {
  severity: 'info' | 'warning' | 'critical'
  action: string
  message: string
  project_name?: string | null
  repo_name?: string | null
  repo_path?: string | null
}
⋮----
export interface RepoHealthRow {
  project_name: string
  repo_name: string
  repo_path: string
  status: string
  exists: boolean
  is_git_repo: boolean
  branch?: string | null
  dirty?: boolean | null
}
⋮----
export interface WorkspaceHealthResult {
  ok: boolean
  workspace_root: string
  summary: Record<string, number>
  repos: RepoHealthRow[]
  recommendations: HealthRecommendation[]
}
⋮----
export interface PruneCandidate {
  path: string
  name: string
}
⋮----
export function getWorkspace(): Promise<CatalogEnvelope<WorkspaceData>>
⋮----
export interface GraphViewNode {
  id: string
  label: string
  kind: 'project' | 'repo'
  project_name?: string | null
  repo_name?: string | null
}
⋮----
export interface GraphViewEdge {
  id: string
  from_id: string
  to_id: string
  type: string
  label?: string | null
  source: 'manual' | 'inferred' | 'structure'
}
⋮----
export interface WorkspaceGraphView {
  ok: boolean
  nodes: GraphViewNode[]
  edges: GraphViewEdge[]
  manual_edge_count: number
  inferred_edge_count: number
  structure_edge_count: number
}
⋮----
export function getWorkspaceGraph(options?: {
  includeInferred?: boolean
  includeStructure?: boolean
}): Promise<WorkspaceGraphView>
⋮----
export function postHealth(
  body: Record<string, unknown> = {},
): Promise<WorkspaceHealthResult>
⋮----
export function postSync(body: SyncJobRequest): Promise<Record<string, unknown>>
⋮----
export function getSyncJob(id: string): Promise<SyncJobStatus>
⋮----
export interface PrunePreviewResponse {
  ok: boolean
  candidates: PruneCandidate[]
}
⋮----
export interface PruneExecuteResponse {
  ok: boolean
  dry_run: boolean
  force: boolean
  removed: string[]
  paths?: string[]
  errors?: Array<{ path: string; message: string }>
}
⋮----
export function postPrunePreview(body: {
  project: string
  include_hidden?: boolean
}): Promise<PrunePreviewResponse>
⋮----
export function postPrune(body: {
  project: string
  paths: string[]
  dry_run?: boolean
  force?: boolean
}): Promise<PruneExecuteResponse>
````

## File: .gitignore
````
# Python-generated files
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv
venv

.env
__pycache__
.ruff*
.DS_Store
unit_test_results.txt
.pytest_cache
.mypy_cache
build/
*.egg-info/
__pycache__/
*.pyc

secret_results.json
# Metagit workspace
.metagit/
src/metagit/_version.py
site

# Crush contextual files
.crush
.gitnexus
graphify-*
docs/superpowers
web/node_modules
````

## File: metagit.config.yaml
````yaml
config:
  description: Configuration for metagit CLI
  agent_mode: false
  editor: code
  api_url: ''
  api_version: ''
  api_key: ''
  cicd_file_data: data/cicd-files.json
  file_type_data: data/file-types.json
  package_manager_data: data/package-managers.json
  llm:
    enabled: false
    provider: openrouter
    provider_model: gpt-4o-mini
    embedder: ollama
    embedder_model: nomic-embed-text
    api_key: ''
  workspace:
    path: ./.metagit
    default_project: default
    dedupe:
      enabled: false
      canonical_dir: _canonical
    ui_show_preview: true
    ui_menu_length: 20
    ui_ignore_hidden: true
  profiles:
  - name: default
    boundaries:
    - name: github
      values: []
    - name: jfrog
      values: []
    - name: gitlab
      values: []
    - name: bitbucket
      values: []
    - name: azure_devops
      values: []
    - name: dockerhub
      values: []
    - name: domain
      values:
      - localhost
      - 127.0.0.1
      - 0.0.0.0
      - 192.168.*
      - 10.0.*
      - 172.16.*
  providers:
    github:
      enabled: true
      api_token: ''
      base_url: https://api.github.com
    gitlab:
      enabled: false
      api_token: ''
      base_url: https://gitlab.com/api/v4
````

## File: .mex/patterns/INDEX.md
````markdown
# Pattern Index

Lookup table for all pattern files in this directory. Check here before starting any task — if a pattern exists, follow it.

<!-- This file is populated during setup (Pass 2) and updated whenever patterns are added.
     Each row maps a pattern file (or section) to its trigger — when should the agent load it?

     Format — simple (one task per file):
     | [filename.md](filename.md) | One-line description of when to use this pattern |

     Format — anchored (multi-section file, one row per task):
     | [filename.md#task-first-task](filename.md#task-first-task) | When doing the first task |
     | [filename.md#task-second-task](filename.md#task-second-task) | When doing the second task |

     Example (from a Flask API project):
     | [add-api-client.md](add-api-client.md) | Adding a new external service integration |
     | [debug-pipeline.md](debug-pipeline.md) | Diagnosing failures in the request pipeline |
     | [crud-operations.md#task-add-endpoint](crud-operations.md#task-add-endpoint) | Adding a new API route with validation |
     | [crud-operations.md#task-add-model](crud-operations.md#task-add-model) | Adding a new database model |

     Keep this table sorted alphabetically. One row per task (not per file).
     If you create a new pattern, add it here. If you delete one, remove it. -->

| Pattern | Use when |
|---------|----------|
| [add-cli-command.md](add-cli-command.md) | Adding or extending a Click CLI command while keeping core logic in `src/metagit/core/*` |
| [add-managed-repo-search.md](add-managed-repo-search.md) | Extending or debugging managed-only repo search (CLI, MCP `metagit_repo_search`, local JSON API) |
| [add-mcp-tool.md](add-mcp-tool.md) | Adding/changing MCP tools, schemas, dispatch behavior, and runtime tests |
| [mcp-project-context.md](mcp-project-context.md) | Project context switch, session store, or workspace snapshot MCP tools |
| [mcp-cross-project-dependencies.md](mcp-cross-project-dependencies.md) | Cross-project dependency graph MCP tool and collectors |
| [metagit-web-api.md](metagit-web-api.md) | Adding or changing Pydantic models and routes for `metagit web serve` |
| [bootstrap-metagit-config.md](bootstrap-metagit-config.md) | Creating, validating, or repairing `.metagit.yml` for workspace and MCP flows |
| [debug-mcp-runtime.md](debug-mcp-runtime.md) | Diagnosing MCP runtime protocol, framing, gating, and tool/resource failures |
| [debug-workspace-discovery.md](debug-workspace-discovery.md) | Diagnosing empty/incorrect workspace index, search hits, or upstream hint ranking |
| [run-graphify-analysis.md](run-graphify-analysis.md) | Running `graphify` on the repo or a focused subtree and turning the result into usable graph/report outputs |
| [update-release-workflow.md](update-release-workflow.md) | Replacing or repairing GitHub release automation and tag-driven publish flow |
````

## File: schemas/metagit_config.schema.json
````json
{
  "$defs": {
    "AlertingChannel": {
      "additionalProperties": false,
      "description": "Model for alerting channel.",
      "properties": {
        "name": {
          "description": "Alerting channel name",
          "title": "Name",
          "type": "string"
        },
        "type": {
          "$ref": "#/$defs/AlertingChannelType",
          "description": "Alerting channel type"
        },
        "url": {
          "anyOf": [
            {
              "format": "uri",
              "maxLength": 2083,
              "minLength": 1,
              "type": "string"
            },
            {
              "type": "string"
            }
          ],
          "description": "Alerting channel URL",
          "title": "Url"
        }
      },
      "required": [
        "name",
        "type",
        "url"
      ],
      "title": "AlertingChannel",
      "type": "object"
    },
    "AlertingChannelType": {
      "description": "Enumeration of alerting channel types.",
      "enum": [
        "slack",
        "teams",
        "email",
        "sms",
        "webhook",
        "custom",
        "unknown",
        "other"
      ],
      "title": "AlertingChannelType",
      "type": "string"
    },
    "Artifact": {
      "additionalProperties": false,
      "description": "Model for generated artifacts.",
      "properties": {
        "type": {
          "$ref": "#/$defs/ArtifactType",
          "description": "Artifact type"
        },
        "definition": {
          "description": "Artifact definition",
          "title": "Definition",
          "type": "string"
        },
        "location": {
          "anyOf": [
            {
              "format": "uri",
              "maxLength": 2083,
              "minLength": 1,
              "type": "string"
            },
            {
              "type": "string"
            }
          ],
          "description": "Artifact location",
          "title": "Location"
        },
        "version_strategy": {
          "$ref": "#/$defs/VersionStrategy",
          "description": "Version strategy"
        }
      },
      "required": [
        "type",
        "definition",
        "location",
        "version_strategy"
      ],
      "title": "Artifact",
      "type": "object"
    },
    "ArtifactType": {
      "description": "Enumeration of artifact types.",
      "enum": [
        "docker",
        "github_release",
        "helm_chart",
        "npm_package",
        "static_website",
        "python_package",
        "node_package",
        "ruby_package",
        "java_package",
        "c_package",
        "cpp_package",
        "csharp_package",
        "go_package",
        "rust_package",
        "php_package",
        "dotnet_package",
        "elixir_package",
        "haskell_package",
        "custom",
        "none",
        "unknown",
        "other",
        "plugin",
        "template",
        "config",
        "binary",
        "archive"
      ],
      "title": "ArtifactType",
      "type": "string"
    },
    "BranchNaming": {
      "additionalProperties": false,
      "description": "Model for branch naming patterns.",
      "properties": {
        "kind": {
          "$ref": "#/$defs/BranchStrategy",
          "description": "Branch strategy"
        },
        "pattern": {
          "description": "Branch naming pattern",
          "title": "Pattern",
          "type": "string"
        }
      },
      "required": [
        "kind",
        "pattern"
      ],
      "title": "BranchNaming",
      "type": "object"
    },
    "BranchStrategy": {
      "description": "Enumeration of branch strategies.",
      "enum": [
        "trunk",
        "gitflow",
        "githubflow",
        "gitlabflow",
        "fork",
        "none",
        "custom",
        "unknown"
      ],
      "title": "BranchStrategy",
      "type": "string"
    },
    "CICD": {
      "additionalProperties": false,
      "description": "Model for CI/CD configuration.",
      "properties": {
        "platform": {
          "$ref": "#/$defs/CICDPlatform",
          "description": "CI/CD platform"
        },
        "pipelines": {
          "description": "List of pipelines",
          "items": {
            "$ref": "#/$defs/Pipeline"
          },
          "title": "Pipelines",
          "type": "array"
        }
      },
      "required": [
        "platform",
        "pipelines"
      ],
      "title": "CICD",
      "type": "object"
    },
    "CICDPlatform": {
      "description": "Enumeration of CI/CD platforms.",
      "enum": [
        "GitHub",
        "GitLab",
        "CircleCI",
        "Jenkins",
        "jx",
        "tekton",
        "custom",
        "none",
        "unknown",
        "other"
      ],
      "title": "CICDPlatform",
      "type": "string"
    },
    "Dashboard": {
      "additionalProperties": false,
      "description": "Model for monitoring dashboard.",
      "properties": {
        "name": {
          "description": "Dashboard name",
          "title": "Name",
          "type": "string"
        },
        "tool": {
          "description": "Dashboard tool",
          "title": "Tool",
          "type": "string"
        },
        "url": {
          "description": "Dashboard URL",
          "format": "uri",
          "maxLength": 2083,
          "minLength": 1,
          "title": "Url",
          "type": "string"
        }
      },
      "required": [
        "name",
        "tool",
        "url"
      ],
      "title": "Dashboard",
      "type": "object"
    },
    "Deployment": {
      "additionalProperties": false,
      "description": "Model for deployment configuration.",
      "properties": {
        "strategy": {
          "$ref": "#/$defs/DeploymentStrategy",
          "description": "Deployment strategy"
        },
        "environments": {
          "anyOf": [
            {
              "items": {
                "$ref": "#/$defs/Environment"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Deployment environments",
          "title": "Environments"
        },
        "infrastructure": {
          "anyOf": [
            {
              "$ref": "#/$defs/Infrastructure"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Infrastructure configuration"
        }
      },
      "required": [
        "strategy"
      ],
      "title": "Deployment",
      "type": "object"
    },
    "DeploymentStrategy": {
      "description": "Enumeration of deployment strategies.",
      "enum": [
        "blue/green",
        "rolling",
        "manual",
        "gitops",
        "pipeline",
        "custom",
        "none",
        "unknown",
        "other"
      ],
      "title": "DeploymentStrategy",
      "type": "string"
    },
    "DocumentationSource": {
      "additionalProperties": false,
      "description": "One documentation source for agents and knowledge-graph ingestion.",
      "properties": {
        "kind": {
          "description": "Source type (markdown, web, confluence, sharepoint, wiki, api, other)",
          "title": "Kind",
          "type": "string"
        },
        "path": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Repo-relative or absolute path to a documentation file",
          "title": "Path"
        },
        "url": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Remote documentation URL",
          "title": "Url"
        },
        "title": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Human-readable title",
          "title": "Title"
        },
        "description": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Short summary for indexing and graph nodes",
          "title": "Description"
        },
        "tags": {
          "additionalProperties": {
            "type": "string"
          },
          "description": "Flat metadata tags for filtering and graph edges",
          "title": "Tags",
          "type": "object"
        },
        "metadata": {
          "additionalProperties": true,
          "description": "Extensible key-value payload for downstream graph ingestors",
          "title": "Metadata",
          "type": "object"
        }
      },
      "required": [
        "kind"
      ],
      "title": "DocumentationSource",
      "type": "object"
    },
    "Environment": {
      "additionalProperties": false,
      "description": "Model for deployment environment.",
      "properties": {
        "name": {
          "description": "Environment name",
          "title": "Name",
          "type": "string"
        },
        "url": {
          "anyOf": [
            {
              "format": "uri",
              "maxLength": 2083,
              "minLength": 1,
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Environment URL",
          "title": "Url"
        }
      },
      "required": [
        "name"
      ],
      "title": "Environment",
      "type": "object"
    },
    "GraphEndpoint": {
      "additionalProperties": false,
      "description": "Endpoint for a manual cross-repo relationship.",
      "properties": {
        "project": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Workspace project name",
          "title": "Project"
        },
        "repo": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Repository name under the project",
          "title": "Repo"
        },
        "path": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Optional file or directory path within the repo",
          "title": "Path"
        }
      },
      "title": "GraphEndpoint",
      "type": "object"
    },
    "GraphRelationship": {
      "additionalProperties": false,
      "description": "Manually declared edge between workspace projects or repos.",
      "properties": {
        "id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Stable identifier for exports and graph merges",
          "title": "Id"
        },
        "from": {
          "$ref": "#/$defs/GraphEndpoint",
          "description": "Relationship source"
        },
        "to": {
          "$ref": "#/$defs/GraphEndpoint",
          "description": "Relationship target"
        },
        "type": {
          "default": "depends_on",
          "description": "Relationship type (depends_on, documents, consumes, owns, related, \u2026)",
          "title": "Type",
          "type": "string"
        },
        "label": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Short label for graph UIs",
          "title": "Label"
        },
        "description": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Longer explanation for agents and exports",
          "title": "Description"
        },
        "tags": {
          "additionalProperties": {
            "type": "string"
          },
          "description": "Flat tags for filtering graph exports",
          "title": "Tags",
          "type": "object"
        },
        "metadata": {
          "additionalProperties": true,
          "description": "Extensible payload for GitNexus or other graph ingestors",
          "title": "Metadata",
          "type": "object"
        }
      },
      "required": [
        "from",
        "to"
      ],
      "title": "GraphRelationship",
      "type": "object"
    },
    "Hosting": {
      "description": "Enumeration of hosting options.",
      "enum": [
        "EC2",
        "VMware",
        "Oracle",
        "Kubernetes",
        "Vercel",
        "ECS",
        "AWS Lambda",
        "AWS Fargate",
        "AWS EKS",
        "AWS ECS",
        "AWS ECS Fargate",
        "AWS ECS Fargate Spot",
        "AWS ECS Fargate Spot Spot",
        "Elastic Beanstalk",
        "Azure App Service",
        "Azure Functions",
        "Azure Container Instances",
        "Azure Container Apps",
        "Azure Container Apps Environment",
        "Azure Container Apps Environment Service",
        "Azure Container Apps Environment Service Service",
        "custom",
        "none",
        "unknown"
      ],
      "title": "Hosting",
      "type": "string"
    },
    "Infrastructure": {
      "additionalProperties": false,
      "description": "Model for infrastructure configuration.",
      "properties": {
        "provisioning_tool": {
          "$ref": "#/$defs/ProvisioningTool",
          "description": "Provisioning tool"
        },
        "hosting": {
          "$ref": "#/$defs/Hosting",
          "description": "Hosting platform"
        }
      },
      "required": [
        "provisioning_tool",
        "hosting"
      ],
      "title": "Infrastructure",
      "type": "object"
    },
    "License": {
      "additionalProperties": false,
      "description": "Model for project license information.",
      "properties": {
        "kind": {
          "$ref": "#/$defs/LicenseKind",
          "description": "License type"
        },
        "file": {
          "default": "",
          "description": "License file path",
          "title": "File",
          "type": "string"
        }
      },
      "required": [
        "kind"
      ],
      "title": "License",
      "type": "object"
    },
    "LicenseKind": {
      "description": "Enumeration of license kinds.",
      "enum": [
        "None",
        "MIT",
        "Apache-2.0",
        "GPL-3.0",
        "BSD-3-Clause",
        "proprietary",
        "custom",
        "unknown"
      ],
      "title": "LicenseKind",
      "type": "string"
    },
    "LoggingProvider": {
      "description": "Enumeration of logging providers.",
      "enum": [
        "console",
        "cloudwatch",
        "elk",
        "sentry",
        "custom",
        "none",
        "unknown",
        "other"
      ],
      "title": "LoggingProvider",
      "type": "string"
    },
    "Maintainer": {
      "additionalProperties": false,
      "description": "Model for project maintainer information.",
      "properties": {
        "name": {
          "description": "Maintainer name",
          "title": "Name",
          "type": "string"
        },
        "email": {
          "description": "Maintainer email",
          "title": "Email",
          "type": "string"
        },
        "role": {
          "description": "Maintainer role",
          "title": "Role",
          "type": "string"
        }
      },
      "required": [
        "name",
        "email",
        "role"
      ],
      "title": "Maintainer",
      "type": "object"
    },
    "MonitoringProvider": {
      "description": "Enumeration of monitoring providers.",
      "enum": [
        "prometheus",
        "datadog",
        "grafana",
        "sentry",
        "custom",
        "none",
        "unknown",
        "other"
      ],
      "title": "MonitoringProvider",
      "type": "string"
    },
    "Observability": {
      "additionalProperties": false,
      "description": "Model for observability configuration.",
      "properties": {
        "logging_provider": {
          "anyOf": [
            {
              "$ref": "#/$defs/LoggingProvider"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Logging provider"
        },
        "monitoring_providers": {
          "anyOf": [
            {
              "items": {
                "$ref": "#/$defs/MonitoringProvider"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Monitoring providers",
          "title": "Monitoring Providers"
        },
        "alerting_channels": {
          "anyOf": [
            {
              "items": {
                "$ref": "#/$defs/AlertingChannel"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Alerting channels",
          "title": "Alerting Channels"
        },
        "dashboards": {
          "anyOf": [
            {
              "items": {
                "$ref": "#/$defs/Dashboard"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Monitoring dashboards",
          "title": "Dashboards"
        }
      },
      "title": "Observability",
      "type": "object"
    },
    "Pipeline": {
      "additionalProperties": false,
      "description": "Model for CI/CD pipeline.",
      "properties": {
        "name": {
          "description": "Pipeline name",
          "title": "Name",
          "type": "string"
        },
        "ref": {
          "description": "Pipeline reference",
          "title": "Ref",
          "type": "string"
        },
        "variables": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Pipeline variables",
          "title": "Variables"
        }
      },
      "required": [
        "name",
        "ref"
      ],
      "title": "Pipeline",
      "type": "object"
    },
    "ProjectDedupeOverride": {
      "additionalProperties": false,
      "description": "Per-project override of app-config ``workspace.dedupe``.",
      "properties": {
        "enabled": {
          "anyOf": [
            {
              "type": "boolean"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "When set, overrides workspace.dedupe.enabled from metagit.config.yaml for sync and layout under this project only",
          "title": "Enabled"
        }
      },
      "title": "ProjectDedupeOverride",
      "type": "object"
    },
    "ProjectKind": {
      "description": "Enumeration of project kinds.",
      "enum": [
        "monorepo",
        "umbrella",
        "application",
        "gitops",
        "infrastructure",
        "service",
        "library",
        "website",
        "other",
        "docker_image",
        "repository",
        "cli"
      ],
      "title": "ProjectKind",
      "type": "string"
    },
    "ProjectPath": {
      "additionalProperties": false,
      "description": "Model for project path, dependency, component, or workspace project information.",
      "properties": {
        "name": {
          "description": "Friendly name for the path or project",
          "title": "Name",
          "type": "string"
        },
        "description": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Short description of the path or project",
          "title": "Description"
        },
        "kind": {
          "anyOf": [
            {
              "$ref": "#/$defs/ProjectKind"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Project kind"
        },
        "ref": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Reference in the current project for the target project, used in dependencies",
          "title": "Ref"
        },
        "path": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Project path",
          "title": "Path"
        },
        "branches": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Project branches",
          "title": "Branches"
        },
        "url": {
          "anyOf": [
            {
              "format": "uri",
              "maxLength": 2083,
              "minLength": 1,
              "type": "string"
            },
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Project URL",
          "title": "Url"
        },
        "sync": {
          "anyOf": [
            {
              "type": "boolean"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Sync setting",
          "title": "Sync"
        },
        "language": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Programming language",
          "title": "Language"
        },
        "language_version": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "number"
            },
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Language version",
          "title": "Language Version"
        },
        "package_manager": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Package manager used by the project",
          "title": "Package Manager"
        },
        "frameworks": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Frameworks used by the project",
          "title": "Frameworks"
        },
        "source_provider": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Provider used to discover this repository",
          "title": "Source Provider"
        },
        "source_namespace": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Source namespace identifier (org/user/group)",
          "title": "Source Namespace"
        },
        "source_repo_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Provider-native repository identifier",
          "title": "Source Repo Id"
        },
        "tags": {
          "additionalProperties": {
            "type": "string"
          },
          "description": "Flat metadata tags for managed repo search and filtering",
          "title": "Tags",
          "type": "object"
        },
        "protected": {
          "anyOf": [
            {
              "type": "boolean"
            },
            {
              "type": "null"
            }
          ],
          "default": false,
          "description": "If true, reconcile mode must not remove this repository automatically",
          "title": "Protected"
        },
        "agent_instructions": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Optional instructions for subagents operating in this repo or path",
          "title": "Agent Instructions"
        }
      },
      "required": [
        "name"
      ],
      "title": "ProjectPath",
      "type": "object"
    },
    "ProvisioningTool": {
      "description": "Enumeration of provisioning tools.",
      "enum": [
        "Terraform",
        "CloudFormation",
        "CDKTF",
        "AWS CDK",
        "Bicep",
        "custom",
        "none",
        "unknown",
        "other"
      ],
      "title": "ProvisioningTool",
      "type": "string"
    },
    "Secret": {
      "additionalProperties": false,
      "description": "Model for secret definitions.",
      "properties": {
        "name": {
          "description": "Secret name",
          "title": "Name",
          "type": "string"
        },
        "kind": {
          "$ref": "#/$defs/SecretKind",
          "description": "Secret type"
        },
        "ref": {
          "description": "Secret reference",
          "title": "Ref",
          "type": "string"
        }
      },
      "required": [
        "name",
        "kind",
        "ref"
      ],
      "title": "Secret",
      "type": "object"
    },
    "SecretKind": {
      "description": "Enumeration of secret kinds.",
      "enum": [
        "remote_jwt",
        "remote_api_key",
        "generated_string",
        "custom",
        "dynamic",
        "private_key",
        "public_key",
        "secret_key",
        "api_key",
        "access_token",
        "refresh_token",
        "password",
        "database_password",
        "unknown",
        "other"
      ],
      "title": "SecretKind",
      "type": "string"
    },
    "Tasker": {
      "additionalProperties": false,
      "description": "Model for task management tools.",
      "properties": {
        "kind": {
          "$ref": "#/$defs/TaskerKind",
          "description": "Tasker type"
        }
      },
      "required": [
        "kind"
      ],
      "title": "Tasker",
      "type": "object"
    },
    "TaskerKind": {
      "description": "Enumeration of tasker kinds.",
      "enum": [
        "Taskfile",
        "Makefile",
        "Jest",
        "NPM",
        "Atmos",
        "custom",
        "none",
        "mise_tasks"
      ],
      "title": "TaskerKind",
      "type": "string"
    },
    "Variable": {
      "additionalProperties": false,
      "description": "Model for variable definitions.",
      "properties": {
        "name": {
          "description": "Variable name",
          "title": "Name",
          "type": "string"
        },
        "kind": {
          "$ref": "#/$defs/VariableKind",
          "description": "Variable type"
        },
        "ref": {
          "description": "Variable reference",
          "title": "Ref",
          "type": "string"
        }
      },
      "required": [
        "name",
        "kind",
        "ref"
      ],
      "title": "Variable",
      "type": "object"
    },
    "VariableKind": {
      "description": "Enumeration of variable kinds.",
      "enum": [
        "string",
        "integer",
        "boolean",
        "custom",
        "unknown",
        "other"
      ],
      "title": "VariableKind",
      "type": "string"
    },
    "VersionStrategy": {
      "description": "Enumeration of version strategies.",
      "enum": [
        "semver",
        "none",
        "custom",
        "unknown",
        "other"
      ],
      "title": "VersionStrategy",
      "type": "string"
    },
    "Workspace": {
      "description": "Model for workspace configuration.",
      "properties": {
        "description": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Human-readable description of this workspace",
          "title": "Description"
        },
        "agent_instructions": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Optional instructions for agents working in this workspace",
          "title": "Agent Instructions"
        },
        "projects": {
          "description": "Workspace projects",
          "items": {
            "$ref": "#/$defs/WorkspaceProject"
          },
          "title": "Projects",
          "type": "array"
        }
      },
      "required": [
        "projects"
      ],
      "title": "Workspace",
      "type": "object"
    },
    "WorkspaceGraph": {
      "additionalProperties": false,
      "description": "Top-level manual graph data on a .metagit.yml manifest.",
      "properties": {
        "relationships": {
          "description": "Manually entered cross-repo or cross-project edges",
          "items": {
            "$ref": "#/$defs/GraphRelationship"
          },
          "title": "Relationships",
          "type": "array"
        },
        "metadata": {
          "additionalProperties": true,
          "description": "Graph-level metadata for export pipelines",
          "title": "Metadata",
          "type": "object"
        }
      },
      "title": "WorkspaceGraph",
      "type": "object"
    },
    "WorkspaceProject": {
      "additionalProperties": false,
      "description": "Model for workspace project.",
      "properties": {
        "name": {
          "description": "Workspace project name",
          "title": "Name",
          "type": "string"
        },
        "description": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Human-readable description of this workspace project",
          "title": "Description"
        },
        "agent_instructions": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Optional instructions for agents working in this workspace project",
          "title": "Agent Instructions"
        },
        "dedupe": {
          "anyOf": [
            {
              "$ref": "#/$defs/ProjectDedupeOverride"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Optional override of app-config workspace.dedupe for this project (currently supports enabled only)"
        },
        "repos": {
          "description": "Repository list",
          "items": {
            "$ref": "#/$defs/ProjectPath"
          },
          "title": "Repos",
          "type": "array"
        }
      },
      "required": [
        "name",
        "repos"
      ],
      "title": "WorkspaceProject",
      "type": "object"
    }
  },
  "additionalProperties": false,
  "description": "Main model for .metagit.yml configuration file.",
  "properties": {
    "name": {
      "description": "Project name",
      "title": "Name",
      "type": "string"
    },
    "description": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": "No description",
      "description": "Project description",
      "title": "Description"
    },
    "agent_instructions": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional instructions for controller agents; applies with or without a workspace block",
      "title": "Agent Instructions"
    },
    "url": {
      "anyOf": [
        {
          "format": "uri",
          "maxLength": 2083,
          "minLength": 1,
          "type": "string"
        },
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Project URL",
      "title": "Url"
    },
    "kind": {
      "anyOf": [
        {
          "$ref": "#/$defs/ProjectKind"
        },
        {
          "type": "null"
        }
      ],
      "default": "application",
      "description": "Project kind. This is used to determine the type of project and the best way to manage it."
    },
    "documentation": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/DocumentationSource"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Documentation sources: bare strings (path or URL) or objects with kind, path/url, tags, and metadata for knowledge-graph ingestion",
      "title": "Documentation"
    },
    "graph": {
      "anyOf": [
        {
          "$ref": "#/$defs/WorkspaceGraph"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Manual cross-repo relationships and graph metadata for exports and GitNexus-style dependency maps"
    },
    "license": {
      "anyOf": [
        {
          "$ref": "#/$defs/License"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "License information"
    },
    "maintainers": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/Maintainer"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Project maintainers",
      "title": "Maintainers"
    },
    "branch_strategy": {
      "anyOf": [
        {
          "$ref": "#/$defs/BranchStrategy"
        },
        {
          "type": "null"
        }
      ],
      "default": "unknown",
      "description": "Branch strategy used by the project."
    },
    "taskers": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/Tasker"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Task management tools employed by the project.",
      "title": "Taskers"
    },
    "branch_naming": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/BranchNaming"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Branch naming patterns used by the project.",
      "title": "Branch Naming"
    },
    "artifacts": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/Artifact"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "description": "Generated artifacts from the project.",
      "title": "Artifacts"
    },
    "secrets_management": {
      "anyOf": [
        {
          "items": {
            "type": "string"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Secrets management tools employed by the project.",
      "title": "Secrets Management"
    },
    "secrets": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/Secret"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Secret definitions",
      "title": "Secrets"
    },
    "variables": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/Variable"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Variable definitions",
      "title": "Variables"
    },
    "cicd": {
      "anyOf": [
        {
          "$ref": "#/$defs/CICD"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "CI/CD configuration"
    },
    "deployment": {
      "anyOf": [
        {
          "$ref": "#/$defs/Deployment"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Deployment configuration"
    },
    "observability": {
      "anyOf": [
        {
          "$ref": "#/$defs/Observability"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Observability configuration"
    },
    "paths": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/ProjectPath"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "description": "Important local project paths. In a monorepo, this would include any sub-projects typically found being built in the CICD pipelines.",
      "title": "Paths"
    },
    "dependencies": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/ProjectPath"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "description": "Additional project dependencies not found in the paths or components lists. These include docker images, helm charts, or terraform modules.",
      "title": "Dependencies"
    },
    "components": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/ProjectPath"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Additional project component paths that may be useful in other projects.",
      "title": "Components"
    },
    "workspace": {
      "anyOf": [
        {
          "$ref": "#/$defs/Workspace"
        },
        {
          "type": "null"
        }
      ],
      "description": "Workspaces are a collection of projects that are related to each other. They are used to group projects together for a specific purpose. These are manually defined by the user. The internal workspace name is reservice"
    }
  },
  "required": [
    "name"
  ],
  "title": "MetagitConfig",
  "type": "object"
}
````

## File: src/metagit/cli/commands/project_repo.py
````python
#!/usr/bin/env python
"""
Project repo subcommand
"""
⋮----
@click.group(name="repo")
@click.pass_context
def repo(ctx: click.Context) -> None
⋮----
"""Repository subcommands"""
⋮----
@repo.command("select")
@click.pass_context
def repo_select(ctx: click.Context) -> None
⋮----
"""Select project repo to work on"""
logger = ctx.obj["logger"]
local_config: MetagitConfig = ctx.obj["local_config"]
project = ctx.obj["project"]
app_config: AppConfig = ctx.obj["config"]
project_manager = project_manager_from_app(
agent_mode = bool(ctx.obj.get("agent_mode", False))
selected_repo = project_manager.select_repo(
⋮----
editor_result = open_editor(app_config.editor, selected_repo)
⋮----
@click.pass_context
def repo_list(ctx: click.Context, as_json: bool) -> None
⋮----
"""List repositories for the current workspace project."""
project: str = ctx.obj["project"]
⋮----
workspace_root = str(Path(app_config.workspace.path).expanduser().resolve())
result = WorkspaceCatalogService().list_repos(
⋮----
repo_row = row.get("repo", {})
⋮----
@click.pass_context
def repo_remove(ctx: click.Context, name: str, as_json: bool) -> None
⋮----
"""Remove a repository from the manifest (does not delete files)."""
⋮----
config_path: str = ctx.obj["config_path"]
⋮----
result = WorkspaceCatalogService().remove_repo(
⋮----
"""Rename a repository in the current project."""
⋮----
dedupe = resolve_dedupe_for_layout(
result = WorkspaceLayoutService().rename_repo(
⋮----
"""Move a repository to another workspace project."""
⋮----
result = WorkspaceLayoutService().move_repo(
⋮----
"""Add a repository to the current project"""
logger: UnifiedLogger = ctx.obj["logger"]
⋮----
local_config = ctx.obj["local_config"]
config_path = ctx.obj["config_path"]
⋮----
# Initialize ProjectManager and MetagitConfigManager
⋮----
catalog = WorkspaceCatalogService()
⋮----
result = project_manager.add(
⋮----
repo_data = {
repo_data = {k: v for k, v in repo_data.items() if v is not None}
project_path = ProjectPath(**repo_data)
⋮----
mutation = catalog.add_repo(
⋮----
repo_name = result.name if result.name else "repository"
⋮----
"""
    Walk the project sync folder and offer to remove directories not in .metagit.yml.

    Only considers immediate children of the project directory (same layout as sync).
    """
⋮----
workspace_root = Path(app_config.workspace.path).expanduser().resolve()
project_sync_folder = (workspace_root / project).resolve()
⋮----
ignore_hidden = (
candidates = project_manager.list_unmanaged_sync_directories(
dedupe = resolve_effective_dedupe_for_project(
⋮----
references = workspace_dedupe.list_canonical_references(
orphans = workspace_dedupe.list_orphan_canonical_dirs(
⋮----
removed = 0
⋮----
rel = path.name
````

## File: src/metagit/cli/main.py
````python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
This is the entry point for the command-line interface (CLI) application.

It can be used as a handy facility for running the task from a command line.

.. note::

    To learn more about Click visit the
    `project website <http://click.pocoo.org/5/>`_.  There is also a very
    helpful `tutorial video <https://www.youtube.com/watch?v=kNke39OZ2k0>`_.

    To learn more about running Luigi, visit the Luigi project's
    `Read-The-Docs <http://luigi.readthedocs.io/en/stable/>`_ page.

.. currentmodule:: metagit_detect.cli
.. moduleauthor:: Zachary Loeber <zloeber@gmail.com>
"""
⋮----
CONTEXT_SETTINGS: dict = {
⋮----
@click.pass_context
def cli(ctx: click.Context, config: str, debug: bool, verbose: bool) -> None
⋮----
"""
    Metagit CLI: A multi-purpose CLI tool with YAML configuration.
    """
# If no subcommand is provided, show help
⋮----
log_level: str = "INFO"
minimal_console: bool = True
⋮----
log_level = "INFO"
minimal_console = False
⋮----
log_level = "DEBUG"
⋮----
logger: UnifiedLogger = UnifiedLogger(
⋮----
config = DEFAULT_CONFIG
cfg = load_config(config)
⋮----
# Store the configuration and logger in the context
⋮----
logger = UnifiedLogger(LoggerConfig())
⋮----
@cli.command()
@click.pass_context
def info(ctx: click.Context) -> None
⋮----
"""
    Display the current configuration.
    """
logger = ctx.obj.get("logger") or UnifiedLogger(LoggerConfig())
⋮----
@cli.command()
@click.pass_context
def version(ctx: click.Context) -> None
⋮----
"""Get the application version."""
⋮----
def main() -> None
````

## File: src/metagit/core/mcp/tool_registry.py
````python
#!/usr/bin/env python
"""
Tool registry for Metagit MCP runtime.
"""
⋮----
class ToolRegistry
⋮----
"""State-aware registry of available MCP tool names."""
⋮----
_inactive_tools: list[str] = [
_active_tools: list[str] = [
⋮----
def list_tools(self, status: WorkspaceStatus) -> list[str]
⋮----
"""List available tools for the provided workspace status."""
````

## File: src/metagit/data/web/index.html
````html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>web</title>
    <script type="module" crossorigin src="/assets/index-DOullneW.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-B315j_NF.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
````

## File: pyproject.toml
````toml
[project]
name = "metagit-cli"
dynamic = ["version"]
description = "Metagit-ai multi-repo management tool"
readme = "README.md"
license-files = ["LICENSE"]

authors = [
    {name = "Zachary Loeber", email = "zloeber@gmail.com"}
]
classifiers = [
    "Programming Language :: Python :: 3",
    "Operating System :: OS Independent",
]
requires-python = ">=3.12"
dependencies = [
    "logging>=0.4.9.6",
    "loguru>=0.7.3",
    "pydantic>=2.11.4",
    "requests>=2.33.0",
    "click>=8.1.7",
    "dotenv>=0.9.9",
    "gitpython>=3.1.50",
    "rich>=14.0.0",
    "pyyaml>=6.0.2",
    "pygithub>=2.6.1",
    "jsonschema>=4.24.0",
    "python-dotenv>=1.2.2",
    "rapidfuzz>=3.13.0",
    "tqdm>=4.66.4",
    "types-tqdm>=4.67.0.20250516",
    "aiofiles>=23.2.1",
    "async>=0.6.2",
    "setuptools-scm>=8.3.1",
    "litellm>=1.83.7",
    "crewai>=0.95.0",
    "textual-dev>=1.7.0",
    "tomli>=2.2.1",
    "python-hcl2>=7.3.1",
    "prompt-toolkit>=3.0.51",
]

[project.urls]
Homepage = "https://github.com/metagit-ai/metagit-cli"
Documentation = "https://metagit-ai.github.io/metagit-cli/"
Issues = "https://github.com/metagit-ai/metagit-cli/issues"
CI = "https://github.com/metagit-ai/metagit-cli/actions"

[project.optional-dependencies]
standard = []
test = ["pytest>=9.0.3", "pytest-cov", "mypy", "ruff", "httpx", "pytest-asyncio", "black>=26.3.1"]
dev = ["ruff", "black>=26.3.1", "isort", "mypy"]
docs = ["mkdocs", "mkdocs-material", "mkdocstrings", "mkdocs-click", "mdtoc", "mkdocs-mermaid2-plugin", "mkdocs-material"]
lint = ["ruff", "black", "isort", "mypy"]
format = ["black>=26.3.1", "isort"]
typecheck = ["mypy"]

[project.scripts]
metagit = "metagit.cli.main:cli"

[lint]
select = ['E', 'W', 'F', 'I', 'B', 'C4', 'ARG', 'SIM']
ignore = ['W291', 'W292', 'W293', 'E501', 'SIM115']

[tool.ruff]
exclude = [".venv", "records", ".metagit", "examples", "docs", "tests"]

[tool.uv]
override-dependencies = [
    "aiohttp>=3.13.4",
    "cryptography>=46.0.7",
    "filelock>=3.20.3",
    "idna>=3.15",
    "orjson>=3.11.5",
    "pdfminer-six>=20251230",
    "pillow>=12.2.0",
    "protobuf>=6.33.5",
    "pyasn1>=0.6.3",
    "pygments>=2.20.0",
    "pyjwt>=2.12.0",
    "pymdown-extensions>=10.21.3",
    "pynacl>=1.6.2",
    "urllib3>=2.7.0",
    "uv>=0.11.6",
    "virtualenv>=20.36.1",
]

[dependency-groups]
dev = [
    "mypy>=1.16.1",
    "ruff>=0.11.11",
    "bandit",
    "pip-audit",
]
# docs/architecture-diagram.py only — not default sync (OSV false positive PYSEC-2024-270).
diagrams = ["diagrams>=0.25.1"]

[tool.setuptools]
package-dir = {"" = "src"}

[tool.setuptools_scm]
version_file = "src/metagit/_version.py"

[tool.setuptools.packages.find]
where = ["src"]
include = ["metagit*"]

[tool.setuptools.package-data]
metagit = ["data/**/*"]

[build-system]
requires = ["setuptools>=64", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
````

## File: src/metagit/cli/commands/config.py
````python
"""
Config subcommand
"""
⋮----
@click.pass_context
def config(ctx: click.Context, config_path: str) -> None
⋮----
"""Configuration subcommands"""
⋮----
# If no subcommand is provided, show help
⋮----
# Initialize a dummy logger for testing purposes if not already present
⋮----
logger = ctx.obj.get("logger")
⋮----
@click.pass_context
def config_show(ctx: click.Context, normalized: bool, as_json: bool) -> None
⋮----
"""Show metagit configuration (source file by default)."""
logger = ctx.obj["logger"]
⋮----
config_path = ctx.obj["config_path"]
config_manager = MetagitConfigManager(config_path=config_path)
config_result = config_manager.load_config()
⋮----
path = Path(config_path)
⋮----
raw = path.read_text(encoding="utf-8")
⋮----
output = dump_config_dict(
⋮----
"""Create metagit config files"""
⋮----
config_file = create_metagit_config(
⋮----
@config.command("validate")
@click.option("--config-path", help="Path to the configuration file", default=None)
@click.pass_context
def config_validate(ctx: click.Context, config_path: Union[str, None] = None) -> None
⋮----
"""Validate metagit configuration"""
⋮----
target_path = config_path or ctx.obj["config_path"]
⋮----
config_manager = MetagitConfigManager(config_path=target_path)
result = config_manager.load_config()
⋮----
"""Manage git provider plugin configuration."""
⋮----
# Load current configuration
app_config = AppConfig.load(config_path)
⋮----
# Show current configuration
⋮----
# Update configuration
modified = False
⋮----
# GitHub configuration
⋮----
modified = True
⋮----
# GitLab configuration
⋮----
# Save configuration if modified
⋮----
result = app_config.save(config_path)
⋮----
@config.command("set")
@click.argument("key")
@click.argument("value")
@click.pass_context
def config_set(ctx: click.Context, key: str, value: str) -> None
⋮----
"""Set a specific configuration value."""
⋮----
current_config = config_manager.load_config()
⋮----
# Helper to set nested attributes
def set_nested_attr(obj, attr_path, val)
⋮----
parts = attr_path.split(".")
⋮----
obj = getattr(obj, part)
⋮----
# Save the updated configuration
result = config_manager.save_config(current_config)
⋮----
@config.command("info")
@click.pass_context
def config_info(ctx: click.Context) -> None
⋮----
"""
    Display information about the local project configuration.
    """
⋮----
project_count = (
⋮----
"""
    Generate a non-production YAML exemplar with optional field descriptions.

    Merges src/metagit/data/config-example-overrides.yml when present.
    """
⋮----
generator = ConfigExampleGenerator(overrides=load_example_overrides())
rendered = generator.render_yaml(
⋮----
@click.pass_context
def config_tree(ctx: click.Context, as_json: bool) -> None
⋮----
"""Show schema-backed field tree for .metagit.yml (same model as web Config Studio)."""
⋮----
result = ConfigPatchService().build_tree("metagit", config_path)
⋮----
"""Preview .metagit.yml after applying draft operations (no save)."""
⋮----
operations = (
result = ConfigPatchService().preview(
⋮----
"""
    Apply schema operations to .metagit.yml (enable/disable/set/append/remove).

    Same operation model as the web Config Studio PATCH API. Example operations file:

    {"operations": [{"op": "set", "path": "name", "value": "my-workspace"}]}
    """
⋮----
operations = resolve_operations(
result = ConfigPatchService().patch(
⋮----
@config.group("graph")
@click.pass_context
def config_graph(ctx: click.Context) -> None
⋮----
"""Export workspace graph data for GitNexus / Cypher ingest."""
⋮----
"""
    Export manual graph.relationships as GitNexus-ingestible Cypher.

    Emits MERGE/CREATE statements for MetagitEntity / MetagitLink overlay tables and
    matching gitnexus_cypher MCP tool_calls. Run schema statements once per target index.
    """
⋮----
app_config = ctx.obj.get("config")
⋮----
manager = MetagitConfigManager(config_path=config_path)
loaded = manager.load_config()
⋮----
root = workspace_root or str(
result = GraphCypherExportService().export(
⋮----
lines = [*result.schema_statements, *result.statements]
rendered = "\n".join(lines) + ("\n" if lines else "")
⋮----
rendered = json.dumps(
⋮----
rendered = json.dumps(result.model_dump(mode="json"), indent=2)
⋮----
@click.pass_context
def config_schema(ctx: click.Context, output_path: str) -> None
⋮----
"""
    Generate a JSON schema for the MetagitConfig class and write it to a file.
    """
⋮----
schema = MetagitConfig.model_json_schema()
````

## File: mkdocs.yml
````yaml
site_name: Metagit-AI Documentation
theme:
  palette:
    scheme: slate
  name: material
nav:
  - 'index.md'
  - 'Installation': 'install.md'
  - 'Terminology': 'terminology.md'
  - 'Application Logic': 'app.logic.md'
  - 'Development': 'development.md'
  - 'Secrets': 'secrets.analysis.md'
  - 'CLI Reference': 'cli_reference.md'
  - 'Features':
    - 'Repository Detection': 'repository_detection.md'
    - 'Hermes & org IaC guide': 'hermes-iac-workspace-guide.md'
    - 'Hermes orchestrator workspace': 'hermes-orchestrator-workspace.md'
    - 'Configuration exemplar': 'reference/metagit-config.md'
    - 'Workspace layout API': 'reference/workspace-layout-api.md'
    - 'Metagit Web UI': 'reference/metagit-web.md'
    - 'Skills': 'skills.md'
markdown_extensions:
  - mkdocs-click
  - admonition         # Adds boxes for Notes, Warnings, etc.
  - attr_list          # Adds attribute lists to generated HTML
  - codehilite         # Syntax coloring for code blocks
  - def_list           # Adds syntax for definition lists
  - sane_lists         # Enables list items with multiple paragraphs
  - toc:               # Builds a table of contents
      permalink: "#"   # Puts a "#" at the end of each header
  - pymdownx.superfences:
        # make exceptions to highlighting of code:
      custom_fences:
        - name: mermaid
          class: mermaid
          format: !!python/name:mermaid2.fence_mermaid_custom
````

## File: src/metagit/core/appconfig/models.py
````python
#!/usr/bin/env python
⋮----
success_blurb: str = "Success! ✅"
failure_blurb: str = "Failed! ❌"
⋮----
class WorkspaceDedupeScope(str, Enum)
⋮----
"""Where repository deduplication is applied."""
⋮----
WORKSPACE = "workspace"
GLOBAL = "global"
⋮----
class WorkspaceDedupeStrategy(str, Enum)
⋮----
"""How duplicate repository identities share disk layout."""
⋮----
SYMLINK = "symlink"
⋮----
class WorkspaceDedupeConfig(BaseModel)
⋮----
"""Deduplicate synced repos within a workspace via a canonical directory."""
⋮----
model_config = ConfigDict(use_enum_values=True, extra="forbid")
⋮----
enabled: bool = Field(
scope: WorkspaceDedupeScope = Field(
strategy: WorkspaceDedupeStrategy = Field(
canonical_dir: str = Field(
⋮----
class WorkspaceConfig(BaseModel)
⋮----
"""Model for workspace configuration in AppConfig."""
⋮----
path: str = Field(default="./.metagit", description="Workspace path")
default_project: str = Field(default="default", description="Default project")
dedupe: WorkspaceDedupeConfig = Field(
ui_show_preview: Optional[bool] = Field(
ui_menu_length: Optional[int] = Field(
ui_preview_height: Optional[int] = Field(
ui_ignore_hidden: bool = Field(
⋮----
class Config
⋮----
"""Pydantic configuration."""
⋮----
extra = "forbid"
⋮----
class LLM(BaseModel)
⋮----
"""Model for LLM configuration in AppConfig."""
⋮----
enabled: bool = Field(default=False, description="Whether LLM is enabled")
provider: str = Field(default="openrouter", description="LLM provider")
provider_model: str = Field(default="gpt-4o-mini", description="LLM provider model")
embedder: str = Field(default="ollama", description="Embedding provider")
embedder_model: str = Field(
api_key: str = Field(default="", description="API key for LLM provider")
⋮----
class Boundary(BaseModel)
⋮----
"""Model for organization boundaries in AppConfig."""
⋮----
name: str = Field(..., description="Boundary name")
values: List[str] = Field(default_factory=list, description="Boundary values")
⋮----
class Profile(BaseModel)
⋮----
"""Model for profile configuration."""
⋮----
name: str = Field(default="default", description="Profile name")
boundaries: Optional[List[Boundary]] = Field(
⋮----
"0.0.0.0",  # nosec B104 — domain boundary allowlist, not a bind address
⋮----
class GitHubProvider(BaseModel)
⋮----
"""Model for GitHub provider configuration in AppConfig."""
⋮----
api_token: str = Field(default="", description="GitHub API token")
base_url: str = Field(
⋮----
class GitLabProvider(BaseModel)
⋮----
"""Model for GitLab provider configuration in AppConfig."""
⋮----
api_token: str = Field(default="", description="GitLab API token")
⋮----
class Providers(BaseModel)
⋮----
"""Model for Git provider configuration in AppConfig."""
⋮----
github: GitHubProvider = Field(
gitlab: GitLabProvider = Field(
⋮----
class AppConfig(BaseModel)
⋮----
"""Application-level settings (not the Metagit package release version — use `metagit version`)."""
⋮----
model_config = ConfigDict(extra="ignore")
⋮----
agent_mode: bool = Field(
description: str = "Metagit configuration"
editor: str = Field(default="code", description="The editor to use for the CLI")
# Reserved for future use
api_url: str = Field(default="", description="The API URL to use for the CLI")
⋮----
api_version: str = Field(
⋮----
api_key: str = Field(default="", description="The API key to use for the CLI")
⋮----
cicd_file_data: str = Field(
file_type_data: str = Field(
package_manager_data: str = Field(
llm: LLM = Field(default=LLM(), description="The LLM configuration")
workspace: WorkspaceConfig = Field(
profiles: List[Profile] = Field(
providers: Providers = Field(
⋮----
@classmethod
    def load(cls, config_path: str = None) -> Union["AppConfig", Exception]
⋮----
"""
        Load AppConfig from file.

        Args:
            config_path: Path to configuration file (optional)

        Returns:
            AppConfig object or Exception
        """
⋮----
config_path = os.path.join(
⋮----
config_file = Path(config_path)
⋮----
config_data = yaml.safe_load(f)
⋮----
config = cls(**config_data["config"])
⋮----
config = cls(**config_data)
⋮----
# Override with environment variables
config = cls._override_from_environment(config)
⋮----
@classmethod
    def _override_from_environment(cls, config: "AppConfig") -> "AppConfig"
⋮----
"""
        Override configuration with environment variables.

        Args:
            config: AppConfig to override

        Returns:
            Updated AppConfig
        """
⋮----
# LLM configuration
⋮----
# API configuration
⋮----
# Workspace configuration
⋮----
# GitHub provider configuration
⋮----
# GitLab provider configuration
⋮----
def save(self, config_path: str = None) -> Union[bool, Exception]
⋮----
"""
        Save AppConfig to file.

        Args:
            config_path: Path to save configuration file (optional)

        Returns:
            True if successful, Exception if failed
        """
````

## File: schemas/metagit_appconfig.schema.json
````json
{
  "$defs": {
    "Boundary": {
      "additionalProperties": false,
      "description": "Model for organization boundaries in AppConfig.",
      "properties": {
        "name": {
          "description": "Boundary name",
          "title": "Name",
          "type": "string"
        },
        "values": {
          "description": "Boundary values",
          "items": {
            "type": "string"
          },
          "title": "Values",
          "type": "array"
        }
      },
      "required": [
        "name"
      ],
      "title": "Boundary",
      "type": "object"
    },
    "GitHubProvider": {
      "additionalProperties": false,
      "description": "Model for GitHub provider configuration in AppConfig.",
      "properties": {
        "enabled": {
          "default": false,
          "description": "Whether GitHub provider is enabled",
          "title": "Enabled",
          "type": "boolean"
        },
        "api_token": {
          "default": "",
          "description": "GitHub API token",
          "title": "Api Token",
          "type": "string"
        },
        "base_url": {
          "default": "https://api.github.com",
          "description": "GitHub API base URL",
          "title": "Base Url",
          "type": "string"
        }
      },
      "title": "GitHubProvider",
      "type": "object"
    },
    "GitLabProvider": {
      "additionalProperties": false,
      "description": "Model for GitLab provider configuration in AppConfig.",
      "properties": {
        "enabled": {
          "default": false,
          "description": "Whether GitLab provider is enabled",
          "title": "Enabled",
          "type": "boolean"
        },
        "api_token": {
          "default": "",
          "description": "GitLab API token",
          "title": "Api Token",
          "type": "string"
        },
        "base_url": {
          "default": "https://gitlab.com/api/v4",
          "description": "GitLab API base URL",
          "title": "Base Url",
          "type": "string"
        }
      },
      "title": "GitLabProvider",
      "type": "object"
    },
    "LLM": {
      "additionalProperties": false,
      "description": "Model for LLM configuration in AppConfig.",
      "properties": {
        "enabled": {
          "default": false,
          "description": "Whether LLM is enabled",
          "title": "Enabled",
          "type": "boolean"
        },
        "provider": {
          "default": "openrouter",
          "description": "LLM provider",
          "title": "Provider",
          "type": "string"
        },
        "provider_model": {
          "default": "gpt-4o-mini",
          "description": "LLM provider model",
          "title": "Provider Model",
          "type": "string"
        },
        "embedder": {
          "default": "ollama",
          "description": "Embedding provider",
          "title": "Embedder",
          "type": "string"
        },
        "embedder_model": {
          "default": "nomic-embed-text",
          "description": "Embedding model",
          "title": "Embedder Model",
          "type": "string"
        },
        "api_key": {
          "default": "",
          "description": "API key for LLM provider",
          "title": "Api Key",
          "type": "string"
        }
      },
      "title": "LLM",
      "type": "object"
    },
    "Profile": {
      "additionalProperties": false,
      "description": "Model for profile configuration.",
      "properties": {
        "name": {
          "default": "default",
          "description": "Profile name",
          "title": "Name",
          "type": "string"
        },
        "boundaries": {
          "anyOf": [
            {
              "items": {
                "$ref": "#/$defs/Boundary"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": [
            {
              "name": "github",
              "values": []
            },
            {
              "name": "jfrog",
              "values": []
            },
            {
              "name": "gitlab",
              "values": []
            },
            {
              "name": "bitbucket",
              "values": []
            },
            {
              "name": "azure_devops",
              "values": []
            },
            {
              "name": "dockerhub",
              "values": []
            },
            {
              "name": "domain",
              "values": [
                "localhost",
                "127.0.0.1",
                "0.0.0.0",
                "192.168.*",
                "10.0.*",
                "172.16.*"
              ]
            }
          ],
          "description": "Organization boundaries. Items in this list are internal to the profile.",
          "title": "Boundaries"
        }
      },
      "title": "Profile",
      "type": "object"
    },
    "Providers": {
      "additionalProperties": false,
      "description": "Model for Git provider configuration in AppConfig.",
      "properties": {
        "github": {
          "$ref": "#/$defs/GitHubProvider",
          "description": "GitHub provider configuration"
        },
        "gitlab": {
          "$ref": "#/$defs/GitLabProvider",
          "description": "GitLab provider configuration"
        }
      },
      "title": "Providers",
      "type": "object"
    },
    "WorkspaceConfig": {
      "additionalProperties": false,
      "description": "Model for workspace configuration in AppConfig.",
      "properties": {
        "path": {
          "default": "./.metagit",
          "description": "Workspace path",
          "title": "Path",
          "type": "string"
        },
        "default_project": {
          "default": "default",
          "description": "Default project",
          "title": "Default Project",
          "type": "string"
        },
        "dedupe": {
          "$ref": "#/$defs/WorkspaceDedupeConfig",
          "description": "Optional workspace-scoped repository deduplication settings"
        },
        "ui_show_preview": {
          "anyOf": [
            {
              "type": "boolean"
            },
            {
              "type": "null"
            }
          ],
          "default": true,
          "description": "Show preview in fuzzy finder console UI",
          "title": "Ui Show Preview"
        },
        "ui_menu_length": {
          "anyOf": [
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": 10,
          "description": "Number of items to show in menu",
          "title": "Ui Menu Length"
        },
        "ui_preview_height": {
          "anyOf": [
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": 3,
          "description": "Height of preview in fuzzy finder console UI",
          "title": "Ui Preview Height"
        },
        "ui_ignore_hidden": {
          "default": true,
          "description": "When true, hide dotfiles and dot-directories from repo picker UI",
          "title": "Ui Ignore Hidden",
          "type": "boolean"
        }
      },
      "title": "WorkspaceConfig",
      "type": "object"
    },
    "WorkspaceDedupeConfig": {
      "additionalProperties": false,
      "description": "Deduplicate synced repos within a workspace via a canonical directory.",
      "properties": {
        "enabled": {
          "default": false,
          "description": "When true, clone once under canonical_dir and symlink per project",
          "title": "Enabled",
          "type": "boolean"
        },
        "scope": {
          "$ref": "#/$defs/WorkspaceDedupeScope",
          "default": "workspace",
          "description": "Dedupe scope (v1 implements workspace only)"
        },
        "strategy": {
          "$ref": "#/$defs/WorkspaceDedupeStrategy",
          "default": "symlink",
          "description": "How project mounts reference canonical checkouts"
        },
        "canonical_dir": {
          "default": "_canonical",
          "description": "Directory under workspace.path holding canonical checkouts",
          "title": "Canonical Dir",
          "type": "string"
        }
      },
      "title": "WorkspaceDedupeConfig",
      "type": "object"
    },
    "WorkspaceDedupeScope": {
      "description": "Where repository deduplication is applied.",
      "enum": [
        "workspace",
        "global"
      ],
      "title": "WorkspaceDedupeScope",
      "type": "string"
    },
    "WorkspaceDedupeStrategy": {
      "description": "How duplicate repository identities share disk layout.",
      "enum": [
        "symlink"
      ],
      "title": "WorkspaceDedupeStrategy",
      "type": "string"
    }
  },
  "description": "Application-level settings (not the Metagit package release version \u2014 use `metagit version`).",
  "properties": {
    "agent_mode": {
      "default": false,
      "description": "When true, disable interactive UIs (fuzzy finder, prompts, editor). Overridden by METAGIT_AGENT_MODE when set.",
      "title": "Agent Mode",
      "type": "boolean"
    },
    "description": {
      "default": "Metagit configuration",
      "title": "Description",
      "type": "string"
    },
    "editor": {
      "default": "code",
      "description": "The editor to use for the CLI",
      "title": "Editor",
      "type": "string"
    },
    "api_url": {
      "default": "",
      "description": "The API URL to use for the CLI",
      "title": "Api Url",
      "type": "string"
    },
    "api_version": {
      "default": "",
      "description": "Reserved for a future remote API contract version (METAGIT_API_VERSION)",
      "title": "Api Version",
      "type": "string"
    },
    "api_key": {
      "default": "",
      "description": "The API key to use for the CLI",
      "title": "Api Key",
      "type": "string"
    },
    "cicd_file_data": {
      "default": "/Users/zacharyloeber/Zach-Projects/Personal/active/metagit-cli/src/metagit/data/cicd-files.json",
      "description": "The path to the cicd file data",
      "title": "Cicd File Data",
      "type": "string"
    },
    "file_type_data": {
      "default": "/Users/zacharyloeber/Zach-Projects/Personal/active/metagit-cli/src/metagit/data/file-types.json",
      "description": "The path to the file type data",
      "title": "File Type Data",
      "type": "string"
    },
    "package_manager_data": {
      "default": "/Users/zacharyloeber/Zach-Projects/Personal/active/metagit-cli/src/metagit/data/package-managers.json",
      "description": "The path to the package manager data",
      "title": "Package Manager Data",
      "type": "string"
    },
    "llm": {
      "$ref": "#/$defs/LLM",
      "default": {
        "enabled": false,
        "provider": "openrouter",
        "provider_model": "gpt-4o-mini",
        "embedder": "ollama",
        "embedder_model": "nomic-embed-text",
        "api_key": ""
      },
      "description": "The LLM configuration"
    },
    "workspace": {
      "$ref": "#/$defs/WorkspaceConfig",
      "default": {
        "path": "./.metagit",
        "default_project": "default",
        "dedupe": {
          "canonical_dir": "_canonical",
          "enabled": false,
          "scope": "workspace",
          "strategy": "symlink"
        },
        "ui_show_preview": true,
        "ui_menu_length": 10,
        "ui_preview_height": 3,
        "ui_ignore_hidden": true
      },
      "description": "The workspace configuration"
    },
    "profiles": {
      "default": [
        {
          "name": "default",
          "boundaries": [
            {
              "name": "github",
              "values": []
            },
            {
              "name": "jfrog",
              "values": []
            },
            {
              "name": "gitlab",
              "values": []
            },
            {
              "name": "bitbucket",
              "values": []
            },
            {
              "name": "azure_devops",
              "values": []
            },
            {
              "name": "dockerhub",
              "values": []
            },
            {
              "name": "domain",
              "values": [
                "localhost",
                "127.0.0.1",
                "0.0.0.0",
                "192.168.*",
                "10.0.*",
                "172.16.*"
              ]
            }
          ]
        }
      ],
      "description": "The profiles available to this appconfig",
      "items": {
        "$ref": "#/$defs/Profile"
      },
      "title": "Profiles",
      "type": "array"
    },
    "providers": {
      "$ref": "#/$defs/Providers",
      "default": {
        "github": {
          "api_token": "",
          "base_url": "https://api.github.com",
          "enabled": false
        },
        "gitlab": {
          "api_token": "",
          "base_url": "https://gitlab.com/api/v4",
          "enabled": false
        }
      },
      "description": "Git provider plugin configuration"
    }
  },
  "title": "AppConfig",
  "type": "object"
}
````

## File: src/metagit/core/mcp/runtime.py
````python
#!/usr/bin/env python
"""
Minimal MCP stdio runtime for Metagit tools and resources.
"""
⋮----
class InvalidToolArgumentsError(Exception)
⋮----
"""Raised when tool call arguments are invalid."""
⋮----
class MetagitMcpRuntime
⋮----
"""MCP runtime over stdio transport."""
⋮----
def __init__(self, root: Optional[str] = None) -> None
⋮----
def status_snapshot(self) -> dict[str, Any]
⋮----
"""Return a one-shot runtime status snapshot."""
⋮----
def run_stdio(self) -> None
⋮----
"""Run JSON-RPC message loop over stdio framing."""
⋮----
request = self._read_message()
⋮----
response = self._handle_request(request=request)
⋮----
def _handle_request(self, request: dict[str, Any]) -> Optional[dict[str, Any]]
⋮----
request_id = request.get("id")
method = request.get("method")
params = request.get("params", {})
⋮----
result = self._handle_initialize()
⋮----
result = self._handle_tools_list()
⋮----
result = self._handle_tools_call(params=params)
⋮----
result = self._handle_resources_list()
⋮----
result = self._handle_resources_read(params=params)
⋮----
result = {}
⋮----
def _handle_initialize(self) -> dict[str, Any]
⋮----
# MCP clients advertise capabilities at initialize time.
# We use this to decide whether server-driven sampling can be requested.
params = self._last_init_params
client_capabilities = params.get("capabilities", {}) if params else {}
⋮----
def _handle_tools_list(self) -> dict[str, Any]
⋮----
tool_names = self._registry.list_tools(status=status)
tools: list[dict[str, Any]] = []
⋮----
def _handle_tools_call(self, params: dict[str, Any]) -> dict[str, Any]
⋮----
name = params.get("name", "")
arguments = params.get("arguments", {}) or {}
⋮----
allowed = set(self._registry.list_tools(status=status))
⋮----
result = self._dispatch_tool(
⋮----
def _handle_resources_list(self) -> dict[str, Any]
⋮----
resources = []
⋮----
def _handle_resources_read(self, params: dict[str, Any]) -> dict[str, Any]
⋮----
uri = params.get("uri")
⋮----
repos = self._build_repo_index(status=status, config=config)
health_payload = None
⋮----
health_payload = self._workspace_health.check(
payload = self._resources.get_resource(
⋮----
raw_repos = arguments.get("repos")
repo_selectors = (
repo_paths = self._search_service.filter_repo_paths(
query = str(arguments.get("query", "")).strip()
⋮----
raw_paths = arguments.get("paths")
raw_exclude = arguments.get("exclude")
⋮----
raw_sem_repos = arguments.get("repos")
sem_selectors = (
⋮----
sem_query = str(arguments.get("query", "")).strip()
⋮----
limit_sem = int(arguments.get("limit_per_repo", 5))
⋮----
timeout_sem = int(arguments.get("timeout_seconds", 120))
⋮----
raw_tags = arguments.get("tags")
tag_filter: dict[str, str] | None = None
⋮----
tag_filter = {str(k): str(v) for k, v in raw_tags.items()}
limit_raw = arguments.get("limit", 10)
⋮----
limit_val = int(limit_raw)
⋮----
raw_status = arguments.get("status")
status_filter = (
sort_val = str(arguments.get("sort", "score"))
⋮----
has_url = arguments.get("has_url")
has_url_val = bool(has_url) if isinstance(has_url, bool) else None
sync_enabled = arguments.get("sync_enabled")
sync_enabled_val = (
result = self._managed_repo_search.search(
⋮----
blocker = str(arguments.get("blocker", "")).strip()
⋮----
repo_path = str(arguments.get("repo_path", "")).strip()
⋮----
only_if = str(arguments.get("only_if", "any"))
⋮----
max_parallel_raw = arguments.get("max_parallel", 4)
⋮----
max_parallel = int(max_parallel_raw)
⋮----
project_name = str(arguments.get("project_name", "")).strip()
⋮----
bundle = self._project_context.switch(
⋮----
snapshot_id = str(arguments.get("snapshot_id", "")).strip()
⋮----
raw_warn = arguments.get("branch_head_warning_days")
raw_crit = arguments.get("branch_head_critical_days")
raw_integration = arguments.get("integration_stale_days")
⋮----
branch_warn = float(raw_warn) if raw_warn is not None else 180.0
branch_crit = float(raw_crit) if raw_crit is not None else 365.0
integration_td = (
⋮----
loaded_app = AppConfig.load()
dedupe_cfg = (
⋮----
intent = arguments.get("intent")
pattern = arguments.get("pattern")
⋮----
raw_scope = arguments.get("project_scope")
selectors = (
⋮----
template = str(arguments.get("template", "")).strip()
raw_targets = arguments.get("target_projects")
⋮----
source_project = str(arguments.get("source_project", "")).strip()
⋮----
raw_types = arguments.get("dependency_types")
dependency_types = (
depth_raw = arguments.get("depth", 2)
⋮----
depth_val = int(depth_raw)
⋮----
gitnexus_repo = arguments.get("gitnexus_repo")
repo_name = (
⋮----
_ = (config_path, workspace_root)
⋮----
project_name = str(arguments.get("name", "")).strip()
⋮----
_ = config_path
project_filter = arguments.get("project_name")
⋮----
built = self._workspace_catalog.build_repo_from_fields(
⋮----
dry_run = bool(arguments.get("dry_run", False))
move_disk = bool(arguments.get("move_disk", True))
force = bool(arguments.get("force", False))
⋮----
recent = arguments.get("recent_repos")
recent_repos = (
env_raw = arguments.get("env_overrides")
env_overrides = (
⋮----
root = status.root_path or str(Path.cwd())
context = self._discovery_service.build_context(repo_root=root)
⋮----
sampling_payload = self._request_client_sampling(context=context)
⋮----
sampled_text = self._extract_sampling_text(sampling_payload)
⋮----
def sampler(_payload: dict[str, str]) -> str
⋮----
_ = _payload
⋮----
sampled_service = BootstrapSamplingService(
⋮----
def _resolve_status_and_config(self) -> tuple[WorkspaceStatus, Any]
⋮----
resolved_root = self._resolver.resolve(
status = self._gate.evaluate(root_path=resolved_root)
config = None
⋮----
manager = MetagitConfigManager(
loaded = manager.load_config()
config = None if isinstance(loaded, Exception) else loaded
⋮----
def _catalog_paths(self, status: WorkspaceStatus, config: Any) -> tuple[str, str]
⋮----
config_path = str(Path(status.root_path) / ".metagit.yml")
⋮----
error: dict[str, Any] = {"code": code, "message": message}
⋮----
def _read_message(self) -> Optional[dict[str, Any]]
⋮----
"""Read one MCP-framed JSON-RPC message from stdin."""
content_length: Optional[int] = None
⋮----
header_line = sys.stdin.buffer.readline()
⋮----
header_text = header_line.decode("utf-8").strip()
⋮----
content_length = int(value.strip())
⋮----
body = sys.stdin.buffer.read(content_length)
⋮----
def _write_message(self, payload: dict[str, Any]) -> None
⋮----
"""Write one MCP-framed JSON-RPC message to stdout."""
body = json.dumps(payload).encode("utf-8")
header = f"Content-Length: {len(body)}\r\n\r\n".encode("utf-8")
⋮----
def _request_client_sampling(self, context: dict[str, str]) -> dict[str, Any]
⋮----
"""Request client sampling synchronously when supported."""
request_id = self._next_server_request_id
⋮----
sampling_request = {
⋮----
inbound = self._read_message()
⋮----
# Handle regular inbound requests while waiting on sampling response.
response = self._handle_request(request=inbound)
⋮----
def _extract_sampling_text(self, sampling_result: dict[str, Any]) -> Optional[str]
⋮----
"""Extract sampled text from sampling/createMessage result."""
content = sampling_result.get("content")
````

## File: src/metagit/core/utils/fuzzyfinder.py
````python
#! /usr/bin/env python3
⋮----
"""
This is a fuzzy finder that uses Textual and rapidfuzz to find items in a list.
I'm only doing this because I don't want to have to wrap the fzf binary in a python script.
"""
⋮----
class FuzzyFinderTarget(BaseModel)
⋮----
"""A target for a fuzzy finder."""
⋮----
name: str
description: str
color: Optional[str] = None
opacity: Optional[float] = None
⋮----
class FuzzyFinderConfig(BaseModel)
⋮----
"""Configuration for a fuzzy finder using Textual and rapidfuzz."""
⋮----
items: List[Union[str, Any]] = Field(
display_field: Optional[str] = Field(
score_threshold: float = Field(
max_results: int = Field(
scorer: str = Field(
prompt_text: str = Field(
case_sensitive: bool = Field(
multi_select: bool = Field(False, description="Allow selecting multiple items.")
enable_preview: bool = Field(
preview_field: Optional[str] = Field(
preview_header: Optional[str] = Field(None, description="Header for preview pane.")
sort_items: bool = Field(True, description="Whether to sort the items.")
# Styling options
highlight_color: str = Field(
normal_color: str = Field("white", description="Color/style for normal items.")
prompt_color: str = Field("bold cyan", description="Color/style for prompt text.")
separator_color: str = Field("gray", description="Color/style for separator line.")
item_opacity: Optional[float] = Field(
custom_colors: Optional[Dict[str, str]] = Field(
color_field: Optional[str] = Field(
total_count: Optional[int] = Field(
query_mode_label: str = Field(
⋮----
@field_validator("items")
@classmethod
    def validate_items(cls, v: List[Any], info: Any) -> List[Any]
⋮----
"""Ensure items are valid and consistent with display_field."""
⋮----
@field_validator("scorer")
@classmethod
    def validate_scorer(cls, v: str) -> str
⋮----
"""Ensure scorer is valid."""
valid_scorers = ["partial_ratio", "ratio", "token_sort_ratio"]
⋮----
@field_validator("preview_field")
@classmethod
    def validate_preview_field(cls, v: Optional[str], info: Any) -> Optional[str]
⋮----
"""Ensure preview_field is valid if enable_preview is True."""
⋮----
def get_scorer_function(self) -> Union[Callable[..., float], Exception]
⋮----
"""Return the rapidfuzz scorer function based on configuration."""
⋮----
scorer_map: Dict[str, Callable[..., float]] = {
⋮----
def get_display_value(self, item: Any) -> Union[str, Exception]
⋮----
"""Extract the display value from an item."""
⋮----
def get_preview_value(self, item: Any) -> Union[Optional[str], Exception]
⋮----
"""Extract the preview value from an item if preview is enabled."""
⋮----
def get_item_color(self, item: Any) -> Optional[str]
⋮----
"""Get the color for an item, prioritizing FuzzyFinderTarget.color over custom_colors."""
⋮----
# First check if item is a FuzzyFinderTarget with a color property
⋮----
# Fall back to custom_colors mapping if available
⋮----
# Determine the key to use for color lookup
⋮----
# Use specified color field
⋮----
color_key = item
⋮----
color_key = str(getattr(item, self.color_field))
⋮----
# Use display field
color_key = str(getattr(item, self.display_field))
⋮----
# Use string representation
color_key = str(item)
⋮----
def get_item_opacity(self, item: Any) -> Optional[float]
⋮----
"""Get the opacity for an item, prioritizing FuzzyFinderTarget.opacity over config.item_opacity."""
⋮----
# First check if item is a FuzzyFinderTarget with an opacity property
⋮----
# Fall back to config's item_opacity
⋮----
class FuzzyFinderApp(App)
⋮----
"""A Textual app for fuzzy finding."""
⋮----
CSS = """
⋮----
BINDINGS = [
⋮----
def __init__(self, config: FuzzyFinderConfig, **kwargs)
⋮----
def compose(self) -> ComposeResult
⋮----
"""Create child widgets for the app."""
⋮----
# Input field
⋮----
# Just results
⋮----
def on_mount(self) -> None
⋮----
"""Called when app starts."""
# Initial search with empty query
⋮----
# Focus the input
⋮----
def on_input_changed(self, event: Input.Changed) -> None
⋮----
"""Called when the input changes."""
⋮----
def _perform_search(self, query: str) -> None
⋮----
"""Perform fuzzy search and update results."""
⋮----
results = self._search(query)
⋮----
# Handle error - for now just show empty results
results = []
⋮----
# Handle error gracefully
⋮----
def _update_results_meta(self, query: str) -> None
⋮----
"""Show concise result counters for current query."""
⋮----
meta = self.query_one("#results_meta", Static)
shown_count = len(self.current_results)
total_count = (
cap_count = self.config.max_results
mode_label = self.config.query_mode_label
query_label = query if query else "all"
⋮----
def _update_results_list(self) -> None
⋮----
"""Update the results ListView."""
results_list = self.query_one("#results_list", ListView)
⋮----
display_value = self.config.get_display_value(result)
⋮----
display_value = str(result)
⋮----
# Create list item
item = ListItem(Label(display_value))
⋮----
# Apply highlighting
⋮----
# Apply custom color if configured
custom_color = self.config.get_item_color(result)
⋮----
# Parse and apply the custom color
⋮----
# Apply opacity - prioritize FuzzyFinderTarget.opacity over config.item_opacity
item_opacity = self.config.get_item_opacity(result)
⋮----
# Set opacity via inline style
⋮----
# Set the ListView's index to match our highlighted_index
⋮----
def _apply_custom_color(self, item: ListItem, color_spec: str) -> None
⋮----
"""Apply custom color to a list item based on color specification."""
⋮----
# Handle different color formats
⋮----
# Hex color
⋮----
# Background color
bg_color = color_spec[3:]  # Remove 'bg:' prefix
⋮----
# Color with background (e.g., "white bg:#ff0000")
parts = color_spec.split(" bg:")
⋮----
# Basic color names
⋮----
# Try to apply as-is (could be a rich color spec)
⋮----
# If color application fails, silently continue
⋮----
def _update_preview(self) -> None
⋮----
"""Update the preview pane."""
⋮----
preview_pane = self.query_one("#preview_pane", Static)
⋮----
highlighted_item = self.current_results[self.highlighted_index]
preview_value = self.config.get_preview_value(highlighted_item)
⋮----
preview_value = str(highlighted_item)
⋮----
preview_text = f"{self.config.preview_header}\n\n{preview_value}"
⋮----
preview_text = preview_value
⋮----
def on_list_view_selected(self, event: ListView.Selected) -> None
⋮----
"""Called when a list item is selected."""
⋮----
# Update highlighted index based on selection
⋮----
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None
⋮----
"""Called when a list item is highlighted (but not selected)."""
⋮----
# Keep our highlighted_index in sync with ListView
⋮----
def action_cursor_up(self) -> None
⋮----
"""Move cursor up."""
⋮----
def action_cursor_down(self) -> None
⋮----
"""Move cursor down."""
⋮----
def _scroll_to_highlighted(self) -> None
⋮----
"""Scroll the results list to ensure the highlighted item is visible."""
⋮----
# Get the highlighted list item
highlighted_item = results_list.children[self.highlighted_index]
# Scroll to make the item visible
⋮----
# If scrolling fails, continue without it
⋮----
def action_page_up(self) -> None
⋮----
"""Move cursor up by a page (10 items)."""
⋮----
page_size = 10
⋮----
def action_page_down(self) -> None
⋮----
"""Move cursor down by a page (10 items)."""
⋮----
max_index = len(self.current_results) - 1
⋮----
def action_cursor_home(self) -> None
⋮----
"""Move cursor to the first item."""
⋮----
def action_cursor_end(self) -> None
⋮----
"""Move cursor to the last item."""
⋮----
def action_select(self) -> None
⋮----
"""Select the highlighted item."""
# First try to get the current selection from the ListView
⋮----
# Select the highlighted item
⋮----
def action_quit(self) -> None
⋮----
"""Quit the application."""
⋮----
def _search(self, query: str) -> Union[List[Any], Exception]
⋮----
"""Perform fuzzy search based on the query."""
⋮----
items_to_search = self.config.items
⋮----
# Sort items based on their display value
items_to_search = sorted(
⋮----
# If sorting fails, proceed without sorting
⋮----
choices_with_originals = [
# Check for exceptions
choice_exceptions = [
⋮----
choices = [str(c[0]) for c in choices_with_originals]
⋮----
# Prepare query for case-insensitive matching
query_lower = query.lower() if not self.config.case_sensitive else query
⋮----
scorer_func = self.config.get_scorer_function()
⋮----
# Get fuzzy search results
results = process.extract(
⋮----
limit=len(choices),  # Get all results for custom sorting
⋮----
# Custom scoring and sorting to prioritize exact matches
scored_results = []
⋮----
choice_lower = (
⋮----
# Calculate custom score based on match type
custom_score = score
⋮----
# Bonus for exact matches
⋮----
# Bonus for prefix matches
⋮----
# Bonus for longer matches (more specific)
⋮----
length_bonus = min(100, (len(choice_lower) - len(query_lower)) * 10)
⋮----
# Sort by custom score (highest first) and then by original string length (shorter first for same score)
⋮----
# Return all matched results to allow full manual scrolling.
⋮----
class FuzzyFinder
⋮----
"""A reusable fuzzy finder using Textual and rapidfuzz with navigation support."""
⋮----
def __init__(self, config: FuzzyFinderConfig)
⋮----
"""Initialize the fuzzy finder with a configuration."""
⋮----
def run(self) -> Union[Optional[Union[str, List[str], Any]], Exception]
⋮----
"""Run the fuzzy finder application."""
⋮----
app = FuzzyFinderApp(self.config)
result = app.run()
⋮----
# Multi-select not fully implemented yet
⋮----
def fuzzyfinder(query: str, collection: List[str]) -> List[str]
⋮----
"""
    Simple fuzzy finder function that returns matching items from a collection.

    Args:
        query: Search query string
        collection: List of strings to search in

    Returns:
        List of matching strings
    """
⋮----
# Use rapidfuzz to find matches
⋮----
# Return items with score >= 70
````

## File: README.md
````markdown
# Metagit

Metagit gives you situational awareness across Git repositories. It helps multi-repo projects feel manageable by keeping stack details, generated artifacts, dependencies, and related metadata in one place.

## About

This tool works well for scenarios like:

1. At-a-glance view of a project's technical stacks, languages, external dependencies, and generated artifacts.
2. Switching between many Git projects during the day without losing context.
3. Isolating outside dependencies that weaken the security and dependability of your software delivery pipelines.
4. Automated documentation of a code's provenance.
5. Helping new contributors get from onboarding to first commit faster.

Metagit is designed for developers, SREs, and AI agents who work across connected repositories. It tracks the dependencies and project relationships that are easy to miss when you only look at one repo at a time.

## Quick start

Install or upgrade the CLI globally with [uv](https://docs.astral.sh/uv/):

```bash
uv tool install metagit-cli
uv tool install -U metagit-cli   # upgrade later
metagit version
```

> Use the PyPI package name **`metagit-cli`**. The `metagit` package on PyPI is a different project.

Install bundled agent skills (OpenClaw, Hermes, Claude Code, and others):

```bash
metagit skills list
metagit skills install --scope user --target openclaw --target hermes
```

Use `--scope project` when installing into a specific umbrella repository checkout. See [Skills](docs/skills.md) for targets, MCP install, and the project-management skill for agents.

## Audience

This tool targets:

- DevOps Engineers
- Polyglot developers
- New team members
- Project Managers
- SREs
- Solution Engineers
- AI Agents (more to come!)

## Metagit is NOT...

### ...an SBOM tool

SBOM output is often thousands of lines and includes full transitive dependency trees. That level of detail is usually too heavy for day-to-day situational awareness and agent context. Metagit may read SBOM manifests as an input in the future, but it is not trying to replace SBOM tooling.

Metagit uses common project files (for example `go.mod`, `package.json`, and `requirements.txt`) for detection and validation boundaries. These are used to identify stack composition, not to provide exhaustive version intelligence.

### ...a Git client

Despite the name, this still relies on Git and your existing hosting platform.

## ...a full project packer

Metagit intentionally focuses on the highest-value project signals. It does not package full repositories. If you need full-project packing, use [repomix](https://github.com/yamadashy/repomix/tree/main).

### Why brevity?

One of the core goals is reducing cognitive load when understanding project relationships. A practical side effect is lower token usage for automated AI workflows.

## How It Works

Metagit stores project configuration metadata in `.metagit.yml` inside the repository. That file follows a schema that the CLI can validate and read.

If you use Metagit for dozens of repositories (an umbrella workspace), you can edit the config manually or refresh it with heuristics and AI-assisted workflows.

## Modes

Metagit supports several operating modes:

### Workspace Mode

This is the first planned open-source CLI mode.

In this mode, you group related repositories into one workspace that you can open in VS Code or access individually from the terminal.

> **AKA** Multi-repo as Monorepo

You use one top-level umbrella project with a single metagit definition file that tracks related repositories and local target folders. You can then sync that workspace locally.

The metagit configuration file is committed to version control as its own project artifact.

**Managed repo lookup:** Use `metagit search` / `metagit find` for quick CLI lookup of repos declared in `.metagit.yml` (with optional tags and JSON output). MCP clients can call `metagit_repo_search`, and `metagit api serve` exposes the same search and resolve behavior over a small local JSON HTTP API for agents and scripts.

This mode is useful for:

- Creating umbrella projects for new team members of a multi-repo project
- Individual power users that need to quickly pivot between several project repositories that comprise a larger team effort
- Keeping loosely coupled Git projects grouped without relying on submodules

## Metadata Mode

This mode uses the same config file as workspace mode, with additional metadata such as primary language, frameworks, and other context you want available when entering a repo.

Configuring this by hand for one project is simple. Doing it across dozens or thousands of repos is not. Metagit uses detection heuristics to automate as much as possible and can use AI workflows where deterministic code is not enough.

> **Note**: AI-assisted detection should be monitored and converted into deterministic logic over time.

In this mode, Metagit helps answer questions like:

- What other projects are related to this project?
- What application and development stacks does this project use?
- What external dependencies exist for this project?
- What artifacts does this project create?
- What branch strategy is employed?
- What version strategy is employed?

> **External dependencies** are a common source of pipeline instability.

## Install

Global install and skill setup are covered in [Quick start](#quick-start) above.

### Local first-run

Inside any Git repository:

```bash
metagit init
```

That creates `.metagit.yml` and updates `.gitignore`.

## Skills

Bundled skills ship with the package and install via `metagit skills install` (see [docs/skills.md](docs/skills.md)). For development in this repository, `skills/` is the source tree; run `task skills:sync` to mirror into `.cursor/skills/`.

## Agent guides

- [Hermes agents and organization-wide IaC](hermes-iac-workspace-guide.md) — illustrated workflow for using Metagit as a control plane across Terraform, policy, and module repositories (controller + subagents, layered `agent_instructions`, MCP tools).

## Documentation

For installation guidance, detailed usage, including full CLI command surface, local MCP runtime setup, API-oriented flows, and advanced examples, use the documentation site:

- [Documentation](https://metagit-ai.github.io/metagit-cli/)

## License

This project is licensed under the MIT License. See the [LICENSE](./LICENSE.md) file for details.
````

## File: src/metagit/core/project/manager.py
````python
#!/usr/bin/env python
"""
Class for managing projects.
"""
⋮----
"""Construct a ProjectManager using workspace path and effective dedupe settings."""
project: Optional[WorkspaceProject] = None
⋮----
project = find_project(metagit_config, project_name)
dedupe = resolve_effective_dedupe(app_config.workspace.dedupe, project)
⋮----
class ProjectManager
⋮----
"""
    Manager class for handling projects within a workspace.
    """
⋮----
"""
        Initialize the ProjectManager.

        Args:
            workspace_path: The root path of the workspace.
            logger: The logger instance for output.
            dedupe: When enabled, sync uses a canonical directory and project symlinks.
        """
⋮----
"""
        Add a repository to a specific project in the configuration.

        Args:
            project_name: The name of the project to add the repository to.
            repo: The ProjectPath object representing the repository to add. If None, will prompt for data.
            metagit_config: The MetagitConfig instance to work with configuration data.

        Returns:
            Union[ProjectPath, Exception]: ProjectPath if successful, Exception if failed.
        """
config_manager = MetagitConfigManager(metagit_config=metagit_config)
⋮----
# Validate inputs
⋮----
# Check if workspace configuration exists
⋮----
# Find the target project
target_project = None
⋮----
target_project = project
⋮----
repo_result = UserPrompt.prompt_for_model(
⋮----
repo = repo_result
⋮----
# Check if name already exists in the project
⋮----
duplicates = workspace_dedupe.find_duplicate_identities(
⋮----
locations = ", ".join(f"{proj}/{name}" for proj, name in duplicates)
⋮----
# Add the repository to the project
⋮----
# Save the updated configuration
save_result = config_manager.save_config(metagit_config, config_path)
⋮----
def sync(self, project: WorkspaceProject, *, hydrate: bool = False) -> bool
⋮----
"""
        Sync a workspace project concurrently.

        Iterates through each repository in the project and either creates a
        symbolic link for local paths or clones it if it's a remote repository.
        After syncing, creates a VS Code workspace file.

        When ``hydrate`` is True, symlink mounts are replaced with full directory
        copies (see ``hydrate_project``).

        Returns:
            bool: True if sync is successful, False otherwise.
        """
project_dir = os.path.join(self.workspace_path, project.name)
⋮----
future_to_repo = {
⋮----
repo = future_to_repo[future]
⋮----
hydrate_ok = self.hydrate_project(project)
⋮----
# Create VS Code workspace file after successful sync
workspace_result = self._create_vscode_workspace(project, project_dir)
⋮----
# Don't fail the entire sync for workspace file creation issues
# else:
#     tqdm.write(f"Created VS Code workspace file: {workspace_result}")
⋮----
def hydrate_project(self, project: WorkspaceProject) -> bool
⋮----
"""
        Materialize symlink mounts under a project into full directory copies.

        Returns:
            bool: True when all hydrate steps succeed or are skipped cleanly.
        """
project_dir = self.workspace_path / project.name
⋮----
ok = True
⋮----
mount = project_dir / repo.name
⋮----
ok = False
⋮----
"""
        Create a VS Code workspace file for the project.

        Args:
            project: The workspace project containing repository information
            project_dir: The directory where the project is located

        Returns:
            Path to the created workspace file on success, Exception on failure
        """
⋮----
# Get list of repository names that were successfully synced
repo_names = []
⋮----
repo_path = os.path.join(project_dir, repo.name)
⋮----
# Create workspace file content
workspace_content = create_vscode_workspace(project.name, repo_names)
⋮----
# Write workspace file
workspace_file_path = os.path.join(project_dir, "workspace.code-workspace")
⋮----
"""
        Sync a single repository.

        This method is called by the thread pool executor.
        """
mount_path = os.path.join(project_dir, repo.name)
⋮----
"""Sync using canonical storage and a per-project symlink mount."""
⋮----
identity = workspace_dedupe.build_repo_identity(repo)
⋮----
canonical = workspace_dedupe.canonical_path(
mount = Path(mount_path)
⋮----
"""Place source under canonical (symlink) and mount project symlink."""
⋮----
desc = f"  ✅   🔗 {repo.name}"
⋮----
bar_format = (
⋮----
"""Clone into canonical when missing, then symlink the project mount."""
⋮----
desc = f"  🔗 {repo.name}"
⋮----
def _sync_local(self, repo: ProjectPath, target_path: str, position: int) -> None
⋮----
"""Handle syncing of a local repository via symlink."""
source_path = Path(repo.path).expanduser().resolve()
⋮----
mount = Path(target_path)
⋮----
def _sync_remote(self, repo: ProjectPath, target_path: str, position: int) -> None
⋮----
"""Handle syncing of a remote repository via git clone."""
⋮----
class CloneProgressHandler(git.RemoteProgress)
⋮----
def __init__(self, pbar: tqdm) -> None
⋮----
op_code: int,  # noqa: ARG002
⋮----
self.pbar.update(0)  # Manually update the progress bar
⋮----
desc = f"  ⤵️ {repo.name}"
⋮----
"""
        Directories or symlinks under the project sync folder that are not declared repos.

        Uses the same .gitignore rules as the repo picker. When ``ignore_hidden`` is true,
        dotfiles and dot-directories are skipped.
        """
project_path = Path(self.workspace_path) / project
⋮----
workspace_project = metagit_config.local_workspace_project
⋮----
matches = [
⋮----
workspace_project = matches[0]
⋮----
managed = {repo.name for repo in workspace_project.repos}
ignore_patterns = parse_gitignore(project_path / ".gitignore")
unmanaged: List[Path] = []
⋮----
@staticmethod
    def remove_sync_directory(path: Path) -> None
⋮----
"""Remove a synced repo directory or symlink under a project folder."""
⋮----
"""
        Select a repository from a synced project.
        """
⋮----
project_path: str = os.path.join(self.workspace_path, project)
⋮----
workspace_project = [
⋮----
project_dict = {}
ignore_patterns = parse_gitignore(Path(project_path) / ".gitignore")
# Iterate through the project path and add the directories and symlinks to the project_dict
⋮----
# Determine type
item_type = "Symlink" if f.is_symlink() else "Directory"
⋮----
# Check for git repository
has_git = (f / ".git").exists() if f.is_dir() else False
⋮----
# Check for .metagit.yml file
has_metagit_yml = (f / ".metagit.yml").exists() if f.is_dir() else False
⋮----
# Build initial description
description_parts = [
⋮----
target_path = f.readlink()
# Convert absolute path to relative path from project_path
⋮----
relative_target = os.path.relpath(target_path, project_path)
⋮----
# Fallback to original path if relative path calculation fails
⋮----
# Add git and metagit info
⋮----
# Track which items exist in the project configuration
managed_repos = {repo.name for repo in workspace_project.repos}
⋮----
# Iterate through the workspace project and add the repo descriptions to the project_dict
⋮----
# Update the description with management status and repo description
summary_lines = self._build_project_repo_summary(repo)
⋮----
# This repo is configured but doesn't exist on filesystem
⋮----
# Add unmanaged status to items that exist on filesystem but not in config
⋮----
current_description = project_dict[item_name]
⋮----
projects: List[FuzzyFinderTarget] = []
⋮----
# Determine color based on directory type (check filesystem)
target_path = Path(project_path) / target
is_symlink = target_path.is_symlink()
⋮----
# Set color: white for directories, light blue for symlinks
color = (
⋮----
)  # light blue for symlinks, white for directories
⋮----
# Set opacity: 1.0 if managed (exists in project list), 0.5 if not managed
opacity = 1.0 if target in managed_repos else 0.5
⋮----
finder_config = FuzzyFinderConfig(
finder = FuzzyFinder(finder_config)
selected = finder.run()
⋮----
selected_path = os.path.join(project_path, selected.name)
# If the path starts with ../../, replace it with ./
⋮----
selected_path = selected_path.replace("../../", "./", 1)
⋮----
@staticmethod
    def _append_preview_lines(existing: str, lines: List[str]) -> str
⋮----
"""Append non-empty lines to an existing preview block."""
final_lines = [line for line in lines if line]
⋮----
@staticmethod
    def _build_preview_sections(lines: List[str]) -> str
⋮----
"""Build a readable preview body from collected lines."""
⋮----
@staticmethod
    def _build_project_repo_summary(repo: ProjectPath) -> List[str]
⋮----
"""Build metadata lines for configured project repositories."""
summary_lines = ["Status: ✅ Managed"]
````

## File: CHANGELOG.md
````markdown
# Changelog

## Unreleased

### Added

- `.metagit.yml` **documentation** entries support rich objects (`kind`, `path`, `url`, `tags`, `metadata`) plus legacy bare strings; `documentation_graph_nodes()` exports ingest payloads.
- Top-level **graph** block for manual cross-repo **relationships** (merged into cross-project dependency maps and `graph_export_payload()` for GitNexus-style exports).
- Per-project `dedupe.enabled` override on `workspace.projects[]` in `.metagit.yml` (overrides app-config `workspace.dedupe.enabled` for sync and layout under that project).
- `metagit prompt` kind `repo-enrich` (repo scope): CLI workflow to discover repo metadata (`metagit detect`, `project source sync`) and merge into the workspace manifest entry.
- Bundled skill `metagit-cli`: CLI-only shortcuts for agents, including every `metagit prompt` kind and common catalog/detect/sync commands (no MCP or HTTP API).
- `metagit prompt` command group: `list`, `workspace`, `project`, and `repo` subcommands emit built-in operational prompts or composed manifest `agent_instructions` (`--kind`, `--json`, `--text-only`).
- Top-level `agent_mode` in app config (default false), overridable via `METAGIT_AGENT_MODE`; disables interactive UIs (fuzzy finder, prompts, editor, prune confirms) across CLI when enabled.
- `metagit appconfig show` prints the full active configuration with `--format yaml|json|minimal-yaml` (includes `workspace.dedupe` and effective `agent_mode`).

### Added

- `metagit project sync --hydrate` materializes symlink mounts into full directory copies with per-file tqdm progress.

### Changed

- `workspace.dedupe.enabled` defaults to **false** in app config; enable in `metagit.config.yaml` or per-project `dedupe.enabled` in `.metagit.yml` when canonical checkouts are desired.
- `load_config()` applies environment variable overrides (same as `AppConfig.load()`), including `METAGIT_AGENT_MODE` and `METAGIT_WORKSPACE_DEDUPE_ENABLED`.
- `metagit config show` prints the source `.metagit.yml` by default (preserves your formatting); use `--normalized` for a readable model round-trip (`|` blocks, Unicode not escaped) or `--json` for agents.

### Added

- Workspace layout rename/move: rename projects and repos (manifest + sync folders), move repos across projects; CLI (`workspace project rename`, `workspace repo rename|move`), MCP (`metagit_workspace_project_rename`, `metagit_workspace_repo_rename`, `metagit_workspace_repo_move`), HTTP v2 (`POST /v2/projects/{name}/rename`, `/v2/repos/.../rename|move`). Supports `--dry-run`, `--manifest-only`, dedupe symlink mounts, and session file migration on project rename. See `docs/reference/workspace-layout-api.md`.
- Workspace catalog CRUD with JSON output: CLI (`metagit workspace list|project|repo`, `metagit project list --all`, `project add|remove`, `project repo list|remove`, `--json` on catalog commands), MCP tools (`metagit_workspace_list`, `metagit_workspace_projects_list`, `metagit_workspace_project_add|remove`, `metagit_workspace_repos_list`, `metagit_workspace_repo_add|remove`), and HTTP API v2 (`/v2/workspace`, `/v2/projects`, `/v2/repos`). Manifest-only repo/project removal; use `project repo prune` to delete unmanaged directories on disk.
- Docs: [Hermes agents and organization-wide IaC](docs/hermes-iac-workspace-guide.md) — illustrated controller/subagent workflow, manifest examples, and MCP tool map for platform IaC estates.
- Layered `agent_instructions` on `.metagit.yml` (file, workspace, project, repo/path); legacy `agent_prompt` accepted on load. `AgentInstructionsResolver` composes stacks for MCP project context (`instruction_layers`, `effective_agent_instructions`, per-repo `agent_instructions`).
- MCP `metagit_workspace_semantic_search` runs GitNexus `query` per managed repo (requires registry + index) for vector-ranked process results.
- MCP `metagit_workspace_health_check` includes branch age (`head_commit_age_days`, `merge_base_age_days`) when `check_stale_branches` is enabled, with thresholds `branch_head_warning_days` / `branch_head_critical_days` / `integration_stale_days` and summary counters for stale HEAD and integration drift.
- MCP Phase 3 workspace intelligence: `metagit_workspace_health_check`, `metagit_workspace_discover`, and `metagit_project_template_apply` (dry-run by default), plus resources `metagit://workspace/health` and `metagit://workspace/context`.
- MCP `metagit_cross_project_dependencies` to map declared, import-hint, and shared-config relationships between workspace projects with GitNexus index status per repo.
- MCP Phase 1 search/sync improvements: `metagit_repo_search` filters (`status`, `has_url`, `sync_enabled`, `sort`), `metagit_workspace_search` ripgrep-backed hits with `repos`/`paths`/`exclude`/`context_lines`/`intent`, and batch `metagit_workspace_sync` with `only_if` and `dry_run`.
- MCP project context tools: `metagit_project_context_switch`, `metagit_workspace_state_snapshot`, `metagit_workspace_state_restore`, and `metagit_session_update` for switching workspace projects with persisted session state under `.metagit/sessions/` and git-state snapshots under `.metagit/snapshots/`.
- Managed repository search across `.metagit.yml` workspace repos: CLI (`metagit search` / `metagit find`), MCP tool `metagit_repo_search`, and local JSON HTTP API (`metagit api serve` with `/v1/repos/search` and `/v1/repos/resolve`).
- `metagit project repo prune` to review and remove sync-folder directories not declared in `.metagit.yml` (with `--dry-run`, `--include-hidden`, and `--force` to skip prompts).
- `workspace.ui_ignore_hidden` in app config (default true) to hide dot-directories from the repo picker UI.

### Changed

- Removed redundant `config.version` from application config; use `metagit version` for the installed package. Legacy `version` keys in YAML are ignored on load. `api_version` remains for a future remote API contract (default empty; `METAGIT_API_VERSION` still applies).

### Fixed

- Workspace search: preset names that map to intent globs (e.g. `terraform`) now pass `**/*.tf` include globs to ripgrep; if ripgrep returns no hits while a `preset` or `intent` is set, the term-based filesystem fallback runs so Ubuntu/CI still gets matches when `rg` is installed but misbehaves or misparses.
- Workspace search fallback without `rg` matches preset-expanded terms (e.g. `preset=terraform`) instead of treating the composed `|` pattern as one literal string; fixes empty results when ripgrep is not installed.
- `task test` now runs `uv run pytest` so tests use the project virtualenv (fixes `ModuleNotFoundError: loguru` when `pytest` was not the venv binary).

## [0.2.2](https://github.com/metagit-ai/metagit-cli/compare/v0.2.1...v0.2.2) (2026-05-06)


### Bug Fixes

* revamp release workflow ([cafd6da](https://github.com/metagit-ai/metagit-cli/commit/cafd6dac4777c8528cc1c996bb8f1a394c40d53d))
````

## File: Taskfile.yml
````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json

version: "3"
silent: true
env:
  PROJECT:
    sh: 'echo "$(basename $(pwd))"'
  LOCAL_BIN_PATH:
    sh: 'echo "{{.ROOT_DIR}}/.venv/bin"'
  BUILD_DATE:
    sh: "date '+%Y-%m-%d-%H:%M:%S'"
  BUILD_DATE_SHORT:
    sh: "date '+%Y-%m-%d-%H%M%S'"
  GIT_LATEST_TAG:
    sh: 'git tag -l 2>/dev/null | sort -r -V | head -n 1 2>/dev/null || echo "not a git repo"'
  TERM: screen-256color
  DOCS_PATH: "{{.ROOT_DIR}}/docs"
  PYTHON_VENV_PATH: "{{.ROOT_DIR}}/.venv"
  SCRIPT_PATH: "{{.ROOT_DIR}}/scripts"
  VERSION: '{{default "unknown" .GIT_LATEST_TAG}}'
  COMPOSE_BAKE: true

dotenv:
  - ".env"
  - ".SECRETS.env"

includes:
  docker:
    taskfile: ./tasks/Taskfile.docker.yml
    optional: true
  github:
    taskfile: ./tasks/Taskfile.github.yml
    optional: true
  python:
    taskfile: ./tasks/Taskfile.python.yml
    optional: true
  mcp:
    taskfile: ./tasks/Taskfile.mcp.yml
    optional: true

tasks:
  default:
    cmds:
      - |
        task -l

  show:
    desc: Show task variables
    cmds:
      - |
        echo "ROOT_PATH: {{.ROOT_DIR}}"
        echo "PROJECT: {{.PROJECT}}"
        echo "VERSION: {{.VERSION}}"
        echo "OS: {{OS}}"
        echo "ARCH: {{ARCH}}"
        echo "LOCAL_BIN_PATH: {{.LOCAL_BIN_PATH}}"
        echo "PYTHON_VENV_PATH: {{.PYTHON_VENV_PATH}}"
        echo "SCRIPT_PATH: {{.SCRIPT_PATH}}"
        echo "BUILD_DATE: {{.BUILD_DATE}}"
        echo "GIT_LATEST_TAG: {{.GIT_LATEST_TAG}}"

  show:all:
    desc: Show all variables for task namespaces
    cmds:
      - |
        echo "## Show ##";
        task show
        echo ""
        for taskitem in $(task -l | cut -d " " -f2 | grep show | sed 's/.$//'); do
          if [[ "$taskitem" != "show:all" ]]; then
            echo "## Show - ${taskitem} ##";
            task $taskitem;
            echo "";
          fi
        done

  list:
    desc: List tasks by namespace (task list -- <namespace>)
    cmds:
      - |
        if [[ "{{.CLI_ARGS}}" != "" ]]; then
          task -l | grep {{default "" .CLI_ARGS}}
        else
          task -l
        fi

  toc:
    desc: Update the table of contents in README.md
    cmds:
      - uv pip install -e ".[docs]"
      - uv run mdtoc README.md

  secrets:
    desc: Create template .SECRETS file if one does not already exist.
    cmds:
      - cp ./config/.SECRETS.example.env ./.SECRETS.env
    status:
      - "test -f .SECRETS.env"

  autocomplete:
    desc: Setup task autocomplete (zsh)
    cmds:
      - sudo curl https://raw.githubusercontent.com/go-task/task/main/completion/zsh/_task \
        -o /usr/local/share/zsh/site-functions/_task
    status:
      - "test -f /usr/local/share/zsh/site-functions/_task"

  install:
    desc: Install everything required to run most scripts.
    cmds:
      - |
        uv sync

  run:
    desc: Run the app
    cmds:
      - |
        uv run -m metagit_detect.example

  lint:
    desc: Run python linting
    cmds:
      - |
        uv run ruff check .
        uv run ruff format --check .

  lint:fix:
    desc: Run python linting and fix issues
    cmds:
      - |
        uv run ruff check --fix .

  format:
    desc: Run python formatting
    cmds:
      - |
        uv run ruff format .
  
  start:mcp:
    desc: Start MCP servers
    cmds:
      - |
        docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN} -e GITHUB_READ_ONLY=1 -e GITHUB_DYNAMIC_TOOLSETS=1 ghcr.io/github/github-mcp-server
  
  fake:workspace:
    desc: Create a test workspace folder structure with 10 projects
    cmds:
      - |
        for i in {1..10}; do
          echo "Creating workspace $i"
          mkdir -p "./.metagit/default/project-$i"
        done

  docs:
    desc: Build the docs
    cmds:
      - task: generate:schema
      - |
        uv pip install -e ".[docs]"
        #gemini -y -p 'You are an expert devops and software engineer. Explore this project for its use of secrets and variables paying special attention to only those affecting the runtime behavior of the code and the cicd workflows used to release the projects artifacts. Create a report in ./docs/secrets.analysis.md on each of the secrets found and were they are sourced from. Using this data create a yaml file at ./docs/secrets.definitions.yml that represents the secrets, their provenance, and where they should be placed.'
        #gemini -y -p 'Perform a comprehensive analysis of the files in this project. Update the yaml file named .metagit.example.yml in the root of this project in a manner that adheres to the jsonschema file located at ./schemas/metagit_config.schema.json. Ensure you skip files matching the patterns in .gitignore. Do your best to update even optional elements of this schema. Pay special attention to dependencies found in Dockerfiles, CICD files, and mcp definitions. When complete also create ./docs/app.logic.md with additional documentation on the project components that includes a mermaid diagram of how they interact.'
        uv run mkdocs build

  test:
    desc: Run the tests
    cmds:
      - |
        uv pip install -e ".[test]"
        uv run pytest --maxfail=1 --disable-warnings -v tests

  qa:prepush:
    desc: Token-optimized local pre-push quality gate
    cmds:
      - |
        zsh ./scripts/prepush-gate.zsh

  qa:prepush:loop:
    desc: Repeat local pre-push gate (use -- rounds=N)
    cmds:
      - |
        zsh ./scripts/prepush-gate.zsh --loop --rounds={{default "3" .CLI_ARGS}}

  skills:sync:
    desc: Sync local skills into .cursor/skills mirrors
    cmds:
      - |
        uv run python - <<'PY'
        from pathlib import Path
        import shutil

        src_root = Path("skills")
        dst_root = Path(".cursor/skills")
        dst_root.mkdir(parents=True, exist_ok=True)

        source_skill_dirs = [p for p in src_root.iterdir() if p.is_dir()]
        for src_dir in source_skill_dirs:
            dst_dir = dst_root / src_dir.name
            if dst_dir.exists():
                shutil.rmtree(dst_dir)
            shutil.copytree(src_dir, dst_dir)
            print(f"synced {src_dir} -> {dst_dir}")
        PY

  typecheck:
    desc: Run the type checker
    cmds:
      - |
        uv run mypy metagit/core

  secret:search:
    desc: Find secrets in the codebase and in git history
    cmds:
      - |
        gitleaks detect --source ./ -f json -r secret_results.json
  
  build:
    desc: Build the project
    cmds:
      - |
        uv build

  clean:
    desc: Clean the project
    cmds:
      - |
        find . -type d -name "__pycache__" -exec rm -rf {} +
        find . -type d -name "*.egg-info" -exec rm -rf {} +
        find . -type f -name ".DS_Store" -delete
        find . -type f -name "*.pyc" -delete
        find . -type f -name "*.pyo" -delete
        rm -rf .mypy_cache
        rm -rf .pytest_cache
        rm -rf .ruff_cache
        rm -rf dist
        rm -rf .coverage
        rm -rf .coverage.*
        rm -rf .coverage.xml
        rm -rf .DS_Store
        rm -rf .pytest_cache
        rm -rf .venv
        rm -rf src/metagit/_version.py


  deep:clean:
    desc: Clean the project deeply. Includes uv cache removal.
    cmds:
      - uv cache clean
      - task: clean

  generate:schema:
    desc: Generate the schema for the config and appconfig files
    cmds:
      - |
        uv pip install -e .
        uv run metagit config schema --output-path ./schemas/metagit_config.schema.json
        uv run metagit appconfig schema --output-path ./schemas/metagit_appconfig.schema.json
        mkdir -p docs/reference
        uv run metagit config example --output docs/reference/metagit-config.full-example.yml

  repomix:
    desc: Run the repo mixin
    cmds:
      - |
        repomix --style=markdown --output=docs/llm.txt --compress
        repomix --style=markdown --output=docs/llm-detailed.txt

  update:github:actions:
    desc: Update GitHub Actions in workflows
    cmds:
      - npx actions-up -y

  skills:validate:
    desc: Validate every SKILL.md under skills/ (frontmatter, naming, required fields)
    cmds:
      - |
        uv pip install -e ".[dev]"
        uv run python scripts/validate_skills.py

  web:install:
    desc: Install web UI dependencies
    dir: web
    cmds:
      - npm ci || npm install

  web:build:
    desc: Build web UI into package data (not part of qa:prepush — run explicitly when changing web/)
    deps: [web:install]
    dir: web
    cmds:
      - npm run build

  web:dev:
    desc: Run Vite dev server
    dir: web
    cmds:
      - npm run dev

  security:deps:
    desc: Frozen sync all extras and audit dependencies (pip-audit)
    cmds:
      - |
        uv sync --frozen --all-extras
        uv run pip-audit

  security:bandit:
    desc: Bandit SAST on src/
    cmds:
      - |
        uv run bandit -r src -ll

  security:gate:
    desc: Fast security gate without lockfile sync (pip-audit + bandit)
    cmds:
      - |
        uv run pip-audit
        uv run bandit -r src -ll

  security:scan:
    desc: Full Python security scans (sync + pip-audit + bandit)
    cmds:
      - task: security:deps
      - task: security:bandit
````

## File: .mex/ROUTER.md
````markdown
---
name: router
description: Session bootstrap and navigation hub. Read at the start of every session before any task. Contains project state, routing table, and behavioural contract.
edges:
  - target: context/architecture.md
    condition: when working on system design, integrations, or understanding how components connect
  - target: context/stack.md
    condition: when working with specific technologies, libraries, or making tech decisions
  - target: context/conventions.md
    condition: when writing new code, reviewing code, or unsure about project patterns
  - target: context/decisions.md
    condition: when making architectural choices or understanding why something is built a certain way
  - target: context/setup.md
    condition: when setting up the dev environment or running the project for the first time
  - target: context/mcp-runtime.md
    condition: when implementing MCP runtime, tool schemas, resource handlers, or protocol behavior
  - target: patterns/INDEX.md
    condition: when starting a task — check the pattern index for a matching pattern file
last_updated: 2026-05-20
---

# Session Bootstrap

If you haven't already read `AGENTS.md`, read it now — it contains the project identity, non-negotiables, and commands.

Then read this file fully before doing anything else in this session.

## Current Project State
**Working:**
- Core CLI command surface (`config`, `detect`, `project`, `record`, `workspace`, `mcp`, `search` / `find`, `api serve` for local JSON v1 search + **v2 catalog CRUD**, `project repo prune` for sync-folder cleanup) with shared app config + logger bootstrapping.
- **Workspace catalog** (`WorkspaceCatalogService`): list/add/remove projects and repos in `.metagit.yml` via CLI (`--json`), MCP (`metagit_workspace_*` catalog tools), and HTTP `/v2/*`.
- **`metagit prompt`**: scoped prompt emission (`workspace` / `project` / `repo`) for agents — manifest instructions plus operational templates (session-start, catalog-edit, sync-safe, **repo-enrich**, etc.). Bundled **`metagit-cli`** skill documents CLI-only shortcuts (all prompt kinds; no MCP/API).
- **`agent_mode`** / **`METAGIT_AGENT_MODE`**: disables interactive CLI (fuzzy finder, prompts, editor); `metagit appconfig show --format json` exposes full config including `workspace.dedupe` (default **disabled**).
- **Workspace layout** (`WorkspaceLayoutService`): rename/move projects and repos (manifest + sync folders, dedupe-aware, session migration); CLI, MCP, HTTP v2 — see `docs/reference/workspace-layout-api.md`.
- `.metagit.yml` manager/model pipeline for load/create/save/validate operations.
- MCP runtime with state-aware gating, tool/resource handlers (search, **semantic search**, sync, cross-project dependencies, project context, snapshots, health check with branch-age staleness, file discover, template apply), resources for health/context, protocol-framed stdio loop, and runtime tests.
- Workspace index/search/upstream hint services, `ManagedRepoSearchService` for managed-only repo matching, local read-only HTTP routes under `metagit.core.api`, and guarded repo inspect/sync flows.
- Skill scaffold + local wrapper scripts in `skills/*/scripts` for token-efficient agent workflows, including `metagit-projects` for OpenClaw/Hermes workspace project lifecycle (check-before-create, register in `.metagit.yml`).
- `docs/skills.md` documents global install, `metagit skills install`, and bundled skill overview.
- Runtime packaging compatibility path for version lookup and `python -m metagit` entrypoint behavior in minimal Python environments.
- Docs build path resolves CLI imports correctly in CI by including interactive prompt runtime dependency.
- A semantic-release workflow now computes and pushes tags from conventional commits on `main`, and tag pushes drive PyPI/TestPyPI publish workflows.
- **`task qa:prepush`** (via `scripts/prepush-gate.py` / `prepush-gate.zsh`) is mandatory in the behavioural contract whenever a session modifies tracked files — not optional “session closeout only.”
- Provider source sync is available via `metagit project source sync` for GitHub org/user and GitLab group recursive discovery with discover/additive/reconcile modes.
- Fuzzy finder repo selection UX now shows result counters, keeps full scrollable match sets, respects project `.gitignore` entries during filesystem candidate discovery, and provides richer repo metadata in preview.
- New `skills` CLI command (`list`, `show`, `install`) plus `mcp install` now support auto-detected agent targets across project/user scopes, with bundled package skills deployed from `src/metagit/data/skills`.
- Focused `graphify` runs on subtrees produce `graphify-out/` HTML/report artifacts quickly enough to use for local command-surface exploration without analyzing the entire repository.
- **Workspace dedupe:** `workspace.dedupe` in app config (default disabled); optional per-project `workspace.projects[].dedupe.enabled` in `.metagit.yml` overrides the flag. `metagit project sync --hydrate` materializes symlink mounts into full copies.
- **`metagit config example`:** generates `docs/reference/metagit-config.full-example.yml` (via `task generate:schema`) with field-description comments.
- **Hermes orchestrator template:** `hermes-orchestrator` under `src/metagit/data/templates/`, example manifest at `examples/hermes-orchestrator/.metagit.yml`, guide at `docs/hermes-orchestrator-workspace.md`.
- **`metagit init`:** bundled init templates (`application`, `umbrella`, `hermes-orchestrator`) with copier-style `{{ var }}` rendering, `--answers-file`, `--no-prompt`, all `ProjectKind` values via `--minimal`.
- **`metagit web serve` groundwork:** Pydantic request/response models for the local web UI API live in `src/metagit/core/web/models.py` (`ConfigTreeResponse`, sync job shapes, config patch types). Thread-safe in-memory sync job tracking + SSE event buffers live in `src/metagit/core/web/job_store.py` (`SyncJobStore`).
- **`metagit web serve` config HTTP:** `build_web_server` in `src/metagit/core/web/server.py` exposes v3 config tree/patch/validate routes via `ConfigWebHandler` (`metagit` + `appconfig` targets, `SchemaTreeService` mutations). PATCH with `save=true` returns HTTP 422 and skips disk write when validation fails; masked sensitive tokens are preserved on noop set.
- **`metagit web serve` ops HTTP:** `OpsWebHandler` (`src/metagit/core/web/ops_handler.py`) — POST health/prune/sync, GET sync job status, SSE sync events; wired in `build_web_server` with workspace root from appconfig.
- **`metagit web serve` static + full server:** `StaticWebHandler` serves packaged SPA from `src/metagit/data/web/`; `build_web_server` dispatches static, v2 catalog/layout, v3 config/ops; CLI `metagit web serve` (`src/metagit/cli/commands/web.py`).
- **Metagit Web UI scaffold:** Vite + React + TypeScript in `web/` (build output → `src/metagit/data/web/`); typed API client, router shell, Taskfile `web:*` tasks.
- **Metagit Web Config Studio:** schema tree + field editor for `/config/metagit` and `/config/appconfig` (TanStack Query PATCH flow, theme toggle, `enum_options` on schema nodes).
- **Metagit Web:** local `metagit web serve` + packaged SPA (**Config Studio** on `/config/*`, **Workspace Console** on `/workspace`) with `task web:dev` / `task web:build` workflow documented in [`docs/reference/metagit-web.md`](../docs/reference/metagit-web.md).

**Not yet built:**
- **Metagit Web hardened/exposed deployments:** intentional v1 localhost-only framing; authentication and safe non-local binds are future scope.
- Full production-grade MCP lifecycle extras (e.g., richer notifications, broader method surface, advanced capability negotiation details).
- End-to-end enterprise mode features described in README (continuous org-wide code mining).
- Matured sampling execution path with robust timeout/retry/error telemetry across diverse MCP hosts.

**Known issues:**
- Local `black` execution path is unstable in this environment; project lint path currently relies on Ruff workflow.
- Some MCP schema/tool contracts are still evolving and may require downstream client adjustments.
- Pydantic deprecation warnings are present in test output due to existing class-based config usage.

## Routing Table

Load the relevant file based on the current task. Always load `context/architecture.md` first if not already in context this session.

| Task type | Load |
|-----------|------|
| Understanding how the system works | `context/architecture.md` |
| Working with a specific technology | `context/stack.md` |
| Writing or reviewing code | `context/conventions.md` |
| Making a design decision | `context/decisions.md` |
| Setting up or running the project | `context/setup.md` |
| Working on MCP runtime/tools/resources/protocol | `context/mcp-runtime.md` |
| Any specific task | Check `patterns/INDEX.md` for a matching pattern |

## Behavioural Contract

For every task, follow this loop:

1. **CONTEXT** — Load the relevant context file(s) from the routing table above. Check `patterns/INDEX.md` for a matching pattern. If one exists, follow it. Narrate what you load: "Loading architecture context..."
2. **BUILD** — Do the work. If a pattern exists, follow its Steps. If you are about to deviate from an established pattern, say so before writing any code — state the deviation and why.
3. **VERIFY** — Load `context/conventions.md` and run the Verify Checklist item by item. State each item and whether the output passes. Do not summarise — enumerate explicitly.
4. **DEBUG** — If verification fails or something breaks, check `patterns/INDEX.md` for a debug pattern. Follow it. Fix the issue and re-run VERIFY.
5. **GROW** — After completing the task:
   - If no pattern exists for this task type, create one in `patterns/` using the format in `patterns/README.md`. Add it to `patterns/INDEX.md`. Flag it: "Created `patterns/<name>.md` from this session."
   - If a pattern exists but you deviated from it or discovered a new gotcha, update it with what you learned.
   - If any `context/` file is now out of date because of this work, update it surgically — do not rewrite entire files.
   - Update the "Current Project State" section above if the work was significant.
6. **QA GATE (mandatory for any delivered work)** — If you changed or added tracked project files in this conversation, run `task qa:prepush` from the repo root before reporting the task as finished. Fix failures and re-run until green. Also run `task skills:sync generate:schema` when bundled skills/schemas need to stay mirrored (see conventions). Omit the QA gate only for strictly read‑only exploration (no file writes) or when the user explicitly waived it in this thread. Document any intentional blockers plainly.

## Commit Message Semantics
- Use `fix:` by default (patch-level intent).
- Use `feat:` only for additive backward-compatible behavior.
- Use breaking-change markers (`type(scope)!:` or `BREAKING CHANGE:`) only when intentionally breaking schema/config compatibility (for example `.metagit.yml` or app configuration schema changes).
````
`````

## File: docs/repository_detection.md
`````markdown
# Repository Detection

The repository detection module provides comprehensive analysis of git repositories, including language detection, project classification, branch analysis, CI/CD detection, and metrics collection.

## Features

- **Language Detection**: Identifies primary and secondary programming languages, frameworks, and build tools
- **Project Classification**: Determines project type (application, library, CLI, etc.) and domain
- **Branch Analysis**: Detects branching strategies and analyzes branch patterns
- **CI/CD Detection**: Identifies CI/CD configurations and platforms
- **Metrics Collection**: Gathers repository metrics including stars, forks, issues, and contributor information
- **Git Provider Integration**: Supports real-time metrics from GitHub and GitLab APIs
- **AppConfig Integration**: Dynamic provider configuration through application settings

## Usage

### Basic Repository Analysis

```python
from metagit.core.detect import DetectionManager

# Analyze a local repository
analysis = DetectionManager.from_path("/path/to/repo")

# Analyze a remote repository (clones it temporarily)
analysis = DetectionManager.from_url("https://github.com/username/repo")

# Generate summary
summary = analysis.summary()
print(summary)

# Convert to MetagitConfig
config = analysis.to_metagit_config()
```

### CLI Usage

```bash
# Analyze current directory
metagit detect repository

# Analyze specific path
metagit detect repository --path /path/to/repo

# Analyze remote repository
metagit detect repository --url https://github.com/username/repo

# Save configuration to .metagit.yml
metagit detect repository --save

# Output in different formats
metagit detect repository --output yaml
metagit detect repository --output json
```

## Git Provider Plugins

The repository detection system supports git provider plugins that enable fetching real-time metrics from hosting platforms like GitHub and GitLab.

### Supported Providers

- **GitHub**: Fetches stars, forks, issues, pull requests, and contributor data
- **GitLab**: Fetches project statistics, merge requests, and member information

### Configuration Methods

#### 1. AppConfig (Recommended)

Configure providers through the application configuration file:

```yaml
# ~/.config/metagit/config.yml or metagit.config.yml
config:
  providers:
    github:
      enabled: true
      api_token: "ghp_your_github_token_here"
      base_url: "https://api.github.com"  # For GitHub Enterprise
    
    gitlab:
      enabled: false
      api_token: "glpat_your_gitlab_token_here"
      base_url: "https://gitlab.com/api/v4"  # For self-hosted GitLab
```

**Benefits:**
- Persistent configuration across sessions
- No need to set environment variables
- Easy to manage multiple environments
- Supports enterprise instances

#### 2. Environment Variables

Set API tokens as environment variables:

```bash
export GITHUB_TOKEN="your_github_personal_access_token"
export GITLAB_TOKEN="your_gitlab_personal_access_token"
```

#### 3. CLI Options

Override configuration for specific commands:

```bash
# Use GitHub token
metagit detect repository --github-token "your_token"

# Use GitLab token
metagit detect repository --gitlab-token "your_token"

# Custom API URLs (for self-hosted instances)
metagit detect repository --github-url "https://github.company.com/api/v3"
metagit detect repository --gitlab-url "https://gitlab.company.com/api/v4"

# Disable AppConfig and use environment variables only
metagit detect repository --use-app-config=false
```

### Configuration Priority

The system uses the following priority order for provider configuration:

1. **CLI Options** (highest priority) - Override all other settings
2. **AppConfig** - Persistent configuration from config files
3. **Environment Variables** - Fallback for legacy support

### Provider Features

#### GitHub Provider

- **Authentication**: Personal Access Token
- **Metrics**: Stars, forks, open issues, pull requests, contributors
- **Metadata**: Repository description, topics, creation date, license
- **URL Support**: github.com, GitHub Enterprise
- **Configuration**: `providers.github.enabled`, `providers.github.api_token`, `providers.github.base_url`

#### GitLab Provider

- **Authentication**: Personal Access Token
- **Metrics**: Star count, forks, open issues, merge requests, members
- **Metadata**: Project description, topics, visibility, namespace
- **URL Support**: gitlab.com, self-hosted GitLab
- **Configuration**: `providers.gitlab.enabled`, `providers.gitlab.api_token`, `providers.gitlab.base_url`

### Fallback Behavior

When no provider is available or API calls fail, the system falls back to git-based metrics:

- **Contributors**: Counted from git commit history
- **Commit Frequency**: Calculated from recent commit patterns
- **Stars/Forks/Issues**: Set to 0 (requires API access)

## Detection Components

### Language Detection

Analyzes file extensions and content to identify:

- **Primary Language**: Most dominant programming language
- **Secondary Languages**: Other languages present
- **Frameworks**: React, Vue, Angular, Terraform, Kubernetes, etc.
- **Package Managers**: npm, pip, cargo, go.mod, etc.
- **Build Tools**: Make, Gradle, Maven, etc.

### Project Type Detection

Classifies projects based on file patterns:

- **Application**: Web apps, mobile apps, desktop apps
- **Library**: Reusable code libraries
- **CLI**: Command-line tools
- **Microservice**: Containerized services
- **Data Science**: ML/AI projects with notebooks
- **Infrastructure as Code**: Terraform, CloudFormation, etc.

### Branch Analysis

Detects branching strategies:

- **Git Flow**: Feature, develop, release, hotfix branches
- **GitHub Flow**: Simple main branch with feature branches
- **GitLab Flow**: Environment-based branching
- **Trunk-Based Development**: Single main branch
- **Custom**: Other branching patterns

### CI/CD Detection

Identifies CI/CD configurations:

- **GitHub Actions**: `.github/workflows/`
- **GitLab CI**: `.gitlab-ci.yml`
- **CircleCI**: `.circleci/config.yml`
- **Jenkins**: `Jenkinsfile`
- **Travis CI**: `.travis.yml`

### Metrics Collection

Gathers repository statistics:

- **Stars**: Repository stars/watches
- **Forks**: Repository forks
- **Open Issues**: Number of open issues
- **Pull Requests**: Open and recently merged PRs
- **Contributors**: Number of contributors
- **Commit Frequency**: Daily, weekly, or monthly activity

## Output Formats

### Summary Output

Human-readable summary of all detected information:

```
Repository Analysis Summary
Path: /path/to/repo
URL: https://github.com/username/repo
Git Repository: True
Primary Language: Python
Secondary Languages: JavaScript, Shell
Frameworks: React, Terraform
Package Managers: pip, npm
Project Type: application
Domain: web
Confidence: 0.85
Branch Strategy: GitHub Flow
Number of Branches: 3
CI/CD Tool: GitHub Actions
Contributors: 5
Commit Frequency: weekly
Stars: 42
Forks: 12
Open Issues: 3
Open PRs: 1
PRs Merged (30d): 8
Metrics Source: GitHub API
Has Docker: True
Has Tests: True
Has Documentation: True
Has Infrastructure as Code: True
```

### YAML Output

Structured YAML configuration:

```yaml
name: "My Project"
description: "A sample project"
url: "https://github.com/username/repo"
kind: "application"
license:
  kind: "mit"
  file: "LICENSE"
maintainers:
  - name: "John Doe"
    email: "john@example.com"
    role: "Maintainer"
branch_strategy: "github_flow"
taskers:
  - kind: "taskfile"
branches:
  - name: "main"
  - name: "develop"
  - name: "feature/new-feature"
cicd:
  platform: "github"
  pipelines:
    - name: "CI"
      ref: ".github/workflows/ci.yml"
metrics:
  stars: 42
  forks: 12
  open_issues: 3
  pull_requests:
    open: 1
    merged_last_30d: 8
  contributors: 5
  commit_frequency: "weekly"
metadata:
  default_branch: "main"
  has_ci: true
  has_tests: true
  has_docs: true
  has_docker: true
  has_iac: true
  created_at: "2024-01-01T00:00:00"
  last_commit_at: "2024-01-15T12:00:00"
workspace:
  projects:
    - name: "default"
      repos:
        - name: "My Project"
          path: "/path/to/repo"
          url: "https://github.com/username/repo"
```

## Examples

### Basic Analysis

```python
from metagit.core.detect import DetectionManager

# Analyze current directory
analysis = DetectionManager.from_path(".")

# Print summary
print(analysis.summary())

# Get configuration
config = analysis.to_metagit_config()
```

### With AppConfig Integration

```python
from metagit.core.detect import DetectionManager
from metagit.core.appconfig import AppConfig
from metagit.core.providers import registry

# Load AppConfig and configure providers
app_config = AppConfig.load()
registry.configure_from_app_config(app_config)

# Analyze repository (will use configured providers for metrics)
analysis = DetectionManager.from_path(".")
print(analysis.summary())
```

### With Manual Provider Configuration

```python
from metagit.core.detect import DetectionManager
from metagit.core.providers.github import GitHubProvider
from metagit.core.providers import registry

# Setup GitHub provider manually
provider = GitHubProvider(api_token="ghp_...")
registry.register(provider)

# Analyze repository
analysis = DetectionManager.from_path(".")
print(analysis.summary())
```

### CLI with AppConfig

```bash
# Create AppConfig file
mkdir -p ~/.config/metagit
cat > ~/.config/metagit/config.yml << EOF
config:
  providers:
    github:
      enabled: true
      api_token: "ghp_..."
    gitlab:
      enabled: false
      api_token: ""
EOF

# Analyze with AppConfig providers
metagit detect repository --path /path/to/repo --output summary

# Save configuration with real metrics
metagit detect repository --path /path/to/repo --save
```

### CLI with Environment Variables

```bash
# Set environment variables
export GITHUB_TOKEN="ghp_..."
export GITLAB_TOKEN="glpat-..."

# Analyze with environment providers
metagit detect repository --path /path/to/repo --output summary

# Disable AppConfig and use environment only
metagit detect repository --use-app-config=false --path /path/to/repo
```

## Error Handling

The detection system gracefully handles errors:

- **Missing Files**: Skips analysis of missing files/directories
- **API Failures**: Falls back to git-based metrics
- **Invalid Repositories**: Returns appropriate error messages
- **Network Issues**: Continues with local analysis
- **Configuration Errors**: Falls back to environment variables or defaults

## Performance Considerations

- **Local Analysis**: Fast, no network required
- **Provider API Calls**: May add 1-3 seconds for metrics
- **Large Repositories**: Analysis time scales with repository size
- **Caching**: No built-in caching (consider implementing for repeated analysis)
- **Configuration Loading**: AppConfig is loaded once per command execution
`````

## File: docs/terminology.md
`````markdown
# Metagit Terminology

IT has a way with words doesn't it? This is a short list of metagit terms and their concise meaning to reduce confusion.

**Path (aka. Target)** - A folder within a git repository.

**Repo** - A git repository. If it happens to be a monorepo there maybe several targets within the repository with unique associated metadata.

**Project** - A collection of git repositories. In VSCode this is a workspace. We define a project at this higher level than the repository because we want a more holistic view of what your code entails as a whole. While a repo maybe produces a single app it may have several internal/external dependencies that make up the whole of what it requires to deploy it.

**Workspace** - A collection of projects. Fundamentally different than a VSCode workspace. For metagit the most important thing to understand is that this is the target folder where all projects and their repos will be cloned/copied into.

This creates a hierarchy like the following:

```
Workspace/
└── ProjectA/
    └── RepoA1/
        ├── Path1
        └── Path2
    └── RepoA2/
        └── Path1
└── ProjectB/
    └── RepoB1/
        ├── Path1
        └── Path2
    └── RepoB2/
      └── Path1
```

> **NOTE** It is entirely possible to have the same repo referenced in multiple projects. Local paths defined this way will simply be soft links. Multiple project repositories defined this way will be treated independently.
`````

## File: examples/appconfig_example.yml
`````yaml
config:
  version: "0.1.0"
  description: "Metagit configuration with provider plugins"
  editor: "code"
  
  # LLM Configuration
  llm:
    enabled: false
    provider: "openrouter"
    provider_model: "gpt-4o-mini"
    embedder: "ollama"
    embedder_model: "nomic-embed-text"
    api_key: ""
  
  # Workspace Configuration
  workspace:
    path: "./.metagit"
    default_project: "default"
  
  # Profiles Configuration
  profiles:
    profile_config_path: "~/.config/metagit/profiles"
    default_profile: "default"
    boundaries: []
  
  # Git Provider Plugin Configuration
  providers:
    github:
      enabled: true  # Set to true to enable GitHub provider
      api_token: "ghp_your_github_token_here"  # Your GitHub Personal Access Token
      base_url: "https://api.github.com"  # Default GitHub API URL
    
    gitlab:
      enabled: false  # Set to true to enable GitLab provider
      api_token: "glpat_your_gitlab_token_here"  # Your GitLab Personal Access Token
      base_url: "https://gitlab.com/api/v4"  # Default GitLab API URL
`````

## File: examples/categorize_directory.py
`````python
#!/usr/bin/env python3
"""
Example script to categorize directory contents using metagit utilities.

This script demonstrates how to use the directory_summary and directory_details
functions to analyze directory structure and output the results in YAML format.
"""

import sys
from pathlib import Path

# Add the parent directory to the path so we can import metagit modules
sys.path.insert(0, str(Path(__file__).parent.parent))

import click
import yaml

from metagit.core.utils.files import (
    FileExtensionLookup,
    directory_details,
    directory_summary,
)


def convert_namedtuple_to_dict(obj):
    """Convert NamedTuple objects to dictionaries for YAML serialization."""
    if hasattr(obj, "_asdict"):
        # Handle NamedTuple
        result = obj._asdict()
        # Recursively convert nested NamedTuples
        for key, value in result.items():
            if isinstance(value, list):
                result[key] = [convert_namedtuple_to_dict(item) for item in value]
            elif hasattr(value, "_asdict"):
                result[key] = convert_namedtuple_to_dict(value)
            elif isinstance(value, dict):
                # Handle nested dictionaries (like file_types)
                result[key] = {
                    k: convert_namedtuple_to_dict(v) for k, v in value.items()
                }
        return result
    elif isinstance(obj, list):
        return [convert_namedtuple_to_dict(item) for item in obj]
    elif isinstance(obj, dict):
        return {k: convert_namedtuple_to_dict(v) for k, v in obj.items()}
    else:
        return obj


@click.command()
@click.option(
    "--path",
    "-p",
    default=".",
    help="Path to the directory to analyze (defaults to current directory)",
)
@click.option(
    "--output-type",
    "-o",
    type=click.Choice(["summary", "details"]),
    default="summary",
    help="Type of output: summary (DirectorySummary) or details (DirectoryDetails)",
)
@click.option(
    "--output-file", "-f", help="Output file path (if not specified, prints to stdout)"
)
def categorize_directory(path: str, output_type: str, output_file: str):
    """
    Categorize directory contents and output in YAML format.

    This script analyzes a directory structure and provides either a summary
    (file counts by extension) or detailed analysis (file types with names and categories).
    """
    try:
        # Validate the path
        target_path = Path(path)
        if not target_path.exists():
            click.echo(f"Error: Path '{path}' does not exist.", err=True)
            sys.exit(1)

        if not target_path.is_dir():
            click.echo(f"Error: Path '{path}' is not a directory.", err=True)
            sys.exit(1)

        click.echo(f"Analyzing directory: {target_path}")

        # Generate the appropriate output based on type
        if output_type == "summary":
            result = directory_summary(str(target_path))
            # Convert Pydantic model to dict for YAML serialization
            output_data = result.model_dump()
        else:  # details
            file_lookup = FileExtensionLookup()
            result = directory_details(str(target_path), file_lookup)
            # Convert NamedTuple to dict for YAML serialization
            output_data = convert_namedtuple_to_dict(result)

        # Convert to YAML
        yaml_output = yaml.dump(
            output_data, default_flow_style=False, indent=2, sort_keys=False
        )

        # Output the result
        if output_file:
            with open(output_file, "w") as f:
                f.write(yaml_output)
            click.echo(f"Output written to: {output_file}")
        else:
            click.echo("Directory Analysis Results:")
            click.echo("=" * 50)
            click.echo(yaml_output)

    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)


if __name__ == "__main__":
    categorize_directory()
`````

## File: examples/cli_record_example.py
`````python
#!/usr/bin/env python
"""
Example script demonstrating the new record CLI commands.

This script shows how to use the record management commands programmatically.
"""

import subprocess
from pathlib import Path


def run_metagit_command(args: list) -> tuple[int, str, str]:
    """Run a metagit command and return the result."""
    try:
        result = subprocess.run(
            ["python", "-m", "metagit.cli.main"] + args,
            capture_output=True,
            text=True,
            cwd=Path.cwd(),
        )
        return result.returncode, result.stdout, result.stderr
    except Exception as e:
        return 1, "", str(e)


def create_sample_config():
    """Create a sample .metagit.yml file for testing."""
    config_content = """
name: example-project
description: An example project for testing record management
url: https://github.com/example/example-project
kind: application
documentation:
  - README.md
  - docs/
license:
  kind: MIT
  file: LICENSE
maintainers:
  - name: John Doe
    email: john@example.com
    role: Maintainer
branch_strategy: trunk
taskers:
  - kind: Taskfile
artifacts:
  - type: docker
    definition: Dockerfile
    location: docker.io/example/project
    version_strategy: semver
cicd:
  platform: GitHub
  pipelines:
    - name: CI
      ref: .github/workflows/ci.yml
deployment:
  strategy: rolling
  environments:
    - name: staging
      url: https://staging.example.com
    - name: production
      url: https://example.com
observability:
  logging_provider: console
  monitoring_providers:
    - prometheus
  alerting_channels:
    - name: slack
      type: slack
      url: https://hooks.slack.com/services/xxx
"""

    with open(".metagit.yml", "w") as f:
        f.write(config_content)

    print("Created sample .metagit.yml file")


def example_record_commands():
    """Demonstrate the record CLI commands."""
    print("Metagit Record CLI Examples")
    print("=" * 50)

    # Create sample config
    print("1. Creating sample configuration...")
    create_sample_config()

    # Test record create
    print("\n2. Creating a record from configuration...")
    returncode, stdout, stderr = run_metagit_command(
        [
            "record",
            "create",
            "--config-path",
            ".metagit.yml",
            "--detection-source",
            "cli-example",
            "--detection-version",
            "1.0.0",
            "--output-file",
            "example-record.yml",
        ]
    )

    if returncode == 0:
        print("✅ Record created successfully")
        print(f"Output: {stdout}")
    else:
        print(f"❌ Failed to create record: {stderr}")
        return

    # Test record show (list all)
    print("\n3. Listing all records...")
    returncode, stdout, stderr = run_metagit_command(["record", "show"])

    if returncode == 0:
        print("✅ Records listed successfully")
        print(f"Output: {stdout}")
    else:
        print(f"❌ Failed to list records: {stderr}")

    # Test record search
    print("\n4. Searching for records...")
    returncode, stdout, stderr = run_metagit_command(
        ["record", "search", "example", "--format", "table"]
    )

    if returncode == 0:
        print("✅ Search completed successfully")
        print(f"Output: {stdout}")
    else:
        print(f"❌ Failed to search records: {stderr}")

    # Test record stats
    print("\n5. Getting record statistics...")
    returncode, stdout, stderr = run_metagit_command(["record", "stats"])

    if returncode == 0:
        print("✅ Statistics retrieved successfully")
        print(f"Output: {stdout}")
    else:
        print(f"❌ Failed to get statistics: {stderr}")

    # Test record export
    print("\n6. Exporting a record...")
    returncode, stdout, stderr = run_metagit_command(
        ["record", "export", "1", "exported-record.yml", "--format", "yaml"]
    )

    if returncode == 0:
        print("✅ Record exported successfully")
        print(f"Output: {stdout}")
    else:
        print(f"❌ Failed to export record: {stderr}")

    # Test record import
    print("\n7. Importing a record...")
    returncode, stdout, stderr = run_metagit_command(
        [
            "record",
            "import",
            "exported-record.yml",
            "--detection-source",
            "imported",
            "--detection-version",
            "2.0.0",
        ]
    )

    if returncode == 0:
        print("✅ Record imported successfully")
        print(f"Output: {stdout}")
    else:
        print(f"❌ Failed to import record: {stderr}")

    # Test OpenSearch backend (if available)
    print("\n8. Testing OpenSearch backend...")
    returncode, stdout, stderr = run_metagit_command(
        [
            "record",
            "--storage-type",
            "opensearch",
            "--opensearch-hosts",
            "localhost:9200",
            "--opensearch-index",
            "metagit-records-test",
            "show",
        ]
    )

    if returncode == 0:
        print("✅ OpenSearch backend test successful")
        print(f"Output: {stdout}")
    else:
        print(
            f"⚠️  OpenSearch backend test failed (expected if OpenSearch not running): {stderr}"
        )

    print("\n🎉 Record CLI examples completed!")


def cleanup():
    """Clean up test files."""
    files_to_remove = [
        ".metagit.yml",
        "example-record.yml",
        "exported-record.yml",
    ]

    for file_path in files_to_remove:
        if Path(file_path).exists():
            Path(file_path).unlink()
            print(f"Cleaned up: {file_path}")


if __name__ == "__main__":
    try:
        example_record_commands()
    finally:
        cleanup()
`````

## File: examples/datetime_serialization_fix_example.py
`````python
#!/usr/bin/env python
"""
Example demonstrating the datetime serialization fix for metagit records.

This example shows how the DateTimeEncoder handles datetime objects when
serializing MetagitRecord objects to JSON, fixing the "Object of type datetime
is not JSON serializable" error.
"""

import asyncio
import json
from datetime import datetime
from pathlib import Path

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.record.manager import LocalFileStorageBackend, MetagitRecordManager
from metagit.core.utils.logging import LoggerConfig, UnifiedLogger


def demonstrate_datetime_serialization_fix():
    """Demonstrate the datetime serialization fix."""
    print("🔧 Datetime Serialization Fix Demonstration")
    print("=" * 50)

    # Show the problem (without DateTimeEncoder)
    print("\n1. The Problem:")
    print(
        "   When trying to serialize datetime objects to JSON without a custom encoder:"
    )

    test_data = {
        "name": "test-project",
        "description": "A test project",
        "detection_timestamp": datetime.now(),
        "last_updated": datetime.now(),
    }

    try:
        # This would fail without DateTimeEncoder
        json.dumps(test_data)
        print("   ✓ JSON serialization works (with fix)")
    except TypeError as e:
        print(f"   ✗ JSON serialization fails: {e}")

    # Show the solution
    print("\n2. The Solution:")
    print("   Using DateTimeEncoder to handle datetime objects:")

    class DateTimeEncoder(json.JSONEncoder):
        """Custom JSON encoder that handles datetime objects."""

        def default(self, obj):
            if isinstance(obj, datetime):
                return obj.isoformat()
            return super().default(obj)

    try:
        json_str = json.dumps(test_data, cls=DateTimeEncoder, indent=2)
        print("   ✓ JSON serialization works with DateTimeEncoder")
        print(f"   Serialized output:\n{json_str}")
    except Exception as e:
        print(f"   ✗ JSON serialization still fails: {e}")

    return True


async def demonstrate_record_creation():
    """Demonstrate record creation with datetime handling."""
    print("\n3. Record Creation with Datetime Handling:")
    print("=" * 50)

    try:
        # Check if config file exists
        config_path = Path("metagit.config.yaml")
        if not config_path.exists():
            print("   ⚠️  metagit.config.yaml not found, creating a simple example...")

            # Create a simple config for demonstration
            from metagit.core.config.models import (
                MetagitConfig,
                ProjectKind,
                ProjectType,
            )

            config = MetagitConfig(
                name="example-project",
                description="An example project for datetime serialization testing",
                type=ProjectType.APPLICATION,
                kind=ProjectKind.WEB_APP,
                detection_timestamp=datetime.now(),
            )
        else:
            # Load existing config
            config_manager = MetagitConfigManager(config_path=config_path)
            config_result = config_manager.load_config()

            if isinstance(config_result, Exception):
                print(f"   ✗ Failed to load config: {config_result}")
                return False

            config = config_result

        # Create record manager with local storage
        storage_dir = Path("./example_records")
        backend = LocalFileStorageBackend(storage_dir)
        logger = UnifiedLogger(LoggerConfig(log_level="INFO", minimal_console=True))
        record_manager = MetagitRecordManager(
            storage_backend=backend,
            metagit_config_manager=(
                config_manager if "config_manager" in locals() else None
            ),
            logger=logger,
        )

        # Create record from config
        record = record_manager.create_record_from_config(
            config=config, detection_source="example", detection_version="1.0.0"
        )

        if isinstance(record, Exception):
            print(f"   ✗ Failed to create record: {record}")
            return False

        print("   ✓ Record created successfully")
        print(f"   - Name: {record.name}")
        print(f"   - Description: {record.description}")
        print(f"   - Detection timestamp: {record.detection_timestamp}")
        print(f"   - Detection source: {record.detection_source}")

        # Store the record (this is where datetime serialization happens)
        record_id = await record_manager.store_record(record)

        if isinstance(record_id, Exception):
            print(f"   ✗ Failed to store record: {record_id}")
            return False

        print(f"   ✓ Record stored successfully with ID: {record_id}")

        # Verify the stored record can be retrieved
        retrieved_record = await record_manager.get_record(record_id)

        if isinstance(retrieved_record, Exception):
            print(f"   ✗ Failed to retrieve record: {retrieved_record}")
            return False

        print("   ✓ Record retrieved successfully")
        print(f"   - Retrieved name: {retrieved_record.name}")
        print(f"   - Retrieved timestamp: {retrieved_record.detection_timestamp}")

        # Clean up
        await record_manager.delete_record(record_id)
        print("   ✓ Record cleaned up")

        return True

    except Exception as e:
        print(f"   ✗ Record creation failed: {e}")
        return False


def main():
    """Run the datetime serialization fix demonstration."""
    print("🚀 Metagit Datetime Serialization Fix Example")
    print("=" * 60)

    # Demonstrate the fix
    if not demonstrate_datetime_serialization_fix():
        print("\n❌ Datetime serialization demonstration failed")
        return

    # Demonstrate record creation
    try:
        success = asyncio.run(demonstrate_record_creation())
        if success:
            print("\n🎉 All demonstrations completed successfully!")
            print("\n✅ The datetime serialization fix is working correctly.")
            print("   You can now use 'metagit.cli.main record create' without errors.")
        else:
            print("\n❌ Record creation demonstration failed")
    except Exception as e:
        print(f"\n❌ Demonstration failed with error: {e}")


if __name__ == "__main__":
    main()
`````

## File: examples/detection_config_example.yml
`````yaml
# DetectionManager Configuration Example
# This file demonstrates how to configure which analysis methods are enabled

# Default configuration - all current methods enabled
default:
  branch_analysis_enabled: true
  ci_config_analysis_enabled: true
  directory_summary_enabled: true
  directory_details_enabled: true
  commit_analysis_enabled: false  # Future feature
  tag_analysis_enabled: false     # Future feature

# Minimal configuration - only essential methods
minimal:
  branch_analysis_enabled: true
  ci_config_analysis_enabled: true
  directory_summary_enabled: false
  directory_details_enabled: false
  commit_analysis_enabled: false
  tag_analysis_enabled: false

# Comprehensive configuration - all methods enabled
comprehensive:
  branch_analysis_enabled: true
  ci_config_analysis_enabled: true
  directory_summary_enabled: true
  directory_details_enabled: true
  commit_analysis_enabled: true
  tag_analysis_enabled: true

# CI/CD focused configuration - only CI/CD and branch analysis
cicd_focused:
  branch_analysis_enabled: true
  ci_config_analysis_enabled: true
  directory_summary_enabled: false
  directory_details_enabled: false
  commit_analysis_enabled: false
  tag_analysis_enabled: false

# Directory analysis focused configuration
directory_focused:
  branch_analysis_enabled: false
  ci_config_analysis_enabled: false
  directory_summary_enabled: true
  directory_details_enabled: true
  commit_analysis_enabled: false
  tag_analysis_enabled: false
`````

## File: examples/detection_manager_config_example.py
`````python
#!/usr/bin/env python3

"""
Example demonstrating the use of DetectionManagerConfig to control which analysis methods are enabled.
"""

import sys
from pathlib import Path

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.detect.manager import DetectionManager, DetectionManagerConfig


def example_basic_usage():
    """Demonstrate basic usage with default configuration."""
    print("=== Basic Usage (Default Configuration) ===")

    # Create a DetectionManager with default config (all enabled)
    manager = DetectionManager.from_path("./")
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    # Run all enabled analyses
    result = manager.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    # Print summary
    summary = manager.summary()
    if isinstance(summary, Exception):
        print(f"Error getting summary: {summary}")
    else:
        print(summary)
    print()


def example_custom_config():
    """Demonstrate usage with custom configuration."""
    print("=== Custom Configuration ===")

    # Create a custom configuration
    config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=True,
        directory_summary_enabled=False,  # Disable directory summary
        directory_details_enabled=False,  # Disable directory details
        commit_analysis_enabled=False,
        tag_analysis_enabled=False,
    )

    print(f"Custom config enabled methods: {', '.join(config.get_enabled_methods())}")

    # Create DetectionManager with custom config
    manager = DetectionManager.from_path("./", config=config)
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    # Run analyses
    result = manager.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    print("Analysis completed with custom configuration")
    print()


def example_preset_configs():
    """Demonstrate usage with preset configurations."""
    print("=== Preset Configurations ===")

    # Use minimal configuration
    minimal_config = DetectionManagerConfig.minimal()
    print(
        f"Minimal config enabled methods: {', '.join(minimal_config.get_enabled_methods())}"
    )

    # Use all enabled configuration
    all_enabled_config = DetectionManagerConfig.all_enabled()
    print(
        f"All enabled config methods: {', '.join(all_enabled_config.get_enabled_methods())}"
    )
    print()


def example_specific_method():
    """Demonstrate running a specific analysis method."""
    print("=== Running Specific Method ===")

    # Create a configuration with only branch analysis enabled
    config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=False,
        directory_summary_enabled=False,
        directory_details_enabled=False,
        commit_analysis_enabled=False,
        tag_analysis_enabled=False,
    )

    manager = DetectionManager.from_path("./", config=config)
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    # Run only branch analysis
    result = manager.run_specific("branch_analysis")
    if result is not None:
        print(f"Error running branch analysis: {result}")
        return

    print("Branch analysis completed successfully")
    if manager.branch_analysis:
        print(f"Detected strategy: {manager.branch_analysis.strategy_guess}")
    print()


def example_config_serialization():
    """Demonstrate configuration serialization."""
    print("=== Configuration Serialization ===")

    # Create a configuration
    config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=True,
        directory_summary_enabled=False,
        directory_details_enabled=True,
    )

    # Convert to dict
    config_dict = config.model_dump()
    print(f"Configuration as dict: {config_dict}")

    # Create from dict
    new_config = DetectionManagerConfig(**config_dict)
    print(
        f"Recreated config enabled methods: {', '.join(new_config.get_enabled_methods())}"
    )
    print()


def example_metagit_record_integration():
    """Demonstrate MetagitRecord integration."""
    print("=== MetagitRecord Integration ===")

    # Create DetectionManager (inherits from MetagitRecord)
    manager = DetectionManager.from_path("./")
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    # Run analysis
    result = manager.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    # Access MetagitRecord fields
    print(f"Project name: {manager.name}")
    print(f"Project path: {manager.path}")
    print(f"Detection timestamp: {manager.detection_timestamp}")
    print(f"Detection source: {manager.detection_source}")
    print(f"Detection version: {manager.detection_version}")

    # Access detection-specific fields
    print(
        f"Branch analysis enabled: {manager.detection_config.branch_analysis_enabled}"
    )
    print(f"Has branch analysis: {manager.branch_analysis is not None}")
    print(f"Has CI/CD analysis: {manager.ci_config_analysis is not None}")

    # Convert to YAML (includes both MetagitRecord and detection data)
    yaml_output = manager.to_yaml()
    if isinstance(yaml_output, Exception):
        print(f"Error converting to YAML: {yaml_output}")
    else:
        print("Successfully converted to YAML (includes all detection data)")
    print()


if __name__ == "__main__":
    print("DetectionManagerConfig Examples\n")

    try:
        example_basic_usage()
        example_custom_config()
        example_preset_configs()
        example_specific_method()
        example_config_serialization()
        example_metagit_record_integration()

        print("All examples completed successfully!")

    except Exception as e:
        print(f"Error running examples: {e}")
        sys.exit(1)
`````

## File: examples/detection_manager_example.py
`````python
#!/usr/bin/env python3

"""
Example demonstrating the updated DetectionManager that uses RepositoryAnalysis for all detection details.

This example shows how DetectionManager now uses RepositoryAnalysis as the single source for all
detection analysis results while maintaining the MetagitRecord interface.
"""

import sys
from pathlib import Path

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.detect.manager import DetectionManager, DetectionManagerConfig


def example_local_repository_analysis():
    """Demonstrate analyzing a local repository."""
    print("=== Local Repository Analysis ===")

    # Create DetectionManager for local path
    manager = DetectionManager.from_path("./")
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    print(f"Created DetectionManager for: {manager.path}")
    print(f"Project name: {manager.name}")
    print(f"Detection source: {manager.detection_source}")

    # Run all analyses
    result = manager.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    print("Analysis completed successfully!")
    print(f"Detection timestamp: {manager.detection_timestamp}")
    print()


def example_remote_repository_analysis():
    """Demonstrate analyzing a remote repository."""
    print("=== Remote Repository Analysis ===")

    # Example repository URL
    repo_url = "https://github.com/octocat/Hello-World.git"

    # Create DetectionManager for remote URL
    manager = DetectionManager.from_url(repo_url)
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    print(f"Created DetectionManager for: {manager.url}")
    print(f"Project name: {manager.name}")
    print(f"Detection source: {manager.detection_source}")

    # Run all analyses
    result = manager.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    print("Analysis completed successfully!")
    print(f"Detection timestamp: {manager.detection_timestamp}")

    # Clean up cloned repository
    manager.cleanup()
    print()


def example_configuration_options():
    """Demonstrate different configuration options."""
    print("=== Configuration Options ===")

    # Minimal configuration
    print("Minimal configuration:")
    config = DetectionManagerConfig.minimal()
    manager = DetectionManager.from_path("./", config=config)
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    print(f"Enabled methods: {', '.join(config.get_enabled_methods())}")
    print()

    # All enabled configuration
    print("All enabled configuration:")
    config = DetectionManagerConfig.all_enabled()
    manager = DetectionManager.from_path("./", config=config)
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    print(f"Enabled methods: {', '.join(config.get_enabled_methods())}")
    print()

    # Custom configuration
    print("Custom configuration:")
    config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=True,
        directory_summary_enabled=False,
        directory_details_enabled=False,
        commit_analysis_enabled=False,
        tag_analysis_enabled=False,
    )
    manager = DetectionManager.from_path("./", config=config)
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    print(f"Enabled methods: {', '.join(config.get_enabled_methods())}")
    print()


def example_metagit_record_integration():
    """Demonstrate MetagitRecord integration."""
    print("=== MetagitRecord Integration ===")

    # Create DetectionManager
    manager = DetectionManager.from_path("./")
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    # Run analysis
    result = manager.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    # Access MetagitRecord fields
    print("MetagitRecord fields:")
    print(f"  Name: {manager.name}")
    print(f"  Description: {manager.description}")
    print(f"  Kind: {manager.kind}")
    print(f"  Domain: {manager.domain}")
    print(f"  Language: {manager.language}")
    print(f"  Language Version: {manager.language_version}")
    print(f"  Branch Strategy: {manager.branch_strategy}")
    print(f"  URL: {manager.url}")
    print(f"  Path: {manager.path}")

    # Access detection-specific fields
    print("\nDetection-specific fields:")
    print(f"  Detection Timestamp: {manager.detection_timestamp}")
    print(f"  Detection Source: {manager.detection_source}")
    print(f"  Detection Version: {manager.detection_version}")
    print(f"  Analysis Completed: {manager.analysis_completed}")

    # Access RepositoryAnalysis results
    if manager.repository_analysis:
        print("\nRepositoryAnalysis results:")
        print(
            f"  Has Branch Analysis: {manager.repository_analysis.branch_analysis is not None}"
        )
        print(
            f"  Has CI/CD Analysis: {manager.repository_analysis.ci_config_analysis is not None}"
        )
        print(
            f"  Has Directory Summary: {manager.repository_analysis.directory_summary is not None}"
        )
        print(
            f"  Has Directory Details: {manager.repository_analysis.directory_details is not None}"
        )

        # Access specific analysis results
        if manager.repository_analysis.branch_analysis:
            print(
                f"  Branch Strategy Guess: {manager.repository_analysis.branch_analysis.strategy_guess}"
            )
            print(
                f"  Number of Branches: {len(manager.repository_analysis.branch_analysis.branches)}"
            )

        if manager.repository_analysis.ci_config_analysis:
            print(
                f"  CI/CD Tool: {manager.repository_analysis.ci_config_analysis.detected_tool}"
            )

        if manager.repository_analysis.directory_summary:
            print(
                f"  Total Files: {manager.repository_analysis.directory_summary.num_files}"
            )
            print(
                f"  File Types: {len(manager.repository_analysis.directory_summary.file_types)}"
            )

    print()


def example_output_formats():
    """Demonstrate different output formats."""
    print("=== Output Formats ===")

    # Create DetectionManager
    manager = DetectionManager.from_path("./")
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    # Run analysis
    result = manager.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    # Summary output
    summary = manager.summary()
    if isinstance(summary, Exception):
        print(f"Error getting summary: {summary}")
    else:
        print("Summary output:")
        print(summary[:200] + "..." if len(summary) > 200 else summary)
        print()

    # YAML output (includes all detection data)
    yaml_output = manager.to_yaml()
    if isinstance(yaml_output, Exception):
        print(f"Error converting to YAML: {yaml_output}")
    else:
        print("YAML output (first 200 chars):")
        print(yaml_output[:200] + "..." if len(yaml_output) > 200 else yaml_output)
        print()

    # JSON output (includes all detection data)
    json_output = manager.to_json()
    if isinstance(json_output, Exception):
        print(f"Error converting to JSON: {json_output}")
    else:
        print("JSON output (first 200 chars):")
        print(json_output[:200] + "..." if len(json_output) > 200 else json_output)
        print()


def example_specific_analysis_methods():
    """Demonstrate running specific analysis methods."""
    print("=== Specific Analysis Methods ===")

    # Create DetectionManager with minimal config
    config = DetectionManagerConfig.minimal()
    manager = DetectionManager.from_path("./", config=config)
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    # Run specific methods
    methods = ["branch_analysis", "ci_config_analysis"]

    for method in methods:
        print(f"Running {method}...")
        result = manager.run_specific(method)
        if result is not None:
            print(f"Error running {method}: {result}")
        else:
            print(f"✅ {method} completed successfully")

    # Test disabled method
    print("Testing disabled method...")
    result = manager.run_specific("directory_summary")
    if isinstance(result, Exception):
        print(f"✅ Correctly rejected disabled method: {result}")
    else:
        print("❌ Should have rejected disabled method")

    print()


def example_repository_analysis_access():
    """Demonstrate direct access to RepositoryAnalysis."""
    print("=== RepositoryAnalysis Access ===")

    # Create DetectionManager
    manager = DetectionManager.from_path("./")
    if isinstance(manager, Exception):
        print(f"Error creating DetectionManager: {manager}")
        return

    # Run analysis
    result = manager.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    # Access RepositoryAnalysis directly
    if manager.repository_analysis:
        repo_analysis = manager.repository_analysis

        print("Direct RepositoryAnalysis access:")
        print(f"  Path: {repo_analysis.path}")
        print(f"  Name: {repo_analysis.name}")
        print(f"  Is Git Repo: {repo_analysis.is_git_repo}")
        print(f"  Is Cloned: {repo_analysis.is_cloned}")

        # Access language detection
        if repo_analysis.language_detection:
            print(f"  Primary Language: {repo_analysis.language_detection.primary}")
            if repo_analysis.language_detection.secondary:
                print(
                    f"  Secondary Languages: {', '.join(repo_analysis.language_detection.secondary)}"
                )

        # Access project type detection
        if repo_analysis.project_type_detection:
            print(f"  Project Type: {repo_analysis.project_type_detection.type}")
            print(f"  Domain: {repo_analysis.project_type_detection.domain}")
            print(f"  Confidence: {repo_analysis.project_type_detection.confidence}")

        # Access file analysis
        print(f"  Has Docker: {repo_analysis.has_docker}")
        print(f"  Has Tests: {repo_analysis.has_tests}")
        print(f"  Has Docs: {repo_analysis.has_docs}")
        print(f"  Has IaC: {repo_analysis.has_iac}")

        # Access metrics
        if repo_analysis.metrics:
            print(f"  Contributors: {repo_analysis.metrics.contributors}")
            print(f"  Commit Frequency: {repo_analysis.metrics.commit_frequency}")

    print()


def main():
    """Run all examples."""
    print("DetectionManager Examples")
    print("=" * 60)
    print()

    # Run examples
    example_local_repository_analysis()
    example_configuration_options()
    example_metagit_record_integration()
    example_output_formats()
    example_specific_analysis_methods()
    example_repository_analysis_access()

    # Note: Remote analysis is commented out to avoid cloning during examples
    # Uncomment the line below to test remote repository analysis
    # example_remote_repository_analysis()

    print("All examples completed!")


if __name__ == "__main__":
    main()
`````

## File: examples/fuzzyfinder_comprehensive_test.py
`````python
#!/usr/bin/env python

"""
Comprehensive test script demonstrating all FuzzyFinder features.
This script shows various configurations and use cases for the FuzzyFinder.
"""

import sys
from pathlib import Path
from typing import List, Optional

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.utils.fuzzyfinder import FuzzyFinder, FuzzyFinderConfig


class FileItem:
    """Example object representing a file with multiple attributes."""

    def __init__(
        self,
        name: str,
        path: str,
        size: int,
        type: str,
        description: str,
        tags: List[str],
    ):
        self.name = name
        self.path = path
        self.size = size
        self.type = type
        self.description = description
        self.tags = tags

    def __str__(self) -> str:
        return f"{self.name} ({self.type})"

    def get_preview_text(self) -> str:
        """Get formatted preview text for this file."""
        return f"""File: {self.name}
Path: {self.path}
Type: {self.type}
Size: {self.size:,} bytes
Tags: {', '.join(self.tags)}
Description: {self.description}"""


def create_sample_files() -> List[FileItem]:
    """Create a list of sample files for testing."""
    return [
        FileItem(
            name="main.py",
            path="src/main.py",
            size=2048,
            type="Python",
            description="Main application entry point with CLI interface and core functionality",
            tags=["python", "cli", "main"],
        ),
        FileItem(
            name="config.yaml",
            path="config/config.yaml",
            size=512,
            type="YAML",
            description="Application configuration file with database settings and API keys",
            tags=["config", "yaml", "settings"],
        ),
        FileItem(
            name="requirements.txt",
            path="requirements.txt",
            size=256,
            type="Text",
            description="Python dependencies list with version constraints",
            tags=["dependencies", "python", "requirements"],
        ),
        FileItem(
            name="README.md",
            path="README.md",
            size=1024,
            type="Markdown",
            description="Project documentation with installation and usage instructions",
            tags=["documentation", "markdown", "readme"],
        ),
        FileItem(
            name="docker-compose.yml",
            path="docker-compose.yml",
            size=768,
            type="YAML",
            description="Docker Compose configuration for local development environment",
            tags=["docker", "yaml", "devops"],
        ),
        FileItem(
            name="test_main.py",
            path="tests/test_main.py",
            size=1536,
            type="Python",
            description="Unit tests for main application functionality",
            tags=["python", "tests", "unit"],
        ),
        FileItem(
            name="Dockerfile",
            path="Dockerfile",
            size=640,
            type="Dockerfile",
            description="Docker image configuration for containerized deployment",
            tags=["docker", "container", "deployment"],
        ),
        FileItem(
            name="setup.py",
            path="setup.py",
            size=384,
            type="Python",
            description="Package setup script for distribution and installation",
            tags=["python", "setup", "distribution"],
        ),
        FileItem(
            name=".env.example",
            path=".env.example",
            size=128,
            type="Environment",
            description="Example environment variables template for configuration",
            tags=["config", "environment", "template"],
        ),
        FileItem(
            name="api.py",
            path="src/api.py",
            size=1792,
            type="Python",
            description="REST API implementation with FastAPI framework",
            tags=["python", "api", "fastapi", "rest"],
        ),
        FileItem(
            name="database.py",
            path="src/database.py",
            size=1280,
            type="Python",
            description="Database connection and ORM models using SQLAlchemy",
            tags=["python", "database", "sqlalchemy", "orm"],
        ),
        FileItem(
            name="utils.py",
            path="src/utils.py",
            size=896,
            type="Python",
            description="Utility functions for common operations and helpers",
            tags=["python", "utils", "helpers"],
        ),
        FileItem(
            name="models.py",
            path="src/models.py",
            size=1024,
            type="Python",
            description="Data models and Pydantic schemas for API validation",
            tags=["python", "models", "pydantic", "validation"],
        ),
        FileItem(
            name="middleware.py",
            path="src/middleware.py",
            size=768,
            type="Python",
            description="Custom middleware for authentication and logging",
            tags=["python", "middleware", "auth", "logging"],
        ),
        FileItem(
            name="cli.py",
            path="src/cli.py",
            size=1152,
            type="Python",
            description="Command-line interface implementation using Click",
            tags=["python", "cli", "click"],
        ),
    ]


def run_fuzzyfinder_test(
    title: str, config: FuzzyFinderConfig, description: str
) -> Optional[FileItem]:
    """Run a specific FuzzyFinder test configuration."""
    print(f"\n{title}")
    print("=" * len(title))
    print(description)
    print()

    try:
        finder = FuzzyFinder(config)
        result = finder.run()

        if isinstance(result, Exception):
            print(f"Error occurred: {result}")
            return None

        return result
    except KeyboardInterrupt:
        print("\nExited by user.")
        return None
    except Exception as e:
        print(f"Unexpected error: {e}")
        return None


def main():
    """Main function to demonstrate various FuzzyFinder configurations."""
    print("Comprehensive FuzzyFinder Test Suite")
    print("=" * 50)
    print("This demo shows various configurations and features of FuzzyFinder.")
    print("Each test will show a different aspect of the functionality.")
    print()

    # Create sample data
    files = create_sample_files()

    # Test 1: Basic functionality with preview
    print("Test 1: Basic functionality with preview enabled")
    config1 = FuzzyFinderConfig(
        items=files,
        display_field="name",
        preview_field="description",
        enable_preview=True,
        prompt_text="Search files: ",
        max_results=6,
        score_threshold=60.0,
        scorer="partial_ratio",
        case_sensitive=False,
        highlight_color="bold white bg:#0066cc",
        normal_color="white",
        prompt_color="bold green",
        separator_color="gray",
    )

    result1 = run_fuzzyfinder_test(
        "Test 1: Basic with Preview",
        config1,
        "Search through files with preview pane showing descriptions. Try typing 'py' or 'test'.",
    )

    if result1:
        print(f"\nSelected: {result1.name}")
        print(f"Description: {result1.description}")

    # Test 2: Different scorer
    print("\nTest 2: Using token_sort_ratio scorer")
    config2 = FuzzyFinderConfig(
        items=files,
        display_field="name",
        preview_field="description",
        enable_preview=True,
        prompt_text="Search (token_sort): ",
        max_results=6,
        score_threshold=50.0,
        scorer="token_sort_ratio",
        case_sensitive=False,
        highlight_color="bold white bg:#cc6600",
        normal_color="white",
        prompt_color="bold orange",
        separator_color="gray",
    )

    result2 = run_fuzzyfinder_test(
        "Test 2: Token Sort Ratio Scorer",
        config2,
        "This scorer is better for word order variations. Try typing 'main test' or 'api python'.",
    )

    if result2:
        print(f"\nSelected: {result2.name}")
        print(f"Description: {result2.description}")

    # Test 3: Case sensitive search
    print("\nTest 3: Case sensitive search")
    config3 = FuzzyFinderConfig(
        items=files,
        display_field="name",
        preview_field="description",
        enable_preview=True,
        prompt_text="Search (case-sensitive): ",
        max_results=6,
        score_threshold=70.0,
        scorer="partial_ratio",
        case_sensitive=True,
        highlight_color="bold white bg:#6600cc",
        normal_color="white",
        prompt_color="bold purple",
        separator_color="gray",
    )

    result3 = run_fuzzyfinder_test(
        "Test 3: Case Sensitive Search",
        config3,
        "Case sensitive matching. Try 'PY' vs 'py' to see the difference.",
    )

    if result3:
        print(f"\nSelected: {result3.name}")
        print(f"Description: {result3.description}")

    # Test 4: Higher threshold
    print("\nTest 4: Higher score threshold")
    config4 = FuzzyFinderConfig(
        items=files,
        display_field="name",
        preview_field="description",
        enable_preview=True,
        prompt_text="Search (high threshold): ",
        max_results=6,
        score_threshold=85.0,
        scorer="partial_ratio",
        case_sensitive=False,
        highlight_color="bold white bg:#cc0066",
        normal_color="white",
        prompt_color="bold pink",
        separator_color="gray",
    )

    result4 = run_fuzzyfinder_test(
        "Test 4: High Score Threshold",
        config4,
        "Higher threshold means more exact matches required. Try partial matches.",
    )

    if result4:
        print(f"\nSelected: {result4.name}")
        print(f"Description: {result4.description}")

    print("\nAll tests completed!")
    print("You can run individual tests by modifying the script.")


if __name__ == "__main__":
    main()
`````

## File: examples/fuzzyfinder_debug_test.py
`````python
#!/usr/bin/env python

"""
Debug test script for FuzzyFinder to verify fixes.
This script tests basic functionality with minimal configuration.
"""

import sys
from pathlib import Path

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.utils.fuzzyfinder import FuzzyFinder, FuzzyFinderConfig


def main():
    """Simple test to verify FuzzyFinder works."""
    print("FuzzyFinder Debug Test")
    print("=" * 30)
    print("Testing basic functionality...")
    print()

    # Simple string list
    items = ["python", "javascript", "typescript", "golang", "rust"]

    # Basic configuration
    config = FuzzyFinderConfig(
        items=items,
        prompt_text="Search: ",
        max_results=5,
        score_threshold=50.0,
        enable_preview=False,  # Disable preview for basic test
    )

    print("Starting FuzzyFinder...")
    print("Type to search, use arrow keys to navigate, Enter to select, Ctrl+C to exit")
    print()

    try:
        finder = FuzzyFinder(config)
        result = finder.run()

        if isinstance(result, Exception):
            print(f"Error occurred: {result}")
            return

        if result is None:
            print("No selection made.")
        else:
            print(f"Selected: {result}")

    except KeyboardInterrupt:
        print("\nExited by user.")
    except Exception as e:
        print(f"Unexpected error: {e}")
        import traceback

        traceback.print_exc()


if __name__ == "__main__":
    main()
`````

## File: examples/fuzzyfinder_preview_test.py
`````python
#!/usr/bin/env python

"""
Test script demonstrating FuzzyFinder with object list and preview functionality.
This script shows how to use the FuzzyFinder with a list of objects that have
multiple fields, including a preview field that displays additional information
when an item is selected.
"""

import sys
import traceback
from pathlib import Path
from typing import List

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.utils.fuzzyfinder import FuzzyFinder, FuzzyFinderConfig


class ProjectFile:
    """Example object representing a project file with multiple attributes."""

    def __init__(self, name: str, path: str, size: int, type: str, description: str):
        self.name = name
        self.path = path
        self.size = size
        self.type = type
        self.description = description

    def __str__(self) -> str:
        return f"{self.name} ({self.type})"


def create_sample_project_files() -> List[ProjectFile]:
    """Create a list of sample project files for testing."""
    return [
        ProjectFile(
            name="main.py",
            path="src/main.py",
            size=2048,
            type="Python",
            description="Main application entry point with CLI interface and core functionality",
        ),
        ProjectFile(
            name="config.yaml",
            path="config/config.yaml",
            size=512,
            type="YAML",
            description="Application configuration file with database settings and API keys",
        ),
        ProjectFile(
            name="requirements.txt",
            path="requirements.txt",
            size=256,
            type="Text",
            description="Python dependencies list with version constraints",
        ),
        ProjectFile(
            name="README.md",
            path="README.md",
            size=1024,
            type="Markdown",
            description="Project documentation with installation and usage instructions",
        ),
        ProjectFile(
            name="docker-compose.yml",
            path="docker-compose.yml",
            size=768,
            type="YAML",
            description="Docker Compose configuration for local development environment",
        ),
        ProjectFile(
            name="test_main.py",
            path="tests/test_main.py",
            size=1536,
            type="Python",
            description="Unit tests for main application functionality",
        ),
        ProjectFile(
            name="Dockerfile",
            path="Dockerfile",
            size=640,
            type="Dockerfile",
            description="Docker image configuration for containerized deployment",
        ),
        ProjectFile(
            name="setup.py",
            path="setup.py",
            size=384,
            type="Python",
            description="Package setup script for distribution and installation",
        ),
        ProjectFile(
            name=".env.example",
            path=".env.example",
            size=128,
            type="Environment",
            description="Example environment variables template for configuration",
        ),
        ProjectFile(
            name="api.py",
            path="src/api.py",
            size=1792,
            type="Python",
            description="REST API implementation with FastAPI framework",
        ),
        ProjectFile(
            name="database.py",
            path="src/database.py",
            size=1280,
            type="Python",
            description="Database connection and ORM models using SQLAlchemy",
        ),
        ProjectFile(
            name="utils.py",
            path="src/utils.py",
            size=896,
            type="Python",
            description="Utility functions for common operations and helpers",
        ),
        ProjectFile(
            name="models.py",
            path="src/models.py",
            size=1024,
            type="Python",
            description="Data models and Pydantic schemas for API validation",
        ),
        ProjectFile(
            name="middleware.py",
            path="src/middleware.py",
            size=768,
            type="Python",
            description="Custom middleware for authentication and logging",
        ),
        ProjectFile(
            name="cli.py",
            path="src/cli.py",
            size=1152,
            type="Python",
            description="Command-line interface implementation using Click",
        ),
    ]


def format_preview_text(file_obj: ProjectFile) -> str:
    """Format the preview text for a project file."""
    return f"""
File: {file_obj.name}
Path: {file_obj.path}
Type: {file_obj.type}
Size: {file_obj.size} bytes
Description: {file_obj.description}
""".strip()


def main():
    """Main function to demonstrate FuzzyFinder with preview functionality."""
    print("FuzzyFinder Preview Test")
    print("=" * 50)
    print("This demo shows FuzzyFinder with a list of project files.")
    print("Use arrow keys to navigate, type to search, and Enter to select.")
    print("The preview pane shows detailed information about the selected file.")
    print()

    # Create sample data
    project_files = create_sample_project_files()

    # Configure FuzzyFinder with preview enabled
    config = FuzzyFinderConfig(
        items=project_files,
        display_field="name",  # Use the 'name' field for display/search
        preview_field="description",  # Use 'description' for preview
        enable_preview=True,
        prompt_text="Search files: ",
        max_results=8,
        score_threshold=60.0,
        scorer="partial_ratio",
        case_sensitive=False,
        # Custom styling
        highlight_color="bold white bg:#0066cc",
        normal_color="white",
        prompt_color="bold green",
        separator_color="gray",
    )

    # Create and run the fuzzy finder
    finder = FuzzyFinder(config)

    print("Starting FuzzyFinder...")
    print("(Press Ctrl+C to exit)")
    print()

    try:
        result = finder.run()

        if isinstance(result, Exception):
            print(f"Error occurred: {result}")
            return

        if result is None:
            print("No selection made.")
        else:
            selected_file = result
            print(f"\nSelected: {selected_file.name}")
            print(f"Path: {selected_file.path}")
            print(f"Type: {selected_file.type}")
            print(f"Size: {selected_file.size} bytes")
            print(f"Description: {selected_file.description}")

    except KeyboardInterrupt:
        print("\nExited by user.")
    except Exception as e:
        print(f"Unexpected error: {e}")


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print("[TOP-LEVEL EXCEPTION]", e)
        print(traceback.format_exc())
`````

## File: examples/fuzzyfinder_simple_test.py
`````python
#!/usr/bin/env python

"""
Simple test script demonstrating basic FuzzyFinder functionality.
This script shows how to use the FuzzyFinder with a simple list of strings.
"""

import sys
from pathlib import Path
from typing import List

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.utils.fuzzyfinder import FuzzyFinder, FuzzyFinderConfig


def create_sample_strings() -> List[str]:
    """Create a list of sample strings for testing."""
    return [
        "python",
        "javascript",
        "typescript",
        "golang",
        "rust",
        "c++",
        "java",
        "kotlin",
        "swift",
        "dart",
        "flutter",
        "react",
        "vue",
        "angular",
        "node.js",
        "express",
        "fastapi",
        "django",
        "flask",
        "spring",
        "laravel",
        "rails",
        "asp.net",
        "php",
        "ruby",
        "scala",
        "clojure",
        "haskell",
        "erlang",
        "elixir",
        "metagit",
        "metagit_cli",
        "metagit_api",
        "metagit_web",
    ]


def main():
    """Main function to demonstrate basic FuzzyFinder functionality."""
    print("Simple FuzzyFinder Test")
    print("=" * 30)
    print("This demo shows FuzzyFinder with a simple list of programming languages.")
    print("Use arrow keys to navigate, type to search, and Enter to select.")
    print()

    # Create sample data
    languages = create_sample_strings()

    # Configure FuzzyFinder
    config = FuzzyFinderConfig(
        items=languages,
        prompt_text="Search languages: ",
        max_results=10,
        score_threshold=50.0,
        scorer="partial_ratio",
        case_sensitive=False,
        # Custom styling
        highlight_color="bold white bg:#00aa00",
        normal_color="white",
        prompt_color="bold blue",
        separator_color="gray",
    )

    # Create and run the fuzzy finder
    finder = FuzzyFinder(config)

    print("Starting FuzzyFinder...")
    print("(Press Ctrl+C to exit)")
    print()

    try:
        result = finder.run()

        if isinstance(result, Exception):
            print(f"Error occurred: {result}")
            return

        if result is None:
            print("No selection made.")
        else:
            print(f"\nSelected language: {result}")

    except KeyboardInterrupt:
        print("\nExited by user.")
    except Exception as e:
        print(f"Unexpected error: {e}")


if __name__ == "__main__":
    main()
`````

## File: examples/gitcache_differences_example.py
`````python
#!/usr/bin/env python
"""
Example demonstrating git cache differences functionality.

This example shows how the GitCacheManager now checks for differences
between local and remote repositories before pulling updates, and
includes detailed difference information in cache entries.
"""

import tempfile
from pathlib import Path

from metagit.core.gitcache import GitCacheConfig, GitCacheManager


def create_test_git_repo(path: Path) -> None:
    """Create a test git repository with some commits."""
    import os
    import subprocess

    # Initialize git repository
    subprocess.run(["git", "init"], cwd=path, check=True)

    # Create initial file
    (path / "README.md").write_text("# Test Repository\n\nThis is a test repository.")
    subprocess.run(["git", "add", "README.md"], cwd=path, check=True)
    subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=path, check=True)

    # Create another file
    (path / "main.py").write_text("print('Hello, World!')")
    subprocess.run(["git", "add", "main.py"], cwd=path, check=True)
    subprocess.run(["git", "commit", "-m", "Add main.py"], cwd=path, check=True)


def main():
    """Demonstrate git cache differences functionality."""
    print("=== Git Cache Differences Example ===\n")

    # Create temporary directories
    with tempfile.TemporaryDirectory() as temp_dir:
        temp_path = Path(temp_dir)
        cache_root = temp_path / "cache"

        # Create configuration
        config = GitCacheConfig(
            cache_root=cache_root, default_timeout_minutes=30, max_cache_size_gb=1.0
        )

        # Create manager
        manager = GitCacheManager(config)

        # Create a test git repository
        test_repo_path = temp_path / "test_repo"
        test_repo_path.mkdir()
        create_test_git_repo(test_repo_path)

        print("1. Caching a local git repository...")
        entry = manager.cache_repository(str(test_repo_path), name="test-repo")

        if isinstance(entry, Exception):
            print(f"   Error: {entry}")
            return
        else:
            print(f"   Successfully cached: {entry.name}")
            print(f"   Cache path: {entry.cache_path}")
            print(
                f"   Local commit: {entry.local_commit_hash[:8] if entry.local_commit_hash else 'None'}"
            )
            print(f"   Local branch: {entry.local_branch}")
            print(f"   Has upstream changes: {entry.has_upstream_changes}")

        print("\n2. Getting detailed cache entry information...")
        details = manager.get_cache_entry_details("test-repo")

        if isinstance(details, Exception):
            print(f"   Error: {details}")
        else:
            print("   Cache Entry Details:")
            for key, value in details.items():
                if key not in ["metadata"]:  # Skip metadata for cleaner output
                    print(f"     {key}: {value}")

        print("\n3. Simulating upstream changes...")
        # Add a new commit to the original repository
        (test_repo_path / "new_file.txt").write_text(
            "This is a new file added after caching."
        )
        import subprocess

        subprocess.run(["git", "add", "new_file.txt"], cwd=test_repo_path, check=True)
        subprocess.run(
            ["git", "commit", "-m", "Add new file after caching"],
            cwd=test_repo_path,
            check=True,
        )

        print("   Added new commit to original repository")

        print("\n4. Re-caching to check for differences...")
        entry = manager.cache_repository(str(test_repo_path), name="test-repo")

        if isinstance(entry, Exception):
            print(f"   Error: {entry}")
        else:
            print(f"   Successfully updated: {entry.name}")
            print(
                f"   Local commit: {entry.local_commit_hash[:8] if entry.local_commit_hash else 'None'}"
            )
            print(
                f"   Remote commit: {entry.remote_commit_hash[:8] if entry.remote_commit_hash else 'None'}"
            )
            print(f"   Has upstream changes: {entry.has_upstream_changes}")
            print(f"   Changes summary: {entry.upstream_changes_summary}")

        print("\n5. Getting updated cache entry details...")
        details = manager.get_cache_entry_details("test-repo")

        if isinstance(details, Exception):
            print(f"   Error: {details}")
        else:
            print("   Updated Cache Entry Details:")
            for key, value in details.items():
                if key not in ["metadata"]:  # Skip metadata for cleaner output
                    print(f"     {key}: {value}")

        print("\n6. Listing all cache entries...")
        entries = manager.list_cache_entries()
        for entry in entries:
            print(f"   - {entry.name}: {entry.cache_type} ({entry.status})")
            if entry.cache_type == "git":
                print(
                    f"     Local: {entry.local_commit_hash[:8] if entry.local_commit_hash else 'None'}"
                )
                print(
                    f"     Remote: {entry.remote_commit_hash[:8] if entry.remote_commit_hash else 'None'}"
                )
                print(f"     Changes: {entry.has_upstream_changes}")

        print("\n7. Cache statistics...")
        stats = manager.get_cache_stats()
        print(f"   Total entries: {stats['total_entries']}")
        print(f"   Git entries: {stats['git_entries']}")
        print(f"   Local entries: {stats['local_entries']}")
        print(f"   Total size: {stats['total_size_gb']:.2f} GB")


if __name__ == "__main__":
    main()
`````

## File: examples/gitcache_example.py
`````python
#!/usr/bin/env python
"""
Example script demonstrating the git cache management system.

This script shows how to use the GitCacheManager to cache both
git repositories and local directories with both sync and async operations.
"""

import asyncio
import tempfile
from pathlib import Path

from metagit.core.gitcache import GitCacheConfig, GitCacheManager


def create_sample_local_directory() -> Path:
    """Create a sample local directory for testing."""
    temp_dir = Path(tempfile.mkdtemp())

    # Create some sample files
    (temp_dir / "README.md").write_text(
        "# Sample Project\n\nThis is a sample project for testing."
    )
    (temp_dir / "main.py").write_text('print("Hello, World!")')
    (temp_dir / "config.json").write_text('{"name": "sample", "version": "1.0.0"}')

    # Create a subdirectory
    subdir = temp_dir / "src"
    subdir.mkdir()
    (subdir / "utils.py").write_text('def helper_function():\n    return "helper"')

    return temp_dir


def sync_example():
    """Demonstrate synchronous git cache operations."""
    print("=== Synchronous Git Cache Example ===\n")

    # Create configuration
    config = GitCacheConfig(
        cache_root=Path("./.metagit/.cache"),
        default_timeout_minutes=30,
        max_cache_size_gb=5.0,
    )

    # Create manager
    manager = GitCacheManager(config)

    # Example 1: Cache a git repository
    print("1. Caching a git repository...")
    try:
        entry = manager.cache_repository("https://github.com/octocat/Hello-World.git")
        if isinstance(entry, Exception):
            print(f"   Error: {entry}")
        else:
            print(f"   Successfully cached: {entry.name}")
            print(f"   Cache path: {entry.cache_path}")
            print(f"   Cache type: {entry.cache_type}")
            print(f"   Status: {entry.status}")
    except Exception as e:
        print(f"   Error: {e}")

    print()

    # Example 2: Cache a local directory
    print("2. Caching a local directory...")
    try:
        sample_dir = create_sample_local_directory()
        entry = manager.cache_repository(str(sample_dir), name="sample-project")
        if isinstance(entry, Exception):
            print(f"   Error: {entry}")
        else:
            print(f"   Successfully cached: {entry.name}")
            print(f"   Cache path: {entry.cache_path}")
            print(f"   Cache type: {entry.cache_type}")
            print(f"   Status: {entry.status}")
    except Exception as e:
        print(f"   Error: {e}")

    print()

    # Example 3: List cache entries
    print("3. Listing cache entries...")
    entries = manager.list_cache_entries()
    for entry in entries:
        print(f"   - {entry.name}: {entry.cache_type} ({entry.status})")

    print()

    # Example 4: Get cache statistics
    print("4. Cache statistics...")
    stats = manager.get_cache_stats()
    print(f"   Total entries: {stats['total_entries']}")
    print(f"   Git entries: {stats['git_entries']}")
    print(f"   Local entries: {stats['local_entries']}")
    print(f"   Total size: {stats['total_size_gb']:.2f} GB")
    print(f"   Cache full: {stats['cache_full']}")

    print()

    # Example 5: Get cached repository path
    print("5. Getting cached repository path...")
    try:
        cache_path = manager.get_cached_repository("Hello-World")
        if isinstance(cache_path, Exception):
            print(f"   Error: {cache_path}")
        else:
            print(f"   Cache path: {cache_path}")
            if cache_path.exists():
                print(f"   Directory exists: {cache_path.exists()}")
                print(f"   Contents: {list(cache_path.iterdir())}")
    except Exception as e:
        print(f"   Error: {e}")

    print()

    # Example 6: Refresh cache entry
    print("6. Refreshing cache entry...")
    try:
        entry = manager.refresh_cache_entry("Hello-World")
        if isinstance(entry, Exception):
            print(f"   Error: {entry}")
        else:
            print(f"   Successfully refreshed: {entry.name}")
            print(f"   Last updated: {entry.last_updated}")
    except Exception as e:
        print(f"   Error: {e}")


async def async_example():
    """Demonstrate asynchronous git cache operations."""
    print("=== Asynchronous Git Cache Example ===\n")

    # Create configuration
    config = GitCacheConfig(
        cache_root=Path("./.metagit/.cache"),
        default_timeout_minutes=30,
        max_cache_size_gb=5.0,
        enable_async=True,
    )

    # Create manager
    manager = GitCacheManager(config)

    # Example 1: Cache multiple repositories concurrently
    print("1. Caching multiple repositories concurrently...")
    repositories = [
        "https://github.com/octocat/Hello-World.git",
        "https://github.com/octocat/Spoon-Knife.git",
    ]

    tasks = []
    for repo_url in repositories:
        task = manager.cache_repository_async(repo_url)
        tasks.append(task)

    try:
        results = await asyncio.gather(*tasks, return_exceptions=True)
        for i, result in enumerate(results):
            if isinstance(result, Exception):
                print(f"   Error caching {repositories[i]}: {result}")
            else:
                print(f"   Successfully cached: {result.name}")
    except Exception as e:
        print(f"   Error: {e}")

    print()

    # Example 2: Cache local directories concurrently
    print("2. Caching local directories concurrently...")
    try:
        sample_dir1 = create_sample_local_directory()
        sample_dir2 = create_sample_local_directory()

        # Add some unique content to distinguish them
        (sample_dir1 / "project.txt").write_text("Project 1")
        (sample_dir2 / "project.txt").write_text("Project 2")

        tasks = [
            manager.cache_repository_async(str(sample_dir1), name="project1"),
            manager.cache_repository_async(str(sample_dir2), name="project2"),
        ]

        results = await asyncio.gather(*tasks, return_exceptions=True)
        for i, result in enumerate(results):
            if isinstance(result, Exception):
                print(f"   Error caching project{i+1}: {result}")
            else:
                print(f"   Successfully cached: {result.name}")
    except Exception as e:
        print(f"   Error: {e}")

    print()

    # Example 3: Refresh multiple entries concurrently
    print("3. Refreshing multiple entries concurrently...")
    try:
        entries = manager.list_cache_entries()
        if entries:
            tasks = []
            for entry in entries[:2]:  # Refresh first 2 entries
                task = manager.refresh_cache_entry_async(entry.name)
                tasks.append(task)

            results = await asyncio.gather(*tasks, return_exceptions=True)
            for i, result in enumerate(results):
                if isinstance(result, Exception):
                    print(f"   Error refreshing {entries[i].name}: {result}")
                else:
                    print(f"   Successfully refreshed: {result.name}")
        else:
            print("   No entries to refresh")
    except Exception as e:
        print(f"   Error: {e}")


def cleanup_example():
    """Demonstrate cache cleanup operations."""
    print("=== Cache Cleanup Example ===\n")

    # Create configuration
    config = GitCacheConfig(
        cache_root=Path("./.metagit/.cache"),
        default_timeout_minutes=30,
        max_cache_size_gb=5.0,
    )

    # Create manager
    manager = GitCacheManager(config)

    # Example 1: Remove specific cache entry
    print("1. Removing specific cache entry...")
    try:
        result = manager.remove_cache_entry("Hello-World")
        if isinstance(result, Exception):
            print(f"   Error: {result}")
        else:
            print("   Successfully removed cache entry")
    except Exception as e:
        print(f"   Error: {e}")

    print()

    # Example 2: Clear all cache
    print("2. Clearing all cache...")
    try:
        result = manager.clear_cache()
        if isinstance(result, Exception):
            print(f"   Error: {result}")
        else:
            print("   Successfully cleared all cache")
    except Exception as e:
        print(f"   Error: {e}")


def main():
    """Run all examples."""
    print("Git Cache Management System Examples")
    print("=" * 50)
    print()

    # Run synchronous examples
    sync_example()

    print()
    print("=" * 50)
    print()

    # Run asynchronous examples
    asyncio.run(async_example())

    print()
    print("=" * 50)
    print()

    # Run cleanup examples
    cleanup_example()

    print()
    print("Examples completed!")


if __name__ == "__main__":
    main()
`````

## File: examples/load_config_from_yaml.py
`````python
#!/usr/bin/env python3

"""
Example demonstrating how to load DetectionManagerConfig from YAML files.
"""

import sys
from pathlib import Path

import yaml

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.detect.manager import DetectionManager, DetectionManagerConfig


def load_config_from_yaml(
    file_path: str, config_name: str = "default"
) -> DetectionManagerConfig:
    """
    Load a DetectionManagerConfig from a YAML file.

    Args:
        file_path: Path to the YAML configuration file
        config_name: Name of the configuration section to load

    Returns:
        DetectionManagerConfig instance
    """
    try:
        with open(file_path, "r") as f:
            configs = yaml.safe_load(f)

        if config_name not in configs:
            raise ValueError(f"Configuration '{config_name}' not found in {file_path}")

        config_data = configs[config_name]
        return DetectionManagerConfig(**config_data)

    except Exception as e:
        print(f"Error loading configuration: {e}")
        # Fallback to default configuration
        return DetectionManagerConfig()


def example_load_from_yaml():
    """Demonstrate loading configurations from YAML file."""
    print("=== Loading Configuration from YAML ===")

    config_file = Path(__file__).parent / "detection_config_example.yml"

    # Load different configurations
    configs_to_load = [
        "default",
        "minimal",
        "comprehensive",
        "cicd_focused",
        "directory_focused",
    ]

    for config_name in configs_to_load:
        print(f"\nLoading '{config_name}' configuration:")
        config = load_config_from_yaml(str(config_file), config_name)
        enabled_methods = config.get_enabled_methods()
        print(f"  Enabled methods: {', '.join(enabled_methods)}")

    print()


def example_use_loaded_config():
    """Demonstrate using a loaded configuration with DetectionManager."""
    print("=== Using Loaded Configuration ===")

    config_file = Path(__file__).parent / "detection_config_example.yml"

    # Load minimal configuration
    config = load_config_from_yaml(str(config_file), "minimal")
    print(f"Using 'minimal' configuration: {', '.join(config.get_enabled_methods())}")

    # Create DetectionManager with loaded config
    manager = DetectionManager(path="./", config=config)

    # Run analysis
    result = manager.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    # Print summary
    summary = manager.summary()
    if isinstance(summary, Exception):
        print(f"Error getting summary: {summary}")
    else:
        print(summary)
    print()


def example_create_yaml_config():
    """Demonstrate creating a YAML configuration file programmatically."""
    print("=== Creating YAML Configuration ===")

    # Create different configurations
    configs = {
        "custom_analysis": DetectionManagerConfig(
            branch_analysis_enabled=True,
            ci_config_analysis_enabled=False,
            directory_summary_enabled=True,
            directory_details_enabled=True,
            commit_analysis_enabled=False,
            tag_analysis_enabled=False,
        ),
        "git_only": DetectionManagerConfig(
            branch_analysis_enabled=True,
            ci_config_analysis_enabled=False,
            directory_summary_enabled=False,
            directory_details_enabled=False,
            commit_analysis_enabled=True,
            tag_analysis_enabled=True,
        ),
    }

    # Convert to YAML
    yaml_data = {}
    for name, config in configs.items():
        yaml_data[name] = config.model_dump()

    # Print YAML
    yaml_output = yaml.dump(yaml_data, default_flow_style=False, sort_keys=False)
    print("Generated YAML configuration:")
    print(yaml_output)

    # Save to file
    output_file = Path(__file__).parent / "generated_config.yml"
    with open(output_file, "w") as f:
        f.write(yaml_output)

    print(f"Configuration saved to: {output_file}")
    print()


if __name__ == "__main__":
    print("DetectionManagerConfig YAML Loading Examples\n")

    try:
        example_load_from_yaml()
        example_use_loaded_config()
        example_create_yaml_config()

        print("All YAML configuration examples completed successfully!")

    except Exception as e:
        print(f"Error running examples: {e}")
        sys.exit(1)
`````

## File: examples/provider_example.py
`````python
#!/usr/bin/env python3
import os
import sys
from pathlib import Path

from metagit.core.detect import DetectionManager
from metagit.core.providers import registry
from metagit.core.providers.github import GitHubProvider
from metagit.core.providers.gitlab import GitLabProvider

# Add the project root to the Python path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))

"""
Example script demonstrating git provider plugins for repository analysis.

This script shows how to:
1. Configure providers using AppConfig
2. Configure providers using environment variables
3. Analyze repositories with real metrics from APIs
4. Compare different configuration methods
"""


def setup_providers_from_appconfig():
    """Setup git provider plugins using AppConfig."""
    print("🔧 Setting up providers from AppConfig...")

    try:
        from metagit.core.appconfig import AppConfig

        app_config = AppConfig.load()

        if isinstance(app_config, Exception):
            print(f"❌ Failed to load AppConfig: {app_config}")
            return False

        registry.configure_from_app_config(app_config)

        providers = registry.get_all_providers()
        if providers:
            provider_names = [p.get_name() for p in providers]
            print(
                f"✅ Configured providers from AppConfig: {', '.join(provider_names)}"
            )
            return True
        else:
            print("⚠️  No providers configured in AppConfig")
            return False

    except ImportError:
        print("❌ AppConfig not available")
        return False


def setup_providers_from_environment():
    """Setup git provider plugins using environment variables."""
    print("🔧 Setting up providers from environment variables...")

    registry.configure_from_environment()

    providers = registry.get_all_providers()
    if providers:
        provider_names = [p.get_name() for p in providers]
        print(f"✅ Configured providers from environment: {', '.join(provider_names)}")
        return True
    else:
        print("⚠️  No providers configured in environment")
        return False


def setup_providers_manually():
    """Setup git provider plugins manually."""
    print("🔧 Setting up providers manually...")

    # GitHub provider
    github_token = os.getenv("GITHUB_TOKEN")
    if github_token:
        github_provider = GitHubProvider(api_token=github_token)
        registry.register(github_provider)
        print("✅ GitHub provider configured manually")

    # GitLab provider
    gitlab_token = os.getenv("GITLAB_TOKEN")
    if gitlab_token:
        gitlab_provider = GitLabProvider(api_token=gitlab_token)
        registry.register(gitlab_provider)
        print("✅ GitLab provider configured manually")

    providers = registry.get_all_providers()
    if providers:
        provider_names = [p.get_name() for p in providers]
        print(f"📊 Available providers: {', '.join(provider_names)}")
        return True
    else:
        print("⚠️  No providers configured")
        return False


def analyze_local_repo(repo_path: str):
    """Analyze a local repository."""
    print(f"\n🔍 Analyzing local repository: {repo_path}")

    analysis = DetectionManager.from_path(repo_path)
    if isinstance(analysis, Exception):
        print(f"❌ Analysis failed: {analysis}")
        return

    print("\n📋 Analysis Summary:")
    summary = analysis.summary()
    if isinstance(summary, Exception):
        print(f"❌ Summary failed: {summary}")
        return

    print(summary)

    # Show metrics details
    if analysis.metrics:
        print("\n📊 Metrics Details:")
        print(f"  Stars: {analysis.metrics.stars}")
        print(f"  Forks: {analysis.metrics.forks}")
        print(f"  Open Issues: {analysis.metrics.open_issues}")
        print(f"  Contributors: {analysis.metrics.contributors}")
        print(f"  Commit Frequency: {analysis.metrics.commit_frequency.value}")
        print(f"  Open PRs: {analysis.metrics.pull_requests.open}")
        print(f"  PRs Merged (30d): {analysis.metrics.pull_requests.merged_last_30d}")


def analyze_remote_repo(repo_url: str):
    """Analyze a remote repository by cloning it."""
    print(f"\n🌐 Analyzing remote repository: {repo_url}")

    analysis = DetectionManager.from_url(repo_url)
    if isinstance(analysis, Exception):
        print(f"❌ Analysis failed: {analysis}")
        return

    print("\n📋 Analysis Summary:")
    summary = analysis.summary()
    if isinstance(summary, Exception):
        print(f"❌ Summary failed: {summary}")
        return

    print(summary)

    # Clean up
    analysis.cleanup()


def demonstrate_configuration_methods():
    """Demonstrate different configuration methods."""
    print("🚀 Git Provider Plugin Configuration Examples")
    print("=" * 60)

    # Method 1: AppConfig
    print("\n📋 Method 1: AppConfig Configuration")
    print("-" * 40)
    success = setup_providers_from_appconfig()
    if success:
        print("✅ AppConfig configuration successful")
    else:
        print("❌ AppConfig configuration failed")

    # Clear providers for next method
    registry.clear()

    # Method 2: Environment Variables
    print("\n📋 Method 2: Environment Variables")
    print("-" * 40)
    success = setup_providers_from_environment()
    if success:
        print("✅ Environment configuration successful")
    else:
        print("❌ Environment configuration failed")

    # Clear providers for next method
    registry.clear()

    # Method 3: Manual Configuration
    print("\n📋 Method 3: Manual Configuration")
    print("-" * 40)
    success = setup_providers_manually()
    if success:
        print("✅ Manual configuration successful")
    else:
        print("❌ Manual configuration failed")


def main():
    """Main example function."""
    print("🚀 Git Provider Plugin Example")
    print("=" * 50)

    # Demonstrate configuration methods
    demonstrate_configuration_methods()

    # Setup providers for analysis (using AppConfig if available, otherwise environment)
    print("\n" + "=" * 50)
    print("🔧 Setting up providers for analysis...")

    # Try AppConfig first, fall back to environment
    if not setup_providers_from_appconfig():
        setup_providers_from_environment()

    # Example 1: Analyze current directory
    print("\n" + "=" * 50)
    print("Example 1: Analyzing current directory")
    analyze_local_repo(".")

    # Example 2: Analyze a specific local repository
    # Uncomment and modify the path as needed
    # print("\n" + "=" * 50)
    # print("Example 2: Analyzing specific local repository")
    # analyze_local_repo("/path/to/your/repo")

    # Example 3: Analyze a remote repository
    # Uncomment and modify the URL as needed
    # print("\n" + "=" * 50)
    # print("Example 3: Analyzing remote repository")
    # analyze_remote_repo("https://github.com/username/repo")

    print("\n" + "=" * 50)
    print("✅ Example completed!")


if __name__ == "__main__":
    main()
`````

## File: examples/README_fuzzyfinder_tests.md
`````markdown
# FuzzyFinder Test Scripts

This directory contains test scripts that demonstrate the functionality of the `FuzzyFinder` utility from the `metagit.core.utils.fuzzyfinder` module.

## Overview

The `FuzzyFinder` is a powerful interactive search tool that provides:
- Fuzzy text matching using rapidfuzz
- Interactive navigation with arrow keys
- Preview pane for detailed information
- Customizable styling and configuration
- Support for both string lists and object lists

## Test Scripts

### 1. `fuzzyfinder_simple_test.py`
**Purpose**: Basic demonstration with simple string list
**Features**:
- Uses a list of programming language names
- Shows basic search functionality
- Demonstrates navigation and selection

**Usage**:
```bash
python examples/fuzzyfinder_simple_test.py
```

### 2. `fuzzyfinder_preview_test.py`
**Purpose**: Demonstrates preview functionality with object list
**Features**:
- Uses `ProjectFile` objects with multiple attributes
- Shows preview pane with file descriptions
- Demonstrates `display_field` and `preview_field` configuration

**Usage**:
```bash
python examples/fuzzyfinder_preview_test.py
```

### 3. `fuzzyfinder_comprehensive_test.py`
**Purpose**: Comprehensive demonstration of all features
**Features**:
- Multiple test configurations
- Different scorers (partial_ratio, token_sort_ratio)
- Case-sensitive vs case-insensitive search
- Various score thresholds
- Different styling configurations

**Usage**:
```bash
python examples/fuzzyfinder_comprehensive_test.py
```

## Key Features Demonstrated

### Object Support
The FuzzyFinder can work with both simple strings and complex objects:

```python
# Simple string list
config = FuzzyFinderConfig(
    items=["python", "javascript", "typescript"],
    prompt_text="Search: "
)

# Object list with custom fields
config = FuzzyFinderConfig(
    items=file_objects,
    display_field="name",      # Field to display and search
    preview_field="description", # Field to show in preview
    enable_preview=True
)
```

### Preview Functionality
When `enable_preview=True` is set, the FuzzyFinder shows a preview pane below the search results:

```python
config = FuzzyFinderConfig(
    items=files,
    display_field="name",
    preview_field="description",
    enable_preview=True,
    prompt_text="Search files: "
)
```

### Different Scorers
The FuzzyFinder supports multiple fuzzy matching algorithms:

- `partial_ratio`: Best for partial string matches
- `ratio`: Best for exact string similarity
- `token_sort_ratio`: Best for word order variations

```python
config = FuzzyFinderConfig(
    items=items,
    scorer="token_sort_ratio",  # or "partial_ratio", "ratio"
    score_threshold=70.0
)
```

### Custom Styling
You can customize the appearance with different colors and styles:

```python
config = FuzzyFinderConfig(
    items=items,
    highlight_color="bold white bg:#0066cc",
    normal_color="white",
    prompt_color="bold green",
    separator_color="gray"
)
```

## Navigation Controls

- **Arrow Keys**: Navigate up/down through results
- **Type**: Search/filter results
- **Enter**: Select highlighted item
- **Ctrl+C**: Exit without selection

## Configuration Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `items` | List | Required | Items to search (strings or objects) |
| `display_field` | str | None | Field name for display/search (objects only) |
| `preview_field` | str | None | Field name for preview (objects only) |
| `enable_preview` | bool | False | Enable preview pane |
| `score_threshold` | float | 70.0 | Minimum match score (0-100) |
| `max_results` | int | 10 | Maximum results to display |
| `scorer` | str | "partial_ratio" | Fuzzy matching algorithm |
| `case_sensitive` | bool | False | Case-sensitive matching |
| `prompt_text` | str | "> " | Input prompt text |
| `highlight_color` | str | "bold white bg:#4444aa" | Highlighted item style |
| `normal_color` | str | "white" | Normal item style |
| `prompt_color` | str | "bold cyan" | Prompt text style |
| `separator_color` | str | "gray" | Separator line style |

## Example Output

When running the preview test, you'll see an interface like this:

```
Search files: py
> main.py
  api.py
  database.py
  models.py
  utils.py
  middleware.py
  cli.py
=====================================
Preview:
Main application entry point with CLI interface and core functionality
```

## Requirements

The test scripts require the following dependencies:
- `prompt_toolkit`: For the interactive terminal interface
- `rapidfuzz`: For fuzzy string matching
- `pydantic`: For configuration validation

These should be installed as part of the metagit package dependencies.

## Troubleshooting

### Common Issues

1. **Import Error**: Make sure you're running from the project root directory
2. **Display Issues**: Ensure your terminal supports the color codes used
3. **Navigation Problems**: Check that your terminal supports arrow key input

### Debug Mode

To see more detailed error information, you can modify the scripts to catch and display exceptions:

```python
try:
    result = finder.run()
    if isinstance(result, Exception):
        print(f"Error: {result}")
except Exception as e:
    print(f"Unexpected error: {e}")
    import traceback
    traceback.print_exc()
`````

## File: examples/record_conversion_advanced_example.py
`````python
#!/usr/bin/env python
"""
Advanced example demonstrating automatic field detection in MetagitRecord conversion.

This script shows how the new approach automatically handles field differences
without requiring manual field lists or attribute definitions.
"""

import sys
from datetime import datetime
from pathlib import Path

# Add the project root to the Python path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))

from metagit.core.config.models import (
    Branch,
    BranchStrategy,
    CICD,
    CICDPlatform,
    Language,
    License,
    LicenseKind,
    Maintainer,
    Metrics,
    MetagitConfig,
    Pipeline,
    PullRequests,
    RepoMetadata,
)
from metagit.core.project.models import ProjectKind
from metagit.core.record.models import MetagitRecord


def main():
    """Demonstrate advanced MetagitRecord conversion with automatic field detection."""
    print("🚀 Advanced MetagitRecord Conversion Example")
    print("=" * 60)

    # Example 1: Show field differences automatically
    print("\n1. Automatic Field Difference Detection:")
    print("-" * 40)

    differences = MetagitRecord.get_field_differences()
    print(f"📊 Field Analysis:")
    print(f"   Total MetagitRecord fields: {differences['total_record_fields']}")
    print(f"   Total MetagitConfig fields: {differences['total_config_fields']}")
    print(f"   Common fields: {differences['common_field_count']}")
    print(f"   Record-only fields: {len(differences['record_only_fields'])}")
    print(f"   Config-only fields: {len(differences['config_only_fields'])}")

    print(f"\n🔗 Common Fields (automatically detected):")
    for field in differences["common_fields"][:10]:  # Show first 10
        print(f"   ✓ {field}")
    if len(differences["common_fields"]) > 10:
        print(f"   ... and {len(differences['common_fields']) - 10} more")

    print(f"\n📝 Record-Only Fields (automatically excluded):")
    for field in differences["record_only_fields"][:5]:  # Show first 5
        print(f"   ✗ {field}")
    if len(differences["record_only_fields"]) > 5:
        print(f"   ... and {len(differences['record_only_fields']) - 5} more")

    # Example 2: Show compatible fields
    print(f"\n2. Compatible Fields Detection:")
    print("-" * 40)

    compatible_fields = MetagitRecord.get_compatible_fields()
    print(f"✅ Fields that can be converted automatically: {len(compatible_fields)}")
    print(f"   Core fields: {', '.join(sorted(list(compatible_fields))[:5])}...")

    # Example 3: Create a complex record
    print(f"\n3. Creating Complex Record with Detection Data:")
    print("-" * 40)

    record = MetagitRecord(
        name="advanced-example",
        description="A project demonstrating automatic field detection",
        url="https://github.com/example/advanced-project.git",
        kind=ProjectKind.APPLICATION,
        branch_strategy=BranchStrategy.TRUNK,
        license=License(kind=LicenseKind.MIT, file="LICENSE"),
        maintainers=[
            Maintainer(name="Auto Field", email="auto@example.com", role="Developer"),
        ],
        cicd=CICD(
            platform=CICDPlatform.GITHUB,
            pipelines=[Pipeline(name="Auto CI", ref=".github/workflows/auto.yml")],
        ),
        # Detection-specific fields (will be automatically excluded)
        branch="main",
        checksum="auto123detect456",
        last_updated=datetime.now(),
        branches=[Branch(name="main", environment="production")],
        metrics=Metrics(
            stars=200,
            forks=30,
            open_issues=10,
            pull_requests=PullRequests(open=6, merged_last_30d=25),
            contributors=15,
            commit_frequency="daily",
        ),
        metadata=RepoMetadata(
            tags=["python", "automatic", "field-detection"],
            has_ci=True,
            has_tests=True,
            has_docs=True,
            has_docker=True,
            has_iac=True,
        ),
        language=Language(primary="Python", secondary=["TypeScript"]),
        language_version="3.12",
        domain="web",
        detection_timestamp=datetime.now(),
        detection_source="automatic",
        detection_version="3.0.0",
    )

    print(f"✅ Created record with {len(MetagitRecord.model_fields)} total fields")
    print(f"   Detection fields: {len(differences['record_only_fields'])}")
    print(f"   Config fields: {len(differences['common_fields'])}")

    # Example 4: Automatic conversion without manual field lists
    print(f"\n4. Automatic Conversion (No Manual Field Lists):")
    print("-" * 40)

    # This conversion happens automatically without any manual field definitions
    config = record.to_metagit_config()

    print(f"✅ Converted to config with {len(MetagitConfig.model_fields)} fields")
    print(f"   Original record fields: {len(MetagitRecord.model_fields)}")
    print(f"   Converted config fields: {len(MetagitConfig.model_fields)}")
    print(
        f"   Fields automatically excluded: {len(MetagitRecord.model_fields) - len(MetagitConfig.model_fields)}"
    )

    # Verify that detection fields were automatically excluded
    detection_fields_excluded = all(
        not hasattr(config, field) for field in differences["record_only_fields"]
    )
    print(f"   Detection fields properly excluded: {detection_fields_excluded}")

    # Example 5: Show what was preserved
    print(f"\n5. Field Preservation Analysis:")
    print("-" * 40)

    print(f"✅ Preserved in conversion:")
    for field in differences["common_fields"][:8]:  # Show first 8
        record_value = getattr(record, field, None)
        config_value = getattr(config, field, None)
        status = "✓" if record_value == config_value else "⚠"
        print(f"   {status} {field}: {record_value} -> {config_value}")

    print(f"\n❌ Automatically excluded:")
    for field in differences["record_only_fields"][:5]:  # Show first 5
        record_value = getattr(record, field, None)
        has_config_field = hasattr(config, field)
        print(f"   ✗ {field}: {record_value} (config has field: {has_config_field})")

    # Example 6: Performance comparison
    print(f"\n6. Performance with Automatic Field Detection:")
    print("-" * 40)

    import time

    # Test conversion performance
    start_time = time.time()
    for i in range(1000):
        test_record = MetagitRecord(
            name=f"perf-test-{i}",
            description=f"Performance test {i}",
            detection_source="automatic",
            detection_version="3.0.0",
            branch=f"branch-{i}",
            checksum=f"hash-{i}",
        )
        test_config = test_record.to_metagit_config()
    end_time = time.time()

    conversion_time = end_time - start_time
    print(f"✅ 1000 conversions with automatic field detection: {conversion_time:.3f}s")
    print(f"   Average time per conversion: {(conversion_time / 1000) * 1000:.2f}ms")

    # Example 7: Advanced conversion with kwargs
    print(f"\n7. Advanced Conversion with Flexible Parameters:")
    print("-" * 40)

    base_config = MetagitConfig(
        name="flexible-config",
        description="A config for advanced conversion",
        kind=ProjectKind.SERVICE,
    )

    # Use the advanced method with flexible kwargs
    advanced_record = MetagitRecord.from_metagit_config_advanced(
        base_config,
        detection_source="advanced",
        detection_version="4.0.0",
        branch="feature/advanced",
        checksum="advanced123",
        metrics=Metrics(
            stars=500,
            forks=50,
            open_issues=20,
            pull_requests=PullRequests(open=10, merged_last_30d=50),
            contributors=25,
            commit_frequency="daily",
        ),
    )

    print(f"✅ Advanced conversion successful")
    print(f"   Detection source: {advanced_record.detection_source}")
    print(f"   Detection version: {advanced_record.detection_version}")
    print(f"   Branch: {advanced_record.branch}")
    print(
        f"   Metrics stars: {advanced_record.metrics.stars if advanced_record.metrics else 'None'}"
    )

    # Example 8: Demonstrate the benefits
    print(f"\n8. Benefits of Automatic Field Detection:")
    print("-" * 40)

    print("🎯 Key Advantages:")
    print("   • No manual field lists to maintain")
    print("   • Automatically adapts to model changes")
    print("   • Type-safe with full Pydantic validation")
    print("   • Performance optimized with field introspection")
    print("   • Future-proof against schema evolution")

    print(f"\n📈 Maintainability Benefits:")
    print("   • Adding new fields to either model requires no code changes")
    print("   • Field differences are automatically detected")
    print("   • Conversion logic is centralized and reusable")
    print("   • Error handling is consistent across all conversions")

    print(f"\n⚡ Performance Benefits:")
    print("   • Uses Pydantic's optimized field introspection")
    print("   • Minimal memory allocation")
    print("   • No deep copying of nested objects")
    print("   • Fast validation with C-optimized code")

    print(f"\n🎉 Advanced conversion example completed successfully!")
    print(f"\nThe new approach eliminates the need for manual field management")
    print(f"while providing better performance, maintainability, and type safety.")


if __name__ == "__main__":
    main()
`````

## File: examples/record_conversion_example.py
`````python
#!/usr/bin/env python
"""
Example script demonstrating fast conversion between MetagitRecord and MetagitConfig.

This script shows how to use the latest Pydantic best practices for efficient
conversion between MetagitRecord and MetagitConfig data structures.
"""

import sys
from datetime import datetime
from pathlib import Path

# Add the project root to the Python path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))

from metagit.core.config.models import (
    Branch,
    BranchStrategy,
    CICD,
    CICDPlatform,
    Language,
    License,
    LicenseKind,
    Maintainer,
    Metrics,
    MetagitConfig,
    Pipeline,
    PullRequests,
    RepoMetadata,
)
from metagit.core.project.models import ProjectKind
from metagit.core.record.models import MetagitRecord


def main():
    """Demonstrate MetagitRecord conversion methods."""
    print("🚀 MetagitRecord Conversion Example")
    print("=" * 50)

    # Example 1: Create a MetagitConfig
    print("\n1. Creating a MetagitConfig...")
    config = MetagitConfig(
        name="example-project",
        description="A comprehensive example project",
        url="https://github.com/example/project.git",
        kind=ProjectKind.APPLICATION,
        branch_strategy=BranchStrategy.TRUNK,
        license=License(kind=LicenseKind.MIT, file="LICENSE"),
        maintainers=[
            Maintainer(
                name="Alice Developer", email="alice@example.com", role="Lead Developer"
            ),
            Maintainer(
                name="Bob Maintainer", email="bob@example.com", role="Maintainer"
            ),
        ],
        cicd=CICD(
            platform=CICDPlatform.GITHUB,
            pipelines=[
                Pipeline(name="CI", ref=".github/workflows/ci.yml"),
                Pipeline(name="CD", ref=".github/workflows/cd.yml"),
            ],
        ),
    )
    print(f"✅ Created MetagitConfig: {config.name}")

    # Example 2: Convert MetagitConfig to MetagitRecord
    print("\n2. Converting MetagitConfig to MetagitRecord...")
    record = MetagitRecord.from_metagit_config(
        config,
        detection_source="github",
        detection_version="2.0.0",
        additional_detection_data={
            "branch": "main",
            "checksum": "abc123def456",
            "metrics": Metrics(
                stars=150,
                forks=25,
                open_issues=8,
                pull_requests=PullRequests(open=5, merged_last_30d=20),
                contributors=12,
                commit_frequency="daily",
            ),
            "metadata": RepoMetadata(
                tags=["python", "fastapi", "postgresql"],
                has_ci=True,
                has_tests=True,
                has_docs=True,
                has_docker=True,
                has_iac=True,
            ),
            "language": Language(
                primary="Python", secondary=["JavaScript", "TypeScript"]
            ),
            "language_version": "3.11",
            "domain": "web",
        },
    )
    print(f"✅ Created MetagitRecord with detection data")

    # Example 3: Show detection summary
    print("\n3. Detection Summary:")
    summary = record.get_detection_summary()
    for key, value in summary.items():
        if key == "metrics":
            print(f"  {key}:")
            for metric_key, metric_value in value.items():
                print(f"    {metric_key}: {metric_value}")
        elif key == "metadata":
            print(f"  {key}:")
            for meta_key, meta_value in value.items():
                print(f"    {meta_key}: {meta_value}")
        else:
            print(f"  {key}: {value}")

    # Example 4: Convert MetagitRecord back to MetagitConfig
    print("\n4. Converting MetagitRecord back to MetagitConfig...")
    converted_config = record.to_metagit_config()
    print(f"✅ Converted back to MetagitConfig: {converted_config.name}")

    # Example 5: Verify round-trip conversion
    print("\n5. Verifying round-trip conversion...")
    print(f"  Original config name: {config.name}")
    print(f"  Converted config name: {converted_config.name}")
    print(f"  Names match: {config.name == converted_config.name}")
    print(f"  Descriptions match: {config.description == converted_config.description}")
    print(f"  Kinds match: {config.kind == converted_config.kind}")
    print(
        f"  Branch strategies match: {config.branch_strategy == converted_config.branch_strategy}"
    )

    # Example 6: Performance test
    print("\n6. Performance test (1000 conversions)...")
    import time

    start_time = time.time()
    for i in range(1000):
        test_config = MetagitConfig(name=f"perf-test-{i}")
        test_record = MetagitRecord.from_metagit_config(test_config)
        back_to_config = test_record.to_metagit_config()
    end_time = time.time()

    conversion_time = end_time - start_time
    print(f"✅ Completed 1000 round-trip conversions in {conversion_time:.3f} seconds")
    print(f"   Average time per conversion: {(conversion_time / 1000) * 1000:.2f} ms")

    # Example 7: Complex nested object conversion
    print("\n7. Complex nested object conversion...")
    complex_config = MetagitConfig(
        name="complex-project",
        description="A project with complex nested objects",
        kind=ProjectKind.SERVICE,
        branch_strategy=BranchStrategy.GITHUBFLOW,
        license=License(kind=LicenseKind.APACHE_2_0, file="LICENSE"),
        maintainers=[
            Maintainer(
                name="Complex Alice", email="alice@complex.com", role="Architect"
            ),
            Maintainer(name="Complex Bob", email="bob@complex.com", role="Developer"),
            Maintainer(name="Complex Carol", email="carol@complex.com", role="DevOps"),
        ],
        cicd=CICD(
            platform=CICDPlatform.GITLAB,
            pipelines=[
                Pipeline(name="Build", ref=".gitlab-ci.yml"),
                Pipeline(name="Test", ref=".gitlab-ci-test.yml"),
                Pipeline(name="Deploy", ref=".gitlab-ci-deploy.yml"),
            ],
        ),
    )

    complex_record = MetagitRecord.from_metagit_config(
        complex_config,
        detection_source="gitlab",
        detection_version="3.0.0",
    )

    back_to_complex_config = complex_record.to_metagit_config()

    print(f"✅ Complex conversion successful")
    print(f"  Maintainers preserved: {len(back_to_complex_config.maintainers)}")
    print(f"  Pipelines preserved: {len(back_to_complex_config.cicd.pipelines)}")
    print(f"  Platform preserved: {back_to_complex_config.cicd.platform}")

    # Example 8: Error handling demonstration
    print("\n8. Error handling demonstration...")
    try:
        # This should work fine
        minimal_config = MetagitConfig(name="minimal")
        minimal_record = MetagitRecord.from_metagit_config(minimal_config)
        back_to_minimal = minimal_record.to_metagit_config()
        print("✅ Minimal conversion successful")
    except Exception as e:
        print(f"❌ Minimal conversion failed: {e}")

    print("\n🎉 All conversion examples completed successfully!")
    print("\nKey Benefits of this implementation:")
    print("  • Fast conversion using Pydantic's optimized validation")
    print("  • Type-safe with proper error handling")
    print("  • Memory efficient with minimal object copying")
    print("  • Supports complex nested objects")
    print("  • Maintains data integrity through round-trip conversion")
    print("  • Provides detection data summary for quick analysis")


if __name__ == "__main__":
    main()
`````

## File: examples/record_manager_example.py
`````python
#!/usr/bin/env python
"""
Example script demonstrating the updated MetagitRecordManager.

This script shows how to:
1. Create records from existing metagit config data
2. Store records using local file storage backend
3. Store records using OpenSearch storage backend
4. Search and retrieve records
"""

import asyncio
from pathlib import Path

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.record.manager import (
    LocalFileStorageBackend,
    MetagitRecordManager,
    OpenSearchStorageBackend,
)
from metagit.core.utils.logging import LoggerConfig, UnifiedLogger


async def example_local_file_storage():
    """Example using local file storage backend."""
    print("=== Local File Storage Example ===")

    # Initialize logger
    logger = UnifiedLogger(LoggerConfig(log_level="INFO", minimal_console=True))

    # Create local file storage backend
    storage_dir = Path("./records")
    local_backend = LocalFileStorageBackend(storage_dir)

    # Initialize record manager with local backend
    record_manager = MetagitRecordManager(
        storage_backend=local_backend,
        logger=logger,
    )

    # Create a sample config manager
    config_manager = MetagitConfigManager()

    # Create a sample config
    sample_config = config_manager.create_config(
        name="example-project",
        description="An example project for testing",
        url="https://github.com/example/example-project",
        kind="application",
    )

    if isinstance(sample_config, Exception):
        print(f"Error creating config: {sample_config}")
        return

    # Create record from config
    record = record_manager.create_record_from_config(
        config=sample_config,
        detection_source="local",
        detection_version="1.0.0",
        additional_data={
            "language": {"primary": "python", "secondary": ["javascript"]},
            "domain": "web",
        },
    )

    if isinstance(record, Exception):
        print(f"Error creating record: {record}")
        return

    print(f"Created record: {record.name}")

    # Store the record
    record_id = await record_manager.store_record(record)
    if isinstance(record_id, Exception):
        print(f"Error storing record: {record_id}")
        return

    print(f"Stored record with ID: {record_id}")

    # Retrieve the record
    retrieved_record = await record_manager.get_record(record_id)
    if isinstance(retrieved_record, Exception):
        print(f"Error retrieving record: {retrieved_record}")
        return

    print(f"Retrieved record: {retrieved_record.name}")

    # Search records
    search_results = await record_manager.search_records("example")
    if isinstance(search_results, Exception):
        print(f"Error searching records: {search_results}")
        return

    print(f"Search results: {len(search_results['records'])} records found")

    # List all records
    all_records = await record_manager.list_records()
    if isinstance(all_records, Exception):
        print(f"Error listing records: {all_records}")
        return

    print(f"Total records: {len(all_records)}")


async def example_opensearch_storage():
    """Example using OpenSearch storage backend."""
    print("\n=== OpenSearch Storage Example ===")

    # Note: This example requires a running OpenSearch instance
    # You would need to configure the OpenSearchService first

    try:
        # Import OpenSearchService (this would fail if opensearchpy is not installed)
        from metagit.api.opensearch import OpenSearchService

        # Initialize OpenSearch service
        opensearch_service = OpenSearchService(
            hosts=[{"host": "localhost", "port": 9200}],
            index_name="metagit-records-example",
            use_ssl=False,
            verify_certs=False,
        )

        # Create OpenSearch storage backend
        opensearch_backend = OpenSearchStorageBackend(opensearch_service)

        # Initialize record manager with OpenSearch backend
        record_manager = MetagitRecordManager(
            storage_backend=opensearch_backend,
        )

        # Create a sample config
        config_manager = MetagitConfigManager()
        sample_config = config_manager.create_config(
            name="opensearch-example",
            description="Example project for OpenSearch testing",
            url="https://github.com/example/opensearch-example",
            kind="library",
        )

        if isinstance(sample_config, Exception):
            print(f"Error creating config: {sample_config}")
            return

        # Create record from config
        record = record_manager.create_record_from_config(
            config=sample_config,
            detection_source="github",
            detection_version="1.0.0",
        )

        if isinstance(record, Exception):
            print(f"Error creating record: {record}")
            return

        print(f"Created record: {record.name}")

        # Store the record
        record_id = await record_manager.store_record(record)
        if isinstance(record_id, Exception):
            print(f"Error storing record: {record_id}")
            return

        print(f"Stored record with ID: {record_id}")

        # Search records
        search_results = await record_manager.search_records("opensearch")
        if isinstance(search_results, Exception):
            print(f"Error searching records: {search_results}")
            return

        print(f"Search results: {len(search_results['records'])} records found")

    except ImportError:
        print("OpenSearch example skipped - opensearchpy not available")
    except Exception as e:
        print(f"OpenSearch example failed: {e}")


def example_file_operations():
    """Example of direct file operations."""
    print("\n=== File Operations Example ===")

    # Initialize record manager without storage backend
    record_manager = MetagitRecordManager()

    # Create a sample config
    config_manager = MetagitConfigManager()
    sample_config = config_manager.create_config(
        name="file-example",
        description="Example project for file operations",
        url="https://github.com/example/file-example",
        kind="cli",
    )

    if isinstance(sample_config, Exception):
        print(f"Error creating config: {sample_config}")
        return

    # Create record from config
    record = record_manager.create_record_from_config(
        config=sample_config,
        detection_source="local",
        detection_version="1.0.0",
    )

    if isinstance(record, Exception):
        print(f"Error creating record: {record}")
        return

    print(f"Created record: {record.name}")

    # Save record to file
    file_path = Path("./example-record.yml")
    save_result = record_manager.save_record_to_file(record, file_path)
    if isinstance(save_result, Exception):
        print(f"Error saving record: {save_result}")
        return

    print(f"Saved record to: {file_path}")

    # Load record from file
    loaded_record = record_manager.load_record_from_file(file_path)
    if isinstance(loaded_record, Exception):
        print(f"Error loading record: {loaded_record}")
        return

    print(f"Loaded record: {loaded_record.name}")

    # Clean up
    file_path.unlink(missing_ok=True)
    print("Cleaned up example file")


async def main():
    """Run all examples."""
    print("MetagitRecordManager Examples")
    print("=" * 50)

    # Run local file storage example
    await example_local_file_storage()

    # Run OpenSearch storage example
    await example_opensearch_storage()

    # Run file operations example
    example_file_operations()

    print("\nExamples completed!")


if __name__ == "__main__":
    asyncio.run(main())
`````

## File: examples/repository_analysis_example.py
`````python
#!/usr/bin/env python3

"""
Example demonstrating the updated DetectionManager with all detection analysis results.

This example shows how DetectionManager now contains all the analysis results that were
previously in RepositoryAnalysis, including branch analysis, CI/CD analysis, and directory analysis.
"""

import sys
from pathlib import Path

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.detect import DetectionManager


def example_local_repository_analysis():
    """Demonstrate analyzing a local repository with all analysis results."""
    print("=== Local Repository Analysis ===")

    # Create DetectionManager for local path
    analysis = DetectionManager.from_path("./")
    if isinstance(analysis, Exception):
        print(f"Error creating DetectionManager: {analysis}")
        return

    print(f"Created DetectionManager for: {analysis.path}")
    print(f"Project name: {analysis.name}")
    print(f"Git repository: {analysis.is_git_repo}")

    # Run analysis
    result = analysis.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    # Display all analysis results
    print("\nAnalysis Results:")
    print("=" * 50)

    # Language detection
    if analysis.language_detection:
        print(f"Primary language: {analysis.language_detection.primary}")
        if analysis.language_detection.secondary:
            print(
                f"Secondary languages: {', '.join(analysis.language_detection.secondary)}"
            )
        if analysis.language_detection.frameworks:
            print(f"Frameworks: {', '.join(analysis.language_detection.frameworks)}")
        if analysis.language_detection.package_managers:
            print(
                f"Package managers: {', '.join(analysis.language_detection.package_managers)}"
            )
        if analysis.language_detection.build_tools:
            print(f"Build tools: {', '.join(analysis.language_detection.build_tools)}")

    # Project type detection
    if analysis.project_type_detection:
        print(f"Project type: {analysis.project_type_detection.type}")
        print(f"Domain: {analysis.project_type_detection.domain}")
        print(f"Confidence: {analysis.project_type_detection.confidence}")
        if analysis.project_type_detection.indicators:
            print(
                f"Indicators: {', '.join(analysis.project_type_detection.indicators)}"
            )

    # Branch analysis
    if analysis.branch_analysis:
        print(f"Branch strategy: {analysis.branch_analysis.strategy_guess}")
        print(f"Number of branches: {len(analysis.branch_analysis.branches)}")
        print("Branches:")
        for branch in analysis.branch_analysis.branches:
            print(f"  - {'[remote]' if branch.is_remote else '[local]'} {branch.name}")

    # CI/CD analysis
    if analysis.ci_config_analysis:
        print(f"CI/CD tool: {analysis.ci_config_analysis.detected_tool}")
        if analysis.ci_config_analysis.ci_config_path:
            print(f"CI/CD config path: {analysis.ci_config_analysis.ci_config_path}")
        print(f"Pipeline count: {analysis.ci_config_analysis.pipeline_count}")

    # Directory analysis
    if analysis.directory_summary:
        print(f"Total files: {analysis.directory_summary.num_files}")
        print(f"File types: {len(analysis.directory_summary.file_types)}")

    if analysis.directory_details:
        print(f"Detailed files: {analysis.directory_details.num_files}")
        print(f"File categories: {len(analysis.directory_details.file_types)}")

    # File analysis
    print(f"Has Docker: {analysis.has_docker}")
    print(f"Has tests: {analysis.has_tests}")
    print(f"Has docs: {analysis.has_docs}")
    print(f"Has IaC: {analysis.has_iac}")

    # Metrics
    if analysis.metrics:
        print(f"Contributors: {analysis.metrics.contributors}")
        print(f"Commit frequency: {analysis.metrics.commit_frequency}")

    # Metadata
    if analysis.metadata:
        print(f"Has CI: {analysis.metadata.has_ci}")
        print(f"Has tests: {analysis.metadata.has_tests}")
        print(f"Has docs: {analysis.metadata.has_docs}")
        print(f"Has Docker: {analysis.metadata.has_docker}")
        print(f"Has IaC: {analysis.metadata.has_iac}")

    print()


def example_remote_repository_analysis():
    """Demonstrate analyzing a remote repository."""
    print("=== Remote Repository Analysis ===")

    # Example repository URL
    repo_url = "https://github.com/octocat/Hello-World.git"

    # Create DetectionManager for remote URL
    analysis = DetectionManager.from_url(repo_url)
    if isinstance(analysis, Exception):
        print(f"Error creating DetectionManager: {analysis}")
        return

    print(f"Created DetectionManager for: {analysis.url}")
    print(f"Project name: {analysis.name}")
    print(f"Cloned to: {analysis.path}")
    print(f"Git repository: {analysis.is_git_repo}")
    print(f"Cloned: {analysis.is_cloned}")

    # Run analysis
    result = analysis.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    # Display key analysis results
    print("\nKey Analysis Results:")
    print("=" * 50)

    if analysis.language_detection:
        print(f"Primary language: {analysis.language_detection.primary}")

    if analysis.project_type_detection:
        print(f"Project type: {analysis.project_type_detection.type}")
        print(f"Domain: {analysis.project_type_detection.domain}")

    if analysis.branch_analysis:
        print(f"Branch strategy: {analysis.branch_analysis.strategy_guess}")
        print(f"Number of branches: {len(analysis.branch_analysis.branches)}")

    if analysis.ci_config_analysis:
        print(f"CI/CD tool: {analysis.ci_config_analysis.detected_tool}")

    if analysis.directory_summary:
        print(f"Total files: {analysis.directory_summary.num_files}")

    # Clean up cloned repository
    analysis.cleanup()
    print("Cleaned up cloned repository")
    print()


def example_specific_analysis():
    """Demonstrate running specific analysis methods."""
    print("=== Specific Analysis Methods ===")

    # Create DetectionManager
    analysis = DetectionManager.from_path("./")
    if isinstance(analysis, Exception):
        print(f"Error creating DetectionManager: {analysis}")
        return

    # Run specific analysis methods
    methods = ["language_detection", "project_type_detection", "branch_analysis"]

    for method in methods:
        print(f"\nRunning {method}...")
        result = analysis.run_specific(method)
        if result is not None:
            print(f"Error running {method}: {result}")
        else:
            print(f"✅ {method} completed successfully")

    print()


def example_configuration():
    """Demonstrate using different detection configurations."""
    print("=== Detection Configuration ===")

    # Create minimal configuration
    minimal_config = DetectionManagerConfig.minimal()
    print(f"Minimal config enabled methods: {minimal_config.get_enabled_methods()}")

    # Create full configuration
    full_config = DetectionManagerConfig.all_enabled()
    print(f"Full config enabled methods: {full_config.get_enabled_methods()}")

    # Create custom configuration
    custom_config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=True,
        directory_summary_enabled=False,
        directory_details_enabled=False,
    )
    print(f"Custom config enabled methods: {custom_config.get_enabled_methods()}")

    # Use custom configuration
    analysis = DetectionManager.from_path("./", config=custom_config)
    if isinstance(analysis, Exception):
        print(f"Error creating DetectionManager: {analysis}")
        return

    result = analysis.run_all()
    if result is not None:
        print(f"Error running analysis: {result}")
        return

    print("✅ Analysis completed with custom configuration")
    print()


def main():
    """Run all examples."""
    print("DetectionManager Examples")
    print("=" * 60)

    example_local_repository_analysis()
    example_remote_repository_analysis()
    example_specific_analysis()
    example_configuration()

    print("All examples completed!")


if __name__ == "__main__":
    main()
`````

## File: examples/repository_detection.py
`````python
#!/usr/bin/env python3
"""
Example script demonstrating the repository detection module.

This script shows how to use the RepositoryAnalysis class to analyze
both local repositories and remote git repositories.
"""

import os
import sys
import tempfile
from pathlib import Path

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.detect import DetectionManager
from metagit.core.utils.logging import LoggerConfig, UnifiedLogger


def analyze_local_repository(path: str) -> None:
    """Analyze a local repository path."""
    print(f"\n{'=' * 60}")
    print(f"ANALYZING LOCAL REPOSITORY: {path}")
    print(f"{'=' * 60}")

    # Create logger
    logger = UnifiedLogger(LoggerConfig(log_level="INFO", minimal_console=True))

    # Analyze the repository
    analysis = DetectionManager.from_path(path, logger)

    if isinstance(analysis, Exception):
        print(f"❌ Analysis failed: {analysis}")
        return

    # Print summary
    summary = analysis.summary()
    if isinstance(summary, Exception):
        print(f"❌ Summary failed: {summary}")
    else:
        print(summary)

    # Convert to MetagitConfig
    config = analysis.to_metagit_config()
    if isinstance(config, Exception):
        print(f"❌ Config conversion failed: {config}")
    else:
        print("\n✅ Successfully created MetagitConfig:")
        print(f"   Name: {config.name}")
        print(f"   Type: {config.kind}")
        print(f"   Description: {config.description}")
        print(f"   Branch Strategy: {config.branch_strategy}")
        print(f"   Has CI/CD: {config.metadata.has_ci if config.metadata else False}")
        print(
            f"   Has Tests: {config.metadata.has_tests if config.metadata else False}"
        )
        print(
            f"   Has Docker: {config.metadata.has_docker if config.metadata else False}"
        )


def analyze_remote_repository(url: str) -> None:
    """Analyze a remote git repository by cloning it."""
    print(f"\n{'=' * 60}")
    print(f"ANALYZING REMOTE REPOSITORY: {url}")
    print(f"{'=' * 60}")

    # Create logger
    logger = UnifiedLogger(LoggerConfig(log_level="INFO", minimal_console=True))

    # Create temporary directory for cloning
    temp_dir = tempfile.mkdtemp(prefix="metagit_example_")

    try:
        # Analyze the repository
        analysis = DetectionManager.from_url(url, logger, temp_dir)

        if isinstance(analysis, Exception):
            print(f"❌ Analysis failed: {analysis}")
            return

        # Print summary
        summary = analysis.summary()
        if isinstance(summary, Exception):
            print(f"❌ Summary failed: {summary}")
        else:
            print(summary)

        # Convert to MetagitConfig
        config = analysis.to_metagit_config()
        if isinstance(config, Exception):
            print(f"❌ Config conversion failed: {config}")
        else:
            print("\n✅ Successfully created MetagitConfig:")
            print(f"   Name: {config.name}")
            print(f"   Type: {config.kind}")
            print(f"   Description: {config.description}")
            print(f"   Branch Strategy: {config.branch_strategy}")
            print(
                f"   Has CI/CD: {config.metadata.has_ci if config.metadata else False}"
            )
            print(
                f"   Has Tests: {config.metadata.has_tests if config.metadata else False}"
            )
            print(
                f"   Has Docker: {config.metadata.has_docker if config.metadata else False}"
            )
        config.save_to_file("metagit.config.yaml")
        # Clean up
        print(f"Cleaning up temporary directory: {analysis.temp_dir}")
        # analysis.cleanup()

    except Exception as e:
        print(f"❌ Unexpected error: {e}")
        # Clean up temp directory if analysis failed
        if os.path.exists(temp_dir):
            import shutil

            shutil.rmtree(temp_dir)


def main():
    """Main function demonstrating repository detection."""
    print("Metagit Repository Detection Example")
    print("=" * 60)

    # Example 1: Analyze current directory (if it's a git repo)
    current_dir = os.getcwd()
    if os.path.exists(os.path.join(current_dir, ".git")):
        analyze_local_repository(current_dir)
    else:
        print(f"\nCurrent directory is not a git repository: {current_dir}")

    # Example 2: Analyze a well-known open source repository
    # Using a small, well-known repository for demonstration
    remote_url = "https://github.com/octocat/Hello-World.git"
    analyze_remote_repository(remote_url)

    # Example 3: Analyze another repository
    # You can uncomment and modify these lines to test with other repositories
    # analyze_remote_repository("https://github.com/python/cpython.git")
    # analyze_remote_repository("https://github.com/torvalds/linux.git")

    print(f"\n{'=' * 60}")
    print("Example completed!")
    print(f"{'=' * 60}")


if __name__ == "__main__":
    main()
`````

## File: examples/test_appconfig_env.py
`````python
#!/usr/bin/env python

"""
Test script to verify AppConfig environment variable loading.
"""

import os
import tempfile

from metagit.core.appconfig.models import AppConfig


def test_appconfig_env_loading():
    """Test that AppConfig loads environment variables correctly."""

    # Create a temporary .env file
    with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
        f.write(
            """# Test environment variables
METAGIT_LLM_API_KEY=test_llm_key
METAGIT_GITHUB_TOKEN=test_github_token
METAGIT_GITHUB_URL=https://api.github.test.com
METAGIT_GITLAB_TOKEN=test_gitlab_token
METAGIT_GITLAB_URL=https://gitlab.test.com/api/v4
METAGIT_API_KEY=test_api_key
METAGIT_API_URL=https://api.test.com
METAGIT_API_VERSION=v2
"""
        )
        env_file = f.name

    try:
        # Set environment variables manually for testing
        os.environ["METAGIT_LLM_API_KEY"] = "test_llm_key"
        os.environ["METAGIT_GITHUB_TOKEN"] = "test_github_token"
        os.environ["METAGIT_GITHUB_URL"] = "https://api.github.test.com"
        os.environ["METAGIT_GITLAB_TOKEN"] = "test_gitlab_token"
        os.environ["METAGIT_GITLAB_URL"] = "https://gitlab.test.com/api/v4"
        os.environ["METAGIT_API_KEY"] = "test_api_key"
        os.environ["METAGIT_API_URL"] = "https://api.test.com"
        os.environ["METAGIT_API_VERSION"] = "v2"

        # Load AppConfig
        config = AppConfig.load()

        if isinstance(config, Exception):
            print(f"❌ Failed to load AppConfig: {config}")
            return False

        # Verify environment variables were loaded
        print("🔍 Testing AppConfig environment variable loading...")

        # Test LLM configuration
        if config.llm.api_key == "test_llm_key":
            print("✅ LLM API key loaded correctly")
        else:
            print(f"❌ LLM API key not loaded correctly: {config.llm.api_key}")
            return False

        # Test GitHub provider configuration
        if config.providers.github.api_token == "test_github_token":
            print("✅ GitHub token loaded correctly")
        else:
            print(
                f"❌ GitHub token not loaded correctly: {config.providers.github.api_token}"
            )
            return False

        if config.providers.github.base_url == "https://api.github.test.com":
            print("✅ GitHub URL loaded correctly")
        else:
            print(
                f"❌ GitHub URL not loaded correctly: {config.providers.github.base_url}"
            )
            return False

        if config.providers.github.enabled:
            print("✅ GitHub provider enabled correctly")
        else:
            print("❌ GitHub provider not enabled")
            return False

        # Test GitLab provider configuration
        if config.providers.gitlab.api_token == "test_gitlab_token":
            print("✅ GitLab token loaded correctly")
        else:
            print(
                f"❌ GitLab token not loaded correctly: {config.providers.gitlab.api_token}"
            )
            return False

        if config.providers.gitlab.base_url == "https://gitlab.test.com/api/v4":
            print("✅ GitLab URL loaded correctly")
        else:
            print(
                f"❌ GitLab URL not loaded correctly: {config.providers.gitlab.base_url}"
            )
            return False

        if config.providers.gitlab.enabled:
            print("✅ GitLab provider enabled correctly")
        else:
            print("❌ GitLab provider not enabled")
            return False

        # Test main API configuration
        if config.api_key == "test_api_key":
            print("✅ Main API key loaded correctly")
        else:
            print(f"❌ Main API key not loaded correctly: {config.api_key}")
            return False

        if config.api_url == "https://api.test.com":
            print("✅ Main API URL loaded correctly")
        else:
            print(f"❌ Main API URL not loaded correctly: {config.api_url}")
            return False

        if config.api_version == "v2":
            print("✅ Main API version loaded correctly")
        else:
            print(f"❌ Main API version not loaded correctly: {config.api_version}")
            return False

        print("\n🎉 All environment variable tests passed!")
        return True

    finally:
        # Clean up
        if os.path.exists(env_file):
            os.unlink(env_file)

        # Clean up environment variables
        for key in [
            "METAGIT_LLM_API_KEY",
            "METAGIT_GITHUB_TOKEN",
            "METAGIT_GITHUB_URL",
            "METAGIT_GITLAB_TOKEN",
            "METAGIT_GITLAB_URL",
            "METAGIT_API_KEY",
            "METAGIT_API_URL",
            "METAGIT_API_VERSION",
        ]:
            if key in os.environ:
                del os.environ[key]


if __name__ == "__main__":
    success = test_appconfig_env_loading()
    exit(0 if success else 1)
`````

## File: examples/test_record_manager_simple.py
`````python
#!/usr/bin/env python
"""
Simple test script for the updated MetagitRecordManager.

This script tests the basic functionality without requiring pytest.
"""

import asyncio
import tempfile
from pathlib import Path

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.record.manager import LocalFileStorageBackend, MetagitRecordManager
from metagit.core.record.models import MetagitRecord


def test_basic_functionality():
    """Test basic functionality of the updated MetagitRecordManager."""
    print("Testing MetagitRecordManager basic functionality...")

    # Create a temporary directory for testing
    with tempfile.TemporaryDirectory() as temp_dir:
        temp_path = Path(temp_dir)

        # Test 1: Create storage backend
        print("1. Creating LocalFileStorageBackend...")
        backend = LocalFileStorageBackend(temp_path)
        print(f"   ✓ Storage backend created in {temp_path}")

        # Test 2: Create record manager
        print("2. Creating MetagitRecordManager...")
        record_manager = MetagitRecordManager(storage_backend=backend)
        print("   ✓ Record manager created")

        # Test 3: Create sample config
        print("3. Creating sample config...")
        config_manager = MetagitConfigManager()
        sample_config = config_manager.create_config(
            name="test-project",
            description="A test project for validation",
            url="https://github.com/test/test-project",
            kind="application",
        )
        print(f"   ✓ Sample config created: {sample_config.name}")

        # Test 4: Create record from config
        print("4. Creating record from config...")
        record = record_manager.create_record_from_config(
            config=sample_config,
            detection_source="test",
            detection_version="1.0.0",
            additional_data={
                "language": {"primary": "python", "secondary": ["javascript"]},
                "domain": "web",
            },
        )
        print(f"   ✓ Record created: {record.name}")
        print(f"   ✓ Detection source: {record.detection_source}")
        print(f"   ✓ Language: {record.language.primary}")
        print(f"   ✓ Domain: {record.domain}")

        # Test 5: Store record
        print("5. Storing record...")

        async def store_record():
            record_id = await record_manager.store_record(record)
            return record_id

        record_id = asyncio.run(store_record())
        print(f"   ✓ Record stored with ID: {record_id}")

        # Test 6: Retrieve record
        print("6. Retrieving record...")

        async def get_record():
            retrieved_record = await record_manager.get_record(record_id)
            return retrieved_record

        retrieved_record = asyncio.run(get_record())
        print(f"   ✓ Record retrieved: {retrieved_record.name}")

        # Test 7: Search records
        print("7. Searching records...")

        async def search_records():
            search_results = await record_manager.search_records("test")
            return search_results

        search_results = asyncio.run(search_records())
        print(f"   ✓ Search results: {len(search_results['records'])} records found")

        # Test 8: List records
        print("8. Listing records...")

        async def list_records():
            records = await record_manager.list_records()
            return records

        records = asyncio.run(list_records())
        print(f"   ✓ Total records: {len(records)}")

        # Test 9: File operations
        print("9. Testing file operations...")
        file_path = temp_path / "test-record.yml"
        save_result = record_manager.save_record_to_file(record, file_path)
        if save_result is None:
            print("   ✓ Record saved to file")

            loaded_record = record_manager.load_record_from_file(file_path)
            print(f"   ✓ Record loaded from file: {loaded_record.name}")
        else:
            print(f"   ✗ Error saving record: {save_result}")

        print("\n✅ All tests passed!")


def test_error_handling():
    """Test error handling scenarios."""
    print("\nTesting error handling...")

    # Test 1: Record manager without storage backend
    print("1. Testing record manager without storage backend...")
    manager = MetagitRecordManager()

    async def test_no_backend():
        record = MetagitRecord(
            name="test",
            description="test",
            detection_timestamp="2024-01-01T00:00:00",
            detection_source="test",
            detection_version="1.0.0",
        )
        result = await manager.store_record(record)
        return result

    result = asyncio.run(test_no_backend())
    if isinstance(result, ValueError):
        print("   ✓ Correctly handled missing storage backend")
    else:
        print(f"   ✗ Unexpected result: {result}")

    # Test 2: Loading non-existent file
    print("2. Testing loading non-existent file...")
    result = manager.load_record_from_file(Path("nonexistent.yml"))
    if isinstance(result, FileNotFoundError):
        print("   ✓ Correctly handled non-existent file")
    else:
        print(f"   ✗ Unexpected result: {result}")

    print("✅ Error handling tests completed!")


if __name__ == "__main__":
    print("MetagitRecordManager Simple Test")
    print("=" * 50)

    try:
        test_basic_functionality()
        test_error_handling()
        print("\n🎉 All tests completed successfully!")
    except Exception as e:
        print(f"\n❌ Test failed with error: {e}")
        import traceback

        traceback.print_exc()
`````

## File: schemas/backend_metadata.yml
`````yaml
project:
  name: example-service
  slug: example-service
  repo_url: https://github.com/org/example-service
  provider: github
  project_type: application  # [application, library, cli, api, infra, etc.]
  languages:
    - python
    - javascript
  frameworks:
    - fastapi
    - react
  runtime_environments:
    - python3.11
    - node18
  build_tools:
    - poetry
    - npm
  deployment_targets:
    - aws-lambda
    - ecs

metrics:
  sloc: 8124
  file_count: 152
  dependency_count: 42

artifacts:
  containers:
    - name: example-service
      image: ghcr.io/org/example-service:latest
      dockerfile_path: ./Dockerfile
      base_image: python:3.11-slim
      exposed_ports: [8000]
      env_vars:
        - ENV
        - DEBUG
  libraries:
    - name: example-lib
      version: 1.2.0
      output_type: wheel
      language: python
      exported_modules:
        - example.auth
        - example.utils
      compatible_with:
        - python3.8
        - python3.11
  cli_tools:
    - name: example-cli
      entrypoint: cli.py
      flags:
        - --config
        - --dry-run

components:
  - name: auth_utils
    type: module
    path: src/example/auth/utils.py
    language: python
    functions:
      - name: hash_password
        parameters: [password: str]
        returns: str
        docstring: "Hash a plaintext password using bcrypt."
        examples:
          - "hash_password('mypassword')"
      - name: verify_password
        parameters: [plain: str, hashed: str]
        returns: bool
  - name: HealthCheckRoute
    type: class
    path: src/example/routes/health.py
    description: "Provides /health endpoint."

semantics:
  inferred_tags:
    - auth
    - api
    - observability
  tech_stack:
    - fastapi
    - pydantic
    - postgresql
  role: backend
  domain_tags:
    - account-management
    - user-authentication

dependencies:
  internal:
    - repo: shared-utils
      path: ./libs/shared-utils
      version: latest
  external:
    - name: pydantic
      version: 2.1.0
      license: MIT
    - name: requests
      version: 2.31.0
      license: Apache-2.0

ownership:
  maintainers:
    - name: Jane Doe
      email: jane@company.com
  last_commit: 2025-06-17T14:20:00Z
  recent_contributors:
    - alice
    - bob
  open_issues:
    - "Fix flaky auth tests"
    - "Add rate limiting to login route"

quality:
  test_coverage: 88
  lint_score: 9.2
  security_scan:
    last_scan: 2025-06-15
    high_risk_issues: 0
  build_status: passing
  ci_pipeline: github-actions

agent_assist:
  actionable_exports:
    - hash_password
    - verify_password
  openapi_endpoints:
    - GET /health
    - POST /login
  test_coverage_map:
    - function: hash_password
      covered: true
    - function: send_email
      covered: false
  embeddings_index: embeddings/example-service.index

navigation:
  ast_available: true
  call_graph_available: true
  semantic_search_index: search/example-service.vecdb
`````

## File: schemas/repo_metadata.yml
`````yaml
repo:
  name: my-repo-name
  description: Brief summary of the project
  url: https://github.com/org/my-repo
  visibility: public | private | internal
  owner:
    org: org-name
    team: platform-team
    contact: dev-team@example.com

project:
  type: application | library | microservice | cli | iac | config | data-science | plugin | template | docs | test | other
  domain: web | mobile | devops | ml | database | security | finance | gaming | iot | agent | other
  language:
    primary: python
    secondary: [bash, terraform]
  framework: [django, fastapi]
  package_managers: [pip, poetry]
  build_tool: make | cmake | bazel | none
  deploy_targets: [ecs, lambda, kubernetes]

metadata:
  tags: [ml, nlp, internal-tool, devops, infra, agentic, data-science, cli, website, other]
  created_at: 2021-08-15
  last_commit_at: 2025-06-18
  default_branch: main
  license: MIT | Apache-2.0 | proprietary
  topics: [nlp, fastapi, containerized]
  forked_from: null | https://github.com/upstream/original
  archived: false
  template: false
  has_ci: true
  has_tests: true
  has_docs: true
  has_docker: true
  has_iac: true

metrics:
  stars: 15
  forks: 3
  open_issues: 2
  pull_requests:
    open: 1
    merged_last_30d: 4
  contributors: 6
  commit_frequency: weekly | daily | monthly

ci_cd:
  platform: github-actions | gitlab | jenkins | circleci | none
  pipelines: [test, lint, build, deploy]
  status_badges: true

dependencies:
  external_services: [s3, postgres, stripe-api]
  critical_libs: [torch, pandas]

iac:
  type: terraform | cloudformation | pulumi | none
  environments:
    - name: dev
      cloud: aws
      region: us-west-2
      config_path: infra/dev
    - name: prod
      cloud: aws
      region: us-east-1
      config_path: infra/prod

agentic:
  reusable_components:
    - name: embed_text
      path: lib/embedding.py
      description: Generates sentence embeddings using SentenceTransformers
  entrypoints:
    - name: main_app
      file: app/main.py
      function: run_app()
  test_interface:
    test_framework: pytest
    test_coverage: 85%
`````

## File: src/metagit/cli/commands/__init__.py
`````python

`````

## File: src/metagit/cli/commands/gitcache.py
`````python
#!/usr/bin/env python
"""
CLI commands for git cache management.

This module provides command-line interface for managing git cache operations.
"""

import asyncio
from pathlib import Path
from typing import Optional

import click

from metagit.core.gitcache import GitCacheConfig, GitCacheManager


@click.group()
def gitcache():
    """Git cache management commands."""
    pass


@gitcache.command()
@click.argument("source")
@click.option("--name", "-n", help="Custom cache name")
@click.option(
    "--cache-root", "-c", default="./.metagit/.cache", help="Cache root directory"
)
@click.option("--timeout", "-t", default=60, help="Cache timeout in minutes")
@click.option("--max-size", "-s", default=10.0, help="Maximum cache size in GB")
@click.option("--async", "use_async", is_flag=True, help="Use async operations")
def cache(
    source: str,
    name: Optional[str],
    cache_root: str,
    timeout: int,
    max_size: float,
    use_async: bool,
):
    """Cache a repository or local directory."""
    try:
        config = GitCacheConfig(
            cache_root=Path(cache_root),
            default_timeout_minutes=timeout,
            max_cache_size_gb=max_size,
            enable_async=use_async,
        )

        manager = GitCacheManager(config)

        if use_async:
            entry = asyncio.run(manager.cache_repository_async(source, name))
        else:
            entry = manager.cache_repository(source, name)

        if isinstance(entry, Exception):
            click.echo(f"Error: {entry}", err=True)
            return

        click.echo(f"Successfully cached: {entry.name}")
        click.echo(f"Cache path: {entry.cache_path}")
        click.echo(f"Cache type: {entry.cache_type}")
        click.echo(f"Status: {entry.status}")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)


@gitcache.command()
@click.option(
    "--cache-root", "-c", default="./.metagit/.cache", help="Cache root directory"
)
def list(cache_root: str):
    """List all cache entries."""
    try:
        config = GitCacheConfig(cache_root=Path(cache_root))
        manager = GitCacheManager(config)

        entries = manager.list_cache_entries()

        if not entries:
            click.echo("No cache entries found.")
            return

        click.echo("Cache entries:")
        for entry in entries:
            click.echo(f"  - {entry.name}: {entry.cache_type} ({entry.status})")
            click.echo(f"    Source: {entry.source_url}")
            click.echo(f"    Path: {entry.cache_path}")
            if entry.size_bytes:
                click.echo(f"    Size: {entry.size_bytes / (1024 * 1024):.2f} MB")

            # Show git information for git repositories
            if entry.cache_type == "git":
                if entry.local_commit_hash:
                    click.echo(f"    Local: {entry.local_commit_hash[:8]}...")
                if entry.remote_commit_hash:
                    click.echo(f"    Remote: {entry.remote_commit_hash[:8]}...")
                if entry.has_upstream_changes:
                    click.echo(
                        f"    ⚠️  Has upstream changes: {entry.upstream_changes_summary}"
                    )

            click.echo()

    except Exception as e:
        click.echo(f"Error: {e}", err=True)


@gitcache.command()
@click.option(
    "--cache-root", "-c", default="./.metagit/.cache", help="Cache root directory"
)
def stats(cache_root: str):
    """Show cache statistics."""
    try:
        config = GitCacheConfig(cache_root=Path(cache_root))
        manager = GitCacheManager(config)

        stats = manager.get_cache_stats()

        click.echo("Cache Statistics:")
        click.echo(f"  Total entries: {stats['total_entries']}")
        click.echo(f"  Git entries: {stats['git_entries']}")
        click.echo(f"  Local entries: {stats['local_entries']}")
        click.echo(f"  Fresh entries: {stats['fresh_entries']}")
        click.echo(f"  Stale entries: {stats['stale_entries']}")
        click.echo(f"  Missing entries: {stats['missing_entries']}")
        click.echo(f"  Error entries: {stats['error_entries']}")
        click.echo(f"  Total size: {stats['total_size_gb']:.2f} GB")
        click.echo(f"  Max size: {stats['max_size_gb']:.2f} GB")
        click.echo(f"  Cache full: {stats['cache_full']}")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)


@gitcache.command()
@click.argument("name")
@click.option(
    "--cache-root", "-c", default="./.metagit/.cache", help="Cache root directory"
)
@click.option("--async", "use_async", is_flag=True, help="Use async operations")
def refresh(name: str, cache_root: str, use_async: bool):
    """Refresh a cache entry."""
    try:
        config = GitCacheConfig(cache_root=Path(cache_root))
        manager = GitCacheManager(config)

        if use_async:
            entry = asyncio.run(manager.refresh_cache_entry_async(name))
        else:
            entry = manager.refresh_cache_entry(name)

        if isinstance(entry, Exception):
            click.echo(f"Error: {entry}", err=True)
            return

        click.echo(f"Successfully refreshed: {entry.name}")
        click.echo(f"Last updated: {entry.last_updated}")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)


@gitcache.command()
@click.argument("name")
@click.option(
    "--cache-root", "-c", default="./.metagit/.cache", help="Cache root directory"
)
def remove(name: str, cache_root: str):
    """Remove a cache entry."""
    try:
        config = GitCacheConfig(cache_root=Path(cache_root))
        manager = GitCacheManager(config)

        result = manager.remove_cache_entry(name)

        if isinstance(result, Exception):
            click.echo(f"Error: {result}", err=True)
            return

        click.echo(f"Successfully removed cache entry: {name}")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)


@gitcache.command()
@click.option(
    "--cache-root", "-c", default="./.metagit/.cache", help="Cache root directory"
)
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
def clear(cache_root: str, yes: bool):
    """Clear all cache entries."""
    try:
        if not yes:
            if not click.confirm("Are you sure you want to clear all cache entries?"):
                return

        config = GitCacheConfig(cache_root=Path(cache_root))
        manager = GitCacheManager(config)

        result = manager.clear_cache()

        if isinstance(result, Exception):
            click.echo(f"Error: {result}", err=True)
            return

        click.echo("Successfully cleared all cache entries")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)


@gitcache.command()
@click.argument("name")
@click.option(
    "--cache-root", "-c", default="./.metagit/.cache", help="Cache root directory"
)
def path(name: str, cache_root: str):
    """Get the path to a cached repository."""
    try:
        config = GitCacheConfig(cache_root=Path(cache_root))
        manager = GitCacheManager(config)

        cache_path = manager.get_cached_repository(name)

        if isinstance(cache_path, Exception):
            click.echo(f"Error: {cache_path}", err=True)
            return

        click.echo(f"Cache path: {cache_path}")

        if cache_path.exists():
            click.echo(f"Directory exists: {cache_path.exists()}")
            contents = list(cache_path.iterdir())
            click.echo(f"Contents: {[item.name for item in contents]}")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)


@gitcache.command()
@click.argument("name")
@click.option(
    "--cache-root", "-c", default="./.metagit/.cache", help="Cache root directory"
)
def details(name: str, cache_root: str):
    """Get detailed information about a cache entry."""
    try:
        config = GitCacheConfig(cache_root=Path(cache_root))
        manager = GitCacheManager(config)

        details = manager.get_cache_entry_details(name)

        if isinstance(details, Exception):
            click.echo(f"Error: {details}", err=True)
            return

        click.echo(f"Cache Entry Details for '{name}':")
        click.echo("=" * 50)

        # Basic information
        click.echo(f"Name: {details['name']}")
        click.echo(f"Source URL: {details['source_url']}")
        click.echo(f"Cache Type: {details['cache_type']}")
        click.echo(f"Cache Path: {details['cache_path']}")
        click.echo(f"Status: {details['status']}")
        click.echo(f"Exists: {details['exists']}")
        click.echo(f"Is Stale: {details['is_stale']}")

        if details["error_message"]:
            click.echo(f"Error: {details['error_message']}")

        # Size information
        if details["size_bytes"]:
            click.echo(f"Size: {details['size_mb']} MB ({details['size_bytes']} bytes)")

        # Timestamps
        click.echo(f"Created: {details['created_at']}")
        click.echo(f"Last Updated: {details['last_updated']}")
        click.echo(f"Last Accessed: {details['last_accessed']}")

        # Git-specific information
        if details["cache_type"] == "git":
            click.echo("\nGit Information:")
            click.echo("-" * 20)

            if details.get("local_commit_hash"):
                click.echo(f"Local Commit: {details['local_commit_hash'][:8]}...")
            if details.get("local_branch"):
                click.echo(f"Local Branch: {details['local_branch']}")
            if details.get("remote_commit_hash"):
                click.echo(f"Remote Commit: {details['remote_commit_hash'][:8]}...")
            if details.get("remote_branch"):
                click.echo(f"Remote Branch: {details['remote_branch']}")

            click.echo(
                f"Has Upstream Changes: {details.get('has_upstream_changes', False)}"
            )

            if details.get("upstream_changes_summary"):
                click.echo(f"Changes Summary: {details['upstream_changes_summary']}")

            if details.get("last_diff_check"):
                click.echo(f"Last Diff Check: {details['last_diff_check']}")

            # Current information (if different from stored)
            if "current_local_commit_hash" in details:
                click.echo("\nCurrent Git Information:")
                click.echo("-" * 25)
                click.echo(
                    f"Current Local Commit: {details['current_local_commit_hash'][:8]}..."
                )
                click.echo(f"Current Local Branch: {details['current_local_branch']}")
                click.echo(
                    f"Current Remote Commit: {details['current_remote_commit_hash'][:8]}..."
                )
                click.echo(f"Current Remote Branch: {details['current_remote_branch']}")
                click.echo(
                    f"Current Has Changes: {details['current_has_upstream_changes']}"
                )
                click.echo(
                    f"Current Changes Summary: {details['current_upstream_changes_summary']}"
                )
                click.echo(f"Diff Check Time: {details['diff_check_timestamp']}")

            if "diff_check_error" in details:
                click.echo(f"Diff Check Error: {details['diff_check_error']}")

    except Exception as e:
        click.echo(f"Error: {e}", err=True)
`````

## File: src/metagit/cli/commands/record.py
`````python
#!/usr/bin/env python
"""
Record management subcommands for metagit.

This module provides CLI commands for managing metagit records using the
MetagitRecordManager with support for multiple storage backends.
"""

import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import Optional

import click

from metagit import __version__
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.record.manager import (
    LocalFileStorageBackend,
    MetagitRecordManager,
    OpenSearchStorageBackend,
)
from metagit.core.utils.yaml_class import yaml


class DateTimeEncoder(json.JSONEncoder):
    """Custom JSON encoder that handles datetime objects."""

    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()
        return super().default(obj)


@click.group(name="record", invoke_without_command=True)
@click.option(
    "--storage-type",
    type=click.Choice(["local", "opensearch"]),
    default="local",
    help="Storage backend type for records",
)
@click.option(
    "--storage-path",
    help="Path for local storage (used when storage-type is 'local')",
    default="./records",
)
@click.option(
    "--opensearch-hosts",
    help="OpenSearch hosts (comma-separated, used when storage-type is 'opensearch')",
    default="localhost:9200",
)
@click.option(
    "--opensearch-index",
    help="OpenSearch index name (used when storage-type is 'opensearch')",
    default="metagit-records",
)
@click.option(
    "--opensearch-username",
    help="OpenSearch username (used when storage-type is 'opensearch')",
    default=None,
)
@click.option(
    "--opensearch-password",
    help="OpenSearch password (used when storage-type is 'opensearch')",
    default=None,
)
@click.option(
    "--opensearch-use-ssl",
    is_flag=True,
    help="Use SSL for OpenSearch connection",
)
@click.pass_context
def record(
    ctx: click.Context,
    storage_type: str,
    storage_path: str,
    opensearch_hosts: str,
    opensearch_index: str,
    opensearch_username: str,
    opensearch_password: str,
    opensearch_use_ssl: bool,
) -> None:
    """Record management subcommands"""
    try:
        # If no subcommand is provided, show help
        if ctx.invoked_subcommand is None:
            click.echo(ctx.get_help())
            return

        # Store storage configuration in context
        ctx.obj["storage_type"] = storage_type
        ctx.obj["storage_path"] = storage_path
        ctx.obj["opensearch_hosts"] = opensearch_hosts
        ctx.obj["opensearch_index"] = opensearch_index
        ctx.obj["opensearch_username"] = opensearch_username
        ctx.obj["opensearch_password"] = opensearch_password
        ctx.obj["opensearch_use_ssl"] = opensearch_use_ssl

    except Exception as e:
        logger = ctx.obj.get("logger")
        if logger:
            logger.error(f"An error occurred in the record command: {e}")
        else:
            click.echo(f"An error occurred: {e}", err=True)
        ctx.abort()


def _get_record_manager(ctx: click.Context) -> MetagitRecordManager:
    """Get a configured MetagitRecordManager instance."""
    logger = ctx.obj["logger"]
    storage_type = ctx.obj["storage_type"]

    try:
        if storage_type == "local":
            storage_path = Path(ctx.obj["storage_path"])
            backend = LocalFileStorageBackend(storage_path)
            logger.debug(f"Using local storage backend at {storage_path}")

        elif storage_type == "opensearch":
            # Import OpenSearchService here to avoid import issues if not installed
            try:
                from metagit.api.opensearch import OpenSearchService
            except ImportError as exc:
                raise ImportError(
                    "opensearch-py is required for OpenSearch backend. Install with: pip install opensearch-py"
                ) from exc

            # Parse hosts
            hosts = []
            for host_str in ctx.obj["opensearch_hosts"].split(","):
                if ":" in host_str:
                    host, port = host_str.split(":", 1)
                    hosts.append({"host": host.strip(), "port": int(port.strip())})
                else:
                    hosts.append({"host": host_str.strip(), "port": 9200})

            opensearch_service = OpenSearchService(
                hosts=hosts,
                index_name=ctx.obj["opensearch_index"],
                username=ctx.obj["opensearch_username"],
                password=ctx.obj["opensearch_password"],
                use_ssl=ctx.obj["opensearch_use_ssl"],
                verify_certs=False,
                ssl_show_warn=False,
            )
            backend = OpenSearchStorageBackend(opensearch_service)
            logger.debug(
                f"Using OpenSearch backend with index {ctx.obj['opensearch_index']}"
            )

        else:
            raise ValueError(f"Unsupported storage type: {storage_type}")

        return MetagitRecordManager(storage_backend=backend, logger=logger)

    except Exception as e:
        logger.error(f"Failed to initialize record manager: {e}")
        raise


@record.command("create")
@click.option(
    "--config-path",
    help="Path to the metagit configuration file",
    default=".metagit.yml",
)
@click.option(
    "--detection-source",
    help="Source of the detection",
    default="local",
)
@click.option(
    "--detection-version",
    help="Version of the detection system",
    default=__version__,
)
@click.option(
    "--output-file",
    help="Save record to file (optional)",
    default=None,
)
@click.pass_context
def record_create(
    ctx: click.Context,
    config_path: str,
    detection_source: str,
    detection_version: str,
    output_file: Optional[str],
) -> None:
    """Create a record from metagit configuration"""
    logger = ctx.obj["logger"]

    try:
        # Load configuration
        config_manager = MetagitConfigManager(config_path=Path(config_path))
        config_result = config_manager.load_config()
        if isinstance(config_result, Exception):
            raise config_result

        # Create record manager
        record_manager = _get_record_manager(ctx)

        # Create record from config
        record = record_manager.create_record_from_config(
            config=config_result,
            detection_source=detection_source,
            detection_version=detection_version,
        )

        if isinstance(record, Exception):
            raise record

        logger.success(f"Created record for project: {record.name}")
        logger.info(f"Detection source: {record.detection_source}")
        logger.info(f"Detection version: {record.detection_version}")

        # Save to file if requested
        if output_file:
            file_path = Path(output_file)
            save_result = record_manager.save_record_to_file(record, file_path)
            if isinstance(save_result, Exception):
                raise save_result
            logger.success(f"Record saved to: {file_path}")

        # Store in backend
        async def store_record():
            return await record_manager.store_record(record)

        record_id = asyncio.run(store_record())
        if isinstance(record_id, Exception):
            raise record_id

        logger.success(f"Record stored with ID: {record_id}")

    except Exception as e:
        logger.error(f"Failed to create record: {e}")
        ctx.abort()


@record.command("show")
@click.argument("record_id", required=False)
@click.option(
    "--format",
    type=click.Choice(["yaml", "json"]),
    default="yaml",
    help="Output format",
)
@click.pass_context
def record_show(ctx: click.Context, record_id: Optional[str], format: str) -> None:
    """Show record(s)"""
    logger = ctx.obj["logger"]

    try:
        record_manager = _get_record_manager(ctx)

        if record_id:
            # Show specific record
            async def get_record():
                return await record_manager.get_record(record_id)

            record = asyncio.run(get_record())
            if isinstance(record, Exception):
                raise record

            if format == "yaml":
                yaml.Dumper.ignore_aliases = lambda *args: True  # noqa: ARG005
                output = yaml.dump(
                    record.model_dump(exclude_none=True, exclude_defaults=True),
                    default_flow_style=False,
                    sort_keys=False,
                    indent=2,
                )
                logger.echo(output)
            else:  # json
                logger.echo(
                    record.model_dump_json(
                        exclude_none=True, exclude_defaults=True, indent=2
                    )
                )

        else:
            # List all records
            async def list_records():
                return await record_manager.list_records()

            records = asyncio.run(list_records())
            if isinstance(records, Exception):
                raise records

            if not records:
                logger.info("No records found")
                return

            logger.info(f"Found {len(records)} record(s):")
            for record in records:
                logger.echo(f"  ID: {getattr(record, 'record_id', 'N/A')}")
                logger.echo(f"  Name: {record.name}")
                logger.echo(f"  Description: {record.description or 'N/A'}")
                logger.echo(f"  Detection Source: {record.detection_source}")
                logger.echo(f"  Detection Timestamp: {record.detection_timestamp}")
                logger.echo("  ---")

    except Exception as e:
        logger.error(f"Failed to show record(s): {e}")
        ctx.abort()


@record.command("search")
@click.argument("query", required=True)
@click.option(
    "--page",
    type=int,
    default=1,
    help="Page number for pagination",
)
@click.option(
    "--size",
    type=int,
    default=20,
    help="Number of records per page",
)
@click.option(
    "--format",
    type=click.Choice(["yaml", "json", "table"]),
    default="table",
    help="Output format",
)
@click.pass_context
def record_search(
    ctx: click.Context, query: str, page: int, size: int, format: str
) -> None:
    """Search records"""
    logger = ctx.obj["logger"]

    try:
        record_manager = _get_record_manager(ctx)

        async def search_records():
            return await record_manager.search_records(query, page=page, size=size)

        results = asyncio.run(search_records())
        if isinstance(results, Exception):
            raise results

        records = results.get("records", [])
        total = results.get("total", 0)
        current_page = results.get("page", 1)
        total_pages = results.get("pages", 1)

        logger.info(f"Search results for '{query}': {total} total records")
        logger.info(f"Page {current_page} of {total_pages}")

        if not records:
            logger.info("No records found")
            return

        if format == "table":
            # Simple table format
            logger.echo("ID\tName\tDescription\tSource\tTimestamp")
            logger.echo("-" * 80)
            for record in records:
                record_id = getattr(record, "record_id", "N/A")
                description = record.description or "N/A"
                if len(description) > 30:
                    description = description[:27] + "..."
                logger.echo(
                    f"{record_id}\t{record.name}\t{description}\t{record.detection_source}\t{record.detection_timestamp}"
                )

        elif format == "yaml":
            yaml.Dumper.ignore_aliases = lambda *args: True  # noqa: ARG005
            output = yaml.dump(
                [
                    record.model_dump(exclude_none=True, exclude_defaults=True)
                    for record in records
                ],
                default_flow_style=False,
                sort_keys=False,
                indent=2,
            )
            logger.echo(output)

        else:  # json
            output = json.dumps(
                [
                    record.model_dump(exclude_none=True, exclude_defaults=True)
                    for record in records
                ],
                indent=2,
                cls=DateTimeEncoder,
            )
            logger.echo(output)

    except Exception as e:
        logger.error(f"Failed to search records: {e}")
        ctx.abort()


@record.command("update")
@click.argument("record_id", required=True)
@click.option(
    "--config-path",
    help="Path to the updated metagit configuration file",
    default=".metagit.yml",
)
@click.option(
    "--detection-source",
    help="Updated detection source",
    default=None,
)
@click.option(
    "--detection-version",
    help="Updated detection version",
    default=None,
)
@click.pass_context
def record_update(
    ctx: click.Context,
    record_id: str,
    config_path: str,
    detection_source: Optional[str],
    detection_version: Optional[str],
) -> None:
    """Update an existing record"""
    logger = ctx.obj["logger"]

    try:
        record_manager = _get_record_manager(ctx)

        # Get existing record
        async def get_record():
            return await record_manager.get_record(record_id)

        existing_record = asyncio.run(get_record())
        if isinstance(existing_record, Exception):
            raise existing_record

        # Load updated config if provided
        if Path(config_path).exists():
            config_manager = MetagitConfigManager(config_path=Path(config_path))
            config_result = config_manager.load_config()
            if isinstance(config_result, Exception):
                raise config_result

            # Create updated record
            updated_record = record_manager.create_record_from_config(
                config=config_result,
                detection_source=detection_source or existing_record.detection_source,
                detection_version=detection_version
                or existing_record.detection_version,
            )

            if isinstance(updated_record, Exception):
                raise updated_record
        else:
            # Update only specific fields
            updated_record = existing_record
            if detection_source:
                updated_record.detection_source = detection_source
            if detection_version:
                updated_record.detection_version = detection_version
            updated_record.detection_timestamp = (
                None  # Will be set by create_record_from_config
            )

        # Update the record
        async def update_record():
            return await record_manager.update_record(record_id, updated_record)

        result = asyncio.run(update_record())
        if isinstance(result, Exception):
            raise result

        logger.success(f"Record {record_id} updated successfully")

    except Exception as e:
        logger.error(f"Failed to update record: {e}")
        ctx.abort()


@record.command("delete")
@click.argument("record_id", required=True)
@click.option(
    "--force",
    is_flag=True,
    help="Force deletion without confirmation",
)
@click.pass_context
def record_delete(ctx: click.Context, record_id: str, force: bool) -> None:
    """Delete a record"""
    logger = ctx.obj["logger"]

    try:
        if not force:
            # Show record info before deletion
            record_manager = _get_record_manager(ctx)

            async def get_record():
                return await record_manager.get_record(record_id)

            record = asyncio.run(get_record())
            if isinstance(record, Exception):
                raise record

            logger.info("About to delete record:")
            logger.info(f"  ID: {record_id}")
            logger.info(f"  Name: {record.name}")
            logger.info(f"  Description: {record.description or 'N/A'}")

            if not click.confirm("Are you sure you want to delete this record?"):
                logger.info("Deletion cancelled")
                return

        # Delete the record
        record_manager = _get_record_manager(ctx)

        async def delete_record():
            return await record_manager.delete_record(record_id)

        result = asyncio.run(delete_record())
        if isinstance(result, Exception):
            raise result

        logger.success(f"Record {record_id} deleted successfully")

    except Exception as e:
        logger.error(f"Failed to delete record: {e}")
        ctx.abort()


@record.command("export")
@click.argument("record_id", required=True)
@click.argument("output_file", required=True)
@click.option(
    "--format",
    type=click.Choice(["yaml", "json"]),
    default="yaml",
    help="Export format",
)
@click.pass_context
def record_export(
    ctx: click.Context, record_id: str, output_file: str, format: str
) -> None:
    """Export a record to file"""
    logger = ctx.obj["logger"]

    try:
        record_manager = _get_record_manager(ctx)

        # Get the record
        async def get_record():
            return await record_manager.get_record(record_id)

        record = asyncio.run(get_record())
        if isinstance(record, Exception):
            raise record

        # Export to file
        file_path = Path(output_file)

        if format == "yaml":
            save_result = record_manager.save_record_to_file(record, file_path)
            if isinstance(save_result, Exception):
                raise save_result
        else:  # json
            with open(file_path, "w", encoding="utf-8") as f:
                json.dump(
                    record.model_dump(exclude_none=True, exclude_defaults=True),
                    f,
                    indent=2,
                    cls=DateTimeEncoder,
                )

        logger.success(f"Record exported to: {file_path}")

    except Exception as e:
        logger.error(f"Failed to export record: {e}")
        ctx.abort()


@record.command("import")
@click.argument("input_file", required=True)
@click.option(
    "--detection-source",
    help="Override detection source",
    default=None,
)
@click.option(
    "--detection-version",
    help="Override detection version",
    default=None,
)
@click.pass_context
def record_import(
    ctx: click.Context,
    input_file: str,
    detection_source: Optional[str],
    detection_version: Optional[str],
) -> None:
    """Import a record from file"""
    logger = ctx.obj["logger"]

    try:
        record_manager = _get_record_manager(ctx)

        # Load record from file
        file_path = Path(input_file)
        record = record_manager.load_record_from_file(file_path)
        if isinstance(record, Exception):
            raise record

        # Override fields if specified
        if detection_source:
            record.detection_source = detection_source
        if detection_version:
            record.detection_version = detection_version

        # Store the record
        async def store_record():
            return await record_manager.store_record(record)

        record_id = asyncio.run(store_record())
        if isinstance(record_id, Exception):
            raise record_id

        logger.success(f"Record imported with ID: {record_id}")
        logger.info(f"Name: {record.name}")
        logger.info(f"Detection source: {record.detection_source}")

    except Exception as e:
        logger.error(f"Failed to import record: {e}")
        ctx.abort()


@record.command("stats")
@click.pass_context
def record_stats(ctx: click.Context) -> None:
    """Show record storage statistics"""
    logger = ctx.obj["logger"]

    try:
        record_manager = _get_record_manager(ctx)

        # Get all records for statistics
        async def list_records():
            return await record_manager.list_records(page=1, size=1000)

        records = asyncio.run(list_records())
        if isinstance(records, Exception):
            raise records

        total_records = len(records)

        if total_records == 0:
            logger.info("No records found")
            return

        # Calculate statistics
        sources = {}
        kinds = {}
        languages = {}

        for record in records:
            # Count by detection source
            source = record.detection_source or "unknown"
            sources[source] = sources.get(source, 0) + 1

            # Count by project kind
            kind = record.kind or "unknown"
            kinds[kind] = kinds.get(kind, 0) + 1

            # Count by primary language
            if record.language and record.language.primary:
                lang = record.language.primary
                languages[lang] = languages.get(lang, 0) + 1

        # Display statistics
        logger.info("Record Statistics:")
        logger.info(f"  Total records: {total_records}")
        logger.info(f"  Storage type: {ctx.obj['storage_type']}")

        logger.info("\nBy Detection Source:")
        for source, count in sorted(sources.items()):
            logger.info(f"  {source}: {count}")

        logger.info("\nBy Project Kind:")
        for kind, count in sorted(kinds.items()):
            logger.info(f"  {kind}: {count}")

        logger.info("\nBy Primary Language:")
        for lang, count in sorted(languages.items()):
            logger.info(f"  {lang}: {count}")

    except Exception as e:
        logger.error(f"Failed to get record statistics: {e}")
        ctx.abort()
`````

## File: src/metagit/cli/__init__.py
`````python

`````

## File: src/metagit/core/config/manager.py
`````python
#!/usr/bin/env python
"""
Class for managing .metagit.yml configuration files.

This package provides a class for managing .metagit.yml configuration files.
"""

from pathlib import Path
from typing import Optional, Union

from git import Repo

from metagit.core.config.models import MetagitConfig
from metagit.core.utils.logging import LoggerConfig, UnifiedLogger
from metagit.core.utils.yaml_class import yaml
from metagit.core.workspace.models import Workspace, WorkspaceProject


class MetagitConfigManager:
    """
    Manager class for handling .metagit.yml configuration files.

    This class provides methods for loading, validating, and creating
    .metagit.yml configuration files with proper error handling and validation.
    """

    def __init__(
        self,
        config_path: Optional[Path] = None,
        metagit_config: Optional[MetagitConfig] = None,
    ):
        """
        Initialize the MetagitConfigManager.

        Args:
            config_path: Path to the .metagit.yml file. If None, defaults to .metagit.yml in current directory.
        """
        self.config_path: str = config_path or Path(".metagit.yml")
        self._config: Optional[MetagitConfig] = metagit_config

    @property
    def config(self) -> Union[MetagitConfig, None, Exception]:
        """
        Get the loaded configuration.

        Returns:
            MetagitConfig: The loaded configuration, or None if not loaded
        """
        return self._config

    def load_config(self) -> Union[MetagitConfig, Exception]:
        """
        Load and validate a .metagit.yml configuration file.

        Returns:
            MetagitConfig: Validated configuration object

        Raises:
            FileNotFoundError: If the configuration file is not found
            yaml.YAMLError: If the YAML file is malformed
            ValidationError: If the configuration doesn't match the expected schema
        """
        try:
            if not Path(self.config_path).exists():
                return FileNotFoundError(
                    f"Configuration file not found: {self.config_path}"
                )

            with open(self.config_path, "r", encoding="utf-8") as f:
                yaml_data = yaml.safe_load(f)

            self._config = MetagitConfig(**yaml_data)
            return self._config
        except Exception as e:
            return e

    def validate_config(self) -> Union[bool, Exception]:
        """
        Validate a .metagit.yml configuration file without loading it into memory.

        Returns:
            bool: True if the configuration is valid, False otherwise
        """
        try:
            load_result = self.load_config()
            not isinstance(load_result, Exception)
        except Exception as e:
            return e

    def create_config(
        self,
        name: Optional[str] = None,
        description: Optional[str] = None,
        url: Optional[str] = None,
        kind: Optional[str] = None,
    ) -> Union[MetagitConfig, str, Exception]:
        """
        Create a .metagit.yml project configuration file.

        Args:
            output_path: Path where to save the configuration. If None, returns the config as string.

        Returns:
            MetagitConfig or str: The created configuration object or YAML string
        """
        try:
            workspace = None
            if kind == "umbrella":
                workspace = Workspace(
                    projects=[
                        WorkspaceProject(
                            name="default",
                            repos=[],
                        )
                    ],
                )
            project_config = MetagitConfig(
                name=name,
                description=description,
                url=url,
                kind=kind,
                workspace=workspace,
            )
            return project_config
        except Exception as e:
            return e

    def reload_config(self) -> Union[MetagitConfig, Exception]:
        """
        Reload the configuration from disk.

        Returns:
            MetagitConfig: The reloaded configuration object
        """
        self._config = None
        return self.load_config()

    def save_config(
        self, config: Optional[MetagitConfig] = None, output_path: Optional[Path] = None
    ) -> Union[None, Exception]:
        """
        Save a configuration to a YAML file.

        Args:
            config: Configuration to save. If None, uses the loaded config.
            output_path: Path where to save the configuration. If None, uses the instance config_path.
        """
        try:
            config_to_save = config or self._config
            if config_to_save is None:
                return ValueError(
                    "No configuration to save. Load a config first or provide one."
                )

            save_path = output_path or self.config_path
            with open(save_path, "w", encoding="utf-8") as f:
                yaml.dump(
                    config_to_save.model_dump(exclude_none=True, exclude_defaults=True),
                    f,
                )
            return None
        except Exception as e:
            return e


def create_metagit_config(
    name: Optional[str] = None,
    description: Optional[str] = None,
    url: Optional[str] = None,
    kind: Optional[str] = None,
    logger: Optional[UnifiedLogger] = None,
    as_yaml: bool = False,
) -> Union[MetagitConfig, str, Exception]:
    """
    Create a top level .metagit.yml configuration file.
    """
    logger = logger or UnifiedLogger(
        LoggerConfig(log_level="INFO", minimal_console=True)
    )
    if name is None:
        try:
            git_repo = Repo(Path.cwd())
            name = Path(git_repo.working_dir).name
        except Exception:
            name = Path.cwd().name

    if description is None:
        description = git_repo.description or "No description"
    if url is None:
        url = git_repo.remote().url or None
    if kind is None:
        kind = "application"
    try:
        config_manager = MetagitConfigManager()
        config_result = config_manager.create_config(
            name=name, description=description, url=url, kind=kind
        )
        if isinstance(config_result, Exception):
            raise config_result
    except Exception as e:
        logger.error(f"Failed to create config: {e}")
        return e
    config_manager.paths = []
    config_manager.dependencies = []
    config_manager.components = []
    config_manager.workspace = Workspace(
        projects=[
            WorkspaceProject(
                name="default",
                repos=[],
            )
        ],
    )

    if as_yaml:
        yaml.Dumper.ignore_aliases = lambda *args: True  # noqa: ARG005
        output = yaml.dump(
            config_result.model_dump(exclude_unset=False, exclude_none=True),
            default_flow_style=False,
            sort_keys=False,
            indent=2,
            line_break=True,
        )
        return output
    else:
        return config_result
`````

## File: src/metagit/core/flows/detect_flow/crews/project_understanding_crew/config/agents.yaml
`````yaml
code_structure_analyst:
  role: Codebase Structure Mapper
  goal: Analyze and document the structure and key files of a codebase
  backstory: >
    A veteran software architect known for deconstructing complex projects by examining
    file hierarchies and naming patterns.

dependency_reviewer:
  role: Dependency Inspector
  goal: Extract and analyze dependencies and configurations
  backstory: >
    A DevOps veteran with deep knowledge of Python packaging, Docker, and environment files.

logic_summarizer:
  role: Code Logic Interpreter
  goal: Summarize key classes and functions
  backstory: >
    A code reader AI trained on thousands of GitHub projects to recognize core logic patterns.

summary_formatter:
  role: Summary Structurer
  goal: Format the collected analysis into a pydantic structure
  backstory: >
    A schema-obsessed engineer AI that formats unstructured output into structured insights.
`````

## File: src/metagit/core/flows/detect_flow/crews/project_understanding_crew/config/tasks.yaml
`````yaml
structure_task:
  description: >
    Traverse the codebase at {path}. Count files, extract file types, and identify entry points.
    Your final answer MUST include `num_files`, `file_types`, and `entry_points`.
  expected_output: >
    {
      "num_files": int,
      "file_types": List[str],
      "entry_points": List[str]
    }

dependency_task:
  description: >
    Parse pyproject.toml, requirements.txt, and Dockerfile at {path}.
    Identify dependencies, Docker usage, and .env or config presence.
    Output must include: `main_dependencies`, `uses_docker`, `uses_env_files`.
  expected_output: >
    {
      "main_dependencies": List[str],
      "uses_docker": bool,
      "uses_env_files": bool
    }

logic_task:
  description: >
    Analyze main modules at {path} and summarize key classes and functions.
    Output must include `modules`, `key_classes`, and `key_functions`.
  expected_output: >
    {
      "modules": List[str],
      "key_classes": List[str],
      "key_functions": List[str]
    }

formatting_task:
  description: >
    Using structure_task, dependency_task, and logic_task outputs, build a MetagitConfig object.
    Your final output MUST match the full MetagitConfig pydantic class structure.
  expected_output: >
    A dict that can be used to instantiate a MetagitConfig pydantic object.
`````

## File: src/metagit/core/flows/detect_flow/crews/project_understanding_crew/project_understanding_crew.py
`````python
from typing import Union

from crewai import Agent, Crew, Process, Task
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.project import CrewBase, agent, crew, task

# If you want to run a snippet of code before or after the crew starts,
# you can use the @before_kickoff and @after_kickoff decorators
# https://docs.crewai.com/concepts/crews#example-crew-class-with-decorators


@CrewBase
class ProjectUnderstandingCrew:
    """ProjectUnderstandingCrew crew"""

    agents: list[BaseAgent]
    tasks: list[Task]

    # Learn more about YAML configuration files here:
    # Agents: https://docs.crewai.com/concepts/agents#yaml-configuration-recommended
    # Tasks: https://docs.crewai.com/concepts/tasks#yaml-configuration-recommended

    # If you would like to add tools to your agents, you can learn more about it here:
    # https://docs.crewai.com/concepts/agents#agent-tools
    @agent
    def researcher(self) -> Union[Agent, Exception]:
        try:
            return Agent(
                config=self.agents_config["researcher"],  # type: ignore[index]
                verbose=True,
            )
        except Exception as e:
            return e

    @agent
    def reporting_analyst(self) -> Union[Agent, Exception]:
        try:
            return Agent(
                config=self.agents_config["reporting_analyst"],  # type: ignore[index]
                verbose=True,
            )
        except Exception as e:
            return e

    # To learn more about structured task outputs,
    # task dependencies, and task callbacks, check out the documentation:
    # https://docs.crewai.com/concepts/tasks#overview-of-a-task
    @task
    def research_task(self) -> Union[Task, Exception]:
        try:
            return Task(
                config=self.tasks_config["research_task"],  # type: ignore[index]
            )
        except Exception as e:
            return e

    @task
    def reporting_task(self) -> Union[Task, Exception]:
        try:
            return Task(
                config=self.tasks_config["reporting_task"],  # type: ignore[index]
                output_file="report.md",
            )
        except Exception as e:
            return e

    @crew
    def crew(self) -> Union[Crew, Exception]:
        """Creates the ProjectUnderstandingCrew crew"""
        try:
            # To learn how to add knowledge sources to your crew, check out the documentation:
            # https://docs.crewai.com/concepts/knowledge#what-is-knowledge

            return Crew(
                agents=self.agents,  # Automatically created by the @agent decorator
                tasks=self.tasks,  # Automatically created by the @task decorator
                process=Process.sequential,
                verbose=True,
                # process=Process.hierarchical, # In case you wanna use that instead https://docs.crewai.com/how-to/Hierarchical/
            )
        except Exception as e:
            return e
`````

## File: src/metagit/core/flows/detect_flow/tools/__init__.py
`````python

`````

## File: src/metagit/core/flows/detect_flow/tools/human.py
`````python
"""Tool for asking human input."""

from collections.abc import Callable
from typing import Union

from crewai.tools import BaseTool
from pydantic import BaseModel, Field


def _print_func(text: str) -> Union[None, Exception]:
    try:
        print("\n")
        print(text)
        return None
    except Exception as e:
        return e


def input_func() -> Union[str, Exception]:
    try:
        print("Insert your text. Press Ctrl-D (or Ctrl-Z on Windows) to end.")
        contents = []
        while True:
            try:
                line = input()
            except EOFError:
                break
            contents.append(line)
        return "\n".join(contents)
    except Exception as e:
        return e


class MyToolInput(BaseModel):
    """Input schema for MyCustomTool."""

    query: str = Field(..., description="Query to the human.")


class HumanTool(BaseTool):
    name: str = "HumanTool"
    description: str = (
        "You can ask a human for guidance when you think you"
        " got stuck or you are not sure what to do next."
        " The input should be a question for the human."
        " This tool version is suitable when you need answers that span over"
        " several lines."
    )
    args_schema: type[BaseModel] = MyToolInput
    prompt_func: Callable[[str], None] = _print_func
    input_func: Callable[[], str] = input_func

    def _run(self, query: str) -> Union[str, Exception]:
        """Use the Multi Line Human input tool."""
        try:
            prompt_result = self.prompt_func(query)
            if isinstance(prompt_result, Exception):
                return prompt_result
            return self.input_func()
        except Exception as e:
            return e
`````

## File: src/metagit/core/flows/detect_flow/tools/index_tools.py
`````python
#! /usr/bin/env python3
"""
Index tools for the detect flow
"""

import os
from pathlib import Path

from crewai import ToolResponse, tool
from langchain.embeddings import OpenAIEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.vectorstores import FAISS


@tool
def index_repo(
    repo_path: str, chunk_size: int = 1000, chunk_overlap: int = 200
) -> ToolResponse:
    """
    Walk the repo, split files, embed, and store in FAISS.
    Returns index metadata location.
    """
    try:
        embeddings = OpenAIEmbeddings()
        text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size, chunk_overlap=chunk_overlap
        )
        docs = []
        for root, _, files in os.walk(repo_path):
            for fn in files:
                if fn.endswith((".py", ".json", ".yaml", ".yml")):
                    full = Path(root) / fn
                    text = full.read_text(encoding="utf-8")
                    for i, chunk in enumerate(text_splitter.split_text(text)):
                        docs.append(
                            {
                                "page_content": chunk,
                                "metadata": {"path": str(full), "chunk": i},
                            }
                        )
        vectorstore = FAISS.from_documents(
            [d["page_content"] for d in docs],
            embeddings,
            metadatas=[d["metadata"] for d in docs],
        )
        vectorstore.save_local(f"{repo_path}/.repo_index")
        return ToolResponse(
            success=True, data={"index_path": f"{repo_path}/.repo_index"}
        )
    except Exception as e:
        return ToolResponse(success=False, error=str(e))
`````

## File: src/metagit/core/flows/detect_flow/__init__.py
`````python

`````

## File: src/metagit/core/flows/detect_flow/main.py
`````python
#!/usr/bin/env python


# class PoemState(BaseModel):
#     sentence_count: int = 1
#     poem: str = ""


# class PoemFlow(Flow[PoemState]):

#     @start()
#     def generate_sentence_count(self):
#         print("Generating sentence count")
#         self.state.sentence_count = randint(1, 5)

#     @listen(generate_sentence_count)
#     def generate_poem(self):
#         print("Generating poem")
#         result = (
#             PoemCrew()
#             .crew()
#             .kickoff(inputs={"sentence_count": self.state.sentence_count})
#         )

#         print("Poem generated", result.raw)
#         self.state.poem = result.raw

#     @listen(generate_poem)
#     def save_poem(self):
#         print("Saving poem")
#         with open("poem.txt", "w") as f:
#             f.write(self.state.poem)


# def kickoff():
#     poem_flow = PoemFlow()
#     poem_flow.kickoff()


# def plot():
#     poem_flow = PoemFlow()
#     poem_flow.plot()


if __name__ == "__main__":
    pass
    # kickoff()
`````

## File: src/metagit/core/gitcache/__init__.py
`````python
#!/usr/bin/env python
"""
Git cache management module.

This module provides functionality for caching git repositories locally,
supporting both remote cloning and local file copying with async and sync operations.
"""

from .config import GitCacheConfig
from .manager import GitCacheManager

__all__ = ["GitCacheConfig", "GitCacheManager"]
`````

## File: src/metagit/core/gitcache/config.py
`````python
#!/usr/bin/env python
"""
Git cache configuration models.

This module defines the Pydantic models used for configuring
the git cache management system.
"""

from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional

from pydantic import BaseModel, Field, field_validator


class CacheType(str, Enum):
    """Enumeration of cache types."""

    GIT = "git"
    LOCAL = "local"


class CacheStatus(str, Enum):
    """Enumeration of cache statuses."""

    FRESH = "fresh"
    STALE = "stale"
    MISSING = "missing"
    ERROR = "error"


class GitCacheEntry(BaseModel):
    """Model for a single git cache entry."""

    name: str = Field(..., description="Cache entry name/identifier")
    source_url: str = Field(..., description="Source URL or local path")
    cache_type: CacheType = Field(..., description="Type of cache entry")
    cache_path: Path = Field(..., description="Local cache path")
    created_at: datetime = Field(
        default_factory=datetime.now, description="Creation timestamp"
    )
    last_updated: datetime = Field(
        default_factory=datetime.now, description="Last update timestamp"
    )
    last_accessed: datetime = Field(
        default_factory=datetime.now, description="Last access timestamp"
    )
    size_bytes: Optional[int] = Field(None, description="Cache size in bytes")
    status: CacheStatus = Field(
        default=CacheStatus.FRESH, description="Current cache status"
    )
    error_message: Optional[str] = Field(
        None, description="Error message if status is ERROR"
    )
    metadata: Dict[str, Any] = Field(
        default_factory=dict, description="Additional metadata"
    )

    # Git-specific tracking fields
    local_commit_hash: Optional[str] = Field(
        None, description="Current local commit hash"
    )
    local_branch: Optional[str] = Field(None, description="Current local branch name")
    remote_commit_hash: Optional[str] = Field(
        None, description="Latest remote commit hash"
    )
    remote_branch: Optional[str] = Field(None, description="Default remote branch name")
    has_upstream_changes: Optional[bool] = Field(
        None, description="Whether upstream has new commits"
    )
    upstream_changes_summary: Optional[str] = Field(
        None, description="Summary of upstream changes"
    )
    last_diff_check: Optional[datetime] = Field(
        None, description="Last time differences were checked"
    )

    @field_validator("cache_path", mode="before")
    @classmethod
    def validate_cache_path(cls, v: Any) -> Path:
        """Convert string to Path object."""
        if isinstance(v, str):
            return Path(v)
        return v

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class GitCacheConfig(BaseModel):
    """Configuration model for git cache management."""

    cache_root: Path = Field(
        default=Path("./.metagit/.cache"),
        description="Root directory for git cache storage",
    )
    default_timeout_minutes: int = Field(
        default=60, description="Default cache timeout in minutes"
    )
    max_cache_size_gb: float = Field(
        default=10.0, description="Maximum cache size in GB"
    )
    enable_async: bool = Field(default=True, description="Enable async operations")
    git_config: Dict[str, Any] = Field(
        default_factory=dict, description="Git configuration options"
    )
    provider_config: Optional[Dict[str, Any]] = Field(
        None, description="Provider-specific configuration"
    )
    entries: Dict[str, GitCacheEntry] = Field(
        default_factory=dict, description="Cache entries"
    )

    @field_validator("cache_root", mode="before")
    @classmethod
    def validate_cache_root(cls, v: Any) -> Path:
        """Convert string to Path object and ensure it exists."""
        if isinstance(v, str):
            cache_path = Path(v)
        else:
            cache_path = v

        # Create cache directory if it doesn't exist
        cache_path.mkdir(parents=True, exist_ok=True)
        return cache_path

    @field_validator("default_timeout_minutes")
    @classmethod
    def validate_timeout(cls, v: int) -> int:
        """Validate timeout is positive."""
        if v <= 0:
            raise ValueError("Timeout must be positive")
        return v

    @field_validator("max_cache_size_gb")
    @classmethod
    def validate_max_size(cls, v: float) -> float:
        """Validate max cache size is positive."""
        if v <= 0:
            raise ValueError("Max cache size must be positive")
        return v

    def get_cache_path(self, name: str) -> Path:
        """Get the cache path for a specific entry."""
        return self.cache_root / name

    def is_entry_stale(self, entry: GitCacheEntry) -> bool:
        """Check if a cache entry is stale based on timeout."""
        timeout_delta = timedelta(minutes=self.default_timeout_minutes)
        return datetime.now() - entry.last_updated > timeout_delta

    def get_cache_size_bytes(self) -> int:
        """Get total cache size in bytes."""
        total_size = 0
        for entry in self.entries.values():
            if entry.cache_path.exists():
                try:
                    total_size += sum(
                        f.stat().st_size
                        for f in entry.cache_path.rglob("*")
                        if f.is_file()
                    )
                except (OSError, PermissionError):
                    continue
        return total_size

    def get_cache_size_gb(self) -> float:
        """Get total cache size in GB."""
        return self.get_cache_size_bytes() / (1024**3)

    def is_cache_full(self) -> bool:
        """Check if cache is at maximum size."""
        return self.get_cache_size_gb() >= self.max_cache_size_gb

    def add_entry(self, entry: GitCacheEntry) -> None:
        """Add a cache entry."""
        self.entries[entry.name] = entry

    def remove_entry(self, name: str) -> bool:
        """Remove a cache entry."""
        if name in self.entries:
            del self.entries[name]
            return True
        return False

    def get_entry(self, name: str) -> Optional[GitCacheEntry]:
        """Get a cache entry by name."""
        return self.entries.get(name)

    def list_entries(self) -> List[GitCacheEntry]:
        """List all cache entries."""
        return list(self.entries.values())

    def clear_entries(self) -> None:
        """Clear all cache entries."""
        self.entries.clear()

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"
`````

## File: src/metagit/core/gitcache/manager.py
`````python
#!/usr/bin/env python
"""
Git cache manager for handling repository caching operations.

This module provides both synchronous and asynchronous operations
for caching git repositories and local directories.
"""

import asyncio
import logging
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union

import git  # Add this at the top with other imports

from metagit.core.gitcache.config import (
    CacheStatus,
    CacheType,
    GitCacheConfig,
    GitCacheEntry,
)
from metagit.core.providers.base import GitProvider
from metagit.core.utils.common import normalize_git_url

logger = logging.getLogger(__name__)


class GitCacheManager:
    """Manager for git cache operations."""

    def __init__(self, config: GitCacheConfig):
        """
        Initialize the git cache manager.

        Args:
            config: Git cache configuration
        """
        self.config = config
        self._providers: Dict[str, GitProvider] = {}

    def register_provider(self, provider: GitProvider) -> None:
        """
        Register a git provider for handling specific URLs.

        Args:
            provider: Git provider instance
        """
        self._providers[provider.get_name()] = provider
        logger.info(f"Registered git provider: {provider.get_name()}")

    def _get_provider_for_url(self, url: str) -> Optional[GitProvider]:
        """
        Get the appropriate provider for a given URL.

        Args:
            url: Repository URL

        Returns:
            Git provider or None if no provider supports the URL
        """
        normalized_url = normalize_git_url(url)
        for provider in self._providers.values():
            if provider.can_handle_url(normalized_url):
                return provider
        return None

    def _generate_cache_name(self, source: str) -> str:
        """
        Generate a cache name from source URL or path.

        Args:
            source: Source URL or local path

        Returns:
            Generated cache name
        """
        # For git URLs, extract repo name
        if source.startswith(("http://", "https://", "git://", "ssh://")):
            normalized_url = normalize_git_url(source)
            # Extract repo name from URL
            if "/" in normalized_url:
                repo_name = normalized_url.split("/")[-1]
                if repo_name.endswith(".git"):
                    repo_name = repo_name[:-4]
                return repo_name
            return normalized_url.replace("/", "_").replace(":", "_")

        # For local paths, use the directory name
        path = Path(source)
        return path.name if path.name else path.stem

    def _is_git_url(self, source: str) -> bool:
        """
        Check if source is a git URL.

        Args:
            source: Source URL or path

        Returns:
            True if source is a git URL
        """
        return source.startswith(("http://", "https://", "git://", "ssh://"))

    def _is_local_path(self, source: str) -> bool:
        """
        Check if source is a local path.

        Args:
            source: Source URL or path

        Returns:
            True if source is a local path
        """
        path = Path(source)
        return path.exists() and path.is_dir()

    def _is_git_repository(self, path: Path) -> bool:
        """
        Check if a local path is a git repository using gitpython.

        Args:
            path: Local path to check

        Returns:
            True if path is a git repository
        """
        try:
            _ = git.Repo(path)
            return True
        except (git.exc.InvalidGitRepositoryError, git.exc.NoSuchPathError):
            return False
        except Exception:
            return False

    def _is_local_git_repository(self, source: str) -> bool:
        """
        Check if source is a local git repository.

        Args:
            source: Source URL or path

        Returns:
            True if source is a local git repository
        """
        path = Path(source)
        return path.exists() and path.is_dir() and self._is_git_repository(path)

    def _clone_repository(self, url: str, cache_path: Path) -> Union[bool, Exception]:
        """
        Clone a git repository using gitpython.

        Args:
            url: Repository URL
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
        try:
            # Remove existing directory if it exists
            if cache_path.exists():
                shutil.rmtree(cache_path)
            cache_path.mkdir(parents=True, exist_ok=True)
            git.Repo.clone_from(url, str(cache_path), depth=1)
            logger.info(f"Successfully cloned repository: {url}")
            return True
        except Exception as e:
            return Exception(f"Git clone error: {str(e)}")

    async def _clone_repository_async(
        self, url: str, cache_path: Path
    ) -> Union[bool, Exception]:
        """
        Clone a git repository asynchronously using gitpython in a thread.

        Args:
            url: Repository URL
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
        try:
            result = await asyncio.to_thread(self._clone_repository, url, cache_path)
            return result
        except Exception as e:
            return Exception(f"Git clone error (async): {str(e)}")

    def _copy_local_directory(
        self, source_path: Path, cache_path: Path
    ) -> Union[bool, Exception]:
        """
        Copy a local directory to cache.

        Args:
            source_path: Source directory path
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
        try:
            # Remove existing directory if it exists
            if cache_path.exists():
                shutil.rmtree(cache_path)

            # Copy directory
            shutil.copytree(source_path, cache_path)

            logger.info(f"Successfully copied local directory: {source_path}")
            return True

        except Exception as e:
            return Exception(f"Directory copy error: {str(e)}")

    async def _copy_local_directory_async(
        self, source_path: Path, cache_path: Path
    ) -> Union[bool, Exception]:
        """
        Copy a local directory to cache asynchronously.

        Args:
            source_path: Source directory path
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
        try:
            # Run copy operation in thread pool to avoid blocking
            loop = asyncio.get_event_loop()
            result = await loop.run_in_executor(
                None, self._copy_local_directory, source_path, cache_path
            )
            return result

        except Exception as e:
            return Exception(f"Directory copy error: {str(e)}")

    def _pull_updates(self, cache_path: Path) -> Union[bool, Exception]:
        """
        Pull updates for an existing git repository using gitpython.

        Args:
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """
        try:
            repo = git.Repo(cache_path)
            origin = repo.remotes.origin
            origin.pull()
            logger.info(f"Successfully pulled updates for: {cache_path}")
            return True
        except Exception as e:
            return Exception(f"Git pull error: {str(e)}")

    async def _pull_updates_async(self, cache_path: Path) -> Union[bool, Exception]:
        """
        Pull updates for an existing git repository asynchronously using gitpython in a thread.

        Args:
            cache_path: Local cache path

        Returns:
            True if successful, Exception if failed
        """

        try:
            result = await asyncio.to_thread(self._pull_updates, cache_path)
            return result
        except Exception as e:
            return Exception(f"Git pull error (async): {str(e)}")

    def _calculate_directory_size(self, path: Path) -> int:
        """
        Calculate directory size in bytes.

        Args:
            path: Directory path

        Returns:
            Size in bytes
        """
        total_size = 0
        try:
            for file_path in path.rglob("*"):
                if file_path.is_file():
                    total_size += file_path.stat().st_size
        except (OSError, PermissionError):
            pass
        return total_size

    def cache_repository(
        self, source: str, name: Optional[str] = None
    ) -> Union[GitCacheEntry, Exception]:
        """
        Cache a repository or local directory synchronously.

        Args:
            source: Source URL or local path
            name: Optional cache name (auto-generated if not provided)

        Returns:
            GitCacheEntry if successful, Exception if failed
        """
        try:
            # Generate cache name if not provided
            if name is None:
                name = self._generate_cache_name(source)

            # Check if entry already exists
            existing_entry = self.config.get_entry(name)
            cache_path = self.config.get_cache_path(name)

            # Determine cache type
            if self._is_git_url(source):
                cache_type = CacheType.GIT
            elif self._is_local_git_repository(source):
                cache_type = CacheType.LOCAL
            else:
                return Exception(
                    f"Invalid source: {source} - must be a git URL or local git repository"
                )

            # Handle existing cache
            if existing_entry and cache_path.exists():
                if cache_type == CacheType.GIT:
                    # Check for differences before pulling updates
                    diff_info = self._check_repository_differences(cache_path)

                    # Update entry with difference information
                    existing_entry.local_commit_hash = diff_info["local_info"][
                        "commit_hash"
                    ]
                    existing_entry.local_branch = diff_info["local_info"]["branch"]
                    existing_entry.remote_commit_hash = diff_info["remote_info"][
                        "commit_hash"
                    ]
                    existing_entry.remote_branch = diff_info["remote_info"]["branch"]
                    existing_entry.has_upstream_changes = diff_info["has_changes"]
                    existing_entry.upstream_changes_summary = diff_info[
                        "changes_summary"
                    ]
                    existing_entry.last_diff_check = datetime.now()

                    # Only pull if there are upstream changes
                    if diff_info["has_changes"]:
                        logger.info(
                            f"Upstream changes detected for {name}: {diff_info['changes_summary']}"
                        )
                        result = self._pull_updates(cache_path)
                        if isinstance(result, Exception):
                            existing_entry.status = CacheStatus.ERROR
                            existing_entry.error_message = str(result)
                            return result
                    else:
                        logger.info(f"No upstream changes for {name}")

                else:
                    # For local directories, recopy
                    result = self._copy_local_directory(Path(source), cache_path)
                    if isinstance(result, Exception):
                        existing_entry.status = CacheStatus.ERROR
                        existing_entry.error_message = str(result)
                        return result

                # Update entry
                existing_entry.last_updated = datetime.now()
                existing_entry.last_accessed = datetime.now()
                existing_entry.status = CacheStatus.FRESH
                existing_entry.size_bytes = self._calculate_directory_size(cache_path)
                existing_entry.error_message = None

                return existing_entry

            # Create new cache entry
            entry = GitCacheEntry(
                name=name,
                source_url=source,
                cache_type=cache_type,
                cache_path=cache_path,
                created_at=datetime.now(),
                last_updated=datetime.now(),
                last_accessed=datetime.now(),
                status=CacheStatus.FRESH,
            )

            # Perform caching operation
            if cache_type == CacheType.GIT:
                result = self._clone_repository(source, cache_path)
            else:
                result = self._copy_local_directory(Path(source), cache_path)

            if isinstance(result, Exception):
                entry.status = CacheStatus.ERROR
                entry.error_message = str(result)
                self.config.add_entry(entry)
                return result

            # Calculate size and add entry
            entry.size_bytes = self._calculate_directory_size(cache_path)

            # For git repositories, populate git information
            if cache_type == CacheType.GIT:
                diff_info = self._check_repository_differences(cache_path)
                entry.local_commit_hash = diff_info["local_info"]["commit_hash"]
                entry.local_branch = diff_info["local_info"]["branch"]
                entry.remote_commit_hash = diff_info["remote_info"]["commit_hash"]
                entry.remote_branch = diff_info["remote_info"]["branch"]
                entry.has_upstream_changes = diff_info["has_changes"]
                entry.upstream_changes_summary = diff_info["changes_summary"]
                entry.last_diff_check = datetime.now()

            self.config.add_entry(entry)

            logger.info(f"Successfully cached: {source} -> {cache_path}")
            return entry

        except Exception as e:
            return Exception(f"Cache operation failed: {str(e)}")

    async def cache_repository_async(
        self, source: str, name: Optional[str] = None
    ) -> Union[GitCacheEntry, Exception]:
        """
        Cache a repository or local directory asynchronously.

        Args:
            source: Source URL or local path
            name: Optional cache name (auto-generated if not provided)

        Returns:
            GitCacheEntry if successful, Exception if failed
        """
        try:
            # Generate cache name if not provided
            if name is None:
                name = self._generate_cache_name(source)

            # Check if entry already exists
            existing_entry = self.config.get_entry(name)
            cache_path = self.config.get_cache_path(name)

            # Determine cache type
            if self._is_git_url(source):
                cache_type = CacheType.GIT
            elif self._is_local_git_repository(source):
                cache_type = CacheType.LOCAL
            else:
                return Exception(
                    f"Invalid source: {source} - must be a git URL or local git repository"
                )

            # Handle existing cache
            if existing_entry and cache_path.exists():
                if cache_type == CacheType.GIT:
                    # Check for differences before pulling updates
                    diff_info = self._check_repository_differences(cache_path)

                    # Update entry with difference information
                    existing_entry.local_commit_hash = diff_info["local_info"][
                        "commit_hash"
                    ]
                    existing_entry.local_branch = diff_info["local_info"]["branch"]
                    existing_entry.remote_commit_hash = diff_info["remote_info"][
                        "commit_hash"
                    ]
                    existing_entry.remote_branch = diff_info["remote_info"]["branch"]
                    existing_entry.has_upstream_changes = diff_info["has_changes"]
                    existing_entry.upstream_changes_summary = diff_info[
                        "changes_summary"
                    ]
                    existing_entry.last_diff_check = datetime.now()

                    # Only pull if there are upstream changes
                    if diff_info["has_changes"]:
                        logger.info(
                            f"Upstream changes detected for {name}: {diff_info['changes_summary']}"
                        )
                        result = await self._pull_updates_async(cache_path)
                        if isinstance(result, Exception):
                            existing_entry.status = CacheStatus.ERROR
                            existing_entry.error_message = str(result)
                            return result
                    else:
                        logger.info(f"No upstream changes for {name}")

                else:
                    # For local directories, recopy
                    result = await self._copy_local_directory_async(
                        Path(source), cache_path
                    )
                    if isinstance(result, Exception):
                        existing_entry.status = CacheStatus.ERROR
                        existing_entry.error_message = str(result)
                        return result

                # Update entry
                existing_entry.last_updated = datetime.now()
                existing_entry.last_accessed = datetime.now()
                existing_entry.status = CacheStatus.FRESH
                existing_entry.size_bytes = self._calculate_directory_size(cache_path)
                existing_entry.error_message = None

                return existing_entry

            # Create new cache entry
            entry = GitCacheEntry(
                name=name,
                source_url=source,
                cache_type=cache_type,
                cache_path=cache_path,
                created_at=datetime.now(),
                last_updated=datetime.now(),
                last_accessed=datetime.now(),
                status=CacheStatus.FRESH,
            )

            # Perform caching operation
            if cache_type == CacheType.GIT:
                result = await self._clone_repository_async(source, cache_path)
            else:
                result = await self._copy_local_directory_async(
                    Path(source), cache_path
                )

            if isinstance(result, Exception):
                entry.status = CacheStatus.ERROR
                entry.error_message = str(result)
                self.config.add_entry(entry)
                return result

            # Calculate size and add entry
            entry.size_bytes = self._calculate_directory_size(cache_path)

            # For git repositories, populate git information
            if cache_type == CacheType.GIT:
                diff_info = self._check_repository_differences(cache_path)
                entry.local_commit_hash = diff_info["local_info"]["commit_hash"]
                entry.local_branch = diff_info["local_info"]["branch"]
                entry.remote_commit_hash = diff_info["remote_info"]["commit_hash"]
                entry.remote_branch = diff_info["remote_info"]["branch"]
                entry.has_upstream_changes = diff_info["has_changes"]
                entry.upstream_changes_summary = diff_info["changes_summary"]
                entry.last_diff_check = datetime.now()

            self.config.add_entry(entry)

            logger.info(f"Successfully cached: {source} -> {cache_path}")
            return entry

        except Exception as e:
            return Exception(f"Cache operation failed: {str(e)}")

    def get_cached_repository(self, name: str) -> Union[Path, Exception]:
        """
        Get the path to a cached repository.

        Args:
            name: Cache entry name

        Returns:
            Path to cached repository or Exception if not found
        """
        entry = self.config.get_entry(name)
        if entry is None:
            return Exception(f"Cache entry not found: {name}")

        if not entry.cache_path.exists():
            entry.status = CacheStatus.MISSING
            return Exception(f"Cache path does not exist: {entry.cache_path}")

        # Update last accessed time
        entry.last_accessed = datetime.now()

        # Check if cache is stale
        if self.config.is_entry_stale(entry):
            entry.status = CacheStatus.STALE

        return entry.cache_path

    def list_cache_entries(self) -> List[GitCacheEntry]:
        """
        List all cache entries.

        Returns:
            List of cache entries
        """
        entries = self.config.list_entries()

        # Update status for each entry
        for entry in entries:
            if not entry.cache_path.exists():
                entry.status = CacheStatus.MISSING
            elif self.config.is_entry_stale(entry):
                entry.status = CacheStatus.STALE
            else:
                entry.status = CacheStatus.FRESH

        return entries

    def remove_cache_entry(self, name: str) -> Union[bool, Exception]:
        """
        Remove a cache entry and its files.

        Args:
            name: Cache entry name

        Returns:
            True if successful, Exception if failed
        """
        try:
            entry = self.config.get_entry(name)
            if entry is None:
                return Exception(f"Cache entry not found: {name}")

            # Remove cache directory
            if entry.cache_path.exists():
                shutil.rmtree(entry.cache_path)

            # Remove entry from config
            self.config.remove_entry(name)

            logger.info(f"Successfully removed cache entry: {name}")
            return True

        except Exception as e:
            return Exception(f"Failed to remove cache entry: {str(e)}")

    def refresh_cache_entry(self, name: str) -> Union[GitCacheEntry, Exception]:
        """
        Refresh a cache entry by re-caching the source.

        Args:
            name: Cache entry name

        Returns:
            Updated GitCacheEntry or Exception if failed
        """
        entry = self.config.get_entry(name)
        if entry is None:
            return Exception(f"Cache entry not found: {name}")

        # Re-cache the source
        return self.cache_repository(entry.source_url, name)

    async def refresh_cache_entry_async(
        self, name: str
    ) -> Union[GitCacheEntry, Exception]:
        """
        Refresh a cache entry by re-caching the source asynchronously.

        Args:
            name: Cache entry name

        Returns:
            Updated GitCacheEntry or Exception if failed
        """
        entry = self.config.get_entry(name)
        if entry is None:
            return Exception(f"Cache entry not found: {name}")

        # Re-cache the source
        return await self.cache_repository_async(entry.source_url, name)

    def clear_cache(self) -> Union[bool, Exception]:
        """
        Clear all cache entries and files.

        Returns:
            True if successful, Exception if failed
        """
        try:
            entries = self.config.list_entries()

            for entry in entries:
                if entry.cache_path.exists():
                    shutil.rmtree(entry.cache_path)

            self.config.clear_entries()

            logger.info("Successfully cleared all cache entries")
            return True

        except Exception as e:
            return Exception(f"Failed to clear cache: {str(e)}")

    def get_cache_stats(self) -> Dict[str, Any]:
        """
        Get cache statistics.

        Returns:
            Dictionary with cache statistics
        """
        entries = self.config.list_entries()

        total_entries = len(entries)
        git_entries = sum(1 for e in entries if e.cache_type == CacheType.GIT)
        local_entries = sum(1 for e in entries if e.cache_type == CacheType.LOCAL)

        fresh_entries = sum(1 for e in entries if e.status == CacheStatus.FRESH)
        stale_entries = sum(1 for e in entries if e.status == CacheStatus.STALE)
        missing_entries = sum(1 for e in entries if e.status == CacheStatus.MISSING)
        error_entries = sum(1 for e in entries if e.status == CacheStatus.ERROR)

        total_size_bytes = self.config.get_cache_size_bytes()
        total_size_gb = self.config.get_cache_size_gb()

        return {
            "total_entries": total_entries,
            "git_entries": git_entries,
            "local_entries": local_entries,
            "fresh_entries": fresh_entries,
            "stale_entries": stale_entries,
            "missing_entries": missing_entries,
            "error_entries": error_entries,
            "total_size_bytes": total_size_bytes,
            "total_size_gb": total_size_gb,
            "max_size_gb": self.config.max_cache_size_gb,
            "cache_full": self.config.is_cache_full(),
        }

    def _get_repository_info(self, repo_path: Path) -> Dict[str, Any]:
        """
        Get repository information including commit hash and branch.

        Args:
            repo_path: Path to the git repository

        Returns:
            Dictionary with repository information
        """
        try:
            repo = git.Repo(repo_path)
            head = repo.head

            info = {
                "commit_hash": head.commit.hexsha,
                "branch": head.name if head.name != "HEAD" else None,
                "is_detached": head.is_detached,
            }

            # Try to get branch name if detached
            if info["is_detached"]:
                try:
                    # Get the branch that HEAD was pointing to
                    info["branch"] = repo.git.rev_parse("--abbrev-ref", "HEAD")
                except Exception as e:
                    logger.warning(f"Failed to get branch info for {repo_path}: {e}")
                    info["branch"] = None

            return info
        except Exception as e:
            logger.warning(f"Failed to get repository info for {repo_path}: {e}")
            return {"commit_hash": None, "branch": None, "is_detached": False}

    def _get_remote_info(
        self, repo_path: Path, remote_name: str = "origin"
    ) -> Dict[str, Any]:
        """
        Get remote repository information.

        Args:
            repo_path: Path to the git repository
            remote_name: Name of the remote (default: origin)

        Returns:
            Dictionary with remote information
        """
        try:
            repo = git.Repo(repo_path)
            remote = repo.remotes[remote_name]

            # Fetch latest info from remote
            remote.fetch()

            # Get default branch
            default_branch = None
            try:
                # Try to get default branch from remote
                default_branch = (
                    repo.git.remote("show", remote_name)
                    .split("\n")[2]
                    .split(":")[1]
                    .strip()
                )
            except Exception as e:
                logger.warning(f"Failed to get default branch for {repo_path}: {e}")
                # Fallback to common default branches
                for branch in ["main", "master"]:
                    try:
                        remote.refs[branch]
                        default_branch = branch
                        break
                    except Exception as e:
                        logger.warning(
                            f"Failed to get default branch for {repo_path}: {e}"
                        )
                        continue

            if default_branch:
                remote_ref = remote.refs[default_branch]
                return {
                    "commit_hash": remote_ref.commit.hexsha,
                    "branch": default_branch,
                    "remote_name": remote_name,
                }

            return {"commit_hash": None, "branch": None, "remote_name": remote_name}

        except Exception as e:
            logger.warning(f"Failed to get remote info for {repo_path}: {e}")
            return {"commit_hash": None, "branch": None, "remote_name": remote_name}

    def _check_repository_differences(self, repo_path: Path) -> Dict[str, Any]:
        """
        Check for differences between local and remote repositories.

        Args:
            repo_path: Path to the git repository

        Returns:
            Dictionary with difference information
        """
        try:
            repo = git.Repo(repo_path)

            # Get local info
            local_info = self._get_repository_info(repo_path)

            # Get remote info
            remote_info = self._get_remote_info(repo_path)

            # Check if there are differences
            has_changes = False
            changes_summary = ""

            if local_info["commit_hash"] and remote_info["commit_hash"]:
                if local_info["commit_hash"] != remote_info["commit_hash"]:
                    has_changes = True

                    # Get commit difference summary
                    try:
                        # Get commits that are in remote but not in local
                        local_commit = repo.commit(local_info["commit_hash"])
                        remote_commit = repo.commit(remote_info["commit_hash"])

                        # Get commits ahead and behind
                        ahead_commits = list(
                            repo.iter_commits(
                                f"{local_commit.hexsha}..{remote_commit.hexsha}"
                            )
                        )
                        behind_commits = list(
                            repo.iter_commits(
                                f"{remote_commit.hexsha}..{local_commit.hexsha}"
                            )
                        )

                        changes_summary = f"Remote is {len(ahead_commits)} commits ahead, local is {len(behind_commits)} commits ahead"

                        if ahead_commits:
                            changes_summary += f". Latest remote commit: {ahead_commits[0].message.split(chr(10))[0]}"

                    except Exception:
                        changes_summary = f"Commit hashes differ: local={local_info['commit_hash'][:8]}, remote={remote_info['commit_hash'][:8]}"

            return {
                "local_info": local_info,
                "remote_info": remote_info,
                "has_changes": has_changes,
                "changes_summary": changes_summary,
            }

        except Exception as e:
            logger.warning(
                f"Failed to check repository differences for {repo_path}: {e}"
            )
            return {
                "local_info": {
                    "commit_hash": None,
                    "branch": None,
                    "is_detached": False,
                },
                "remote_info": {
                    "commit_hash": None,
                    "branch": None,
                    "remote_name": "origin",
                },
                "has_changes": False,
                "changes_summary": f"Error checking differences: {str(e)}",
            }

    def get_cache_entry_details(self, name: str) -> Union[Dict[str, Any], Exception]:
        """
        Get detailed information about a cache entry including git differences.

        Args:
            name: Cache entry name

        Returns:
            Dictionary with detailed entry information or Exception if not found
        """
        entry = self.config.get_entry(name)
        if entry is None:
            return Exception(f"Cache entry not found: {name}")

        details = {
            "name": entry.name,
            "source_url": entry.source_url,
            "cache_type": entry.cache_type,
            "cache_path": str(entry.cache_path),
            "created_at": entry.created_at.isoformat(),
            "last_updated": entry.last_updated.isoformat(),
            "last_accessed": entry.last_accessed.isoformat(),
            "size_bytes": entry.size_bytes,
            "size_mb": (
                round(entry.size_bytes / (1024 * 1024), 2) if entry.size_bytes else None
            ),
            "status": entry.status,
            "error_message": entry.error_message,
            "exists": entry.cache_path.exists(),
            "is_stale": (
                self.config.is_entry_stale(entry)
                if entry.cache_path.exists()
                else False
            ),
        }

        # Add git-specific information for git repositories
        if entry.cache_type == CacheType.GIT and entry.cache_path.exists():
            details.update(
                {
                    "local_commit_hash": entry.local_commit_hash,
                    "local_branch": entry.local_branch,
                    "remote_commit_hash": entry.remote_commit_hash,
                    "remote_branch": entry.remote_branch,
                    "has_upstream_changes": entry.has_upstream_changes,
                    "upstream_changes_summary": entry.upstream_changes_summary,
                    "last_diff_check": (
                        entry.last_diff_check.isoformat()
                        if entry.last_diff_check
                        else None
                    ),
                }
            )

            # Check for fresh differences if last check was more than 5 minutes ago
            if (
                entry.last_diff_check is None
                or (datetime.now() - entry.last_diff_check).total_seconds() > 300
            ):
                try:
                    diff_info = self._check_repository_differences(entry.cache_path)
                    details.update(
                        {
                            "current_local_commit_hash": diff_info["local_info"][
                                "commit_hash"
                            ],
                            "current_local_branch": diff_info["local_info"]["branch"],
                            "current_remote_commit_hash": diff_info["remote_info"][
                                "commit_hash"
                            ],
                            "current_remote_branch": diff_info["remote_info"]["branch"],
                            "current_has_upstream_changes": diff_info["has_changes"],
                            "current_upstream_changes_summary": diff_info[
                                "changes_summary"
                            ],
                            "diff_check_timestamp": datetime.now().isoformat(),
                        }
                    )
                except Exception as e:
                    details["diff_check_error"] = str(e)

        return details
`````

## File: src/metagit/core/gitcache/README.md
`````markdown
# Git Cache Management System

The Git Cache Management System provides a comprehensive solution for caching git repositories and local directories with support for both synchronous and asynchronous operations.

## Features

- **Dual Operation Modes**: Support for both synchronous and asynchronous operations
- **Multiple Source Types**: Cache both git repositories and local directories
- **Provider Integration**: Use existing git provider configurations for authentication
- **Smart Caching**: Check for existing cache and pull updates for git repositories
- **Local Directory Support**: Full directory copying for local sources
- **Cache Management**: List, remove, and refresh cache entries
- **Timeout Management**: Configurable cache timeout with automatic stale detection
- **Size Management**: Track and limit cache size
- **Error Handling**: Comprehensive error handling with detailed status tracking

## Architecture

### Core Components

1. **GitCacheConfig**: Central configuration management using Pydantic models
2. **GitCacheEntry**: Individual cache entry representation
3. **GitCacheManager**: Main manager class handling all cache operations

### File Structure

```
metagit/core/gitcache/
├── __init__.py          # Module exports
├── config.py           # Configuration models
├── manager.py          # Main cache manager
└── README.md           # This file
```

## Usage

### Basic Setup

```python
from metagit.core.gitcache import GitCacheConfig, GitCacheManager
from pathlib import Path

# Create configuration
config = GitCacheConfig(
    cache_root=Path("./.metagit/.cache"),
    default_timeout_minutes=60,
    max_cache_size_gb=10.0
)

# Create manager
manager = GitCacheManager(config)
```

### Caching Git Repositories

```python
# Cache a git repository
entry = manager.cache_repository("https://github.com/octocat/Hello-World.git")

if isinstance(entry, Exception):
    print(f"Error: {entry}")
else:
    print(f"Cached: {entry.name}")
    print(f"Path: {entry.cache_path}")
    print(f"Status: {entry.status}")
```

### Caching Local Directories

```python
# Cache a local directory
entry = manager.cache_repository("/path/to/local/project", name="my-project")

if isinstance(entry, Exception):
    print(f"Error: {entry}")
else:
    print(f"Cached: {entry.name}")
    print(f"Type: {entry.cache_type}")
```

### Asynchronous Operations

```python
import asyncio

async def cache_multiple_repos():
    # Cache multiple repositories concurrently
    tasks = [
        manager.cache_repository_async("https://github.com/user/repo1.git"),
        manager.cache_repository_async("https://github.com/user/repo2.git"),
        manager.cache_repository_async("/path/to/local/project")
    ]
    
    results = await asyncio.gather(*tasks, return_exceptions=True)
    
    for result in results:
        if isinstance(result, Exception):
            print(f"Error: {result}")
        else:
            print(f"Successfully cached: {result.name}")

# Run async function
asyncio.run(cache_multiple_repos())
```

### Cache Management

```python
# List all cache entries
entries = manager.list_cache_entries()
for entry in entries:
    print(f"{entry.name}: {entry.cache_type} ({entry.status})")

# Get cache statistics
stats = manager.get_cache_stats()
print(f"Total entries: {stats['total_entries']}")
print(f"Total size: {stats['total_size_gb']:.2f} GB")

# Get cached repository path
cache_path = manager.get_cached_repository("repo-name")
if isinstance(cache_path, Exception):
    print(f"Error: {cache_path}")
else:
    print(f"Cache path: {cache_path}")

# Refresh cache entry
entry = manager.refresh_cache_entry("repo-name")

# Remove cache entry
result = manager.remove_cache_entry("repo-name")

# Clear all cache
result = manager.clear_cache()
```

### Provider Integration

```python
from metagit.core.providers.github import GitHubProvider

# Register a git provider
provider = GitHubProvider(api_token="your-token")
manager.register_provider(provider)

# The manager will use the provider for authentication when cloning
entry = manager.cache_repository("https://github.com/private/repo.git")
```

## Configuration

### GitCacheConfig Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `cache_root` | Path | `./.metagit/.cache` | Root directory for cache storage |
| `default_timeout_minutes` | int | 60 | Default cache timeout in minutes |
| `max_cache_size_gb` | float | 10.0 | Maximum cache size in GB |
| `enable_async` | bool | True | Enable async operations |
| `git_config` | Dict | {} | Git configuration options |
| `provider_config` | Dict | None | Provider-specific configuration |

### Git Configuration

```python
config = GitCacheConfig(
    git_config={
        "user.name": "Your Name",
        "user.email": "your.email@example.com",
        "http.extraheader": "AUTHORIZATION: basic <base64-encoded-token>"
    }
)
```

## Cache Entry Status

Cache entries have the following statuses:

- **FRESH**: Cache is up-to-date and within timeout
- **STALE**: Cache exists but is older than timeout
- **MISSING**: Cache entry exists but files are missing
- **ERROR**: Cache operation failed

## Error Handling

All operations return either the expected result or an Exception. This allows for comprehensive error handling:

```python
entry = manager.cache_repository("https://github.com/user/repo.git")

if isinstance(entry, Exception):
    # Handle error
    print(f"Cache failed: {entry}")
    # Check if it's a specific type of error
    if "Authentication failed" in str(entry):
        print("Please check your credentials")
    elif "Repository not found" in str(entry):
        print("Repository does not exist or is private")
else:
    # Handle success
    print(f"Successfully cached: {entry.name}")
```

## Best Practices

1. **Use Provider Configuration**: Register git providers for authentication
2. **Handle Errors Gracefully**: Always check return types for exceptions
3. **Monitor Cache Size**: Use `get_cache_stats()` to monitor cache usage
4. **Refresh Stale Cache**: Use `refresh_cache_entry()` for important repositories
5. **Clean Up Regularly**: Remove unused cache entries to save space
6. **Use Async for Multiple Operations**: Use async methods when caching multiple repositories

## Examples

See `examples/gitcache_example.py` for comprehensive usage examples including:

- Synchronous operations
- Asynchronous operations
- Cache management
- Error handling
- Provider integration

## Testing

Run the test suite:

```bash
python -m pytest tests/test_gitcache.py -v
```

The test suite covers:

- Configuration validation
- Cache entry management
- Repository cloning
- Local directory copying
- Async operations
- Error scenarios
- Cache statistics

## Integration

The Git Cache Management System integrates with:

- **Git Providers**: Use existing provider configurations for authentication
- **Project Management**: Cache repositories for analysis
- **CI/CD Pipelines**: Cache dependencies and tools
- **Development Workflows**: Local development with cached repositories
`````

## File: src/metagit/core/providers/__init__.py
`````python
#!/usr/bin/env python
"""
Provider registry for Git hosting platforms.
"""

import logging
import os
from typing import Optional

from metagit.core.providers.base import GitProvider
from metagit.core.providers.github import GitHubProvider
from metagit.core.providers.gitlab import GitLabProvider
from metagit.core.utils.common import normalize_git_url

logger = logging.getLogger(__name__)


class ProviderRegistry:
    """Registry for git provider plugins."""

    def __init__(self):
        self._providers: list[GitProvider] = []
        self._app_config = None

    def register(self, provider: GitProvider) -> None:
        """Register a git provider plugin."""
        self._providers.append(provider)

    def unregister(self, provider_name: str) -> None:
        """Unregister a provider by name."""
        self._providers = [p for p in self._providers if p.get_name() != provider_name]

    def clear(self) -> None:
        """Clear all registered providers."""
        self._providers.clear()

    def get_provider_for_url(self, url: str) -> Optional[GitProvider]:
        """Get the appropriate provider for a given URL."""
        normalized_url = normalize_git_url(url)

        for provider in self._providers:
            if provider.can_handle_url(normalized_url) and provider.is_available():
                return provider

        return None

    def get_all_providers(self) -> list[GitProvider]:
        """Get all registered providers."""
        return self._providers.copy()

    def get_provider_by_name(self, name: str) -> Optional[GitProvider]:
        """Get a provider by name."""
        for provider in self._providers:
            if provider.get_name() == name:
                return provider
        return None

    def configure_from_app_config(self, app_config) -> None:
        """
        Configure providers from AppConfig settings.

        Args:
            app_config: AppConfig instance with provider settings
        """
        self._app_config = app_config

        # Clear existing providers
        self.clear()

        # Configure GitHub provider
        if (
            app_config.providers.github.enabled
            and app_config.providers.github.api_token
        ):
            try:
                github_provider = GitHubProvider(
                    api_token=app_config.providers.github.api_token,
                    base_url=app_config.providers.github.base_url,
                )
                self.register(github_provider)
            except ImportError:
                pass  # GitHub provider not available

        # Configure GitLab provider
        if (
            app_config.providers.gitlab.enabled
            and app_config.providers.gitlab.api_token
        ):
            try:
                gitlab_provider = GitLabProvider(
                    api_token=app_config.providers.gitlab.api_token,
                    base_url=app_config.providers.gitlab.base_url,
                )
                self.register(gitlab_provider)
            except ImportError:
                pass  # GitLab provider not available

    def configure_from_environment(self) -> None:
        """Configure providers from environment variables (legacy method)."""
        # GitHub provider
        github_token = os.getenv("GITHUB_TOKEN")
        if github_token:
            try:
                github_provider = GitHubProvider(api_token=github_token)
                self.register(github_provider)
            except ImportError:
                pass

        # GitLab provider
        gitlab_token = os.getenv("GITLAB_TOKEN")
        if gitlab_token:
            try:
                gitlab_provider = GitLabProvider(api_token=gitlab_token)
                self.register(gitlab_provider)
            except ImportError:
                pass


# Global registry instance
registry = ProviderRegistry()

# Export the registry for backward compatibility
__all__ = ["GitProvider", "ProviderRegistry", "registry"]
`````

## File: src/metagit/core/providers/base.py
`````python
#!/usr/bin/env python
"""
Base provider for Git hosting platforms.
"""

import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Union

from metagit.core.config.models import Metrics
from metagit.core.utils.common import normalize_git_url

logger = logging.getLogger(__name__)


class GitProvider(ABC):
    """Base class for git provider plugins."""

    def __init__(self, api_token: Optional[str] = None, base_url: Optional[str] = None):
        """
        Initialize the git provider.

        Args:
            api_token: API token for authentication
            base_url: Base URL for the API (for self-hosted instances)
        """
        self.api_token = api_token
        self.base_url = base_url
        self._session = None

    @abstractmethod
    def get_name(self) -> str:
        """Get the provider name."""
        pass

    @abstractmethod
    def can_handle_url(self, url: str) -> bool:
        """Check if this provider can handle the given repository URL."""
        pass

    @abstractmethod
    def extract_repo_info(self, url: str) -> Dict[str, str]:
        """
        Extract repository information from URL.

        Returns:
            Dict with keys: owner, repo, api_url
        """
        pass

    @abstractmethod
    def get_repository_metrics(
        self, owner: str, repo: str
    ) -> Union[Metrics, Exception]:
        """
        Get repository metrics from the provider.

        Args:
            owner: Repository owner/organization
            repo: Repository name

        Returns:
            Metrics object or Exception
        """
        pass

    @abstractmethod
    def get_repository_metadata(
        self, owner: str, repo: str
    ) -> Union[Dict[str, Any], Exception]:
        """
        Get additional repository metadata.

        Args:
            owner: Repository owner/organization
            repo: Repository name

        Returns:
            Dict with metadata or Exception
        """
        pass

    def supports_url(self, url: str) -> bool:
        """Check if this provider supports the given URL."""
        normalized_url = normalize_git_url(url)
        return self.can_handle_url(normalized_url)

    def is_available(self) -> bool:
        """Check if the provider is available (has API token, etc.)."""
        return self.api_token is not None
`````

## File: src/metagit/core/providers/github.py
`````python
#!/usr/bin/env python3
"""
GitHub provider for repository metadata and metrics.
"""

import logging
import re
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urlparse

import requests

from metagit.core.config.models import CommitFrequency, Metrics, PullRequests
from metagit.core.providers.base import GitProvider
from metagit.core.utils.common import normalize_git_url

logger = logging.getLogger(__name__)


class GitHubProvider(GitProvider):
    """GitHub provider plugin."""

    def __init__(self, api_token: Optional[str] = None, base_url: Optional[str] = None):
        """
        Initialize GitHub provider.

        Args:
            api_token: GitHub personal access token
            base_url: Base URL for GitHub API (for GitHub Enterprise)
        """
        super().__init__(api_token, base_url)
        self.api_base = base_url or "https://api.github.com"
        self.session = requests.Session()

        if api_token:
            self.session.headers.update(
                {
                    "Authorization": f"token {api_token}",
                    "Accept": "application/vnd.github.v3+json",
                }
            )

    def get_name(self) -> str:
        """Get the provider name."""
        return "GitHub"

    def can_handle_url(self, url: str) -> bool:
        """Check if this provider can handle the given repository URL."""
        parsed = urlparse(url)
        return (
            parsed.netloc in ["github.com", "www.github.com"]
            or parsed.netloc.endswith(".github.com")
            or (self.base_url and parsed.netloc == urlparse(self.base_url).netloc)
        )

    def extract_repo_info(self, url: str) -> Dict[str, str]:
        """Extract owner and repo from GitHub URL."""
        normalized_url = normalize_git_url(url)

        # GitHub URL patterns
        patterns = [
            r"github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?/?$",
            r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?/?$",
        ]

        for pattern in patterns:
            match = re.match(pattern, normalized_url)
            if match:
                return {"owner": match.group(1), "repo": match.group(2)}

        return {}

    def get_repository_metrics(
        self, owner: str, repo: str
    ) -> Union[Metrics, Exception]:
        """Get repository metrics from GitHub API."""
        try:
            if not self.api_token:
                return Exception("GitHub API token required for metrics")

            # Get repository data
            repo_url = f"{self.api_base}/repos/{owner}/{repo}"
            repo_response = self.session.get(repo_url)
            repo_response.raise_for_status()
            repo_data = repo_response.json()

            # Get issues data
            issues_url = f"{self.api_base}/repos/{owner}/{repo}/issues"
            issues_params = {"state": "open", "per_page": 1}
            issues_response = self.session.get(issues_url, params=issues_params)
            issues_response.raise_for_status()

            # Get pull requests data
            prs_url = f"{self.api_base}/repos/{owner}/{repo}/pulls"
            prs_params = {"state": "open", "per_page": 1}
            prs_response = self.session.get(prs_url, params=prs_params)
            prs_response.raise_for_status()

            # Get contributors data
            contributors_url = f"{self.api_base}/repos/{owner}/{repo}/contributors"
            contributors_response = self.session.get(contributors_url)
            contributors_response.raise_for_status()
            contributors_data = contributors_response.json()

            # Get recent commits for commit frequency
            commits_url = f"{self.api_base}/repos/{owner}/{repo}/commits"
            commits_params = {"per_page": 100}
            commits_response = self.session.get(commits_url, params=commits_params)
            commits_response.raise_for_status()
            commits_data = commits_response.json()

            # Calculate commit frequency
            commit_frequency = self._calculate_commit_frequency(commits_data)

            # Create pull requests object
            pull_requests = PullRequests(
                open=len(prs_response.json()),
                merged_last_30d=0,  # Would need additional API call for this
            )

            # Create metrics object
            metrics = Metrics(
                stars=repo_data.get("stargazers_count", 0),
                forks=repo_data.get("forks_count", 0),
                open_issues=repo_data.get("open_issues_count", 0),
                pull_requests=pull_requests,
                contributors=len(contributors_data),
                commit_frequency=commit_frequency,
            )

            return metrics

        except requests.RequestException as e:
            return Exception(f"GitHub API request failed: {e}")
        except Exception as e:
            return Exception(f"Failed to get GitHub metrics: {e}")

    def get_repository_metadata(
        self, owner: str, repo: str
    ) -> Union[Dict[str, Any], Exception]:
        """Get additional repository metadata from GitHub API."""
        try:
            if not self.api_token:
                return Exception("GitHub API token required for metadata")

            repo_url = f"{self.api_base}/repos/{owner}/{repo}"
            response = self.session.get(repo_url)
            response.raise_for_status()
            repo_data = response.json()

            # Get topics
            topics_url = f"{self.api_base}/repos/{owner}/{repo}/topics"
            topics_response = self.session.get(topics_url)
            topics_data = (
                topics_response.json()
                if topics_response.status_code == 200
                else {"names": []}
            )

            metadata = {
                "name": repo_data.get("name"),
                "description": repo_data.get("description"),
                "language": repo_data.get("language"),
                "topics": topics_data.get("names", []),
                "created_at": repo_data.get("created_at"),
                "updated_at": repo_data.get("updated_at"),
                "pushed_at": repo_data.get("pushed_at"),
                "default_branch": repo_data.get("default_branch"),
                "license": (
                    repo_data.get("license", {}).get("name")
                    if repo_data.get("license")
                    else None
                ),
                "archived": repo_data.get("archived", False),
                "fork": repo_data.get("fork", False),
                "private": repo_data.get("private", False),
                "homepage": repo_data.get("homepage"),
                "size": repo_data.get("size"),
                "watchers_count": repo_data.get("watchers_count"),
                "network_count": repo_data.get("network_count"),
                "subscribers_count": repo_data.get("subscribers_count"),
            }

            return metadata

        except requests.RequestException as e:
            return Exception(f"GitHub API request failed: {e}")
        except Exception as e:
            return Exception(f"Failed to get GitHub metadata: {e}")

    def _calculate_commit_frequency(
        self, commits_data: List[Dict[str, Any]]
    ) -> CommitFrequency:
        """Calculate commit frequency from recent commits."""
        if not commits_data or len(commits_data) < 2:
            return CommitFrequency.MONTHLY

        # Get commit dates
        commit_dates = []
        for commit in commits_data:
            if "commit" in commit and "author" in commit["commit"]:
                date_str = commit["commit"]["author"]["date"]
                commit_dates.append(date_str)

        if len(commit_dates) < 2:
            return CommitFrequency.MONTHLY

        # Calculate frequency based on recent activity
        from datetime import datetime, timedelta, timezone

        recent_commits = 0
        week_ago = datetime.now(timezone.utc) - timedelta(days=7)

        for date_str in commit_dates[:10]:  # Check last 10 commits
            try:
                commit_date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
                if commit_date >= week_ago:
                    recent_commits += 1
            except Exception:
                continue

        if recent_commits >= 5:
            return CommitFrequency.DAILY
        elif recent_commits >= 1:
            return CommitFrequency.WEEKLY
        else:
            return CommitFrequency.MONTHLY
`````

## File: src/metagit/core/providers/gitlab.py
`````python
#!/usr/bin/env python3
"""
GitLab provider for repository metadata and metrics.
"""

import logging
import re
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urlparse

import requests

from metagit.core.config.models import CommitFrequency, Metrics, PullRequests
from metagit.core.providers.base import GitProvider
from metagit.core.utils.common import normalize_git_url

logger = logging.getLogger(__name__)


class GitLabProvider(GitProvider):
    """GitLab provider plugin."""

    def __init__(self, api_token: Optional[str] = None, base_url: Optional[str] = None):
        """
        Initialize GitLab provider.

        Args:
            api_token: GitLab personal access token
            base_url: Base URL for GitLab API (for self-hosted instances)
        """
        super().__init__(api_token, base_url)
        self.api_base = base_url or "https://gitlab.com/api/v4"
        self.session = requests.Session()

        if api_token:
            self.session.headers.update(
                {
                    "Authorization": f"Bearer {api_token}",
                    "Content-Type": "application/json",
                }
            )

    def get_name(self) -> str:
        """Get the provider name."""
        return "GitLab"

    def can_handle_url(self, url: str) -> bool:
        """Check if this provider can handle the given repository URL."""
        parsed = urlparse(url)
        return (
            parsed.netloc in ["gitlab.com", "www.gitlab.com"]
            or parsed.netloc.endswith(".gitlab.com")
            or (self.base_url and parsed.netloc == urlparse(self.base_url).netloc)
        )

    def extract_repo_info(self, url: str) -> Dict[str, str]:
        """Extract owner and repo from GitLab URL."""
        normalized_url = normalize_git_url(url)

        # GitLab URL patterns
        patterns = [
            r"gitlab\.com[:/]([^/]+)/([^/]+?)(?:\.git)?/?$",
            r"git@gitlab\.com:([^/]+)/([^/]+?)(?:\.git)?/?$",
        ]

        for pattern in patterns:
            match = re.match(pattern, normalized_url)
            if match:
                return {"owner": match.group(1), "repo": match.group(2)}

        return {}

    def get_repository_metrics(
        self, owner: str, repo: str
    ) -> Union[Metrics, Exception]:
        """Get repository metrics from GitLab API."""
        try:
            if not self.api_token:
                return Exception("GitLab API token required for metrics")

            project_path = f"{owner}/{repo}"
            project_id = project_path.replace("/", "%2F")

            # Get project data
            project_url = f"{self.api_base}/projects/{project_id}"
            project_response = self.session.get(project_url)
            project_response.raise_for_status()
            project_data = project_response.json()

            # Get issues data
            issues_url = f"{self.api_base}/projects/{project_id}/issues"
            issues_params = {"state": "opened", "per_page": 1}
            issues_response = self.session.get(issues_url, params=issues_params)
            issues_response.raise_for_status()

            # Get merge requests data
            mr_url = f"{self.api_base}/projects/{project_id}/merge_requests"
            mr_params = {"state": "opened", "per_page": 1}
            mr_response = self.session.get(mr_url, params=mr_params)
            mr_response.raise_for_status()

            # Get contributors data (approximation using project members)
            members_url = f"{self.api_base}/projects/{project_id}/members"
            members_response = self.session.get(members_url)
            members_data = (
                members_response.json() if members_response.status_code == 200 else []
            )

            # Get recent commits for commit frequency
            commits_url = f"{self.api_base}/projects/{project_id}/repository/commits"
            commits_params = {"per_page": 100}
            commits_response = self.session.get(commits_url, params=commits_params)
            commits_response.raise_for_status()
            commits_data = commits_response.json()

            # Calculate commit frequency
            commit_frequency = self._calculate_commit_frequency(commits_data)

            # Create pull requests object (GitLab calls them merge requests)
            pull_requests = PullRequests(
                open=len(mr_response.json()),
                merged_last_30d=0,  # Would need additional API call for this
            )

            # Create metrics object
            metrics = Metrics(
                stars=project_data.get("star_count", 0),
                forks=project_data.get("forks_count", 0),
                open_issues=len(issues_response.json()),
                pull_requests=pull_requests,
                contributors=len(members_data),
                commit_frequency=commit_frequency,
            )

            return metrics

        except requests.RequestException as e:
            return Exception(f"GitLab API request failed: {e}")
        except Exception as e:
            return Exception(f"Failed to get GitLab metrics: {e}")

    def get_repository_metadata(
        self, owner: str, repo: str
    ) -> Union[Dict[str, Any], Exception]:
        """Get additional repository metadata from GitLab API."""
        try:
            if not self.api_token:
                return Exception("GitLab API token required for metadata")

            project_path = f"{owner}/{repo}"
            project_id = project_path.replace("/", "%2F")

            project_url = f"{self.api_base}/projects/{project_id}"
            response = self.session.get(project_url)
            response.raise_for_status()
            project_data = response.json()

            metadata = {
                "name": project_data.get("name"),
                "description": project_data.get("description"),
                "topics": project_data.get("topics", []),
                "created_at": project_data.get("created_at"),
                "updated_at": project_data.get("last_activity_at"),
                "default_branch": project_data.get("default_branch"),
                "archived": project_data.get("archived", False),
                "fork": project_data.get("forked_from_project") is not None,
                "private": project_data.get("visibility") == "private",
                "homepage": project_data.get("web_url"),
                "size": project_data.get("statistics", {}).get("repository_size"),
                "watchers_count": project_data.get("star_count"),
                "forks_count": project_data.get("forks_count"),
                "open_issues_count": project_data.get("open_issues_count"),
                "visibility": project_data.get("visibility"),
                "namespace": project_data.get("namespace", {}).get("name"),
            }

            return metadata

        except requests.RequestException as e:
            return Exception(f"GitLab API request failed: {e}")
        except Exception as e:
            return Exception(f"Failed to get GitLab metadata: {e}")

    def _calculate_commit_frequency(
        self, commits_data: List[Dict[str, Any]]
    ) -> CommitFrequency:
        """Calculate commit frequency from recent commits."""
        if not commits_data or len(commits_data) < 2:
            return CommitFrequency.MONTHLY

        # Get commit dates
        commit_dates = []
        for commit in commits_data:
            if "created_at" in commit:
                commit_dates.append(commit["created_at"])

        if len(commit_dates) < 2:
            return CommitFrequency.MONTHLY

        # Calculate frequency based on recent activity
        from datetime import datetime, timedelta, timezone

        recent_commits = 0
        week_ago = datetime.now(timezone.utc) - timedelta(days=7)

        for date_str in commit_dates[:10]:  # Check last 10 commits
            try:
                commit_date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
                if commit_date >= week_ago:
                    recent_commits += 1
            except Exception:
                continue

        if recent_commits >= 5:
            return CommitFrequency.DAILY
        elif recent_commits >= 1:
            return CommitFrequency.WEEKLY
        else:
            return CommitFrequency.MONTHLY
`````

## File: src/metagit/core/record/__init__.py
`````python
#!/usr/bin/env python
"""
Record management module for metagit.

This module provides classes for managing metagit records with support for
multiple storage backends (OpenSearch and local files) and the ability to
create records from existing metagit configuration data.
"""

from .manager import (
    LocalFileStorageBackend,
    MetagitRecordManager,
    OpenSearchStorageBackend,
    RecordStorageBackend,
)
from .models import MetagitRecord

__all__ = [
    "MetagitRecord",
    "MetagitRecordManager",
    "RecordStorageBackend",
    "LocalFileStorageBackend",
    "OpenSearchStorageBackend",
]
`````

## File: src/metagit/core/record/manager.py
`````python
#!/usr/bin/env python
"""
Class for managing metagit records.

This package provides a class for managing metagit records with support for
multiple storage backends (OpenSearch and local files) and the ability to
create records from existing metagit configuration data.
"""

import json
from abc import ABC, abstractmethod
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union

from git import Repo

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.record.models import MetagitRecord
from metagit.core.utils.logging import LoggerConfig, UnifiedLogger
from metagit.core.utils.yaml_class import yaml


class DateTimeEncoder(json.JSONEncoder):
    """Custom JSON encoder that handles datetime objects."""

    def default(self, obj):
        if isinstance(obj, datetime):
            return obj.isoformat()
        return super().default(obj)


class RecordStorageBackend(ABC):
    """Abstract base class for record storage backends."""

    @abstractmethod
    async def store_record(self, record: MetagitRecord) -> Union[str, Exception]:
        """Store a record and return the record ID."""
        pass

    @abstractmethod
    async def get_record(self, record_id: str) -> Union[MetagitRecord, Exception]:
        """Retrieve a record by ID."""
        pass

    @abstractmethod
    async def update_record(
        self, record_id: str, record: MetagitRecord
    ) -> Union[bool, Exception]:
        """Update an existing record."""
        pass

    @abstractmethod
    async def delete_record(self, record_id: str) -> Union[bool, Exception]:
        """Delete a record by ID."""
        pass

    @abstractmethod
    async def search_records(
        self,
        query: str,
        filters: Optional[Dict[str, Any]] = None,
        page: int = 1,
        size: int = 20,
    ) -> Union[Dict[str, Any], Exception]:
        """Search records with optional filters."""
        pass

    @abstractmethod
    async def list_records(
        self, page: int = 1, size: int = 20
    ) -> Union[List[MetagitRecord], Exception]:
        """List all records with pagination."""
        pass


class LocalFileStorageBackend(RecordStorageBackend):
    """Local file-based storage backend for records."""

    def __init__(self, storage_dir: Path):
        """
        Initialize local file storage backend.

        Args:
            storage_dir: Directory to store record files
        """
        self.storage_dir = Path(storage_dir)
        self.storage_dir.mkdir(parents=True, exist_ok=True)
        self.index_file = self.storage_dir / "index.json"
        self._ensure_index_exists()

    def _ensure_index_exists(self) -> None:
        """Ensure the index file exists."""
        if not self.index_file.exists():
            with open(self.index_file, "w", encoding="utf-8") as f:
                json.dump({"records": {}, "next_id": 1}, f)

    def _load_index(self) -> Dict[str, Any]:
        """Load the index file."""
        with open(self.index_file, "r", encoding="utf-8") as f:
            return json.load(f)

    def _save_index(self, index_data: Dict[str, Any]) -> None:
        """Save the index file."""
        with open(self.index_file, "w", encoding="utf-8") as f:
            json.dump(index_data, f, indent=2)

    def _get_next_id(self) -> str:
        """Get the next available record ID."""
        index_data = self._load_index()
        next_id = index_data["next_id"]
        index_data["next_id"] += 1
        self._save_index(index_data)
        return str(next_id)

    async def store_record(self, record: MetagitRecord) -> Union[str, Exception]:
        """Store a record to local file."""
        try:
            record_id = self._get_next_id()
            record_file = self.storage_dir / f"{record_id}.json"

            # Add metadata
            record_data = record.model_dump(exclude_none=True, exclude_defaults=True)
            record_data["record_id"] = record_id
            record_data["created_at"] = datetime.now().isoformat()
            record_data["updated_at"] = datetime.now().isoformat()

            with open(record_file, "w", encoding="utf-8") as f:
                json.dump(record_data, f, indent=2, cls=DateTimeEncoder)

            # Update index
            index_data = self._load_index()
            index_data["records"][record_id] = {
                "name": record.name,
                "file": str(record_file),
                "created_at": record_data["created_at"],
                "updated_at": record_data["updated_at"],
            }
            self._save_index(index_data)

            return record_id
        except Exception as e:
            return e

    async def get_record(self, record_id: str) -> Union[MetagitRecord, Exception]:
        """Retrieve a record by ID."""
        try:
            record_file = self.storage_dir / f"{record_id}.json"
            if not record_file.exists():
                return FileNotFoundError(f"Record not found: {record_id}")

            with open(record_file, "r", encoding="utf-8") as f:
                record_data = json.load(f)

            # Remove metadata fields before creating record
            record_data.pop("record_id", None)
            record_data.pop("created_at", None)
            record_data.pop("updated_at", None)

            return MetagitRecord(**record_data)
        except Exception as e:
            return e

    async def update_record(
        self, record_id: str, record: MetagitRecord
    ) -> Union[bool, Exception]:
        """Update an existing record."""
        try:
            record_file = self.storage_dir / f"{record_id}.json"
            if not record_file.exists():
                return FileNotFoundError(f"Record not found: {record_id}")

            # Load existing data to preserve metadata
            with open(record_file, "r", encoding="utf-8") as f:
                existing_data = json.load(f)

            # Update record data
            record_data = record.model_dump(exclude_none=True, exclude_defaults=True)
            record_data["record_id"] = record_id
            record_data["created_at"] = existing_data.get("created_at")
            record_data["updated_at"] = datetime.now().isoformat()

            with open(record_file, "w", encoding="utf-8") as f:
                json.dump(record_data, f, indent=2, cls=DateTimeEncoder)

            # Update index
            index_data = self._load_index()
            if record_id in index_data["records"]:
                index_data["records"][record_id]["updated_at"] = record_data[
                    "updated_at"
                ]
                self._save_index(index_data)

            return True
        except Exception as e:
            return e

    async def delete_record(self, record_id: str) -> Union[bool, Exception]:
        """Delete a record by ID."""
        try:
            record_file = self.storage_dir / f"{record_id}.json"
            if not record_file.exists():
                return FileNotFoundError(f"Record not found: {record_id}")

            record_file.unlink()

            # Update index
            index_data = self._load_index()
            index_data["records"].pop(record_id, None)
            self._save_index(index_data)

            return True
        except Exception as e:
            return e

    async def search_records(
        self,
        query: str,
        filters: Optional[Dict[str, Any]] = None,
        page: int = 1,
        size: int = 20,
    ) -> Union[Dict[str, Any], Exception]:
        """Search records with optional filters."""
        try:
            all_records = await self.list_records(
                page=1, size=1000
            )  # Get all for search
            if isinstance(all_records, Exception):
                return all_records

            # Simple text search
            filtered_records = []
            for record in all_records:
                if query.lower() in record.name.lower() or (
                    record.description and query.lower() in record.description.lower()
                ):
                    filtered_records.append(record)

            # Apply additional filters
            if filters:
                filtered_records = [
                    record
                    for record in filtered_records
                    if all(
                        getattr(record, key, None) == value
                        for key, value in filters.items()
                    )
                ]

            # Pagination
            start_idx = (page - 1) * size
            end_idx = start_idx + size
            paginated_records = filtered_records[start_idx:end_idx]

            return {
                "records": paginated_records,
                "total": len(filtered_records),
                "page": page,
                "size": size,
                "pages": (len(filtered_records) + size - 1) // size,
            }
        except Exception as e:
            return e

    async def list_records(
        self, page: int = 1, size: int = 20
    ) -> Union[List[MetagitRecord], Exception]:
        """List all records with pagination."""
        try:
            index_data = self._load_index()
            record_ids = list(index_data["records"].keys())

            # Pagination
            start_idx = (page - 1) * size
            end_idx = start_idx + size
            paginated_ids = record_ids[start_idx:end_idx]

            records = []
            for record_id in paginated_ids:
                record_result = await self.get_record(record_id)
                if isinstance(record_result, Exception):
                    continue  # Skip failed records
                records.append(record_result)

            return records
        except Exception as e:
            return e


class OpenSearchStorageBackend(RecordStorageBackend):
    """OpenSearch-based storage backend for records."""

    def __init__(self, opensearch_service):
        """
        Initialize OpenSearch storage backend.

        Args:
            opensearch_service: Configured OpenSearchService instance
        """
        self.opensearch_service = opensearch_service

    async def store_record(self, record: MetagitRecord) -> Union[str, Exception]:
        """Store a record to OpenSearch."""
        return await self.opensearch_service.store_record(record)

    async def get_record(self, record_id: str) -> Union[MetagitRecord, Exception]:
        """Retrieve a record by ID."""
        return await self.opensearch_service.get_record(record_id)

    async def update_record(
        self, record_id: str, record: MetagitRecord
    ) -> Union[bool, Exception]:
        """Update an existing record."""
        return await self.opensearch_service.update_record(record_id, record)

    async def delete_record(self, record_id: str) -> Union[bool, Exception]:
        """Delete a record by ID."""
        return await self.opensearch_service.delete_record(record_id)

    async def search_records(
        self,
        query: str,
        filters: Optional[Dict[str, Any]] = None,
        page: int = 1,
        size: int = 20,
    ) -> Union[Dict[str, Any], Exception]:
        """Search records with optional filters."""
        return await self.opensearch_service.search_records(
            query=query,
            filters=filters,
            page=page,
            size=size,
        )

    async def list_records(
        self, page: int = 1, size: int = 20
    ) -> Union[List[MetagitRecord], Exception]:
        """List all records with pagination."""
        try:
            search_result = await self.opensearch_service.search_records(
                query="*", page=page, size=size
            )
            if isinstance(search_result, Exception):
                return search_result

            return search_result.get("records", [])
        except Exception as e:
            return e


class MetagitRecordManager:
    """
    Manager class for handling metagit records.

    This class provides methods for loading, validating, and creating
    metagit records with proper error handling and validation.
    Supports multiple storage backends (OpenSearch and local files).
    """

    def __init__(
        self,
        storage_backend: Optional[RecordStorageBackend] = None,
        metagit_config_manager: Optional[MetagitConfigManager] = None,
        logger: Optional[UnifiedLogger] = None,
    ):
        """
        Initialize the MetagitRecordManager.

        Args:
            storage_backend: Storage backend for records (OpenSearch or local file)
            metagit_config_manager: Optional MetagitConfigManager instance
            logger: Optional logger instance
        """
        self.storage_backend = storage_backend
        self.config_manager: MetagitConfigManager = metagit_config_manager
        self.logger = logger or UnifiedLogger(
            LoggerConfig(log_level="INFO", minimal_console=True)
        )
        self.record: Optional[MetagitRecord] = None

    def create_record_from_config(
        self,
        config: Optional[MetagitConfig] = None,
        detection_source: str = "local",
        detection_version: str = "1.0.0",
        additional_data: Optional[Dict[str, Any]] = None,
    ) -> Union[MetagitRecord, Exception]:
        """
        Create a MetagitRecord from existing MetagitConfig data.

        Args:
            config: MetagitConfig to convert. If None, uses config from config_manager.
            detection_source: Source of the detection (e.g., 'github', 'gitlab', 'local')
            detection_version: Version of the detection system used
            additional_data: Additional data to include in the record

        Returns:
            MetagitRecord: The created record
        """
        try:
            # Get config from parameter or config manager
            if config is None:
                if self.config_manager is None:
                    return ValueError(
                        "No config provided and no config_manager available"
                    )

                config_result = self.config_manager.load_config()
                if isinstance(config_result, Exception):
                    return config_result
                config = config_result

            # Get current git information
            git_info = self._get_git_info()

            # Create record data
            record_data = config.model_dump(exclude_none=True, exclude_defaults=True)

            # Add detection-specific fields
            record_data.update(
                {
                    "detection_timestamp": datetime.now(),
                    "detection_source": detection_source,
                    "detection_version": detection_version,
                    "branch": git_info.get("branch"),
                    "checksum": git_info.get("checksum"),
                    "last_updated": datetime.now(),
                }
            )

            # Add additional data if provided
            if additional_data:
                record_data.update(additional_data)

            # Create and validate record
            record = MetagitRecord(**record_data)
            return record

        except Exception as e:
            return e

    def _get_git_info(self) -> Dict[str, Optional[str]]:
        """Get current git repository information."""
        try:
            repo = Repo(Path.cwd())
            return {
                "branch": repo.active_branch.name if repo.head.is_valid() else None,
                "checksum": repo.head.commit.hexsha if repo.head.is_valid() else None,
            }
        except Exception:
            return {
                "branch": None,
                "checksum": None,
            }

    async def store_record(self, record: MetagitRecord) -> Union[str, Exception]:
        """
        Store a record using the configured storage backend.

        Args:
            record: MetagitRecord to store

        Returns:
            str: Record ID if successful, Exception otherwise
        """
        if self.storage_backend is None:
            return ValueError("No storage backend configured")

        return await self.storage_backend.store_record(record)

    async def get_record(self, record_id: str) -> Union[MetagitRecord, Exception]:
        """
        Retrieve a record by ID.

        Args:
            record_id: ID of the record to retrieve

        Returns:
            MetagitRecord: The retrieved record, or Exception if failed
        """
        if self.storage_backend is None:
            return ValueError("No storage backend configured")

        return await self.storage_backend.get_record(record_id)

    async def update_record(
        self, record_id: str, record: MetagitRecord
    ) -> Union[bool, Exception]:
        """
        Update an existing record.

        Args:
            record_id: ID of the record to update
            record: Updated MetagitRecord

        Returns:
            bool: True if successful, Exception otherwise
        """
        if self.storage_backend is None:
            return ValueError("No storage backend configured")

        return await self.storage_backend.update_record(record_id, record)

    async def delete_record(self, record_id: str) -> Union[bool, Exception]:
        """
        Delete a record by ID.

        Args:
            record_id: ID of the record to delete

        Returns:
            bool: True if successful, Exception otherwise
        """
        if self.storage_backend is None:
            return ValueError("No storage backend configured")

        return await self.storage_backend.delete_record(record_id)

    async def search_records(
        self,
        query: str,
        filters: Optional[Dict[str, Any]] = None,
        page: int = 1,
        size: int = 20,
    ) -> Union[Dict[str, Any], Exception]:
        """
        Search records with optional filters.

        Args:
            query: Search query string
            filters: Optional filters to apply
            page: Page number for pagination
            size: Number of records per page

        Returns:
            Dict: Search results with pagination info
        """
        if self.storage_backend is None:
            return ValueError("No storage backend configured")

        return await self.storage_backend.search_records(
            query=query, filters=filters, page=page, size=size
        )

    async def list_records(
        self, page: int = 1, size: int = 20
    ) -> Union[List[MetagitRecord], Exception]:
        """
        List all records with pagination.

        Args:
            page: Page number for pagination
            size: Number of records per page

        Returns:
            List[MetagitRecord]: List of records
        """
        if self.storage_backend is None:
            return ValueError("No storage backend configured")

        return await self.storage_backend.list_records(page=page, size=size)

    def save_record_to_file(
        self, record: MetagitRecord, file_path: Path
    ) -> Union[None, Exception]:
        """
        Save a record to a local YAML file.

        Args:
            record: MetagitRecord to save
            file_path: Path where to save the record

        Returns:
            None if successful, Exception otherwise
        """
        try:
            file_path.parent.mkdir(parents=True, exist_ok=True)
            with open(file_path, "w", encoding="utf-8") as f:
                yaml.dump(
                    record.model_dump(exclude_none=True, exclude_defaults=True),
                    f,
                    default_flow_style=False,
                )
            return None
        except Exception as e:
            return e

    def load_record_from_file(self, file_path: Path) -> Union[MetagitRecord, Exception]:
        """
        Load a record from a local YAML file.

        Args:
            file_path: Path to the record file

        Returns:
            MetagitRecord: The loaded record, or Exception if failed
        """
        try:
            if not file_path.exists():
                return FileNotFoundError(f"Record file not found: {file_path}")

            with open(file_path, "r", encoding="utf-8") as f:
                yaml_data = yaml.safe_load(f)

            return MetagitRecord(**yaml_data)
        except Exception as e:
            return e
`````

## File: src/metagit/core/record/models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for metagit records.
"""

from datetime import datetime
from typing import Dict, List, Optional, Type, TypeVar

import yaml
from pydantic import BaseModel, Field

from metagit.core.config.models import (
    AlertingChannel,
    Artifact,
    Branch,
    Dashboard,
    Environment,
    Language,
    License,
    Maintainer,
    MetagitConfig,
    Metrics,
    ProjectDomain,
    RepoMetadata,
    Secret,
    Workspace,
)

# Import models from detect module for forward references
try:
    from metagit.core.detect.models import (
        CIConfigAnalysis,
        GitBranchAnalysis,
        LanguageDetection,
        ProjectTypeDetection,
    )
    from metagit.core.utils.files import DirectoryDetails, DirectorySummary
except ImportError:
    # Forward references for type hints
    LanguageDetection = "LanguageDetection"
    ProjectTypeDetection = "ProjectTypeDetection"
    GitBranchAnalysis = "GitBranchAnalysis"
    CIConfigAnalysis = "CIConfigAnalysis"
    DirectoryDetails = "DirectoryDetails"
    DirectorySummary = "DirectorySummary"

from metagit.core.detect.models import (
    CIConfigAnalysis,
    GitBranchAnalysis,
    LanguageDetection,
    ProjectTypeDetection,
)
from metagit.core.utils.files import DirectoryDetails, DirectorySummary

T = TypeVar("T", bound=BaseModel)


def _get_common_fields(
    source_model: Type[BaseModel], target_model: Type[BaseModel]
) -> set[str]:
    """
    Automatically detect common fields between two Pydantic models.

    This utility function uses Pydantic's field introspection to find
    fields that exist in both models, making conversion more maintainable.

    Args:
        source_model: The source model class
        target_model: The target model class

    Returns:
        Set of field names that exist in both models
    """
    source_fields = set(source_model.model_fields.keys())
    target_fields = set(target_model.model_fields.keys())
    return source_fields & target_fields


def _convert_model_data(
    source_data: dict,
    target_model: Type[T],
    field_mapping: Optional[dict[str, str]] = None,
) -> T:
    """
    Convert data between Pydantic models with automatic field mapping.

    This function provides a generic way to convert data between any two
    Pydantic models by automatically detecting compatible fields.

    Args:
        source_data: Dictionary of source model data
        target_model: Target model class
        field_mapping: Optional mapping of source field names to target field names

    Returns:
        Instance of target model

    Raises:
        ValueError: If conversion fails
    """
    try:
        # Apply field mapping if provided
        if field_mapping:
            mapped_data = {}
            for source_key, target_key in field_mapping.items():
                if source_key in source_data:
                    mapped_data[target_key] = source_data[source_key]
            source_data = mapped_data

        # Filter to only include fields that exist in target model
        target_fields = set(target_model.model_fields.keys())
        filtered_data = {k: v for k, v in source_data.items() if k in target_fields}

        # Use model_validate for fast, validated conversion
        return target_model.model_validate(filtered_data)

    except Exception as e:
        raise ValueError(f"Conversion to {target_model.__name__} failed: {e}") from e


class MetagitRecord(MetagitConfig):
    """
    Extended model for metagit records that includes detection-specific data suitable for OpenSearch.

    This class inherits from MetagitConfig and adds detection-specific attributes.
    Now includes all RepositoryAnalysis attributes for comprehensive repository information.
    """

    # Detection-specific attributes
    branch: Optional[str] = Field(None, description="Current branch")
    checksum: Optional[str] = Field(None, description="Branch checksum")
    last_updated: Optional[datetime] = Field(None, description="Last updated timestamp")
    branches: Optional[List[Branch]] = Field(None, description="Release branches")
    metrics: Optional[Metrics] = Field(None, description="Repository metrics")
    metadata: Optional[RepoMetadata] = Field(None, description="Repository metadata")

    # Language and project type detection
    language: Optional[Language] = Field(
        None, description="Detected language information"
    )
    language_version: Optional[str] = Field(
        None, description="Primary language version"
    )
    domain: Optional[ProjectDomain] = Field(None, description="Project domain")

    # Additional detection fields
    detection_timestamp: Optional[datetime] = Field(
        None, description="When this record was last detected/updated"
    )
    detection_source: Optional[str] = Field(
        None, description="Source of the detection (e.g., 'github', 'gitlab', 'local')"
    )
    detection_version: Optional[str] = Field(
        None, description="Version of the detection system used"
    )

    # RepositoryAnalysis attributes merged from repository.py
    # Repository path and URL information
    path: Optional[str] = Field(None, description="Repository path")
    url: Optional[str] = Field(None, description="Repository URL")
    is_git_repo: bool = Field(
        default=False, description="Whether this is a git repository"
    )
    is_cloned: bool = Field(
        default=False, description="Whether this was cloned from a URL"
    )
    temp_dir: Optional[str] = Field(None, description="Temporary directory if cloned")

    # Detection results from RepositoryAnalysis
    language_detection: Optional[LanguageDetection] = Field(
        None, description="Language detection results"
    )
    project_type_detection: Optional[ProjectTypeDetection] = Field(
        None, description="Project type detection results"
    )

    # Analysis results from RepositoryAnalysis
    branch_analysis: Optional[GitBranchAnalysis] = Field(
        None, description="Git branch analysis results"
    )
    ci_config_analysis: Optional[CIConfigAnalysis] = Field(
        None, description="CI/CD configuration analysis results"
    )
    directory_summary: Optional[DirectorySummary] = Field(
        None, description="Directory summary analysis results"
    )
    directory_details: Optional[DirectoryDetails] = Field(
        None, description="Directory details analysis results"
    )

    # Repository metadata from RepositoryAnalysis
    license_info: Optional[License] = Field(None, description="License information")
    maintainers: List[Maintainer] = Field(
        default_factory=list, description="Repository maintainers"
    )
    existing_workspace: Optional[Workspace] = Field(
        None, description="Existing workspace information"
    )

    # Additional metadata from RepositoryAnalysis
    artifacts: Optional[List[Artifact]] = Field(
        None, description="Repository artifacts"
    )
    secrets_management: Optional[List[str]] = Field(
        None, description="Secrets management information"
    )
    secrets: Optional[List[Secret]] = Field(None, description="Repository secrets")
    documentation: Optional[List[str]] = Field(
        None, description="Documentation information"
    )
    alerts: Optional[List[AlertingChannel]] = Field(
        None, description="Alerting channels"
    )
    dashboards: Optional[List[Dashboard]] = Field(None, description="Dashboards")
    environments: Optional[List[Environment]] = Field(None, description="Environments")

    # File analysis from RepositoryAnalysis
    detected_files: Dict[str, List[str]] = Field(
        default_factory=dict, description="Detected files by category"
    )
    has_docker: bool = Field(
        default=False, description="Whether repository has Docker files"
    )
    has_tests: bool = Field(
        default=False, description="Whether repository has test files"
    )
    has_docs: bool = Field(
        default=False, description="Whether repository has documentation"
    )
    has_iac: bool = Field(
        default=False, description="Whether repository has Infrastructure as Code files"
    )

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        validate_assignment = True
        extra = "forbid"

    @classmethod
    def from_yaml(cls, yaml_str: str) -> "MetagitRecord":
        """Create a MetagitRecord from a YAML string."""
        return cls.model_validate_yaml(yaml_str)

    @classmethod
    def from_json(cls, json_str: str) -> "MetagitRecord":
        """Create a MetagitRecord from a JSON string."""
        return cls.model_validate_json(json_str)

    @classmethod
    def from_dict(cls, data: dict) -> "MetagitRecord":
        """Create a MetagitRecord from a dictionary."""
        return cls.model_validate(data)

    def to_yaml(self) -> str:
        """Convert a MetagitRecord to a YAML string."""
        return yaml.safe_dump(self.model_dump(exclude_none=True, exclude_defaults=True))

    @classmethod
    def to_json(self) -> str:
        """Convert a MetagitRecord to a JSON string."""
        return self.model_dump_json(exclude_defaults=True, exclude_none=True)

    def to_metagit_config(self, exclude_detection_fields: bool = True) -> MetagitConfig:
        """
        Fast conversion from MetagitRecord to MetagitConfig using automatic field detection.

        This method efficiently converts a MetagitRecord to a MetagitConfig by:
        1. Using Pydantic's field introspection to automatically detect compatible fields
        2. Leveraging model_dump() with field filtering for optimal performance
        3. Using model_validate() for fast, validated conversion
        4. Automatically handling field differences between models

        Args:
            exclude_detection_fields: Whether to exclude detection-specific fields
                                     (currently always True as MetagitConfig doesn't support them)

        Returns:
            MetagitConfig: A new MetagitConfig instance with the shared fields

        Example:
            record = MetagitRecord(name="my-project", description="A project")
            config = record.to_metagit_config()

        Performance Notes:
            - Uses Pydantic's field introspection for automatic field detection
            - Leverages Pydantic's C-optimized validation
            - Minimal memory allocation through direct field copying
            - No deep copying of nested objects (uses references)
        """
        # Get the model data, excluding None values and defaults for performance
        model_data = self.model_dump(
            exclude_none=True,
            exclude_defaults=True,
        )

        # Use the generic conversion utility for automatic field mapping
        return _convert_model_data(model_data, MetagitConfig)

    def to_metagit_config_advanced(self, **kwargs) -> MetagitConfig:
        """
        Advanced conversion method with automatic field mapping and validation.

        This method provides more sophisticated conversion capabilities:
        1. Automatic field compatibility detection
        2. Type conversion and validation
        3. Support for custom field mappings
        4. Better error handling and reporting

        Args:
            **kwargs: Additional options for conversion behavior

        Returns:
            MetagitConfig: A new MetagitConfig instance

        Example:
            record = MetagitRecord(name="my-project", description="A project")
            config = record.to_metagit_config_advanced()
        """
        try:
            # Use the standard method for now, but this could be extended
            # with more sophisticated field mapping logic
            return self.to_metagit_config()
        except Exception as e:
            # Could add more sophisticated error handling here
            raise ValueError(f"Conversion failed: {e}") from e

    @classmethod
    def from_metagit_config(
        cls,
        config: MetagitConfig,
        detection_source: str = "local",
        detection_version: str = "1.0.0",
        additional_detection_data: Optional[dict] = None,
    ) -> "MetagitRecord":
        """
        Fast conversion from MetagitConfig to MetagitRecord using latest Pydantic best practices.

        This method efficiently converts a MetagitConfig to a MetagitRecord by:
        1. Using model_dump() for optimal serialization performance
        2. Adding detection-specific fields with minimal overhead
        3. Leveraging Pydantic's built-in validation
        4. Supporting additional detection data injection

        Args:
            config: MetagitConfig instance to convert
            detection_source: Source of the detection (e.g., 'github', 'gitlab', 'local')
            detection_version: Version of the detection system used
            additional_detection_data: Additional detection-specific data to include

        Returns:
            MetagitRecord: A new MetagitRecord instance

        Example:
            config = MetagitConfig(name="my-project", description="A project")
            record = MetagitRecord.from_metagit_config(
                config,
                detection_source="github",
                detection_version="2.0.0"
            )

        Performance Notes:
            - Uses model_dump() with exclude_none=True for optimal serialization
            - Leverages Pydantic's C-optimized validation
            - Minimal memory allocation through direct field copying
            - No deep copying of nested objects (uses references)
        """
        # Get the base config data
        record_data = config.model_dump(exclude_none=True, exclude_defaults=True)

        # Add detection-specific fields
        record_data.update(
            {
                "detection_timestamp": datetime.now(),
                "detection_source": detection_source,
                "detection_version": detection_version,
            }
        )

        # Add additional detection data if provided
        if additional_detection_data:
            record_data.update(additional_detection_data)

        # Use model_validate for fast, validated conversion
        return cls.model_validate(record_data)

    @classmethod
    def from_metagit_config_advanced(
        cls, config: MetagitConfig, **detection_kwargs
    ) -> "MetagitRecord":
        """
        Advanced conversion from MetagitConfig to MetagitRecord with automatic field handling.

        This method provides more sophisticated conversion capabilities:
        1. Automatic field mapping and validation
        2. Support for complex detection data structures
        3. Better error handling and reporting
        4. Extensible for future enhancements

        Args:
            config: MetagitConfig instance to convert
            **detection_kwargs: Detection-specific parameters and data

        Returns:
            MetagitRecord: A new MetagitRecord instance

        Example:
            config = MetagitConfig(name="my-project", description="A project")
            record = MetagitRecord.from_metagit_config_advanced(
                config,
                detection_source="github",
                detection_version="2.0.0",
                branch="main",
                checksum="abc123"
            )
        """
        # Extract standard detection parameters
        detection_source = detection_kwargs.pop("detection_source", "local")
        detection_version = detection_kwargs.pop("detection_version", "1.0.0")

        # Use the standard method for now, but this could be extended
        # with more sophisticated field mapping logic
        return cls.from_metagit_config(
            config,
            detection_source=detection_source,
            detection_version=detection_version,
            additional_detection_data=detection_kwargs,
        )

    def get_detection_summary(self) -> dict:
        """
        Get a summary of detection-specific data for quick analysis.

        Returns:
            dict: Summary of detection data including source, version, and key metrics
        """
        summary = {
            "detection_source": self.detection_source,
            "detection_version": self.detection_version,
            "detection_timestamp": self.detection_timestamp,
            "current_branch": self.branch,
            "checksum": self.checksum,
        }

        # Add metrics summary if available
        if self.metrics:
            summary["metrics"] = {
                "stars": self.metrics.stars,
                "forks": self.metrics.forks,
                "open_issues": self.metrics.open_issues,
                "contributors": self.metrics.contributors,
            }

        # Add metadata summary if available
        if self.metadata:
            summary["metadata"] = {
                "has_ci": self.metadata.has_ci,
                "has_tests": self.metadata.has_tests,
                "has_docs": self.metadata.has_docs,
                "has_docker": self.metadata.has_docker,
                "has_iac": self.metadata.has_iac,
            }

        return summary

    @classmethod
    def get_field_differences(cls) -> dict:
        """
        Get the field differences between MetagitRecord and MetagitConfig.

        This method helps understand what fields are unique to each model,
        making it easier to understand the conversion behavior.

        Returns:
            dict: Field differences between the models
        """
        record_fields = set(cls.model_fields.keys())
        config_fields = set(MetagitConfig.model_fields.keys())

        return {
            "common_fields": sorted(record_fields & config_fields),
            "record_only_fields": sorted(record_fields - config_fields),
            "config_only_fields": sorted(config_fields - record_fields),
            "total_record_fields": len(record_fields),
            "total_config_fields": len(config_fields),
            "common_field_count": len(record_fields & config_fields),
        }

    @classmethod
    def get_compatible_fields(cls) -> set[str]:
        """
        Get the fields that are compatible between MetagitRecord and MetagitConfig.

        Returns:
            set: Field names that exist in both models
        """
        return _get_common_fields(cls, MetagitConfig)


MetagitRecord.model_rebuild()

# from metagit.core.detect.models import (
#     CIConfigAnalysis,
#     GitBranchAnalysis,
#     LanguageDetection,
#     ProjectTypeDetection,
# )
# from metagit.core.utils.files import DirectoryDetails, DirectorySummary

# MetagitRecord.model_rebuild()
`````

## File: src/metagit/core/utils/click.py
`````python
"""
Click utility functions
"""

import click


def call_click_command(cmd, *args, **kwargs):
    """Wrapper to call a click command

    :param cmd: click cli command function to call
    :param args: arguments to pass to the function
    :param kwargs: keywrod arguments to pass to the function
    :return: None
    """

    # Get positional arguments from args
    arg_values = {c.name: a for a, c in zip(args, cmd.params, strict=False)}
    args_needed = {c.name: c for c in cmd.params if c.name not in arg_values}

    # build and check opts list from kwargs
    opts = {a.name: a for a in cmd.params if isinstance(a, click.Option)}
    for name in kwargs:
        if name in opts:
            arg_values[name] = kwargs[name]
        else:
            if name in args_needed:
                arg_values[name] = kwargs[name]
                del args_needed[name]
            else:
                raise click.BadParameter("Unknown keyword argument '{}'".format(name))

    # check positional arguments list
    for arg in (a for a in cmd.params if isinstance(a, click.Argument)):
        if arg.name not in arg_values:
            raise click.BadParameter(
                "Missing required positionalparameter '{}'".format(arg.name)
            )

    # build parameter lists
    opts_list = sum([[o.opts[0], str(arg_values[n])] for n, o in opts.items()], [])
    args_list = [str(v) for n, v in arg_values.items() if n not in opts]

    # call the command
    cmd(opts_list + args_list)


def call_click_command_with_ctx(cmd, ctx, *args, **kwargs):
    """Wrapper to call a click command with a Context object

    :param cmd: click cli command function to call
    :param ctx: click context
    :param args: arguments to pass to the function
    :param kwargs: keyword arguments to pass to the function
    :return: None
    """

    # monkey patch make_context
    def make_context(*some_args, **some_kwargs):  # noqa: ARG001 ARG002
        child_ctx = click.Context(cmd, parent=ctx)
        with child_ctx.scope(cleanup=False):
            cmd.parse_args(child_ctx, list(args))
        return child_ctx

    cmd.make_context = make_context
    prev_make_context = cmd.make_context

    # call the command
    call_click_command(cmd, *args, **kwargs)

    # restore make_context
    cmd.make_context = prev_make_context
`````

## File: src/metagit/core/utils/common.py
`````python
"""
common functions
"""

import os
import re
import subprocess
from pathlib import Path
from typing import Any, Dict, Generator, List, MutableMapping, Optional, Union

import yaml  # Use standard PyYAML for dumping

__all__ = [
    "env_override",
    "regex_replace",
    "flatten_dict",
    "to_yaml",
    "merge_dicts",
    "open_editor",
    "create_vscode_workspace",
    "normalize_git_url",
    "get_project_root",
    "ensure_directory",
    "safe_get",
    "flatten_list",
    "is_git_repository",
    "get_relative_path",
    "sanitize_filename",
    "format_bytes",
    "parse_env_list",
    "filter_none_values",
]


def create_vscode_workspace(_: str, repo_paths: List[str]) -> Union[str, Exception]:
    """
    Create VS Code workspace file content.

    Args:
        project_name: The name of the project
        repo_paths: List of repository paths to include in the workspace

    Returns:
        JSON string representing the VS Code workspace file content
    """
    try:
        workspace_data = {
            "folders": [],
            "settings": {
                "files.exclude": {
                    "**/.git": True,
                    "**/.DS_Store": True,
                    "**/node_modules": True,
                    "**/__pycache__": True,
                    "**/*.pyc": True,
                },
                "search.exclude": {
                    "**/node_modules": True,
                    "**/bower_components": True,
                    "**/*.code-search": True,
                },
            },
            "extensions": {
                "recommendations": [
                    "ms-python.python",
                    "ms-vscode.vscode-json",
                    "redhat.vscode-yaml",
                    "ms-vscode.vscode-typescript-next",
                ]
            },
        }

        # Add each repository as a folder in the workspace
        for repo_path in repo_paths:
            workspace_data["folders"].append(
                {
                    "name": os.path.basename(repo_path),
                    "path": f"./{os.path.basename(repo_path)}",
                }
            )

        # Convert to JSON string
        import json

        return json.dumps(workspace_data, indent=2)
    except Exception as e:
        return e


def open_editor(editor: str, path: str) -> Union[None, Exception]:
    """
    Open a path in the specified editor in an OS-agnostic way.

    Args:
        editor: The editor command to use (e.g., 'code', 'vim', 'nano')
        path: The path to open in the editor

    Returns:
        None on success, Exception on failure
    """
    try:
        # Ensure the path exists
        if not os.path.exists(path):
            return Exception(f"Path does not exist: {path}")

        # Use subprocess to open the editor
        # This works cross-platform as subprocess handles the differences
        result = subprocess.run([editor, path], capture_output=True, text=True)

        if result.returncode != 0:
            return Exception(f"Failed to open editor '{editor}': {result.stderr}")

        return None
    except FileNotFoundError:
        return Exception(f"Editor '{editor}' not found in PATH")
    except Exception as e:
        return e


def _flatten_dict_gen(
    d: MutableMapping, parent_key: str, sep: str
) -> Generator[Any, None, None]:
    for k, v in d.items():
        new_key = parent_key + sep + k if parent_key else k
        if isinstance(v, MutableMapping):
            yield from flatten_dict(v, new_key, sep=sep).items()
        else:
            yield new_key, v


def flatten_dict(
    d: MutableMapping, parent_key: str = "", sep: str = "."
) -> Union[Dict[Any, Any], Exception]:
    try:
        return dict(_flatten_dict_gen(d, parent_key, sep))
    except Exception as e:
        return e


def regex_replace(s: str, find: str, replace: str) -> Union[str, Exception]:
    """A non-optimal implementation of a regex filter for use in our Jinja2 template processing"""
    try:
        return re.sub(find, replace, s)
    except Exception as e:
        return e


def env_override(value: str, key: str) -> Union[str, None, Exception]:
    """Can be used to pull env vars into templates"""
    try:
        return os.getenv(key, value)
    except Exception as e:
        return e


def to_yaml(value: Any) -> Union[str, Any, Exception]:
    """convert dicts to yaml"""
    try:
        if isinstance(value, dict):
            return yaml.dump(value)
        elif isinstance(value, str):
            return value
        return value
    except Exception as e:
        return e


def pretty(
    d: Dict[Any, Any], indent: int = 10, result: str = ""
) -> Union[str, Exception]:
    """Pretty up output in Jinja template"""
    try:
        for key, value in d.items():
            result += " " * indent + str(key)
            if isinstance(value, dict):
                pretty_result = pretty(value, indent + 2, result + "\n")
                if isinstance(pretty_result, Exception):
                    return pretty_result
                result = pretty_result
            else:
                result += ": " + str(value) + "\n"
        return result
    except Exception as e:
        return e


def merge_dicts(a: Dict, b: Dict, path: List = None) -> Union[Dict, Exception]:
    """ "merges b into a"""
    try:
        if path is None:
            path = []
        for key in b:
            if key in a:
                if isinstance(a[key], dict) and isinstance(b[key], dict):
                    merge_result = merge_dicts(a[key], b[key], path + [str(key)])
                    if isinstance(merge_result, Exception):
                        return merge_result
                elif a[key] == b[key]:
                    pass  # same leaf value
                else:
                    a[key] = b[key]

            else:
                a[key] = b[key]
        return a
    except Exception as e:
        return e


def parse_checksum_file(file_path: str) -> Union[Dict[str, str], Exception]:
    try:
        checksums = {}
        with open(file_path) as file:
            for line in file:
                checksum, filepath = line.strip().split("  ", 1)
                checksums[filepath] = checksum
        return checksums
    except Exception as e:
        return e


def compare_checksums(
    checksums1: Dict[str, str], checksums2: Dict[str, str], include_same: bool = False
) -> Union[List[Dict[str, str]], Exception]:
    try:
        differences = []
        for filepath, checksum1 in checksums1.items():
            base_filename = filepath.split("/")[-1]
            if filepath in checksums2:
                checksum2 = checksums2[filepath]
                if checksum1 != checksum2:
                    differences.append(
                        {
                            "filepath": filepath,
                            "base_filename": base_filename,
                            "source_id": base_filename.split(".")[0],
                            "source": checksum1,
                            "changetype": "change",
                        }
                    )
                elif include_same:
                    differences.append(
                        {
                            "filepath": filepath,
                            "base_filename": base_filename,
                            "source_id": base_filename.split(".")[0],
                            "source": checksum1,
                            "changetype": "same",
                        }
                    )
            else:
                differences.append(
                    {
                        "filepath": filepath,
                        "base_filename": base_filename,
                        "source_id": base_filename.split(".")[0],
                        "source": checksum1,
                        "changetype": "delete_dest",
                    }
                )

        for filepath, checksum2 in checksums2.items():
            base_filename = filepath.split("/")[-1]
            if filepath not in checksums1:
                differences.append(
                    {
                        "filepath": filepath,
                        "base_filename": base_filename,
                        "source_id": base_filename.split(".")[0],
                        "source": checksum2,
                        "changetype": "delete_source",
                    }
                )

        return differences
    except Exception as e:
        return e


def normalize_git_url(url: Optional[str]) -> Optional[str]:
    """
    Normalize a git URL by removing trailing forward slashes.

    Args:
        url: Git URL to normalize

    Returns:
        Normalized URL without trailing forward slash, or None if input is None
    """
    if url is None:
        return None

    # Convert to string if it's an HttpUrl or other object
    url_str = str(url).strip()

    # Remove trailing forward slash
    if url_str.endswith("/"):
        url_str = url_str.rstrip("/")

    return url_str


def get_project_root() -> Path:
    """Get the project root directory."""
    return Path(__file__).parent.parent.parent


def ensure_directory(path: Union[str, Path]) -> Path:
    """Ensure a directory exists, creating it if necessary."""
    path_obj = Path(path)
    path_obj.mkdir(parents=True, exist_ok=True)
    return path_obj


def safe_get(dictionary: Dict[str, Any], key: str, default: Any = None) -> Any:
    """Safely get a value from a dictionary."""
    return dictionary.get(key, default)


def flatten_list(nested_list: List[Any]) -> List[Any]:
    """Flatten a nested list."""
    flattened = []
    for item in nested_list:
        if isinstance(item, list):
            flattened.extend(flatten_list(item))
        else:
            flattened.append(item)
    return flattened


def is_git_repository(path: Union[str, Path]) -> bool:
    """Check if a path is a git repository."""
    path_obj = Path(path)
    return (path_obj / ".git").exists() and (path_obj / ".git").is_dir()


def get_relative_path(
    base_path: Union[str, Path], target_path: Union[str, Path]
) -> str:
    """Get the relative path from base_path to target_path."""
    base = Path(base_path).resolve()
    target = Path(target_path).resolve()

    try:
        return str(target.relative_to(base))
    except ValueError:
        return str(target)


def sanitize_filename(filename: str) -> str:
    """Sanitize a filename by removing or replacing invalid characters."""
    # Remove or replace invalid characters
    sanitized = re.sub(r'[<>:"/\\|?*]', "_", filename)
    # Remove leading/trailing spaces and dots
    sanitized = sanitized.strip(" .")
    # Ensure it's not empty
    if not sanitized:
        sanitized = "unnamed"
    return sanitized


def format_bytes(bytes_value: int) -> str:
    """Format bytes into a human-readable string."""
    for unit in ["B", "KB", "MB", "GB", "TB"]:
        if bytes_value < 1024.0:
            return f"{bytes_value:.1f} {unit}"
        bytes_value /= 1024.0
    return f"{bytes_value:.1f} PB"


def parse_env_list(env_value: Optional[str], separator: str = ",") -> List[str]:
    """Parse a comma-separated environment variable into a list."""
    if not env_value:
        return []
    return [item.strip() for item in env_value.split(separator) if item.strip()]


def filter_none_values(data: Dict[str, Any]) -> Dict[str, Any]:
    """Remove None values from a dictionary."""
    return {k: v for k, v in data.items() if v is not None}
`````

## File: src/metagit/core/workspace/__init__.py
`````python

`````

## File: src/metagit/core/workspace/manager.py
`````python
#!/usr/bin/env python
"""
Class for managing workspaces.

This package provides a class for managing workspaces.
"""

from typing import List, Optional, Union

from metagit.core.appconfig.models import AppConfig
from metagit.core.workspace.models import Workspace, WorkspaceProject


def get_workspace_path(config: AppConfig) -> Union[str, Exception]:
    """
    Get the workspace path from the config.
    """
    try:
        return config.workspace.path
    except Exception as e:
        return e


def get_synced_projects(config: AppConfig) -> Union[List[WorkspaceProject], Exception]:
    """
    Get the synced projects from the config.
    """
    try:
        return config.workspace.projects
    except Exception as e:
        return e


class WorkspaceManager:
    """
    Manager class for handling workspaces.

    This class provides methods for loading, validating, and creating
    workspaces with proper error handling and validation.
    """

    def __init__(self, workspace_path: str) -> None:
        """
        Initialize the MetagitWorkspaceManager.

        Args:
            workspace_path: Path to the workspace.
        """
        self.workspace_path = workspace_path
        self._workspace: Optional[Workspace] = None
`````

## File: src/metagit/data/prompts/gemini_prompt_filled.md
`````markdown
You are an AI agent specializing in project analysis and configuration. Your task is to generate a new YAML configuration file, `./.metagit.new.yaml`, by analyzing the project located at `./`.

The structure, fields, and data for this new YAML file must be determined by following the JSON schema located at `./.metagit/metagit_config.schema.json`.

**Your process must be as follows:**

1.  **Read and Understand the Schema:** First, read the JSON schema file at `./.metagit/metagit_config.schema.json`.
2.  **Read Existing YAML manifest:** If an existing YAML file exists at `./.metagit.yml` it should be used as the basis for your efforts. The `workspace` attribute in this file **must** be preserved in your generated output file later on.
3.  **Read Existing Project File Data:** Read in the contents of `./.metagit/local_summary.yml` and use it to determine which subfolders to target in your analysis efforts. You **must** only traverse and analyse folders in the paths defined in this folder.
3.  **Follow Schema Descriptions:** For each property in the schema, you **must** use its corresponding `description` field as additional instruction for to help best determine the values for each property.
4.  **Analyze the Project:** Systematically search and read the files within the project directory (`.`) to gather the information needed to populate the YAML fields, as guided by the schemas descriptions. Skip all folders and files if they match any of the patterns defined in `./.gitignore` if the file exists.
5.  **Generate the YAML File:** Construct the `./.metagit.new.yaml` file. The file must be valid YAML and strictly conform to the schema.
6.  **Handle Missing Information:**
    *   If you cannot determine a value for a **required** field after a thorough analysis, state this clearly.
    *   For **optional** fields, omit them if the relevant information cannot be found.

Your final output should be the complete content for the new `./.metagit.new.yaml` file.
`````

## File: src/metagit/data/prompts/gemini_prompt.md
`````markdown
You are an AI agent specializing in project analysis and configuration. Your task is to generate a new YAML configuration file, `{{project_path}}/.metagit.new.yaml`, by analyzing the project located at `{{project_path}}/`.

The structure, fields, and data for this new YAML file must be determined by following the JSON schema located at `{{project_path}}/.metagit/metagit_config.schema.json`.

**Your process must be as follows:**

1.  **Read and Understand the Schema:** First, read the JSON schema file at `{{project_path}}/.metagit/metagit_config.schema.json`.
2.  **Read Existing YAML manifest:** If an existing YAML file exists at `{{project_path}}/.metagit.yml` it should be used as the basis for your efforts. The `workspace` attribute in this file **must** be preserved in your generated output file later on.
3.  **Follow Schema Descriptions:** For each property in the schema, you **must** use its corresponding `description` field as a direct instruction for how to find or infer the correct value from the project's files and structure.
4.  **Analyze the Project:** Systematically search and read the files within the project directory (`{{project_path}}`) to gather the information needed to populate the YAML fields, as guided by the schema's descriptions. Skip all folders and files if they match any of the patterns defined in `{{project_path}}/.gitignore` if the file exists.
5.  **Generate the YAML File:** Construct the `{{project_path}}/.metagit.new.yaml` file. The file must be valid YAML and strictly conform to the schema.
6.  **Handle Missing Information:**
    *   If you cannot determine a value for a **required** field after a thorough analysis, state this clearly.
    *   For **optional** fields, omit them if the relevant information cannot be found.

Your final output should be the complete content for the new `{{project_path}}/.metagit.new.yaml` file.
`````

## File: src/metagit/data/build-files.yaml
`````yaml
build_files:
  general:
    - Makefile
    - CMakeLists.txt
    - build.gradle
    - build.gradle.kts
    - setup.py
    - setup.cfg
    - requirements.txt
    - Taskfile.yaml
    - tasks.json
    - BUCK
  javascript:
    - package.json
    - webpack.config.js
    - vite.config.js
    - rollup.config.js
    - gulpfile.js
    - Gruntfile.js
  important:
    - build.xml
    - package.json
    - pom.xml
    - build.gradle
    - build.sbt
    - .github/workflows/*.yml
    - .gitlab-ci.yml
    - .travis.yml
    - Jenkinsfile
    - circleci/config.yml
    - bitrise.yml
    - appveyor.yml
    - circle.yml
    - .circleci/config.yml
    - codeship-steps.yml
    - wercker.yml
    - docker-compose.yml
    - docker-cloud.yml
    - docker-stack.yml
    - azure-pipelines.yml
    - bitbucket-pipelines.yml
    - Dockerfile
    - docker-compose.yml
    - skaffold.yaml
    - pyproject.toml
    - requirements.txt
    - setup.py
    - setup.cfg
    - '*.tf'
    - Pulumi.yaml
  testing:
    - Jestfile.js
    - jest.config.js
    - karma.conf.js
    - tox.ini
    - .eslintrc.js
    - .eslintrc.json
    - .prettierrc
  containerization:
    - Dockerfile
    - docker-compose.yml
    - skaffold.yaml
  infrastructure:
    - '*.tf'
    - Pulumi.yaml
  python:
    - pyproject.toml
    - requirements.txt
    - setup.py
    - setup.cfg
    - tox.ini
`````

## File: src/metagit/data/cd-files.json
`````json
{
  "**/*.tf": "terraform",
  "skaffold.yaml": "skaffold",
  "Pulumi.yaml": "pulumi"
}
`````

## File: src/metagit/data/ci-files.json
`````json
{
  "appveyor.yml": "Appveyor",
  "circle.yml": "CircleCI",
  ".circleci/config.yml": "CircleCI",
  "codeship-steps.yml": "Codeship",
  "wercker.yml": "Wercker",
  "docker-compose.yml": "Docker",
  "docker-cloud.yml": "Docker Cloud",
  "docker-stack.yml": "Docker Stack",
  ".github/workflows/*.yml": "GitHub Actions",
  ".gitlab-ci.yml": "GitLab CI",
  "Jenkinsfile": "Jenkins",
  ".travis.yml": "Travis CI",
  "azure-pipelines.yml": "Azure DevOps",
  "bitbucket-pipelines.yml": "Bitbucket Pipelines"
}
`````

## File: src/metagit/data/file-types.json
`````json
{
  "extensions": [
    {
      "kind": "ABAP",
      "type": "programming",
      "extensions": [".abap"]
    },
    {
      "kind": "AGS Script",
      "type": "programming",
      "extensions": [".asc", ".ash"]
    },
    {
      "kind": "AMPL",
      "type": "programming",
      "extensions": [".ampl", ".mod"]
    },
    {
      "kind": "ANTLR",
      "type": "programming",
      "extensions": [".g4"]
    },
    {
      "kind": "API Blueprint",
      "type": "markup",
      "extensions": [".apib"]
    },
    {
      "kind": "APL",
      "type": "programming",
      "extensions": [".apl", ".dyalog"]
    },
    {
      "kind": "ASP",
      "type": "programming",
      "extensions": [
        ".asp",
        ".asax",
        ".ascx",
        ".ashx",
        ".asmx",
        ".aspx",
        ".axd"
      ]
    },
    {
      "kind": "ATS",
      "type": "programming",
      "extensions": [".dats", ".hats", ".sats"]
    },
    {
      "kind": "ActionScript",
      "type": "programming",
      "extensions": [".as"]
    },
    {
      "kind": "Ada",
      "type": "programming",
      "extensions": [".adb", ".ada", ".ads"]
    },
    {
      "kind": "Agda",
      "type": "programming",
      "extensions": [".agda"]
    },
    {
      "kind": "Alloy",
      "type": "programming",
      "extensions": [".als"]
    },
    {
      "kind": "Ant Build System",
      "type": "data"
    },
    {
      "kind": "ApacheConf",
      "type": "markup",
      "extensions": [".apacheconf", ".vhost"]
    },
    {
      "kind": "Apex",
      "type": "programming",
      "extensions": [".cls"]
    },
    {
      "kind": "AppleScript",
      "type": "programming",
      "extensions": [".applescript", ".scpt"]
    },
    {
      "kind": "Arc",
      "type": "programming",
      "extensions": [".arc"]
    },
    {
      "kind": "Arduino",
      "type": "programming",
      "extensions": [".ino"]
    },
    {
      "kind": "AsciiDoc",
      "type": "prose",
      "extensions": [".asciidoc", ".adoc", ".asc"]
    },
    {
      "kind": "AspectJ",
      "type": "programming",
      "extensions": [".aj"]
    },
    {
      "kind": "Assembly",
      "type": "programming",
      "extensions": [".asm", ".a51", ".inc", ".nasm"]
    },
    {
      "kind": "Augeas",
      "type": "programming",
      "extensions": [".aug"]
    },
    {
      "kind": "AutoHotkey",
      "type": "programming",
      "extensions": [".ahk", ".ahkl"]
    },
    {
      "kind": "AutoIt",
      "type": "programming",
      "extensions": [".au3"]
    },
    {
      "kind": "Awk",
      "type": "programming",
      "extensions": [".awk", ".auk", ".gawk", ".mawk", ".nawk"]
    },
    {
      "kind": "Batchfile",
      "type": "programming",
      "extensions": [".bat", ".cmd"]
    },
    {
      "kind": "Befunge",
      "type": "programming",
      "extensions": [".befunge"]
    },
    {
      "kind": "Bison",
      "type": "programming",
      "extensions": [".bison"]
    },
    {
      "kind": "BitBake",
      "type": "programming",
      "extensions": [".bb"]
    },
    {
      "kind": "BlitzBasic",
      "type": "programming",
      "extensions": [".bb", ".decls"]
    },
    {
      "kind": "BlitzMax",
      "type": "programming",
      "extensions": [".bmx"]
    },
    {
      "kind": "Bluespec",
      "type": "programming",
      "extensions": [".bsv"]
    },
    {
      "kind": "Boo",
      "type": "programming",
      "extensions": [".boo"]
    },
    {
      "kind": "Brainfuck",
      "type": "programming",
      "extensions": [".b", ".bf"]
    },
    {
      "kind": "Brightscript",
      "type": "programming",
      "extensions": [".brs"]
    },
    {
      "kind": "Bro",
      "type": "programming",
      "extensions": [".bro"]
    },
    {
      "kind": "C",
      "type": "programming",
      "extensions": [".c", ".cats", ".h", ".idc", ".w"]
    },
    {
      "kind": "C#",
      "type": "programming",
      "extensions": [".cs", ".cake", ".cshtml", ".csx"]
    },
    {
      "kind": "C++",
      "type": "programming",
      "extensions": [
        ".cpp",
        ".c++",
        ".cc",
        ".cp",
        ".cxx",
        ".h",
        ".h++",
        ".hh",
        ".hpp",
        ".hxx",
        ".inc",
        ".inl",
        ".ipp",
        ".tcc",
        ".tpp"
      ]
    },
    {
      "kind": "C-ObjDump",
      "type": "data",
      "extensions": [".c-objdump"]
    },
    {
      "kind": "C2hs Haskell",
      "type": "programming",
      "extensions": [".chs"]
    },
    {
      "kind": "CLIPS",
      "type": "programming",
      "extensions": [".clp"]
    },
    {
      "kind": "CMake",
      "type": "programming",
      "extensions": [".cmake", ".cmake.in"]
    },
    {
      "kind": "COBOL",
      "type": "programming",
      "extensions": [".cob", ".cbl", ".ccp", ".cobol", ".cpy"]
    },
    {
      "kind": "CSS",
      "type": "markup",
      "extensions": [".css"]
    },
    {
      "kind": "CSV",
      "type": "data",
      "extensions": [".csv"]
    },
    {
      "kind": "Cap'n Proto",
      "type": "programming",
      "extensions": [".capnp"]
    },
    {
      "kind": "CartoCSS",
      "type": "programming",
      "extensions": [".mss"]
    },
    {
      "kind": "Ceylon",
      "type": "programming",
      "extensions": [".ceylon"]
    },
    {
      "kind": "Chapel",
      "type": "programming",
      "extensions": [".chpl"]
    },
    {
      "kind": "Charity",
      "type": "programming",
      "extensions": [".ch"]
    },
    {
      "kind": "ChucK",
      "type": "programming",
      "extensions": [".ck"]
    },
    {
      "kind": "Cirru",
      "type": "programming",
      "extensions": [".cirru"]
    },
    {
      "kind": "Clarion",
      "type": "programming",
      "extensions": [".clw"]
    },
    {
      "kind": "Clean",
      "type": "programming",
      "extensions": [".icl", ".dcl"]
    },
    {
      "kind": "Click",
      "type": "programming",
      "extensions": [".click"]
    },
    {
      "kind": "Clojure",
      "type": "programming",
      "extensions": [
        ".clj",
        ".boot",
        ".cl2",
        ".cljc",
        ".cljs",
        ".cljs.hl",
        ".cljscm",
        ".cljx",
        ".hic"
      ]
    },
    {
      "kind": "CoffeeScript",
      "type": "programming",
      "extensions": [".coffee", "._coffee", ".cake", ".cjsx", ".cson", ".iced"]
    },
    {
      "kind": "ColdFusion",
      "type": "programming",
      "extensions": [".cfm", ".cfml"]
    },
    {
      "kind": "ColdFusion CFC",
      "type": "programming",
      "extensions": [".cfc"]
    },
    {
      "kind": "Common Lisp",
      "type": "programming",
      "extensions": [
        ".lisp",
        ".asd",
        ".cl",
        ".l",
        ".lsp",
        ".ny",
        ".podsl",
        ".sexp"
      ]
    },
    {
      "kind": "Component Pascal",
      "type": "programming",
      "extensions": [".cp", ".cps"]
    },
    {
      "kind": "Cool",
      "type": "programming",
      "extensions": [".cl"]
    },
    {
      "kind": "Coq",
      "type": "programming",
      "extensions": [".coq", ".v"]
    },
    {
      "kind": "Cpp-ObjDump",
      "type": "data",
      "extensions": [
        ".cppobjdump",
        ".c++-objdump",
        ".c++objdump",
        ".cpp-objdump",
        ".cxx-objdump"
      ]
    },
    {
      "kind": "Creole",
      "type": "prose",
      "extensions": [".creole"]
    },
    {
      "kind": "Crystal",
      "type": "programming",
      "extensions": [".cr"]
    },
    {
      "kind": "Cucumber",
      "type": "programming",
      "extensions": [".feature"]
    },
    {
      "kind": "Cuda",
      "type": "programming",
      "extensions": [".cu", ".cuh"]
    },
    {
      "kind": "Cycript",
      "type": "programming",
      "extensions": [".cy"]
    },
    {
      "kind": "Cython",
      "type": "programming",
      "extensions": [".pyx", ".pxd", ".pxi"]
    },
    {
      "kind": "D",
      "type": "programming",
      "extensions": [".d", ".di"]
    },
    {
      "kind": "D-ObjDump",
      "type": "data",
      "extensions": [".d-objdump"]
    },
    {
      "kind": "DIGITAL Command Language",
      "type": "programming",
      "extensions": [".com"]
    },
    {
      "kind": "DM",
      "type": "programming",
      "extensions": [".dm"]
    },
    {
      "kind": "DNS Zone",
      "type": "data",
      "extensions": [".zone", ".arpa"]
    },
    {
      "kind": "DTrace",
      "type": "programming",
      "extensions": [".d"]
    },
    {
      "kind": "Darcs Patch",
      "type": "data",
      "extensions": [".darcspatch", ".dpatch"]
    },
    {
      "kind": "Dart",
      "type": "programming",
      "extensions": [".dart"]
    },
    {
      "kind": "Diff",
      "type": "data",
      "extensions": [".diff", ".patch"]
    },
    {
      "kind": "Dockerfile",
      "type": "data",
      "extensions": [".dockerfile", ".dockerignore"]
    },
    {
      "kind": "Docker",
      "type": "programming",
      "extensions": ["Dockerfile", "docker-compose.yml", "docker-compose.yaml", "docker-compose.*.yml", "docker-compose.*.yaml"]
    },
    {
      "kind": "Dogescript",
      "type": "programming",
      "extensions": [".djs"]
    },
    {
      "kind": "Dylan",
      "type": "programming",
      "extensions": [".dylan", ".dyl", ".intr", ".lid"]
    },
    {
      "kind": "E",
      "type": "programming",
      "extensions": [".E"]
    },
    {
      "kind": "ECL",
      "type": "programming",
      "extensions": [".ecl", ".eclxml"]
    },
    {
      "kind": "ECLiPSe",
      "type": "programming",
      "extensions": [".ecl"]
    },
    {
      "kind": "Eagle",
      "type": "markup",
      "extensions": [".sch", ".brd"]
    },
    {
      "kind": "Ecere Projects",
      "type": "data",
      "extensions": [".epj"]
    },
    {
      "kind": "Eiffel",
      "type": "programming",
      "extensions": [".e"]
    },
    {
      "kind": "Elixir",
      "type": "programming",
      "extensions": [".ex", ".exs"]
    },
    {
      "kind": "Elm",
      "type": "programming",
      "extensions": [".elm"]
    },
    {
      "kind": "Emacs Lisp",
      "type": "programming",
      "extensions": [".el", ".emacs", ".emacs.desktop"]
    },
    {
      "kind": "EmberScript",
      "type": "programming",
      "extensions": [".em", ".emberscript"]
    },
    {
      "kind": "Erlang",
      "type": "programming",
      "extensions": [".erl", ".es", ".escript", ".hrl", ".xrl", ".yrl"]
    },
    {
      "kind": "F#",
      "type": "programming",
      "extensions": [".fs", ".fsi", ".fsx"]
    },
    {
      "kind": "FLUX",
      "type": "programming",
      "extensions": [".fx", ".flux"]
    },
    {
      "kind": "FORTRAN",
      "type": "programming",
      "extensions": [
        ".f90",
        ".f",
        ".f03",
        ".f08",
        ".f77",
        ".f95",
        ".for",
        ".fpp"
      ]
    },
    {
      "kind": "Factor",
      "type": "programming",
      "extensions": [".factor"]
    },
    {
      "kind": "Fancy",
      "type": "programming",
      "extensions": [".fy", ".fancypack"]
    },
    {
      "kind": "Fantom",
      "type": "programming",
      "extensions": [".fan"]
    },
    {
      "kind": "Filterscript",
      "type": "programming",
      "extensions": [".fs"]
    },
    {
      "kind": "Formatted",
      "type": "data",
      "extensions": [".for", ".eam.fs"]
    },
    {
      "kind": "Forth",
      "type": "programming",
      "extensions": [
        ".fth",
        ".4th",
        ".f",
        ".for",
        ".forth",
        ".fr",
        ".frt",
        ".fs"
      ]
    },
    {
      "kind": "FreeMarker",
      "type": "programming",
      "extensions": [".ftl"]
    },
    {
      "kind": "Frege",
      "type": "programming",
      "extensions": [".fr"]
    },
    {
      "kind": "G-code",
      "type": "data",
      "extensions": [".g", ".gco", ".gcode"]
    },
    {
      "kind": "GAMS",
      "type": "programming",
      "extensions": [".gms"]
    },
    {
      "kind": "GAP",
      "type": "programming",
      "extensions": [".g", ".gap", ".gd", ".gi", ".tst"]
    },
    {
      "kind": "GAS",
      "type": "programming",
      "extensions": [".s", ".ms"]
    },
    {
      "kind": "GDScript",
      "type": "programming",
      "extensions": [".gd"]
    },
    {
      "kind": "GLSL",
      "type": "programming",
      "extensions": [
        ".glsl",
        ".fp",
        ".frag",
        ".frg",
        ".fs",
        ".fsh",
        ".fshader",
        ".geo",
        ".geom",
        ".glslv",
        ".gshader",
        ".shader",
        ".vert",
        ".vrx",
        ".vsh",
        ".vshader"
      ]
    },
    {
      "kind": "Game Maker Language",
      "type": "programming",
      "extensions": [".gml"]
    },
    {
      "kind": "Genshi",
      "type": "programming",
      "extensions": [".kid"]
    },
    {
      "kind": "Gentoo Ebuild",
      "type": "programming",
      "extensions": [".ebuild"]
    },
    {
      "kind": "Gentoo Eclass",
      "type": "programming",
      "extensions": [".eclass"]
    },
    {
      "kind": "Gettext Catalog",
      "type": "prose",
      "extensions": [".po", ".pot"]
    },
    {
      "kind": "Glyph",
      "type": "programming",
      "extensions": [".glf"]
    },
    {
      "kind": "Gnuplot",
      "type": "programming",
      "extensions": [".gp", ".gnu", ".gnuplot", ".plot", ".plt"]
    },
    {
      "kind": "Go",
      "type": "programming",
      "extensions": [".go"]
    },
    {
      "kind": "Golo",
      "type": "programming",
      "extensions": [".golo"]
    },
    {
      "kind": "Gosu",
      "type": "programming",
      "extensions": [".gs", ".gst", ".gsx", ".vark"]
    },
    {
      "kind": "Grace",
      "type": "programming",
      "extensions": [".grace"]
    },
    {
      "kind": "Gradle",
      "type": "data",
      "extensions": [".gradle"]
    },
    {
      "kind": "Grammatical Framework",
      "type": "programming",
      "extensions": [".gf"]
    },
    {
      "kind": "Graph Modeling Language",
      "type": "data",
      "extensions": [".gml"]
    },
    {
      "kind": "GraphQL",
      "type": "data",
      "extensions": [".graphql"]
    },
    {
      "kind": "Graphviz (DOT)",
      "type": "data",
      "extensions": [".dot", ".gv"]
    },
    {
      "kind": "Groff",
      "type": "markup",
      "extensions": [
        ".man",
        ".1",
        ".1in",
        ".1m",
        ".1x",
        ".2",
        ".3",
        ".3in",
        ".3m",
        ".3qt",
        ".3x",
        ".4",
        ".5",
        ".6",
        ".7",
        ".8",
        ".9",
        ".l",
        ".me",
        ".ms",
        ".n",
        ".rno",
        ".roff"
      ]
    },
    {
      "kind": "Groovy",
      "type": "programming",
      "extensions": [".groovy", ".grt", ".gtpl", ".gvy"]
    },
    {
      "kind": "Groovy Server Pages",
      "type": "programming",
      "extensions": [".gsp"]
    },
    {
      "kind": "HCL",
      "type": "programming",
      "extensions": [".hcl", ".tf"]
    },
    {
      "kind": "HLSL",
      "type": "programming",
      "extensions": [".hlsl", ".fx", ".fxh", ".hlsli"]
    },
    {
      "kind": "HTML",
      "type": "markup",
      "extensions": [
        ".html",
        ".htm",
        ".html.hl",
        ".inc",
        ".st",
        ".xht",
        ".xhtml"
      ]
    },
    {
      "kind": "HTML+Django",
      "type": "markup",
      "extensions": [".mustache", ".jinja"]
    },
    {
      "kind": "HTML+EEX",
      "type": "markup",
      "extensions": [".eex"]
    },
    {
      "kind": "HTML+ERB",
      "type": "markup",
      "extensions": [".erb", ".erb.deface"]
    },
    {
      "kind": "HTML+PHP",
      "type": "markup",
      "extensions": [".phtml"]
    },
    {
      "kind": "HTTP",
      "type": "data",
      "extensions": [".http"]
    },
    {
      "kind": "Hack",
      "type": "programming",
      "extensions": [".hh", ".php"]
    },
    {
      "kind": "Haml",
      "type": "markup",
      "extensions": [".haml", ".haml.deface"]
    },
    {
      "kind": "Handlebars",
      "type": "markup",
      "extensions": [".handlebars", ".hbs"]
    },
    {
      "kind": "Harbour",
      "type": "programming",
      "extensions": [".hb"]
    },
    {
      "kind": "Haskell",
      "type": "programming",
      "extensions": [".hs", ".hsc"]
    },
    {
      "kind": "Haxe",
      "type": "programming",
      "extensions": [".hx", ".hxsl"]
    },
    {
      "kind": "Hy",
      "type": "programming",
      "extensions": [".hy"]
    },
    {
      "kind": "HyPhy",
      "type": "programming",
      "extensions": [".bf"]
    },
    {
      "kind": "IDL",
      "type": "programming",
      "extensions": [".pro", ".dlm"]
    },
    {
      "kind": "IGOR Pro",
      "type": "programming",
      "extensions": [".ipf"]
    },
    {
      "kind": "INI",
      "type": "data",
      "extensions": [".ini", ".cfg", ".prefs", ".pro", ".properties"]
    },
    {
      "kind": "IRC log",
      "type": "data",
      "extensions": [".irclog", ".weechatlog"]
    },
    {
      "kind": "Idris",
      "type": "programming",
      "extensions": [".idr", ".lidr"]
    },
    {
      "kind": "Inform 7",
      "type": "programming",
      "extensions": [".ni", ".i7x"]
    },
    {
      "kind": "Inno Setup",
      "type": "programming",
      "extensions": [".iss"]
    },
    {
      "kind": "Io",
      "type": "programming",
      "extensions": [".io"]
    },
    {
      "kind": "Ioke",
      "type": "programming",
      "extensions": [".ik"]
    },
    {
      "kind": "Isabelle",
      "type": "programming",
      "extensions": [".thy"]
    },
    {
      "kind": "Isabelle ROOT",
      "type": "programming"
    },
    {
      "kind": "J",
      "type": "programming",
      "extensions": [".ijs"]
    },
    {
      "kind": "JFlex",
      "type": "programming",
      "extensions": [".flex", ".jflex"]
    },
    {
      "kind": "JSON",
      "type": "data",
      "extensions": [".json", ".geojson", ".lock", ".topojson"]
    },
    {
      "kind": "JSON5",
      "type": "data",
      "extensions": [".json5"]
    },
    {
      "kind": "JSONLD",
      "type": "data",
      "extensions": [".jsonld"]
    },
    {
      "kind": "JSONiq",
      "type": "programming",
      "extensions": [".jq"]
    },
    {
      "kind": "JSX",
      "type": "programming",
      "extensions": [".jsx"]
    },
    {
      "kind": "Jade",
      "type": "markup",
      "extensions": [".jade"]
    },
    {
      "kind": "Jasmin",
      "type": "programming",
      "extensions": [".j"]
    },
    {
      "kind": "Java",
      "type": "programming",
      "extensions": [".java"]
    },
    {
      "kind": "Java Server Pages",
      "type": "programming",
      "extensions": [".jsp"]
    },
    {
      "kind": "JavaScript",
      "type": "programming",
      "extensions": [
        ".js",
        "._js",
        ".bones",
        ".es",
        ".es6",
        ".frag",
        ".gs",
        ".jake",
        ".jsb",
        ".jscad",
        ".jsfl",
        ".jsm",
        ".jss",
        ".njs",
        ".pac",
        ".sjs",
        ".ssjs",
        ".sublime-build",
        ".sublime-commands",
        ".sublime-completions",
        ".sublime-keymap",
        ".sublime-macro",
        ".sublime-menu",
        ".sublime-mousemap",
        ".sublime-project",
        ".sublime-settings",
        ".sublime-theme",
        ".sublime-workspace",
        ".sublime_metrics",
        ".sublime_session",
        ".xsjs",
        ".xsjslib"
      ]
    },
    {
      "kind": "Julia",
      "type": "programming",
      "extensions": [".jl"]
    },
    {
      "kind": "Jupyter Notebook",
      "type": "markup",
      "extensions": [".ipynb"]
    },
    {
      "kind": "KRL",
      "type": "programming",
      "extensions": [".krl"]
    },
    {
      "kind": "KiCad",
      "type": "programming",
      "extensions": [".sch", ".brd", ".kicad_pcb"]
    },
    {
      "kind": "Kit",
      "type": "markup",
      "extensions": [".kit"]
    },
    {
      "kind": "Kotlin",
      "type": "programming",
      "extensions": [".kt", ".ktm", ".kts"]
    },
    {
      "kind": "LFE",
      "type": "programming",
      "extensions": [".lfe"]
    },
    {
      "kind": "LLVM",
      "type": "programming",
      "extensions": [".ll"]
    },
    {
      "kind": "LOLCODE",
      "type": "programming",
      "extensions": [".lol"]
    },
    {
      "kind": "LSL",
      "type": "programming",
      "extensions": [".lsl", ".lslp"]
    },
    {
      "kind": "LabVIEW",
      "type": "programming",
      "extensions": [".lvproj"]
    },
    {
      "kind": "Lasso",
      "type": "programming",
      "extensions": [".lasso", ".las", ".lasso8", ".lasso9", ".ldml"]
    },
    {
      "kind": "Latte",
      "type": "markup",
      "extensions": [".latte"]
    },
    {
      "kind": "Lean",
      "type": "programming",
      "extensions": [".lean", ".hlean"]
    },
    {
      "kind": "Less",
      "type": "markup",
      "extensions": [".less"]
    },
    {
      "kind": "Lex",
      "type": "programming",
      "extensions": [".l", ".lex"]
    },
    {
      "kind": "LilyPond",
      "type": "programming",
      "extensions": [".ly", ".ily"]
    },
    {
      "kind": "Limbo",
      "type": "programming",
      "extensions": [".b", ".m"]
    },
    {
      "kind": "Linker Script",
      "type": "data",
      "extensions": [".ld", ".lds"]
    },
    {
      "kind": "Linux Kernel Module",
      "type": "data",
      "extensions": [".mod"]
    },
    {
      "kind": "Liquid",
      "type": "markup",
      "extensions": [".liquid"]
    },
    {
      "kind": "Literate Agda",
      "type": "programming",
      "extensions": [".lagda"]
    },
    {
      "kind": "Literate CoffeeScript",
      "type": "programming",
      "extensions": [".litcoffee"]
    },
    {
      "kind": "Literate Haskell",
      "type": "programming",
      "extensions": [".lhs"]
    },
    {
      "kind": "LiveScript",
      "type": "programming",
      "extensions": [".ls", "._ls"]
    },
    {
      "kind": "Logos",
      "type": "programming",
      "extensions": [".xm", ".x", ".xi"]
    },
    {
      "kind": "Logtalk",
      "type": "programming",
      "extensions": [".lgt", ".logtalk"]
    },
    {
      "kind": "LookML",
      "type": "programming",
      "extensions": [".lookml"]
    },
    {
      "kind": "LoomScript",
      "type": "programming",
      "extensions": [".ls"]
    },
    {
      "kind": "Lua",
      "type": "programming",
      "extensions": [".lua", ".fcgi", ".nse", ".pd_lua", ".rbxs", ".wlua"]
    },
    {
      "kind": "M",
      "type": "programming",
      "extensions": [".mumps", ".m"]
    },
    {
      "kind": "M4",
      "type": "programming",
      "extensions": [".m4"]
    },
    {
      "kind": "M4Sugar",
      "type": "programming",
      "extensions": [".m4"]
    },
    {
      "kind": "MAXScript",
      "type": "programming",
      "extensions": [".ms", ".mcr"]
    },
    {
      "kind": "MTML",
      "type": "markup",
      "extensions": [".mtml"]
    },
    {
      "kind": "MUF",
      "type": "programming",
      "extensions": [".muf", ".m"]
    },
    {
      "kind": "Makefile",
      "type": "programming",
      "extensions": [".mak", ".d", ".mk", ".mkfile", "Makefile"]
    },
    {
      "kind": "Mako",
      "type": "programming",
      "extensions": [".mako", ".mao"]
    },
    {
      "kind": "Markdown",
      "type": "prose",
      "extensions": [".md", ".markdown", ".mkd", ".mkdn", ".mkdown", ".ron"]
    },
    {
      "kind": "Mask",
      "type": "markup",
      "extensions": [".mask"]
    },
    {
      "kind": "Mathematica",
      "type": "programming",
      "extensions": [
        ".mathematica",
        ".cdf",
        ".m",
        ".ma",
        ".mt",
        ".nb",
        ".nbp",
        ".wl",
        ".wlt"
      ]
    },
    {
      "kind": "Matlab",
      "type": "programming",
      "extensions": [".matlab", ".m"]
    },
    {
      "kind": "Maven POM",
      "type": "data"
    },
    {
      "kind": "Max",
      "type": "programming",
      "extensions": [".maxpat", ".maxhelp", ".maxproj", ".mxt", ".pat"]
    },
    {
      "kind": "MediaWiki",
      "type": "prose",
      "extensions": [".mediawiki", ".wiki"]
    },
    {
      "kind": "Mercury",
      "type": "programming",
      "extensions": [".m", ".moo"]
    },
    {
      "kind": "Metal",
      "type": "programming",
      "extensions": [".metal"]
    },
    {
      "kind": "MiniD",
      "type": "programming",
      "extensions": [".minid"]
    },
    {
      "kind": "Mirah",
      "type": "programming",
      "extensions": [".druby", ".duby", ".mir", ".mirah"]
    },
    {
      "kind": "Modelica",
      "type": "programming",
      "extensions": [".mo"]
    },
    {
      "kind": "Modula-2",
      "type": "programming",
      "extensions": [".mod"]
    },
    {
      "kind": "Module Management System",
      "type": "programming",
      "extensions": [".mms", ".mmk"]
    },
    {
      "kind": "Monkey",
      "type": "programming",
      "extensions": [".monkey"]
    },
    {
      "kind": "Moocode",
      "type": "programming",
      "extensions": [".moo"]
    },
    {
      "kind": "MoonScript",
      "type": "programming",
      "extensions": [".moon"]
    },
    {
      "kind": "Myghty",
      "type": "programming",
      "extensions": [".myt"]
    },
    {
      "kind": "NCL",
      "type": "programming",
      "extensions": [".ncl"]
    },
    {
      "kind": "NL",
      "type": "data",
      "extensions": [".nl"]
    },
    {
      "kind": "NSIS",
      "type": "programming",
      "extensions": [".nsi", ".nsh"]
    },
    {
      "kind": "Nemerle",
      "type": "programming",
      "extensions": [".n"]
    },
    {
      "kind": "NetLinx",
      "type": "programming",
      "extensions": [".axs", ".axi"]
    },
    {
      "kind": "NetLinx+ERB",
      "type": "programming",
      "extensions": [".axs.erb", ".axi.erb"]
    },
    {
      "kind": "NetLogo",
      "type": "programming",
      "extensions": [".nlogo"]
    },
    {
      "kind": "NewLisp",
      "type": "programming",
      "extensions": [".nl", ".lisp", ".lsp"]
    },
    {
      "kind": "Nginx",
      "type": "markup",
      "extensions": [".nginxconf", ".vhost"]
    },
    {
      "kind": "Nimrod",
      "type": "programming",
      "extensions": [".nim", ".nimrod"]
    },
    {
      "kind": "Ninja",
      "type": "data",
      "extensions": [".ninja"]
    },
    {
      "kind": "Nit",
      "type": "programming",
      "extensions": [".nit"]
    },
    {
      "kind": "Nix",
      "type": "programming",
      "extensions": [".nix"]
    },
    {
      "kind": "Nu",
      "type": "programming",
      "extensions": [".nu"]
    },
    {
      "kind": "NumPy",
      "type": "programming",
      "extensions": [".numpy", ".numpyw", ".numsc"]
    },
    {
      "kind": "OCaml",
      "type": "programming",
      "extensions": [".ml", ".eliom", ".eliomi", ".ml4", ".mli", ".mll", ".mly"]
    },
    {
      "kind": "ObjDump",
      "type": "data",
      "extensions": [".objdump"]
    },
    {
      "kind": "Objective-C",
      "type": "programming",
      "extensions": [".m", ".h"]
    },
    {
      "kind": "Objective-C++",
      "type": "programming",
      "extensions": [".mm"]
    },
    {
      "kind": "Objective-J",
      "type": "programming",
      "extensions": [".j", ".sj"]
    },
    {
      "kind": "Omgrofl",
      "type": "programming",
      "extensions": [".omgrofl"]
    },
    {
      "kind": "Opa",
      "type": "programming",
      "extensions": [".opa"]
    },
    {
      "kind": "Opal",
      "type": "programming",
      "extensions": [".opal"]
    },
    {
      "kind": "OpenCL",
      "type": "programming",
      "extensions": [".cl", ".opencl"]
    },
    {
      "kind": "OpenEdge ABL",
      "type": "programming",
      "extensions": [".p", ".cls"]
    },
    {
      "kind": "OpenSCAD",
      "type": "programming",
      "extensions": [".scad"]
    },
    {
      "kind": "Org",
      "type": "prose",
      "extensions": [".org"]
    },
    {
      "kind": "Ox",
      "type": "programming",
      "extensions": [".ox", ".oxh", ".oxo"]
    },
    {
      "kind": "Oxygene",
      "type": "programming",
      "extensions": [".oxygene"]
    },
    {
      "kind": "Oz",
      "type": "programming",
      "extensions": [".oz"]
    },
    {
      "kind": "PAWN",
      "type": "programming",
      "extensions": [".pwn", ".inc"]
    },
    {
      "kind": "PHP",
      "type": "programming",
      "extensions": [
        ".php",
        ".aw",
        ".ctp",
        ".fcgi",
        ".inc",
        ".php3",
        ".php4",
        ".php5",
        ".phps",
        ".phpt"
      ]
    },
    {
      "kind": "PLSQL",
      "type": "programming",
      "extensions": [".pls", ".pck", ".pkb", ".pks", ".plb", ".plsql", ".sql"]
    },
    {
      "kind": "PLpgSQL",
      "type": "programming",
      "extensions": [".sql"]
    },
    {
      "kind": "POV-Ray SDL",
      "type": "programming",
      "extensions": [".pov", ".inc"]
    },
    {
      "kind": "Pan",
      "type": "programming",
      "extensions": [".pan"]
    },
    {
      "kind": "Papyrus",
      "type": "programming",
      "extensions": [".psc"]
    },
    {
      "kind": "Parrot",
      "type": "programming",
      "extensions": [".parrot"]
    },
    {
      "kind": "Parrot Assembly",
      "type": "programming",
      "extensions": [".pasm"]
    },
    {
      "kind": "Parrot Internal Representation",
      "type": "programming",
      "extensions": [".pir"]
    },
    {
      "kind": "Pascal",
      "type": "programming",
      "extensions": [".pas", ".dfm", ".dpr", ".inc", ".lpr", ".pp"]
    },
    {
      "kind": "Perl",
      "type": "programming",
      "extensions": [
        ".pl",
        ".al",
        ".cgi",
        ".fcgi",
        ".perl",
        ".ph",
        ".plx",
        ".pm",
        ".pod",
        ".psgi",
        ".t"
      ]
    },
    {
      "kind": "Perl6",
      "type": "programming",
      "extensions": [
        ".6pl",
        ".6pm",
        ".nqp",
        ".p6",
        ".p6l",
        ".p6m",
        ".pl",
        ".pl6",
        ".pm",
        ".pm6",
        ".t"
      ]
    },
    {
      "kind": "Pickle",
      "type": "data",
      "extensions": [".pkl"]
    },
    {
      "kind": "PicoLisp",
      "type": "programming",
      "extensions": [".l"]
    },
    {
      "kind": "PigLatin",
      "type": "programming",
      "extensions": [".pig"]
    },
    {
      "kind": "Pike",
      "type": "programming",
      "extensions": [".pike", ".pmod"]
    },
    {
      "kind": "Pod",
      "type": "prose",
      "extensions": [".pod"]
    },
    {
      "kind": "PogoScript",
      "type": "programming",
      "extensions": [".pogo"]
    },
    {
      "kind": "Pony",
      "type": "programming",
      "extensions": [".pony"]
    },
    {
      "kind": "PostScript",
      "type": "markup",
      "extensions": [".ps", ".eps"]
    },
    {
      "kind": "PowerShell",
      "type": "programming",
      "extensions": [".ps1", ".psd1", ".psm1"]
    },
    {
      "kind": "Processing",
      "type": "programming",
      "extensions": [".pde"]
    },
    {
      "kind": "Prolog",
      "type": "programming",
      "extensions": [".pl", ".pro", ".prolog", ".yap"]
    },
    {
      "kind": "Propeller Spin",
      "type": "programming",
      "extensions": [".spin"]
    },
    {
      "kind": "Protocol Buffer",
      "type": "markup",
      "extensions": [".proto"]
    },
    {
      "kind": "Public Key",
      "type": "data",
      "extensions": [".asc", ".pub"]
    },
    {
      "kind": "Puppet",
      "type": "programming",
      "extensions": [".pp"]
    },
    {
      "kind": "Pure Data",
      "type": "programming",
      "extensions": [".pd"]
    },
    {
      "kind": "PureBasic",
      "type": "programming",
      "extensions": [".pb", ".pbi"]
    },
    {
      "kind": "PureScript",
      "type": "programming",
      "extensions": [".purs"]
    },
    {
      "kind": "Python",
      "type": "programming",
      "extensions": [
        ".py",
        ".bzl",
        ".cgi",
        ".fcgi",
        ".gyp",
        ".lmi",
        ".pyde",
        ".pyp",
        ".pyt",
        ".pyw",
        ".rpy",
        ".tac",
        ".wsgi",
        ".xpy"
      ]
    },
    {
      "kind": "Python traceback",
      "type": "data",
      "extensions": [".pytb"]
    },
    {
      "kind": "QML",
      "type": "programming",
      "extensions": [".qml", ".qbs"]
    },
    {
      "kind": "QMake",
      "type": "programming",
      "extensions": [".pro", ".pri"]
    },
    {
      "kind": "R",
      "type": "programming",
      "extensions": [".r", ".rd", ".rsx"]
    },
    {
      "kind": "RAML",
      "type": "markup",
      "extensions": [".raml"]
    },
    {
      "kind": "RDoc",
      "type": "prose",
      "extensions": [".rdoc"]
    },
    {
      "kind": "REALbasic",
      "type": "programming",
      "extensions": [
        ".rbbas",
        ".rbfrm",
        ".rbmnu",
        ".rbres",
        ".rbtbar",
        ".rbuistate"
      ]
    },
    {
      "kind": "RHTML",
      "type": "markup",
      "extensions": [".rhtml"]
    },
    {
      "kind": "RMarkdown",
      "type": "prose",
      "extensions": [".rmd"]
    },
    {
      "kind": "Racket",
      "type": "programming",
      "extensions": [".rkt", ".rktd", ".rktl", ".scrbl"]
    },
    {
      "kind": "Ragel in Ruby Host",
      "type": "programming",
      "extensions": [".rl"]
    },
    {
      "kind": "Raw token data",
      "type": "data",
      "extensions": [".raw"]
    },
    {
      "kind": "Rebol",
      "type": "programming",
      "extensions": [".reb", ".r", ".r2", ".r3", ".rebol"]
    },
    {
      "kind": "Red",
      "type": "programming",
      "extensions": [".red", ".reds"]
    },
    {
      "kind": "Redcode",
      "type": "programming",
      "extensions": [".cw"]
    },
    {
      "kind": "Ren'Py",
      "type": "programming",
      "extensions": [".rpy"]
    },
    {
      "kind": "RenderScript",
      "type": "programming",
      "extensions": [".rs", ".rsh"]
    },
    {
      "kind": "RobotFramework",
      "type": "programming",
      "extensions": [".robot"]
    },
    {
      "kind": "Rouge",
      "type": "programming",
      "extensions": [".rg"]
    },
    {
      "kind": "Ruby",
      "type": "programming",
      "extensions": [
        ".rb",
        ".builder",
        ".fcgi",
        ".gemspec",
        ".god",
        ".irbrc",
        ".jbuilder",
        ".mspec",
        ".pluginspec",
        ".podspec",
        ".rabl",
        ".rake",
        ".rbuild",
        ".rbw",
        ".rbx",
        ".ru",
        ".ruby",
        ".thor",
        ".watchr"
      ]
    },
    {
      "kind": "Rust",
      "type": "programming",
      "extensions": [".rs", ".rs.in"]
    },
    {
      "kind": "SAS",
      "type": "programming",
      "extensions": [".sas"]
    },
    {
      "kind": "SCSS",
      "type": "markup",
      "extensions": [".scss"]
    },
    {
      "kind": "SMT",
      "type": "programming",
      "extensions": [".smt2", ".smt"]
    },
    {
      "kind": "SPARQL",
      "type": "data",
      "extensions": [".sparql", ".rq"]
    },
    {
      "kind": "SQF",
      "type": "programming",
      "extensions": [".sqf", ".hqf"]
    },
    {
      "kind": "SQL",
      "type": "data",
      "extensions": [
        ".sql",
        ".cql",
        ".ddl",
        ".inc",
        ".prc",
        ".tab",
        ".udf",
        ".viw"
      ]
    },
    {
      "kind": "SQLPL",
      "type": "programming",
      "extensions": [".sql", ".db2"]
    },
    {
      "kind": "STON",
      "type": "data",
      "extensions": [".ston"]
    },
    {
      "kind": "SVG",
      "type": "data",
      "extensions": [".svg"]
    },
    {
      "kind": "Sage",
      "type": "programming",
      "extensions": [".sage", ".sagews"]
    },
    {
      "kind": "SaltStack",
      "type": "programming",
      "extensions": [".sls"]
    },
    {
      "kind": "Sass",
      "type": "markup",
      "extensions": [".sass"]
    },
    {
      "kind": "Scala",
      "type": "programming",
      "extensions": [".scala", ".sbt", ".sc"]
    },
    {
      "kind": "Scaml",
      "type": "markup",
      "extensions": [".scaml"]
    },
    {
      "kind": "Scheme",
      "type": "programming",
      "extensions": [".scm", ".sld", ".sls", ".sps", ".ss"]
    },
    {
      "kind": "Scilab",
      "type": "programming",
      "extensions": [".sci", ".sce", ".tst"]
    },
    {
      "kind": "Self",
      "type": "programming",
      "extensions": [".self"]
    },
    {
      "kind": "Shell",
      "type": "programming",
      "extensions": [
        ".sh",
        ".bash",
        ".bats",
        ".cgi",
        ".command",
        ".fcgi",
        ".ksh",
        ".sh.in",
        ".tmux",
        ".tool",
        ".zsh"
      ]
    },
    {
      "kind": "ShellSession",
      "type": "programming",
      "extensions": [".sh-session"]
    },
    {
      "kind": "Shen",
      "type": "programming",
      "extensions": [".shen"]
    },
    {
      "kind": "Slash",
      "type": "programming",
      "extensions": [".sl"]
    },
    {
      "kind": "Slim",
      "type": "markup",
      "extensions": [".slim"]
    },
    {
      "kind": "Smali",
      "type": "programming",
      "extensions": [".smali"]
    },
    {
      "kind": "Smalltalk",
      "type": "programming",
      "extensions": [".st", ".cs"]
    },
    {
      "kind": "Smarty",
      "type": "programming",
      "extensions": [".tpl"]
    },
    {
      "kind": "SourcePawn",
      "type": "programming",
      "extensions": [".sp", ".inc", ".sma"]
    },
    {
      "kind": "Squirrel",
      "type": "programming",
      "extensions": [".nut"]
    },
    {
      "kind": "Stan",
      "type": "programming",
      "extensions": [".stan"]
    },
    {
      "kind": "Standard ML",
      "type": "programming",
      "extensions": [".ML", ".fun", ".sig", ".sml"]
    },
    {
      "kind": "Stata",
      "type": "programming",
      "extensions": [
        ".do",
        ".ado",
        ".doh",
        ".ihlp",
        ".mata",
        ".matah",
        ".sthlp"
      ]
    },
    {
      "kind": "Stylus",
      "type": "markup",
      "extensions": [".styl"]
    },
    {
      "kind": "SuperCollider",
      "type": "programming",
      "extensions": [".sc", ".scd"]
    },
    {
      "kind": "Swift",
      "type": "programming",
      "extensions": [".swift"]
    },
    {
      "kind": "SystemVerilog",
      "type": "programming",
      "extensions": [".sv", ".svh", ".vh"]
    },
    {
      "kind": "TOML",
      "type": "data",
      "extensions": [".toml"]
    },
    {
      "kind": "TXL",
      "type": "programming",
      "extensions": [".txl"]
    },
    {
      "kind": "Tcl",
      "type": "programming",
      "extensions": [".tcl", ".adp", ".tm"]
    },
    {
      "kind": "Tcsh",
      "type": "programming",
      "extensions": [".tcsh", ".csh"]
    },
    {
      "kind": "TeX",
      "type": "markup",
      "extensions": [
        ".tex",
        ".aux",
        ".bbx",
        ".bib",
        ".cbx",
        ".cls",
        ".dtx",
        ".ins",
        ".lbx",
        ".ltx",
        ".mkii",
        ".mkiv",
        ".mkvi",
        ".sty",
        ".toc"
      ]
    },
    {
      "kind": "Tea",
      "type": "markup",
      "extensions": [".tea"]
    },
    {
      "kind": "Terra",
      "type": "programming",
      "extensions": [".t"]
    },
    {
      "kind": "Text",
      "type": "prose",
      "extensions": [".txt", ".fr", ".nb", ".ncl", ".no"]
    },
    {
      "kind": "Textile",
      "type": "prose",
      "extensions": [".textile"]
    },
    {
      "kind": "Thrift",
      "type": "programming",
      "extensions": [".thrift"]
    },
    {
      "kind": "Turing",
      "type": "programming",
      "extensions": [".t", ".tu"]
    },
    {
      "kind": "Turtle",
      "type": "data",
      "extensions": [".ttl"]
    },
    {
      "kind": "Twig",
      "type": "markup",
      "extensions": [".twig"]
    },
    {
      "kind": "TypeScript",
      "type": "programming",
      "extensions": [".ts", ".tsx"]
    },
    {
      "kind": "Unified Parallel C",
      "type": "programming",
      "extensions": [".upc"]
    },
    {
      "kind": "Unity3D Asset",
      "type": "data",
      "extensions": [".anim", ".asset", ".mat", ".meta", ".prefab", ".unity"]
    },
    {
      "kind": "Uno",
      "type": "programming",
      "extensions": [".uno"]
    },
    {
      "kind": "UnrealScript",
      "type": "programming",
      "extensions": [".uc"]
    },
    {
      "kind": "UrWeb",
      "type": "programming",
      "extensions": [".ur", ".urs"]
    },
    {
      "kind": "VCL",
      "type": "programming",
      "extensions": [".vcl"]
    },
    {
      "kind": "VHDL",
      "type": "programming",
      "extensions": [
        ".vhdl",
        ".vhd",
        ".vhf",
        ".vhi",
        ".vho",
        ".vhs",
        ".vht",
        ".vhw"
      ]
    },
    {
      "kind": "Vala",
      "type": "programming",
      "extensions": [".vala", ".vapi"]
    },
    {
      "kind": "Verilog",
      "type": "programming",
      "extensions": [".v", ".veo"]
    },
    {
      "kind": "VimL",
      "type": "programming",
      "extensions": [".vim"]
    },
    {
      "kind": "Visual Basic",
      "type": "programming",
      "extensions": [
        ".vb",
        ".bas",
        ".cls",
        ".frm",
        ".frx",
        ".vba",
        ".vbhtml",
        ".vbs"
      ]
    },
    {
      "kind": "Volt",
      "type": "programming",
      "extensions": [".volt"]
    },
    {
      "kind": "Vue",
      "type": "markup",
      "extensions": [".vue"]
    },
    {
      "kind": "Web Ontology Language",
      "type": "markup",
      "extensions": [".owl"]
    },
    {
      "kind": "WebIDL",
      "type": "programming",
      "extensions": [".webidl"]
    },
    {
      "kind": "X10",
      "type": "programming",
      "extensions": [".x10"]
    },
    {
      "kind": "XC",
      "type": "programming",
      "extensions": [".xc"]
    },
    {
      "kind": "XML",
      "type": "data",
      "extensions": [
        ".xml",
        ".ant",
        ".axml",
        ".ccxml",
        ".clixml",
        ".cproject",
        ".csl",
        ".csproj",
        ".ct",
        ".dita",
        ".ditamap",
        ".ditaval",
        ".dll.config",
        ".dotsettings",
        ".filters",
        ".fsproj",
        ".fxml",
        ".glade",
        ".gml",
        ".grxml",
        ".iml",
        ".ivy",
        ".jelly",
        ".jsproj",
        ".kml",
        ".launch",
        ".mdpolicy",
        ".mm",
        ".mod",
        ".mxml",
        ".nproj",
        ".nuspec",
        ".odd",
        ".osm",
        ".plist",
        ".pluginspec",
        ".props",
        ".ps1xml",
        ".psc1",
        ".pt",
        ".rdf",
        ".rss",
        ".scxml",
        ".srdf",
        ".storyboard",
        ".stTheme",
        ".sublime-snippet",
        ".targets",
        ".tmCommand",
        ".tml",
        ".tmLanguage",
        ".tmPreferences",
        ".tmSnippet",
        ".tmTheme",
        ".ts",
        ".tsx",
        ".ui",
        ".urdf",
        ".ux",
        ".vbproj",
        ".vcxproj",
        ".vssettings",
        ".vxml",
        ".wsdl",
        ".wsf",
        ".wxi",
        ".wxl",
        ".wxs",
        ".x3d",
        ".xacro",
        ".xaml",
        ".xib",
        ".xlf",
        ".xliff",
        ".xmi",
        ".xml.dist",
        ".xproj",
        ".xsd",
        ".xul",
        ".zcml"
      ]
    },
    {
      "kind": "XPages",
      "type": "programming",
      "extensions": [".xsp-config", ".xsp.metadata"]
    },
    {
      "kind": "XProc",
      "type": "programming",
      "extensions": [".xpl", ".xproc"]
    },
    {
      "kind": "XQuery",
      "type": "programming",
      "extensions": [".xquery", ".xq", ".xql", ".xqm", ".xqy"]
    },
    {
      "kind": "XS",
      "type": "programming",
      "extensions": [".xs"]
    },
    {
      "kind": "XSLT",
      "type": "programming",
      "extensions": [".xslt", ".xsl"]
    },
    {
      "kind": "Xojo",
      "type": "programming",
      "extensions": [
        ".xojo_code",
        ".xojo_menu",
        ".xojo_report",
        ".xojo_script",
        ".xojo_toolbar",
        ".xojo_window"
      ]
    },
    {
      "kind": "Xtend",
      "type": "programming",
      "extensions": [".xtend"]
    },
    {
      "kind": "YAML",
      "type": "data",
      "extensions": [
        ".yml",
        ".reek",
        ".rviz",
        ".sublime-syntax",
        ".syntax",
        ".yaml",
        ".yaml-tmlanguage"
      ]
    },
    {
      "kind": "YANG",
      "type": "data",
      "extensions": [".yang"]
    },
    {
      "kind": "Yacc",
      "type": "programming",
      "extensions": [".y", ".yacc", ".yy"]
    },
    {
      "kind": "Zephir",
      "type": "programming",
      "extensions": [".zep"]
    },
    {
      "kind": "Zimpl",
      "type": "programming",
      "extensions": [".zimpl", ".zmpl", ".zpl"]
    },
    {
      "kind": "desktop",
      "type": "data",
      "extensions": [".desktop", ".desktop.in"]
    },
    {
      "kind": "eC",
      "type": "programming",
      "extensions": [".ec", ".eh"]
    },
    {
      "kind": "edn",
      "type": "data",
      "extensions": [".edn"]
    },
    {
      "kind": "fish",
      "type": "programming",
      "extensions": [".fish"]
    },
    {
      "kind": "mupad",
      "type": "programming",
      "extensions": [".mu"]
    },
    {
      "kind": "nesC",
      "type": "programming",
      "extensions": [".nc"]
    },
    {
      "kind": "ooc",
      "type": "programming",
      "extensions": [".ooc"]
    },
    {
      "kind": "reStructuredText",
      "type": "prose",
      "extensions": [".rst", ".rest", ".rest.txt", ".rst.txt"]
    },
    {
      "kind": "wisp",
      "type": "programming",
      "extensions": [".wisp"]
    },
    {
      "kind": "xBase",
      "type": "programming",
      "extensions": [".prg", ".ch", ".prw"]
    }
  ]
}
`````

## File: src/metagit/data/package-managers.json
`````json
{
  "requirements.txt": "Python",
  "requirements.*.txt": "Python",
  "pyproject.toml": "Python",
  "setup.py": "Python",
  "setup.cfg": "Python",
  "Pipfile": "Python",
  "poetry.lock": "Python",
  "poetry.toml": "Python",
  "uv.lock": "Python",
  "go.mod": "Go",
  "go.sum": "Go",
  "Cargo.toml": "Rust",
  "Cargo.lock": "Rust",
  "pom.xml": "Java",
  "build.gradle": "Java",
  "build.sbt": "Scala",
  "package.json": "NodeJS",
  "package-lock.json": "NodeJS",
  "yarn.lock": "NodeJS",
  "composer.json": "PHP",
  "composer.lock": "PHP",
  "Gemfile": "Ruby",
  "Gemfile.lock": "Ruby",
  "mix.exs": "Elixir",
  "shard.yml": "Crystal",
  "dub.json": "D",
  "dub.sdl": "D",
  "stack.yaml": "Haskell",
  "cabal.project": "Haskell",
  "elm.json": "Elm",
  "elm-package.json": "Elm",
  "rebar.config": "Erlang",
  "rebar.lock": "Erlang",
  "rebar3.config": "Erlang",
  "rebar3.lock": "Erlang",
  "pubspec.yaml": "Dart",
  "pubspec.lock": "Dart",
  "nuget.config": "C#",
  "project.json": "C#"
}
`````

## File: src/metagit/data/README.md
`````markdown
# Metagit Data

Much of this is unused currently. The only item that matters is the `metagit.config.yaml` file which represents the default configuration if none is passed in or found in the default or directory.
`````

## File: tasks/Taskfile.api.yml
`````yaml
version: "3"
silent: true

tasks:
  show:
    desc: Show python variables for this task
    cmds:
      - |
        echo "GIT_LATEST_TAG: {{.GIT_LATEST_TAG}}"
        echo "VERSION: {{.VERSION}}"

  test:detect:
    desc: Run the api tests
    cmds:
      - |
        curl -X POST "http://localhost:8000/detect" \
          -H "Content-Type: application/json" \
          -d '{
            "repository_url": "https://github.com/zloeber/mag-branch",
            "priority": "normal"
          }'

  dev:
    desc: Run the api in dev mode
    env:
      OPENSEARCH_HOST: localhost
      OPENSEARCH_PORT: 9200
      OPENSEARCH_INDEX: metagit-records
      OPENSEARCH_USE_SSL: false
      OPENSEARCH_VERIFY_CERTS: false
      API_HOST: 0.0.0.0
      API_PORT: 8000
      API_DEBUG: true
      MAX_CONCURRENT_JOBS: 5
    cmds:
      - |
        docker compose -f docker-compose.dev.yml up -d
        uv run -m metagit.api.main
`````

## File: tasks/Taskfile.docker.yml
`````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: "3"
silent: true
vars:
  local_bin_path:
    sh: if [[ "{{.LOCAL_BIN_PATH}}" == "" ]]; then echo "./.local/bin"; else echo "{{.LOCAL_BIN_PATH}}"; fi
  ROVER_VERSION: '{{default "0.2.2" .ROVER_VERSION}}'
  DOCKER_BUILDKIT: 1
  DOCKER_SERVER: '{{default "{{cookiecutter.docker_registry}}" .DOCKER_SERVER}}'
  DOCKER_FILE: '{{default "Dockerfile" .DOCKER_FILE}}'
  DOCKER_PATH: '{{default "." .DOCKER_PATH}}'
  DOCKER_EXTRACT_PATH: '{{default "." .DOCKER_EXTRACT_PATH}}'
  DOCKER_IMAGE: '{{default "." .PROJECT}}'
  docker: docker
  IS_CI: "{{default 0 .IS_CI}}"

tasks:
  show:
    desc: Show terraform variables for this task
    cmds:
      - |
        echo "DOCKER_IMAGE: {{.DOCKER_IMAGE}}"
        echo "DOCKER_FILE: {{.DOCKER_FILE}}"
        echo "DOCKER_PATH: {{.DOCKER_PATH}}"
        echo "local_bin_path: {{.local_bin_path}}"

  login:
    desc: Login to container registry
    cmds:
      - |
        {{.docker}} login {{.DOCKER_SERVER}}

  tag:
    desc: Tag container image
    cmds:
      - |
        {{.docker}} tag {{.DOCKER_IMAGE}}:local {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:{{.GIT_COMMIT}}
        {{.docker}} tag {{.DOCKER_IMAGE}}:local {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:{{.VERSION}}
        {{.docker}} tag {{.DOCKER_IMAGE}}:local {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:latest

  push:
    desc: Push tagged images to registry
    cmds:
      - |
        echo "Pushing container image to registry: latest {{.VERSION}} {{.GIT_COMMIT}}"
        {{.docker}} push {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:{{.GIT_COMMIT}}
        {{.docker}} push {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:{{.VERSION}}
        {{.docker}} push {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:latest

  run:
    desc: Run a local container image for the app
    cmds:
      - |
        {{.docker}} run -t --rm -i --name={{.DOCKER_IMAGE}} {{.DOCKER_IMAGE}}:local {{.CLI_ARGS}}

  scan:
    desc: Run a {{.docker}} snyk security scan
    cmds:
      - |
        {{.docker}} scan {{.DOCKER_SERVER}}/{{.DOCKER_IMAGE}}:latest

  shell:
    desc: Run a local container image for the app
    cmds:
      - |
        {{.docker}} run -t --rm -i --name={{.DOCKER_IMAGE}} {{.DOCKER_IMAGE}}:local /bin/sh

  extract:
    desc: Example of using buildkit to extract files from an image
    cmds:
      - |
        mkdir -p {{.ROOT_DIR}} /.local/artifacts
        {{.docker}} build -f {{.DOCKER_FILE}} \
          --target artifact \
            --output type=local,dest=./.local/artifacts .

  build:
    desc: Build container image
    cmds:
      - |
        {{.docker}} build {{.DOCKER_BUILD_ARGS}} -t {{.DOCKER_IMAGE}}:local -f {{.DOCKER_FILE}} {{.DOCKER_PATH}}

  build:base:
    desc: Build base container image
    cmds:
      - |
        echo "Attempting to build the base image: ./.docker/{{.DOCKER_FILE}}.base"
        {{.docker}} build {{.DOCKER_BUILD_ARGS}} -t {{.DOCKER_IMAGE}}:local-base -f ./.docker/{{.DOCKER_FILE}}.base {{.DOCKER_PATH}}

  build:clean:
    desc: Build container image (no cache)
    cmds:
      - |
        {{.docker}} build --no-cache {{.DOCKER_BUILD_ARGS}} -t {{.DOCKER_IMAGE}}:local -f {{.DOCKER_FILE}} {{.DOCKER_PATH}}

  clean:
    desc: Clean local cached {{.docker}} elements
    cmds:
      - |
        {{.docker}} system prune
`````

## File: tasks/Taskfile.git.yml
`````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json

version: "3"
silent: true
tasks:
  show:
    desc: Show python variables for this task
    cmds:
      - |
        echo "GIT_LATEST_TAG: {{.GIT_LATEST_TAG}}"
        echo "VERSION: {{.VERSION}}"

  lint:
    desc: Lint
    cmds:
      - |
        failedfiles=$(find . -type f \( -iname "*.tf" -o -iname "*.yml" -o -iname "checksum.*" ! -iname ".local" ! -iname "venv*" ! -iname "*inc/Taskfile.git.yml" ! -iname ".git" ! -iname "Makefile" ! -iname "Taskfile.git.yml" \) -exec grep -l "<<<<<<< HEAD" {} \;)
        if [ "$failedfiles" ]; then
          echo "Failed git/lint files: ${failedfiles} "
          exit 1
        fi
        echo "Merge request conflict detrius not found!"

  pre-commit:
    desc: Install pre-commit hooks
    cmds:
      - pre-commit install --install-hooks
      - pre-commit run

  set:upstream:
    desc: Set the current git branch upstream to a branch by the same name on the origin
    cmds:
      - |
        GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo local)
        GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo unknown)
        git branch \
          --set-upstream-to=origin/${GIT_BRANCH} ${GIT_BRANCH}

  merge:renovate:
    desc: Attempt to merge all renovate/* branches
    cmds:
      - |
        branch=$(git symbolic-ref --short -q HEAD)
        echo "Current Branch: ${branch}"
        if [ "$branch" == "master" ]; then
          echo "Cannot run this process against master branch!"
          exit
        fi
        # Ensure working directory in version branch clean
        git update-index -q --refresh
        if ! git diff-index --quiet HEAD --; then
          echo "Working directory not clean, Committing current changes."
          git add . && git commit -m 'fix: merge in renovate updates'
        fi

        echo "Attempting to merge in all renovate/* branches found."
        echo "Any merge conflicts will need to be resolved before proceeding."
        for b in $(git branch -r | grep renovate/); do
          git merge $b;
          echo "Merged ${b} succesfully!"
        done

  # init:submodule:
  #   desc: Iitialize all git submodules
  #   cmds:
  #     - |
  #       git submodule init
  #       git submodule update --recursive --init
  #       git submodule foreach 'git checkout master || git checkout main'

  # init:
  #   desc: Initializes git with author information
  #   cmds:
  #     - |
  #       git config user.name "{{.AUTHOR_NAME}}"
  #       git config user.email "{{.AUTHOR_EMAIL}}"

  security:scan:
    desc: Perform security scan on local repo
    cmds:
      - |
        docker run --rm \
          -e "WORKSPACE=${PWD}" \
          -e ENABLE_OSS_RISK=true \
          -v $PWD:/app shiftleft/sast-scan scan \
          --src /app \
          --type credscan,nodejs,python,yaml,terraform,ansible,bash,dockerfile,bash,depscan \
          --out_dir /app/.local/reports
        echo "View results in ./.local/reports"

  gitleaks:
    desc: Perform gitleaks scan
    cmds:
      - |
        docker pull ghcr.io/zricethezav/gitleaks:latest
        docker run -v ${PWD}:/path zricethezav/gitleaks:latest \
          detect -r /path/.local/reports \
          --source="/path"

  ignore:update:
    desc: Adds a line to an existing .gitignore file if not already present
    cmds:
      - |
        if [[ "{{.TO_IGNORE}}" != "" ]]; then
          FILE=.gitignore
          echo "Attempting to add {{.TO_IGNORE}} to $FILE"
          if [ ! -f "$FILE" ]; then
              echo "{{.TO_IGNORE}}" > "$FILE"
          else
              if ! grep -q '{{.TO_IGNORE}}' "$FILE"; then
                echo -e "\n{{.TO_IGNORE}}" >> "$FILE"
              fi
          fi
          sed -i '/^$/d' "$FILE"
        else
          echo "Need to define the TO_IGNORE variable"
        fi
`````

## File: tasks/Taskfile.workspace.yml
`````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json

version: "3"
silent: true
vars:
  workspace_profiles: "{{.ROOT_DIR}}/profiles"
  workspaces: "workspace"
  PROFILE: '{{default "default" .PROFILE}}'
  CONFIG_FILE: "{{.workspace_profiles}}/{{.PROFILE}}.yml"
  PULL_UPDATES: '{{default "false" .PULL_UPDATES}}'

tasks:
  show:
    desc: Show variables for workspace tasks
    cmds:
      - |
        REPO_COUNT=$(yq '.repos | length' {{.CONFIG_FILE}})
        WORKSPACE=${WORKSPACE:-"$(yq '.workspace' {{.CONFIG_FILE}} )"}
        WORKSPACE_PROJECT=$(yq '.project' {{.CONFIG_FILE}})
        TARGET_PATH="{{.ROOT_DIR}}/${WORKSPACE}/${WORKSPACE_PROJECT}"
        echo "ROOT_DIR: {{.ROOT_DIR}}"
        echo "workspace_profiles: {{.workspace_profiles}}"
        echo "CONFIG_FILE: {{.CONFIG_FILE}}"
        echo "WORKSPACE: ${WORKSPACE}"
        echo "PROFILE: {{.PROFILE}}"
        echo "REPO_COUNT: ${REPO_COUNT}"
        echo "TARGET_PATH: ${TARGET_PATH}"

  sync:
    desc: Initialize and/or sync workspace for profile
    cmds:
      - |
        pushd () {
          command pushd "$@" > /dev/null
        }
        popd () {
          command popd "$@" > /dev/null
        }
        WORKSPACE=${WORKSPACE:-"$(yq '.workspace' {{.CONFIG_FILE}} )"}
        WORKSPACE_PROJECT=$(yq '.project' {{.CONFIG_FILE}})
        TARGET_PATH="{{.ROOT_DIR}}/${WORKSPACE}/${WORKSPACE_PROJECT}"
        REPO_COUNT=$(yq '.repos | length' {{.CONFIG_FILE}})
        echo "WORKSPACE: ${WORKSPACE}"
        echo "CONFIG_FILE: {{.CONFIG_FILE}}"
        echo "PROFILE: {{.PROFILE}}"
        echo "REPO_COUNT: ${REPO_COUNT}"
        echo "TARGET_PATH: ${TARGET_PATH}"
        mkdir -p "${TARGET_PATH}" >/dev/null
        echo "---"
        REPO_INDEX=0
        pushd ${TARGET_PATH}
        while [ $REPO_INDEX -lt $REPO_COUNT ]; do
          cd ${TARGET_PATH}
          CURRENT_NAME=$(yq ".repos.[$REPO_INDEX].name" {{.CONFIG_FILE}})
          CURRENT_URL=$(yq ".repos.[$REPO_INDEX].url" {{.CONFIG_FILE}})
          CLONE_BRANCHES=$(yq ".repos.[$REPO_INDEX].branches | .[]" {{.CONFIG_FILE}})
          CLONE_PATH="${TARGET_PATH}/${CURRENT_NAME}"
          echo "Sync Project: ${CURRENT_NAME}"
          mkdir -p ${CLONE_PATH}
          git clone --recursive ${CURRENT_URL} ${CLONE_PATH} || true
          cd ${CLONE_PATH}
          CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "none" )
          if [[ "{{.PULL_UPDATES}}" != "false" ]]; then
              echo "Running branch updates..."
              for BRANCH in $CLONE_BRANCHES; do
                  echo "  - Update branch: $BRANCH"
                  git checkout $BRANCH
                  git pull --autostash 2>/dev/null
                  #git stash 2>/dev/null
                  #git pull
                  #git stash pop
              done
              git checkout {{.ROOT_DIR}} 2>/dev/null
          fi
          let REPO_INDEX=REPO_INDEX+1
          echo ""
        done
        popd
        echo "Workspace Created: ${TARGET_PATH}"
      - task: vscode:create

  vscode:select:
    desc: Use fzf to select a project in a workspace and open with vscode
    silent: false
    cmds:
      - |
        WORKSPACE=${WORKSPACE:-"$(yq '.workspace' {{.CONFIG_FILE}} )"}
        WORKSPACE_PROJECT=$(yq '.project' {{.CONFIG_FILE}})
        TARGET_PATH="${WORKSPACE}/${WORKSPACE_PROJECT}"
        SELECTION=$(find ${TARGET_PATH} -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort | fzf) && \
        [ "$SELECTION" != "./" ] && code "./${TARGET_PATH}/${SELECTION}" || true

  select:
    desc: Use fzf to select a workspace and open with vscode
    cmds:
      - |
        WORKSPACE=${WORKSPACE:-"$(yq '.workspace' {{.CONFIG_FILE}} )"}
        WORKSPACE_PROJECT=$(yq '.project' {{.CONFIG_FILE}})
        TARGET_PATH="${WORKSPACE}"
        SELECTION=$(find ${TARGET_PATH} -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort | fzf) && \
        [ "$SELECTION" != "./" ] && code "./${TARGET_PATH}/${SELECTION}/workspace.code-workspace" || true

  vscode:create:
    desc: Create vscode workspace file relative to this project
    cmds:
      - |
        WORKSPACE=${WORKSPACE:-"$(yq '.workspace' {{.CONFIG_FILE}} )"}
        WORKSPACE_PROJECT=$(yq '.project' {{.CONFIG_FILE}})
        CONFIG_FILE={{.CONFIG_FILE}}
        REPO_PATHS="$(yq '.repos.[].name' {{.CONFIG_FILE}})"
        VSCODEWS=$(cat ./scripts/vscode-workspace.tpl)
        for wspath in $REPO_PATHS
        do
            newpath="./$wspath"
            echo "Adding path: ${newpath}"
            VSCODEWS=$(echo $VSCODEWS | jq --arg newpath "$newpath" '.folders += [{ "path": $newpath }]')
        done
        echo $VSCODEWS | jq > {{.ROOT_DIR}}/${WORKSPACE}/${WORKSPACE_PROJECT}/workspace.code-workspace
        echo "Workspace file for VSCode created: ./${WORKSPACE}/${WORKSPACE_PROJECT}/workspace.code-workspace"
`````

## File: tests/conftest.py
`````python
import os
import tempfile

import pytest
from dotenv import load_dotenv
from loguru import logger


@pytest.fixture(scope="session", autouse=True)
def load_env():
    """Load environment variables from .env_example for all tests."""
    load_dotenv(
        dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env_example"),
        override=True,
    )


@pytest.fixture(scope="session", autouse=True)
def configure_logging():
    """Configure logging for tests to prevent file I/O errors."""
    # Set environment variable to disable file logging during tests
    os.environ["METAGIT_LOG_TO_FILE"] = "false"
    os.environ["METAGIT_LOG_LEVEL"] = "WARNING"  # Reduce log noise during tests

    # Remove all existing loguru handlers to prevent file I/O issues
    logger.remove()

    # Add a simple console handler for tests
    logger.add(
        lambda msg: None,  # Null sink to suppress output during tests
        level="WARNING",
        format="{message}",
    )


@pytest.fixture(autouse=True)
def cleanup_logging():
    """Clean up logging after each test to prevent file handle issues."""
    yield
    # Remove any handlers that might have been added during the test
    logger.remove()


@pytest.fixture
def temp_dir():
    """Create a temporary directory for test use."""
    with tempfile.TemporaryDirectory() as tmpdirname:
        yield tmpdirname
`````

## File: tests/test_appconfig_init.disabled
`````
#!/usr/bin/env python
"""
Unit tests for metagit.core.appconfig.__init__
"""

import yaml

from metagit.core.appconfig import get_config, load_config
from metagit.core.appconfig.models import AppConfig


def test_load_config_success(tmp_path):
    config_path = tmp_path / "testconfig.yaml"
    data = {"config": AppConfig().model_dump()}
    with open(config_path, "w") as f:
        yaml.dump(data, f)
    cfg = load_config(str(config_path))
    assert isinstance(cfg, AppConfig)


def test_load_config_file_not_found(tmp_path):
    result = load_config(str(tmp_path / "nope.yaml"))
    assert isinstance(result, Exception)


def test_get_config_dict(capsys):
    cfg = AppConfig()
    result = get_config(cfg, output="dict")
    assert isinstance(result, dict)


def test_get_config_json(capsys):
    cfg = AppConfig()
    get_config(cfg, output="json")
    captured = capsys.readouterr()
    assert "config" in captured.out or "config" in captured.err


def test_get_config_yaml(capsys):
    cfg = AppConfig()
    get_config(cfg, output="yaml")
    captured = capsys.readouterr()
    assert "config" in captured.out or "config" in captured.err
`````

## File: tests/test_detect_manager.disabled
`````
#!/usr/bin/env python3
"""
Unit tests for DetectionManager and DetectionManagerConfig.
"""

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from metagit.core.config.models import Language, ProjectDomain, ProjectType
from metagit.core.detect import (
    BranchInfo,
    CIConfigAnalysis,
    DetectionManager,
    DetectionManagerConfig,
    GitBranchAnalysis,
    LanguageDetection,
    ProjectTypeDetection,
)
from metagit.core.record.models import MetagitRecord


@pytest.fixture
def default_path(tmp_path):
    # Use a temporary directory for path
    return str(tmp_path)


def test_config_all_enabled():
    config = DetectionManagerConfig.all_enabled()
    enabled = config.get_enabled_methods()
    assert set(enabled) == {
        "branch_analysis",
        "ci_config_analysis",
        "directory_summary",
        "directory_details",
        "commit_analysis",
        "tag_analysis",
    }


def test_config_minimal():
    config = DetectionManagerConfig.minimal()
    enabled = config.get_enabled_methods()
    assert set(enabled) == {"branch_analysis", "ci_config_analysis"}


def test_config_toggle():
    config = DetectionManagerConfig(
        branch_analysis_enabled=False,
        ci_config_analysis_enabled=True,
        directory_summary_enabled=True,
        directory_details_enabled=False,
    )
    enabled = config.get_enabled_methods()
    assert set(enabled) == {"ci_config_analysis", "directory_summary"}


def test_detection_manager_from_path(default_path):
    """Test creating DetectionManager from path."""
    config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=True,
        directory_summary_enabled=True,
        directory_details_enabled=True,
    )

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)
        assert manager.path == default_path
        # Use the actual path name instead of hardcoded "tmp_path"
        expected_name = Path(default_path).name
        assert manager.name == expected_name
        assert manager.detection_config == config


def test_detection_manager_from_path_with_existing_config(default_path):
    """Test creating DetectionManager from path with existing config."""
    existing_config = MetagitRecord(
        name="test-project", description="A test project", kind="application"
    )

    config = DetectionManagerConfig.minimal()

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=existing_config,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)
        assert manager.name == "test-project"
        assert manager.description == "A test project"
        assert manager.kind == "application"


def test_detection_manager_run_all_and_summary(default_path):
    config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=True,
        directory_summary_enabled=True,
        directory_details_enabled=True,
    )

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)

        # Create a mock RepositoryAnalysis with all analysis results
        mock_repo_analysis = RepositoryAnalysis(
            path=default_path,
            name="test-project",
            description="A test project",
            url="https://github.com/test/project",
            language_detection=LanguageDetection(primary="Python"),
            project_type_detection=ProjectTypeDetection(
                type=ProjectType.APPLICATION, domain=ProjectDomain.WEB
            ),
            branch_analysis=GitBranchAnalysis(
                strategy_guess="Unknown",
                branches=[BranchInfo(name="main", is_remote=False)],
            ),
            ci_config_analysis=CIConfigAnalysis(detected_tool="TestCI"),
            metadata=None,
            metrics=None,
        )

        # Patch RepositoryAnalysis.from_path to return our mock
        with patch(
            "metagit.core.detect.manager.RepositoryAnalysis.from_path",
            return_value=mock_repo_analysis,
        ):
            result = manager.run_all()
            assert result is None
            assert manager.analysis_completed is True
            assert manager.repository_analysis is not None

            # Patch missing fields for summary
            object.__setattr__(manager, "domain", ProjectDomain.WEB)
            object.__setattr__(manager, "kind", ProjectType.APPLICATION)
            object.__setattr__(manager, "language", Language(primary="Python"))
            object.__setattr__(manager, "language_version", "3.9")

            summary = manager.summary()
            assert isinstance(summary, str)
            assert "Unknown" in summary
            assert "TestCI" in summary


def test_detection_manager_run_specific(default_path):
    config = DetectionManagerConfig(
        branch_analysis_enabled=True,
        ci_config_analysis_enabled=False,
        directory_summary_enabled=False,
        directory_details_enabled=False,
    )

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)

        # Test that disabled methods return Exception
        result = manager.run_specific("ci_config_analysis")
        assert isinstance(result, Exception)

        # Test that enabled methods delegate to run_all
        with patch.object(manager, "run_all", return_value=None) as mock_run_all:
            result = manager.run_specific("branch_analysis")
            assert result is None
            mock_run_all.assert_called_once()


def test_detection_manager_run_specific_unknown_method(default_path):
    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path)
        assert not isinstance(manager, Exception)

        result = manager.run_specific("unknown_method")
        assert isinstance(result, Exception)
        assert "Unknown analysis method" in str(result)


def test_detection_manager_summary_handles_missing(default_path):
    config = DetectionManagerConfig.minimal()

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)

        # Test summary without running analysis
        summary = manager.summary()
        assert isinstance(summary, str)
        assert "not available" in summary


def test_detection_manager_serialization(default_path):
    config = DetectionManagerConfig.minimal()

    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path, config=config)
        assert not isinstance(manager, Exception)

        # Test YAML serialization
        yaml_output = manager.to_yaml()
        assert isinstance(yaml_output, str)
        assert "name:" in yaml_output
        assert "project_path:" in yaml_output

        # Test JSON serialization
        json_output = manager.to_json()
        assert isinstance(json_output, str)
        assert '"name"' in json_output
        assert '"project_path"' in json_output


def test_detection_manager_inherits_from_metagit_record(default_path):
    """Test that DetectionManager properly inherits from MetagitRecord."""
    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path)
        assert not isinstance(manager, Exception)

        # Should have MetagitRecord attributes
        assert hasattr(manager, "name")
        assert hasattr(manager, "path")
        assert hasattr(manager, "detection_timestamp")
        assert hasattr(manager, "detection_source")
        assert hasattr(manager, "detection_version")

        # Should also have DetectionManager-specific attributes
        assert hasattr(manager, "detection_config")
        assert hasattr(manager, "repository_analysis")
        assert hasattr(manager, "analysis_completed")
        assert hasattr(manager, "project_path")


def test_detection_manager_cleanup(default_path):
    """Test that DetectionManager cleanup delegates to RepositoryAnalysis."""
    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path)
        assert not isinstance(manager, Exception)

        # Create a mock RepositoryAnalysis
        mock_repo_analysis = MagicMock()
        manager.repository_analysis = mock_repo_analysis

        # Test cleanup
        manager.cleanup()
        mock_repo_analysis.cleanup.assert_called_once()


def test_detection_manager_update_metagit_record(default_path):
    """Test that DetectionManager properly updates MetagitRecord fields from RepositoryAnalysis."""
    with patch(
        "metagit.core.detect.manager.DetectionManager._load_existing_config",
        return_value=None,
    ):
        manager = DetectionManager.from_path(default_path)
        assert not isinstance(manager, Exception)

        # Create a mock RepositoryAnalysis with data
        mock_repo_analysis = RepositoryAnalysis(
            path=default_path,
            name="test-project",
            description="A test project",
            url="https://github.com/test/project",
            language_detection=LanguageDetection(primary="Python"),
            project_type_detection=ProjectTypeDetection(
                type=ProjectType.APPLICATION, domain=ProjectDomain.WEB
            ),
            branch_analysis=GitBranchAnalysis(
                strategy_guess="GitHub Flow",
                branches=[BranchInfo(name="main", is_remote=False)],
            ),
            ci_config_analysis=CIConfigAnalysis(detected_tool="GitHub Actions"),
        )

        manager.repository_analysis = mock_repo_analysis

        # Test _update_metagit_record
        manager._update_metagit_record()

        # Check that MetagitRecord fields were updated
        assert manager.name == "test-project"
        assert manager.description == "A test project"
        assert manager.url == "https://github.com/test/project"
        assert manager.language.primary == "Python"
        assert manager.kind == ProjectType.APPLICATION
        assert manager.domain == ProjectDomain.WEB
        assert manager.branch_strategy == "GitHub Flow"
        assert len(manager.branches) == 1
        assert manager.branches[0].name == "main"
        assert manager.cicd.platform == "GitHub Actions"
`````

## File: tests/test_gitcache.py
`````python
#!/usr/bin/env python
"""
Unit tests for git cache management system.

This module contains comprehensive tests for the GitCacheConfig,
GitCacheEntry, and GitCacheManager classes.
"""

import shutil
import subprocess
import tempfile
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import git

from metagit.core.gitcache.config import (
    CacheStatus,
    CacheType,
    GitCacheConfig,
    GitCacheEntry,
)
from metagit.core.gitcache.manager import GitCacheManager

test_repo_url = "https://github.com/metagit-ai/metagit-detect.git"
test_repo_url_no_ext = "https://github.com/metagit-ai/metagit-detect"
test_repo_does_not_exist = "https://github.com/metagit-ai/does-not-exist.git"
repo_name = "metagit-detect"


class TestGitCacheEntry(unittest.TestCase):
    """Test cases for GitCacheEntry model."""

    def setUp(self):
        """Set up test fixtures."""
        self.temp_dir = tempfile.mkdtemp()
        self.cache_path = Path(self.temp_dir) / "test_cache"

    def tearDown(self):
        """Clean up test fixtures."""
        shutil.rmtree(self.temp_dir, ignore_errors=True)

    def test_git_cache_entry_creation(self):
        """Test creating a GitCacheEntry."""
        entry = GitCacheEntry(
            name="test-repo",
            source_url=test_repo_url,
            cache_type=CacheType.GIT,
            cache_path=self.cache_path,
        )

        self.assertEqual(entry.name, "test-repo")
        self.assertEqual(entry.source_url, test_repo_url)
        self.assertEqual(entry.cache_type, CacheType.GIT)
        self.assertEqual(entry.cache_path, self.cache_path)
        self.assertEqual(entry.status, CacheStatus.FRESH)
        self.assertIsNone(entry.error_message)

    def test_git_cache_entry_with_string_path(self):
        """Test creating GitCacheEntry with string path."""
        entry = GitCacheEntry(
            name="test-repo",
            source_url=test_repo_url,
            cache_type=CacheType.GIT,
            cache_path=str(self.cache_path),
        )

        self.assertEqual(entry.cache_path, self.cache_path)

    def test_git_cache_entry_metadata(self):
        """Test GitCacheEntry with metadata."""
        metadata = {"branch": "main", "commit": "abc123"}
        entry = GitCacheEntry(
            name="test-repo",
            source_url=test_repo_url,
            cache_type=CacheType.GIT,
            cache_path=self.cache_path,
            metadata=metadata,
        )

        self.assertEqual(entry.metadata, metadata)


class TestGitCacheConfig(unittest.TestCase):
    """Test cases for GitCacheConfig model."""

    def setUp(self):
        """Set up test fixtures."""
        self.temp_dir = tempfile.mkdtemp()

    def tearDown(self):
        """Clean up test fixtures."""

        shutil.rmtree(self.temp_dir, ignore_errors=True)

    def test_git_cache_config_defaults(self):
        """Test GitCacheConfig with default values."""
        config = GitCacheConfig()

        self.assertEqual(config.cache_root, Path("./.metagit/.cache"))
        self.assertEqual(config.default_timeout_minutes, 60)
        self.assertEqual(config.max_cache_size_gb, 10.0)
        self.assertTrue(config.enable_async)
        self.assertEqual(config.entries, {})

    def test_git_cache_config_custom_values(self):
        """Test GitCacheConfig with custom values."""
        cache_root = Path(self.temp_dir) / "custom_cache"
        config = GitCacheConfig(
            cache_root=cache_root,
            default_timeout_minutes=120,
            max_cache_size_gb=5.0,
            enable_async=False,
        )

        self.assertEqual(config.cache_root, cache_root)
        self.assertEqual(config.default_timeout_minutes, 120)
        self.assertEqual(config.max_cache_size_gb, 5.0)
        self.assertFalse(config.enable_async)

    def test_git_cache_config_cache_root_creation(self):
        """Test that cache root directory is created."""
        cache_root = Path(self.temp_dir) / "new_cache"
        config = GitCacheConfig(cache_root=cache_root)

        self.assertTrue(cache_root.exists())
        self.assertTrue(cache_root.is_dir())

    def test_git_cache_config_validation(self):
        """Test GitCacheConfig validation."""
        with self.assertRaises(ValueError):
            GitCacheConfig(default_timeout_minutes=0)

        with self.assertRaises(ValueError):
            GitCacheConfig(max_cache_size_gb=0)

    def test_git_cache_config_get_cache_path(self):
        """Test getting cache path for entry."""
        config = GitCacheConfig(cache_root=Path(self.temp_dir))
        cache_path = config.get_cache_path("test-repo")

        self.assertEqual(cache_path, Path(self.temp_dir) / "test-repo")

    def test_git_cache_config_entry_management(self):
        """Test entry management methods."""
        config = GitCacheConfig()
        entry = GitCacheEntry(
            name="test-repo",
            source_url=test_repo_url,
            cache_type=CacheType.GIT,
            cache_path=Path("/tmp/test"),
        )

        # Test adding entry
        config.add_entry(entry)
        self.assertIn("test-repo", config.entries)
        self.assertEqual(config.entries["test-repo"], entry)

        # Test getting entry
        retrieved_entry = config.get_entry("test-repo")
        self.assertEqual(retrieved_entry, entry)

        # Test getting non-existent entry
        self.assertIsNone(config.get_entry("non-existent"))

        # Test listing entries
        entries = config.list_entries()
        self.assertEqual(len(entries), 1)
        self.assertEqual(entries[0], entry)

        # Test removing entry
        self.assertTrue(config.remove_entry("test-repo"))
        self.assertNotIn("test-repo", config.entries)

        # Test removing non-existent entry
        self.assertFalse(config.remove_entry("non-existent"))

    def test_git_cache_config_stale_detection(self):
        """Test stale entry detection."""
        config = GitCacheConfig(default_timeout_minutes=60)

        # Fresh entry
        fresh_entry = GitCacheEntry(
            name="fresh",
            source_url=test_repo_url,
            cache_type=CacheType.GIT,
            cache_path=Path("/tmp/fresh"),
            last_updated=datetime.now(),
        )
        self.assertFalse(config.is_entry_stale(fresh_entry))

        # Stale entry
        stale_entry = GitCacheEntry(
            name="stale",
            source_url=test_repo_url,
            cache_type=CacheType.GIT,
            cache_path=Path("/tmp/stale"),
            last_updated=datetime.now() - timedelta(hours=2),
        )
        self.assertTrue(config.is_entry_stale(stale_entry))


class TestGitCacheManager(unittest.TestCase):
    """Test cases for GitCacheManager class."""

    def setUp(self):
        """Set up test fixtures."""
        self.temp_dir = tempfile.mkdtemp()
        self.cache_root = Path(self.temp_dir) / "cache"
        self.config = GitCacheConfig(cache_root=self.cache_root)
        self.manager = GitCacheManager(self.config)

    def tearDown(self):
        """Clean up test fixtures."""
        shutil.rmtree(self.temp_dir, ignore_errors=True)

    def test_git_cache_manager_initialization(self):
        """Test GitCacheManager initialization."""
        self.assertEqual(self.manager.config, self.config)
        self.assertEqual(self.manager._providers, {})

    def test_git_cache_manager_provider_registration(self):
        """Test provider registration."""
        mock_provider = MagicMock()
        mock_provider.get_name.return_value = "test-provider"

        self.manager.register_provider(mock_provider)

        self.assertIn("test-provider", self.manager._providers)
        self.assertEqual(self.manager._providers["test-provider"], mock_provider)

    def test_git_cache_manager_generate_cache_name(self):
        """Test cache name generation."""
        # Git URL
        git_url = test_repo_url
        name = self.manager._generate_cache_name(git_url)
        print(name)
        self.assertEqual(name, repo_name)

        # Git URL without .git extension
        git_url_no_ext = test_repo_url_no_ext
        name = self.manager._generate_cache_name(git_url_no_ext)
        self.assertEqual(name, repo_name)

        # Local path
        local_path = "/path/to/directory"
        name = self.manager._generate_cache_name(local_path)
        self.assertEqual(name, "directory")

    def test_git_cache_manager_url_detection(self):
        """Test URL type detection."""
        # Git URLs
        self.assertTrue(self.manager._is_git_url(test_repo_url))
        self.assertTrue(self.manager._is_git_url("http://github.com/test/repo.git"))
        self.assertTrue(self.manager._is_git_url("git://github.com/test/repo.git"))
        self.assertTrue(self.manager._is_git_url("ssh://git@github.com/test/repo.git"))

        # Non-git URLs
        self.assertFalse(self.manager._is_git_url("/path/to/directory"))
        self.assertFalse(self.manager._is_git_url("file:///path/to/directory"))

    def test_git_cache_manager_local_path_detection(self):
        """Test local path detection."""
        # Create a temporary directory
        temp_dir = Path(self.temp_dir) / "test_dir"
        temp_dir.mkdir()

        self.assertTrue(self.manager._is_local_path(str(temp_dir)))
        self.assertFalse(self.manager._is_local_path("/non/existent/path"))

    @patch("subprocess.run")
    def test_git_cache_manager_clone_repository(self, mock_run):
        """Test repository cloning."""
        mock_run.return_value.returncode = 0
        mock_run.return_value.stderr = ""

        cache_path = Path.joinpath(Path(self.temp_dir), "test_repo")
        result = self.manager._clone_repository(test_repo_url, cache_path)
        print(result)
        self.assertTrue(result)
        # mock_run.assert_called_once()

    @patch("git.Repo")
    def test_git_cache_manager_clone_repository_failure(self, mock_repo):
        """Test repository cloning failure."""
        mock_repo.clone_from.side_effect = git.exc.GitCommandError(
            "clone", "Authentication failed"
        )

        cache_path = Path.joinpath(Path(self.temp_dir), "test_repo")
        result = self.manager._clone_repository(test_repo_does_not_exist, cache_path)

        self.assertIsInstance(result, Exception)
        self.assertIn("Authentication failed", str(result))
        mock_repo.clone_from.assert_called_once_with(
            test_repo_does_not_exist, str(cache_path), depth=1
        )

    def test_git_cache_manager_copy_local_directory(self):
        """Test local directory copying."""
        # Create source directory with some files
        source_dir = Path.joinpath(Path(self.temp_dir), "source")
        source_dir.mkdir()
        (source_dir / "test.txt").write_text("test content")

        cache_path = Path.joinpath(Path(self.temp_dir), "cache_copy")
        result = self.manager._copy_local_directory(source_dir, cache_path)

        self.assertTrue(result)
        self.assertTrue(cache_path.exists())
        self.assertTrue((cache_path / "test.txt").exists())
        self.assertEqual((cache_path / "test.txt").read_text(), "test content")

    def test_git_cache_manager_copy_local_directory_failure(self):
        """Test local directory copying failure."""
        non_existent_path = Path("/non/existent/path")
        cache_path = Path.joinpath(Path(self.temp_dir), "cache_copy")
        result = self.manager._copy_local_directory(non_existent_path, cache_path)

        self.assertIsInstance(result, Exception)

    def test_git_cache_manager_calculate_directory_size(self):
        """Test directory size calculation."""
        # Create test directory with files
        test_dir = Path.joinpath(Path(self.temp_dir), "size_test")
        test_dir.mkdir()
        (test_dir / "file1.txt").write_text("content1")
        (test_dir / "file2.txt").write_text("content2")

        size = self.manager._calculate_directory_size(test_dir)
        self.assertGreater(size, 0)

    def test_git_cache_manager_cache_stats(self):
        """Test cache statistics."""
        # Add some test entries
        entry1 = GitCacheEntry(
            name="repo1",
            source_url="https://github.com/test/repo1.git",
            cache_type=CacheType.GIT,
            cache_path=Path("/tmp/repo1"),
        )
        entry2 = GitCacheEntry(
            name="repo2",
            source_url="/path/to/local/repo",
            cache_type=CacheType.LOCAL,
            cache_path=Path("/tmp/repo2"),
        )

        self.config.add_entry(entry1)
        self.config.add_entry(entry2)

        stats = self.manager.get_cache_stats()

        self.assertEqual(stats["total_entries"], 2)
        self.assertEqual(stats["git_entries"], 1)
        self.assertEqual(stats["local_entries"], 1)
        self.assertEqual(stats["fresh_entries"], 2)

    def test_git_cache_manager_remove_cache_entry(self):
        """Test removing cache entry."""
        # Create a test cache directory
        cache_path = Path.joinpath(Path(self.temp_dir), "test_cache")
        cache_path.mkdir()
        (cache_path / "test.txt").write_text("test")

        entry = GitCacheEntry(
            name="test-repo",
            source_url=test_repo_url,
            cache_type=CacheType.GIT,
            cache_path=cache_path,
        )
        self.config.add_entry(entry)

        result = self.manager.remove_cache_entry("test-repo")

        self.assertTrue(result)
        self.assertNotIn("test-repo", self.config.entries)
        self.assertFalse(cache_path.exists())

    def test_git_cache_manager_remove_nonexistent_entry(self):
        """Test removing non-existent cache entry."""
        result = self.manager.remove_cache_entry("non-existent")

        self.assertIsInstance(result, Exception)
        self.assertIn("not found", str(result))

    def test_git_cache_manager_rejects_non_git_directory(self):
        """Test that non-git directories cannot be cached as local repositories."""
        # Create a plain directory (not a git repo)
        non_git_dir = Path.joinpath(Path(self.temp_dir), "plain_dir")
        non_git_dir.mkdir()
        (non_git_dir / "file.txt").write_text("not a repo")

        result = self.manager.cache_repository(str(non_git_dir))
        self.assertIsInstance(result, Exception)
        self.assertIn("must be a git URL or local git repository", str(result))

    def test_git_cache_manager_accepts_local_git_repository(self):
        """Test that a valid local git repository can be cached."""
        # Create a git repo
        git_dir = Path.joinpath(Path(self.temp_dir), "git_repo")
        git_dir.mkdir()
        (git_dir / "file.txt").write_text("repo file")
        # Initialize git
        subprocess.run(["git", "init"], cwd=git_dir, check=True)

        result = self.manager.cache_repository(str(git_dir))
        self.assertIsInstance(result, GitCacheEntry)
        self.assertEqual(result.cache_type, CacheType.LOCAL)
        self.assertEqual(result.source_url, str(git_dir))


class TestGitCacheManagerAsync(unittest.IsolatedAsyncioTestCase):
    """Test cases for GitCacheManager async operations."""

    def setUp(self):
        """Set up test fixtures."""
        self.temp_dir = tempfile.mkdtemp()
        self.cache_root = Path.joinpath(Path(self.temp_dir), "cache")
        self.config = GitCacheConfig(cache_root=self.cache_root)
        self.manager = GitCacheManager(self.config)

    def tearDown(self):
        """Clean up test fixtures."""

        shutil.rmtree(self.temp_dir, ignore_errors=True)

    @patch("asyncio.create_subprocess_exec")
    async def test_git_cache_manager_clone_repository_async(
        self, mock_create_subprocess
    ):
        """Test async repository cloning."""
        # Mock subprocess
        mock_process = AsyncMock()
        mock_process.communicate.return_value = (b"", b"")
        mock_process.returncode = 0
        mock_create_subprocess.return_value = mock_process

        cache_path = Path.joinpath(Path(self.temp_dir), "test_repo")
        result = await self.manager._clone_repository_async(test_repo_url, cache_path)
        print(result)
        self.assertTrue(result)
        # mock_create_subprocess.assert_called_once()

    @patch("metagit.core.gitcache.manager.git.Repo.clone_from")
    async def test_git_cache_manager_clone_repository_async_failure(
        self, mock_clone_from
    ):
        """Test async repository cloning failure."""
        # Mock git clone to raise an exception
        mock_clone_from.side_effect = Exception("Authentication failed")

        cache_path = Path.joinpath(Path(self.temp_dir), "test_repo")
        result = await self.manager._clone_repository_async(test_repo_url, cache_path)

        self.assertIsInstance(result, Exception)
        self.assertIn("Authentication failed", str(result))

    async def test_git_cache_manager_copy_local_directory_async(self):
        """Test async local directory copying."""
        # Create source directory with some files
        source_dir = Path.joinpath(Path(self.temp_dir), "source")
        source_dir.mkdir()
        (source_dir / "test.txt").write_text("test content")

        cache_path = Path.joinpath(Path(self.temp_dir), "cache_copy")
        result = await self.manager._copy_local_directory_async(source_dir, cache_path)

        self.assertTrue(result)
        self.assertTrue(cache_path.exists())
        self.assertTrue((cache_path / "test.txt").exists())

    async def test_git_cache_manager_copy_local_directory_async_failure(self):
        """Test async local directory copying failure."""
        non_existent_path = Path("/non/existent/path")
        cache_path = Path.joinpath(Path(self.temp_dir), "cache_copy")
        result = await self.manager._copy_local_directory_async(
            non_existent_path, cache_path
        )

        self.assertIsInstance(result, Exception)

    @patch("metagit.core.gitcache.manager.GitCacheManager._clone_repository_async")
    async def test_git_cache_manager_cache_repository_async(self, mock_clone):
        """Test async repository caching."""
        mock_clone.return_value = True

        result = await self.manager.cache_repository_async(test_repo_url)

        self.assertIsInstance(result, GitCacheEntry)
        self.assertEqual(result.name, repo_name)
        self.assertEqual(result.source_url, test_repo_url)
        self.assertEqual(result.cache_type, CacheType.GIT)

    @patch("metagit.core.gitcache.manager.GitCacheManager._copy_local_directory_async")
    async def test_git_cache_manager_cache_local_directory_async(self, mock_copy):
        """Test async local directory caching."""
        mock_copy.return_value = True

        # Create a temporary directory
        temp_dir = Path.joinpath(Path(self.temp_dir), "test_dir")
        temp_dir.mkdir()
        git.Repo.init(temp_dir)

        result = await self.manager.cache_repository_async(str(temp_dir))
        print(result)
        self.assertIsInstance(result, GitCacheEntry)
        self.assertEqual(result.cache_type, CacheType.LOCAL)

    @patch("metagit.core.gitcache.manager.GitCacheManager.cache_repository_async")
    async def test_git_cache_manager_refresh_cache_entry_async(self, mock_cache):
        """Test async cache entry refresh."""
        # Create test entry
        entry = GitCacheEntry(
            name="test-repo",
            source_url=test_repo_url,
            cache_type=CacheType.GIT,
            cache_path=Path("/tmp/test"),
        )
        self.config.add_entry(entry)

        mock_cache.return_value = entry

        result = await self.manager.refresh_cache_entry_async("test-repo")

        self.assertEqual(result, entry)
        mock_cache.assert_called_once_with(test_repo_url, "test-repo")


if __name__ == "__main__":
    unittest.main()
`````

## File: tests/test_record_conversion.py
`````python
#!/usr/bin/env python
"""
Unit tests for MetagitRecord conversion methods.

This module tests the fast conversion methods between MetagitRecord and MetagitConfig
using the latest Pydantic best practices.
"""

import unittest
from datetime import datetime
from pathlib import Path

from metagit.core.config.models import (
    CICD,
    Branch,
    BranchStrategy,
    CICDPlatform,
    Language,
    License,
    LicenseKind,
    Maintainer,
    MetagitConfig,
    Metrics,
    Pipeline,
    PullRequests,
    RepoMetadata,
)
from metagit.core.project.models import ProjectKind
from metagit.core.record.models import MetagitRecord


class TestMetagitRecordConversion(unittest.TestCase):
    """Test cases for MetagitRecord conversion methods."""

    def setUp(self):
        """Set up test fixtures."""
        self.sample_config = MetagitConfig(
            name="test-project",
            description="A test project for conversion",
            url="https://github.com/test/project.git",
            kind=ProjectKind.APPLICATION,
            branch_strategy=BranchStrategy.TRUNK,
            license=License(kind=LicenseKind.MIT, file="LICENSE"),
            maintainers=[
                Maintainer(
                    name="John Doe", email="john@example.com", role="Lead Developer"
                )
            ],
            cicd=CICD(
                platform=CICDPlatform.GITHUB,
                pipelines=[Pipeline(name="CI", ref=".github/workflows/ci.yml")],
            ),
        )

        self.sample_record = MetagitRecord(
            name="test-project",
            description="A test project for conversion",
            url="https://github.com/test/project.git",
            kind=ProjectKind.APPLICATION,
            branch_strategy=BranchStrategy.TRUNK,
            license=License(kind=LicenseKind.MIT, file="LICENSE"),
            maintainers=[
                Maintainer(
                    name="John Doe", email="john@example.com", role="Lead Developer"
                )
            ],
            cicd=CICD(
                platform=CICDPlatform.GITHUB,
                pipelines=[Pipeline(name="CI", ref=".github/workflows/ci.yml")],
            ),
            # Detection-specific fields
            branch="main",
            checksum="abc123def456",
            last_updated=datetime.now(),
            branches=[Branch(name="main", environment="production")],
            metrics=Metrics(
                stars=100,
                forks=10,
                open_issues=5,
                pull_requests=PullRequests(open=3, merged_last_30d=15),
                contributors=8,
                commit_frequency="daily",
            ),
            metadata=RepoMetadata(
                tags=["python", "api"],
                created_at=datetime.now(),
                has_ci=True,
                has_tests=True,
                has_docs=True,
                has_docker=False,
                has_iac=True,
            ),
            language=Language(primary="Python", secondary=["JavaScript"]),
            language_version="3.9",
            domain="web",
            detection_timestamp=datetime.now(),
            detection_source="github",
            detection_version="1.0.0",
        )

    def test_to_metagit_config_basic(self):
        """Test basic conversion from MetagitRecord to MetagitConfig."""
        config = self.sample_record.to_metagit_config()

        # Should have all MetagitConfig fields
        self.assertEqual(config.name, "test-project")
        self.assertEqual(config.description, "A test project for conversion")
        self.assertEqual(config.url, "https://github.com/test/project.git")
        self.assertEqual(config.kind, ProjectKind.APPLICATION)
        self.assertEqual(config.branch_strategy, BranchStrategy.TRUNK)
        self.assertIsNotNone(config.license)
        self.assertIsNotNone(config.maintainers)
        self.assertIsNotNone(config.cicd)

        # Should not have detection-specific fields
        self.assertFalse(hasattr(config, "branch"))
        self.assertFalse(hasattr(config, "checksum"))
        self.assertFalse(hasattr(config, "last_updated"))
        self.assertFalse(hasattr(config, "branches"))
        self.assertFalse(hasattr(config, "metrics"))
        self.assertFalse(hasattr(config, "metadata"))
        self.assertFalse(hasattr(config, "detection_timestamp"))
        self.assertFalse(hasattr(config, "detection_source"))
        self.assertFalse(hasattr(config, "detection_version"))

    def test_to_metagit_config_with_detection_fields(self):
        """Test conversion keeping detection fields."""
        # This test is removed because MetagitConfig doesn't support detection fields
        # The exclude_detection_fields parameter is for future extensibility
        pass

    def test_field_differences(self):
        """Test field difference detection."""
        differences = MetagitRecord.get_field_differences()

        # Should have field difference information
        self.assertIn("common_fields", differences)
        self.assertIn("record_only_fields", differences)
        self.assertIn("config_only_fields", differences)
        self.assertIn("total_record_fields", differences)
        self.assertIn("total_config_fields", differences)
        self.assertIn("common_field_count", differences)

        # Should have some common fields
        self.assertGreater(len(differences["common_fields"]), 0)
        self.assertIn("name", differences["common_fields"])
        self.assertIn("description", differences["common_fields"])

        # Should have some record-only fields
        self.assertGreater(len(differences["record_only_fields"]), 0)
        self.assertIn("detection_source", differences["record_only_fields"])
        self.assertIn("detection_version", differences["record_only_fields"])

    def test_compatible_fields(self):
        """Test compatible field detection."""
        compatible_fields = MetagitRecord.get_compatible_fields()

        # Should have some compatible fields
        self.assertGreater(len(compatible_fields), 0)
        self.assertIn("name", compatible_fields)
        self.assertIn("description", compatible_fields)
        self.assertIn("url", compatible_fields)
        self.assertIn("kind", compatible_fields)

        # Should not include detection-specific fields
        self.assertNotIn("detection_source", compatible_fields)
        self.assertNotIn("detection_version", compatible_fields)
        self.assertNotIn("branch", compatible_fields)

    def test_from_metagit_config_basic(self):
        """Test basic conversion from MetagitConfig to MetagitRecord."""
        record = MetagitRecord.from_metagit_config(self.sample_config)

        # Should have all MetagitConfig fields
        self.assertEqual(record.name, "test-project")
        self.assertEqual(record.description, "A test project for conversion")
        self.assertEqual(record.url, "https://github.com/test/project.git")
        self.assertEqual(record.kind, ProjectKind.APPLICATION)
        self.assertEqual(record.branch_strategy, BranchStrategy.TRUNK)

        # Should have detection-specific fields with defaults
        self.assertEqual(record.detection_source, "local")
        self.assertEqual(record.detection_version, "1.0.0")
        self.assertIsNotNone(record.detection_timestamp)
        self.assertIsNone(record.branch)
        self.assertIsNone(record.checksum)
        self.assertIsNone(record.metrics)
        self.assertIsNone(record.metadata)

    def test_from_metagit_config_with_custom_detection_data(self):
        """Test conversion with custom detection data."""
        additional_data = {
            "branch": "feature-branch",
            "checksum": "def789ghi012",
            "metrics": Metrics(
                stars=50,
                forks=5,
                open_issues=2,
                pull_requests=PullRequests(open=1, merged_last_30d=8),
                contributors=4,
                commit_frequency="weekly",
            ),
        }

        record = MetagitRecord.from_metagit_config(
            self.sample_config,
            detection_source="gitlab",
            detection_version="2.0.0",
            additional_detection_data=additional_data,
        )

        # Should have custom detection data
        self.assertEqual(record.detection_source, "gitlab")
        self.assertEqual(record.detection_version, "2.0.0")
        self.assertEqual(record.branch, "feature-branch")
        self.assertEqual(record.checksum, "def789ghi012")
        self.assertIsNotNone(record.metrics)
        self.assertEqual(record.metrics.stars, 50)

    def test_conversion_round_trip(self):
        """Test round-trip conversion: Config -> Record -> Config."""
        # Config -> Record
        record = MetagitRecord.from_metagit_config(
            self.sample_config,
            detection_source="github",
            detection_version="1.0.0",
        )

        # Record -> Config
        config = record.to_metagit_config()

        # Should be equivalent to original config
        self.assertEqual(config.name, self.sample_config.name)
        self.assertEqual(config.description, self.sample_config.description)
        self.assertEqual(config.url, self.sample_config.url)
        self.assertEqual(config.kind, self.sample_config.kind)
        self.assertEqual(config.branch_strategy, self.sample_config.branch_strategy)

    def test_conversion_round_trip_with_detection_fields(self):
        """Test round-trip conversion keeping detection fields."""
        # This test is removed because MetagitConfig doesn't support detection fields
        # The exclude_detection_fields parameter is for future extensibility
        pass

    def test_get_detection_summary(self):
        """Test getting detection summary."""
        summary = self.sample_record.get_detection_summary()

        # Should have basic detection info
        self.assertEqual(summary["detection_source"], "github")
        self.assertEqual(summary["detection_version"], "1.0.0")
        self.assertEqual(summary["current_branch"], "main")
        self.assertEqual(summary["checksum"], "abc123def456")
        self.assertIsNotNone(summary["detection_timestamp"])

        # Should have metrics summary
        self.assertIn("metrics", summary)
        self.assertEqual(summary["metrics"]["stars"], 100)
        self.assertEqual(summary["metrics"]["forks"], 10)
        self.assertEqual(summary["metrics"]["open_issues"], 5)
        self.assertEqual(summary["metrics"]["contributors"], 8)

        # Should have metadata summary
        self.assertIn("metadata", summary)
        self.assertTrue(summary["metadata"]["has_ci"])
        self.assertTrue(summary["metadata"]["has_tests"])
        self.assertTrue(summary["metadata"]["has_docs"])
        self.assertFalse(summary["metadata"]["has_docker"])
        self.assertTrue(summary["metadata"]["has_iac"])

    def test_get_detection_summary_without_optional_fields(self):
        """Test detection summary when optional fields are None."""
        record = MetagitRecord(
            name="minimal-record",
            detection_source="local",
            detection_version="1.0.0",
        )

        summary = record.get_detection_summary()

        # Should have basic detection info
        self.assertEqual(summary["detection_source"], "local")
        self.assertEqual(summary["detection_version"], "1.0.0")
        self.assertIsNone(summary["current_branch"])
        self.assertIsNone(summary["checksum"])

        # Should not have metrics or metadata
        self.assertNotIn("metrics", summary)
        self.assertNotIn("metadata", summary)

    def test_conversion_performance(self):
        """Test that conversion is fast and efficient."""
        import time

        # Create a complex record
        record = MetagitRecord(
            name="performance-test",
            description="A project for performance testing",
            kind=ProjectKind.APPLICATION,
            branch_strategy=BranchStrategy.GITFLOW,
            detection_source="github",
            detection_version="1.0.0",
            metrics=Metrics(
                stars=1000,
                forks=100,
                open_issues=50,
                pull_requests=PullRequests(open=25, merged_last_30d=100),
                contributors=50,
                commit_frequency="daily",
            ),
            metadata=RepoMetadata(
                tags=["python", "fastapi", "postgresql"],
                has_ci=True,
                has_tests=True,
                has_docs=True,
                has_docker=True,
                has_iac=True,
            ),
        )

        # Measure conversion time
        start_time = time.time()
        for _ in range(1000):
            config = record.to_metagit_config()
        end_time = time.time()

        # Should complete 1000 conversions in under 1 second
        conversion_time = end_time - start_time
        self.assertLess(conversion_time, 1.0, f"Conversion took {conversion_time:.3f}s")

    def test_conversion_with_complex_nested_objects(self):
        """Test conversion with complex nested objects."""
        # Create config with complex nested objects
        config = MetagitConfig(
            name="complex-project",
            description="A project with complex configuration",
            kind=ProjectKind.SERVICE,
            branch_strategy=BranchStrategy.GITHUBFLOW,
            license=License(kind=LicenseKind.APACHE_2_0, file="LICENSE"),
            maintainers=[
                Maintainer(name="Alice", email="alice@example.com", role="Architect"),
                Maintainer(name="Bob", email="bob@example.com", role="Developer"),
            ],
            cicd=CICD(
                platform=CICDPlatform.GITHUB,
                pipelines=[
                    Pipeline(name="CI", ref=".github/workflows/ci.yml"),
                    Pipeline(name="CD", ref=".github/workflows/cd.yml"),
                ],
            ),
        )

        # Convert to record
        record = MetagitRecord.from_metagit_config(
            config,
            detection_source="gitlab",
            detection_version="2.0.0",
        )

        # Convert back to config
        result_config = record.to_metagit_config()

        # Should preserve complex nested objects
        self.assertEqual(len(result_config.maintainers), 2)
        self.assertEqual(result_config.maintainers[0].name, "Alice")
        self.assertEqual(result_config.maintainers[1].name, "Bob")
        self.assertEqual(len(result_config.cicd.pipelines), 2)
        self.assertEqual(result_config.cicd.pipelines[0].name, "CI")
        self.assertEqual(result_config.cicd.pipelines[1].name, "CD")

    def test_conversion_with_minimal_data(self):
        """Test conversion with minimal required data."""
        # Minimal config
        minimal_config = MetagitConfig(name="minimal-project")

        # Convert to record
        record = MetagitRecord.from_metagit_config(minimal_config)

        # Should have required fields
        self.assertEqual(record.name, "minimal-project")
        self.assertIsNotNone(record.detection_timestamp)
        self.assertEqual(record.detection_source, "local")

        # Convert back to config
        result_config = record.to_metagit_config()

        # Should preserve required fields
        self.assertEqual(result_config.name, "minimal-project")

    def test_conversion_validation(self):
        """Test that conversion maintains data validation."""
        # Create a valid record
        record = MetagitRecord(
            name="valid-project",
            kind=ProjectKind.APPLICATION,
            detection_source="github",
        )

        # Convert to config
        config = record.to_metagit_config()

        # Should be a valid MetagitConfig
        self.assertIsInstance(config, MetagitConfig)
        self.assertEqual(config.name, "valid-project")
        self.assertEqual(config.kind, ProjectKind.APPLICATION)

        # Convert back to record
        new_record = MetagitRecord.from_metagit_config(config)

        # Should be a valid MetagitRecord
        self.assertIsInstance(new_record, MetagitRecord)
        self.assertEqual(new_record.name, "valid-project")
        self.assertEqual(new_record.kind, ProjectKind.APPLICATION)


if __name__ == "__main__":
    unittest.main()
`````

## File: tests/test_record_manager.py
`````python
#!/usr/bin/env python
"""
Tests for the updated MetagitRecordManager.

This module tests the new functionality including:
- Storage backend abstraction
- Local file storage backend
- OpenSearch storage backend
- Record creation from config
- File operations
"""

import json
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

import pytest

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.record.manager import (
    LocalFileStorageBackend,
    MetagitRecordManager,
    OpenSearchStorageBackend,
    RecordStorageBackend,
)
from metagit.core.record.models import MetagitRecord


class TestRecordStorageBackend:
    """Test the abstract RecordStorageBackend class."""

    def test_abstract_methods(self):
        """Test that RecordStorageBackend is abstract."""
        with pytest.raises(TypeError):
            RecordStorageBackend()


class TestLocalFileStorageBackend:
    """Test the LocalFileStorageBackend class."""

    @pytest.fixture
    def temp_dir(self):
        """Create a temporary directory for testing."""
        with tempfile.TemporaryDirectory() as temp_dir:
            yield Path(temp_dir)

    @pytest.fixture
    def backend(self, temp_dir):
        """Create a LocalFileStorageBackend instance."""
        return LocalFileStorageBackend(temp_dir)

    @pytest.fixture
    def sample_record(self):
        """Create a sample MetagitRecord for testing."""
        return MetagitRecord(
            name="test-project",
            description="A test project",
            url="https://github.com/test/test-project",
            kind="application",
            detection_timestamp="2024-01-01T00:00:00",
            detection_source="test",
            detection_version="1.0.0",
        )

    def test_init_creates_directory(self, temp_dir):
        """Test that initialization creates the storage directory."""
        backend = LocalFileStorageBackend(temp_dir)
        assert temp_dir.exists()
        assert (temp_dir / "index.json").exists()

    def test_ensure_index_exists(self, backend):
        """Test that index file is created if it doesn't exist."""
        index_file = backend.storage_dir / "index.json"
        assert index_file.exists()

        with open(index_file, "r") as f:
            index_data = json.load(f)

        assert "records" in index_data
        assert "next_id" in index_data
        assert index_data["next_id"] == 1

    def test_get_next_id(self, backend):
        """Test getting the next available ID."""
        first_id = backend._get_next_id()
        assert first_id == "1"

        second_id = backend._get_next_id()
        assert second_id == "2"

    @pytest.mark.asyncio
    async def test_store_record(self, backend, sample_record):
        """Test storing a record."""
        record_id = await backend.store_record(sample_record)

        assert isinstance(record_id, str)
        assert record_id == "1"

        # Check that record file was created
        record_file = backend.storage_dir / f"{record_id}.json"
        assert record_file.exists()

        # Check index was updated
        index_data = backend._load_index()
        assert record_id in index_data["records"]
        assert index_data["records"][record_id]["name"] == sample_record.name

    @pytest.mark.asyncio
    async def test_get_record(self, backend, sample_record):
        """Test retrieving a record."""
        # Store a record first
        record_id = await backend.store_record(sample_record)

        # Retrieve the record
        retrieved_record = await backend.get_record(record_id)

        assert isinstance(retrieved_record, MetagitRecord)
        assert retrieved_record.name == sample_record.name
        assert retrieved_record.description == sample_record.description

    @pytest.mark.asyncio
    async def test_get_record_not_found(self, backend):
        """Test retrieving a non-existent record."""
        result = await backend.get_record("nonexistent")
        assert isinstance(result, FileNotFoundError)

    @pytest.mark.asyncio
    async def test_update_record(self, backend, sample_record):
        """Test updating a record."""
        # Store a record first
        record_id = await backend.store_record(sample_record)

        # Update the record
        updated_record = MetagitRecord(
            name="updated-project",
            description="An updated test project",
            url="https://github.com/test/updated-project",
            kind="library",
            detection_timestamp="2024-01-01T00:00:00",
            detection_source="test",
            detection_version="1.0.0",
        )

        result = await backend.update_record(record_id, updated_record)
        assert result is True

        # Verify the record was updated
        retrieved_record = await backend.get_record(record_id)
        assert retrieved_record.name == "updated-project"
        assert retrieved_record.description == "An updated test project"

    @pytest.mark.asyncio
    async def test_delete_record(self, backend, sample_record):
        """Test deleting a record."""
        # Store a record first
        record_id = await backend.store_record(sample_record)

        # Delete the record
        result = await backend.delete_record(record_id)
        assert result is True

        # Verify the record was deleted
        get_result = await backend.get_record(record_id)
        assert isinstance(get_result, FileNotFoundError)

    @pytest.mark.asyncio
    async def test_search_records(self, backend, sample_record):
        """Test searching records."""
        # Store a record first
        await backend.store_record(sample_record)

        # Search for the record
        search_results = await backend.search_records("test")

        assert isinstance(search_results, dict)
        assert "records" in search_results
        assert len(search_results["records"]) == 1
        assert search_results["records"][0].name == sample_record.name

    @pytest.mark.asyncio
    async def test_list_records(self, backend, sample_record):
        """Test listing records."""
        # Store a record first
        await backend.store_record(sample_record)

        # List all records
        records = await backend.list_records()

        assert isinstance(records, list)
        assert len(records) == 1
        assert records[0].name == sample_record.name


class TestOpenSearchStorageBackend:
    """Test the OpenSearchStorageBackend class."""

    @pytest.fixture
    def mock_opensearch_service(self):
        """Create a mock OpenSearchService."""
        service = MagicMock()
        service.store_record = AsyncMock(return_value="test-id")
        service.get_record = AsyncMock(
            return_value=MetagitRecord(
                name="test-project",
                description="A test project",
                detection_timestamp="2024-01-01T00:00:00",
                detection_source="test",
                detection_version="1.0.0",
            )
        )
        service.update_record = AsyncMock(return_value=True)
        service.delete_record = AsyncMock(return_value=True)
        service.search_records = AsyncMock(
            return_value={
                "records": [],
                "total": 0,
                "page": 1,
                "size": 20,
            }
        )
        return service

    @pytest.fixture
    def backend(self, mock_opensearch_service):
        """Create an OpenSearchStorageBackend instance."""
        return OpenSearchStorageBackend(mock_opensearch_service)

    @pytest.fixture
    def sample_record(self):
        """Create a sample MetagitRecord for testing."""
        return MetagitRecord(
            name="test-project",
            description="A test project",
            detection_timestamp="2024-01-01T00:00:00",
            detection_source="test",
            detection_version="1.0.0",
        )

    @pytest.mark.asyncio
    async def test_store_record(self, backend, sample_record):
        """Test storing a record."""
        record_id = await backend.store_record(sample_record)
        assert record_id == "test-id"
        backend.opensearch_service.store_record.assert_called_once_with(sample_record)

    @pytest.mark.asyncio
    async def test_get_record(self, backend):
        """Test retrieving a record."""
        record = await backend.get_record("test-id")
        assert isinstance(record, MetagitRecord)
        assert record.name == "test-project"
        backend.opensearch_service.get_record.assert_called_once_with("test-id")

    @pytest.mark.asyncio
    async def test_update_record(self, backend, sample_record):
        """Test updating a record."""
        result = await backend.update_record("test-id", sample_record)
        assert result is True
        backend.opensearch_service.update_record.assert_called_once_with(
            "test-id", sample_record
        )

    @pytest.mark.asyncio
    async def test_delete_record(self, backend):
        """Test deleting a record."""
        result = await backend.delete_record("test-id")
        assert result is True
        backend.opensearch_service.delete_record.assert_called_once_with("test-id")

    @pytest.mark.asyncio
    async def test_search_records(self, backend):
        """Test searching records."""
        results = await backend.search_records("test")
        assert isinstance(results, dict)
        assert "records" in results
        backend.opensearch_service.search_records.assert_called_once_with(
            query="test", filters=None, page=1, size=20
        )

    @pytest.mark.asyncio
    async def test_list_records(self, backend):
        """Test listing records."""
        records = await backend.list_records()
        assert isinstance(records, list)
        backend.opensearch_service.search_records.assert_called_once_with(
            query="*", page=1, size=20
        )


class TestMetagitRecordManager:
    """Test the MetagitRecordManager class."""

    @pytest.fixture
    def temp_dir(self):
        """Create a temporary directory for testing."""
        with tempfile.TemporaryDirectory() as temp_dir:
            yield Path(temp_dir)

    @pytest.fixture
    def local_backend(self, temp_dir):
        """Create a LocalFileStorageBackend instance."""
        return LocalFileStorageBackend(temp_dir)

    @pytest.fixture
    def record_manager(self, local_backend):
        """Create a MetagitRecordManager instance."""
        return MetagitRecordManager(storage_backend=local_backend)

    @pytest.fixture
    def sample_config(self):
        """Create a sample MetagitConfig for testing."""
        return MetagitConfig(
            name="test-project",
            description="A test project",
            url="https://github.com/test/test-project",
            kind="application",
        )

    def test_init_without_backend(self):
        """Test initialization without storage backend."""
        manager = MetagitRecordManager()
        assert manager.storage_backend is None
        assert manager.config_manager is None
        assert manager.record is None

    def test_init_with_backend(self, local_backend):
        """Test initialization with storage backend."""
        manager = MetagitRecordManager(storage_backend=local_backend)
        assert manager.storage_backend == local_backend

    def test_init_with_config_manager(self, local_backend):
        """Test initialization with config manager."""
        config_manager = MetagitConfigManager()
        manager = MetagitRecordManager(
            storage_backend=local_backend,
            metagit_config_manager=config_manager,
        )
        assert manager.config_manager == config_manager

    def test_create_record_from_config(self, record_manager, sample_config):
        """Test creating a record from config."""
        record = record_manager.create_record_from_config(
            config=sample_config,
            detection_source="test",
            detection_version="1.0.0",
        )

        assert isinstance(record, MetagitRecord)
        assert record.name == sample_config.name
        assert record.description == sample_config.description
        assert record.detection_source == "test"
        assert record.detection_version == "1.0.0"
        assert record.detection_timestamp is not None

    def test_create_record_from_config_manager(self, local_backend):
        """Test creating a record using config manager."""
        # config_manager = MetagitConfigManager()
        manager = MetagitRecordManager(
            storage_backend=local_backend,
            metagit_config_manager=None,
        )

        # This should fail because no config file exists
        result = manager.create_record_from_config()
        assert isinstance(result, Exception)

    def test_create_record_with_additional_data(self, record_manager, sample_config):
        """Test creating a record with additional data."""
        additional_data = {
            "language": {"primary": "python", "secondary": ["javascript"]},
            "domain": "web",
        }

        record = record_manager.create_record_from_config(
            config=sample_config,
            detection_source="test",
            detection_version="1.0.0",
            additional_data=additional_data,
        )

        assert isinstance(record, MetagitRecord)
        assert record.language.primary == "python"
        assert record.domain == "web"

    def test_get_git_info(self, record_manager):
        """Test getting git information."""
        git_info = record_manager._get_git_info()
        assert isinstance(git_info, dict)
        assert "branch" in git_info
        assert "checksum" in git_info

    @pytest.mark.asyncio
    async def test_store_record_with_backend(self, record_manager, sample_config):
        """Test storing a record with storage backend."""
        record = record_manager.create_record_from_config(
            config=sample_config,
            detection_source="test",
            detection_version="1.0.0",
        )

        record_id = await record_manager.store_record(record)
        assert isinstance(record_id, str)

    @pytest.mark.asyncio
    async def test_store_record_without_backend(self):
        """Test storing a record without storage backend."""
        manager = MetagitRecordManager()
        record = MetagitRecord(
            name="test-project",
            description="A test project",
            detection_timestamp="2024-01-01T00:00:00",
            detection_source="test",
            detection_version="1.0.0",
        )

        result = await manager.store_record(record)
        assert isinstance(result, ValueError)

    @pytest.mark.asyncio
    async def test_get_record_with_backend(self, record_manager, sample_config):
        """Test getting a record with storage backend."""
        record = record_manager.create_record_from_config(
            config=sample_config,
            detection_source="test",
            detection_version="1.0.0",
        )

        record_id = await record_manager.store_record(record)
        retrieved_record = await record_manager.get_record(record_id)

        assert isinstance(retrieved_record, MetagitRecord)
        assert retrieved_record.name == sample_config.name

    @pytest.mark.asyncio
    async def test_get_record_without_backend(self):
        """Test getting a record without storage backend."""
        manager = MetagitRecordManager()
        result = await manager.get_record("test-id")
        assert isinstance(result, ValueError)

    def test_save_record_to_file(self, record_manager, sample_config, temp_dir):
        """Test saving a record to file."""
        record = record_manager.create_record_from_config(
            config=sample_config,
            detection_source="test",
            detection_version="1.0.0",
        )

        file_path = temp_dir / "test-record.yml"
        result = record_manager.save_record_to_file(record, file_path)

        assert result is None
        assert file_path.exists()

    def test_load_record_from_file(self, record_manager, sample_config, temp_dir):
        """Test loading a record from file."""
        record = record_manager.create_record_from_config(
            config=sample_config,
            detection_source="test",
            detection_version="1.0.0",
        )

        file_path = temp_dir / "test-record.yml"
        record_manager.save_record_to_file(record, file_path)

        loaded_record = record_manager.load_record_from_file(file_path)
        assert isinstance(loaded_record, MetagitRecord)
        assert loaded_record.name == sample_config.name

    def test_load_record_from_nonexistent_file(self, record_manager):
        """Test loading a record from a non-existent file."""
        result = record_manager.load_record_from_file(Path("nonexistent.yml"))
        assert isinstance(result, FileNotFoundError)


if __name__ == "__main__":
    pytest.main([__file__])
`````

## File: tests/test_record_models.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.record.models
"""

from datetime import datetime

import pytest
from pydantic import ValidationError

from metagit.core.config import models
from metagit.core.record import models as record_models


class TestMetagitRecord:
    """Test MetagitRecord class."""

    def test_metagit_record_basic(self):
        """Test basic MetagitRecord creation."""
        record = record_models.MetagitRecord(
            name="test-project",
            description="A test project",
            kind=models.ProjectKind.APPLICATION,
        )

        assert record.name == "test-project"
        assert record.description == "A test project"
        assert record.kind == models.ProjectKind.APPLICATION
        assert record.branches is None
        assert record.metrics is None
        assert record.metadata is None
        assert record.detection_timestamp is None
        assert record.detection_source is None
        assert record.detection_version is None

    def test_metagit_record_with_detection_attributes(self):
        """Test MetagitRecord with detection-specific attributes."""
        timestamp = datetime.now()
        branches = [models.Branch(name="main", environment="production")]
        metrics = models.Metrics(
            stars=100,
            forks=10,
            open_issues=5,
            pull_requests=models.PullRequests(open=3, merged_last_30d=15),
            contributors=8,
            commit_frequency=models.CommitFrequency.DAILY,
        )
        metadata = models.RepoMetadata(
            tags=["python", "api"],
            created_at=timestamp,
            has_ci=True,
            has_tests=True,
        )

        record = record_models.MetagitRecord(
            name="test-project",
            description="A test project",
            kind=models.ProjectKind.APPLICATION,
            branches=branches,
            metrics=metrics,
            metadata=metadata,
            detection_timestamp=timestamp,
            detection_source="github",
            detection_version="1.0.0",
        )

        assert record.name == "test-project"
        assert record.branches == branches
        assert record.metrics == metrics
        assert record.metadata == metadata
        assert record.detection_timestamp == timestamp
        assert record.detection_source == "github"
        assert record.detection_version == "1.0.0"

    def test_metagit_record_inheritance(self):
        """Test that MetagitRecord properly inherits from MetagitConfig."""
        record = record_models.MetagitRecord(
            name="test-project",
            description="A test project",
            kind=models.ProjectKind.APPLICATION,
            branch_strategy=models.BranchStrategy.TRUNK,
        )

        # Should have MetagitConfig attributes
        assert record.name == "test-project"
        assert record.description == "A test project"
        assert record.kind == models.ProjectKind.APPLICATION
        assert record.branch_strategy == models.BranchStrategy.TRUNK

        # Should also have MetagitRecord-specific attributes
        assert hasattr(record, "branches")
        assert hasattr(record, "metrics")
        assert hasattr(record, "metadata")
        assert hasattr(record, "detection_timestamp")
        assert hasattr(record, "detection_source")
        assert hasattr(record, "detection_version")

    def test_metagit_record_serialization(self):
        """Test MetagitRecord serialization."""
        timestamp = datetime.now()
        record = record_models.MetagitRecord(
            name="test-project",
            description="A test project",
            kind=models.ProjectKind.APPLICATION,
            detection_timestamp=timestamp,
            detection_source="github",
            detection_version="1.0.0",
        )

        # Test that it can be serialized to dict
        record_dict = record.model_dump()
        assert record_dict["name"] == "test-project"
        assert record_dict["detection_source"] == "github"
        assert record_dict["detection_version"] == "1.0.0"

    def test_metagit_record_from_config(self):
        """Test creating MetagitRecord from existing MetagitConfig."""
        config = models.MetagitConfig(
            name="test-project",
            description="A test project",
            kind=models.ProjectKind.APPLICATION,
            branch_strategy=models.BranchStrategy.TRUNK,
        )

        # Create record from config, excluding None values
        config_data = config.model_dump(exclude_none=True)
        record = record_models.MetagitRecord(
            **config_data,
            detection_timestamp=datetime.now(),
            detection_source="local",
            detection_version="1.0.0",
        )

        assert record.name == config.name
        assert record.description == config.description
        assert record.kind == config.kind
        assert record.branch_strategy == config.branch_strategy
        assert record.detection_source == "local"

    def test_metagit_record_validation_error(self):
        """Test MetagitRecord validation error."""
        with pytest.raises(ValidationError):
            record_models.MetagitRecord()  # Missing required name field


class TestMetagitRecordDetectionAttributes:
    """Test MetagitRecord detection-specific attributes."""

    def test_branches_attribute(self):
        """Test branches attribute in MetagitRecord."""
        branches = [
            models.Branch(name="main", environment="production"),
            models.Branch(name="develop", environment="development"),
        ]

        record = record_models.MetagitRecord(
            name="test-project",
            branches=branches,
        )

        assert record.branches == branches
        assert len(record.branches) == 2
        assert record.branches[0].name == "main"
        assert record.branches[1].name == "develop"

    def test_metrics_attribute(self):
        """Test metrics attribute in MetagitRecord."""
        metrics = models.Metrics(
            stars=100,
            forks=10,
            open_issues=5,
            pull_requests=models.PullRequests(open=3, merged_last_30d=15),
            contributors=8,
            commit_frequency=models.CommitFrequency.DAILY,
        )

        record = record_models.MetagitRecord(
            name="test-project",
            metrics=metrics,
        )

        assert record.metrics == metrics
        assert record.metrics.stars == 100
        assert record.metrics.pull_requests.open == 3
        assert record.metrics.commit_frequency == models.CommitFrequency.DAILY

    def test_metadata_attribute(self):
        """Test metadata attribute in MetagitRecord."""
        timestamp = datetime.now()
        metadata = models.RepoMetadata(
            tags=["python", "api"],
            created_at=timestamp,
            has_ci=True,
            has_tests=True,
            has_docs=True,
        )

        record = record_models.MetagitRecord(
            name="test-project",
            metadata=metadata,
        )

        assert record.metadata == metadata
        assert record.metadata.tags == ["python", "api"]
        assert record.metadata.has_ci is True
        assert record.metadata.has_tests is True
        assert record.metadata.has_docs is True

    def test_detection_timestamp_attribute(self):
        """Test detection_timestamp attribute in MetagitRecord."""
        timestamp = datetime.now()

        record = record_models.MetagitRecord(
            name="test-project",
            detection_timestamp=timestamp,
        )

        assert record.detection_timestamp == timestamp

    def test_detection_source_attribute(self):
        """Test detection_source attribute in MetagitRecord."""
        record = record_models.MetagitRecord(
            name="test-project",
            detection_source="github",
        )

        assert record.detection_source == "github"

    def test_detection_version_attribute(self):
        """Test detection_version attribute in MetagitRecord."""
        record = record_models.MetagitRecord(
            name="test-project",
            detection_version="1.0.0",
        )

        assert record.detection_version == "1.0.0"


class TestMetagitConfigMetagitRecordSeparation:
    """Test the separation between MetagitConfig and MetagitRecord."""

    def test_config_does_not_have_detection_attributes(self):
        """Test that MetagitConfig does not have detection attributes."""
        config = models.MetagitConfig(name="test-project")

        # These should not be attributes of MetagitConfig
        assert not hasattr(config, "branches")
        assert not hasattr(config, "metrics")
        assert not hasattr(config, "metadata")
        assert not hasattr(config, "detection_timestamp")
        assert not hasattr(config, "detection_source")
        assert not hasattr(config, "detection_version")

    def test_record_has_detection_attributes(self):
        """Test that MetagitRecord has detection attributes."""
        record = record_models.MetagitRecord(name="test-project")

        # These should be attributes of MetagitRecord
        assert hasattr(record, "branches")
        assert hasattr(record, "metrics")
        assert hasattr(record, "metadata")
        assert hasattr(record, "detection_timestamp")
        assert hasattr(record, "detection_source")
        assert hasattr(record, "detection_version")

    def test_config_save_does_not_include_detection_data(self):
        """Test that MetagitConfig serialization doesn't include detection data."""
        config = models.MetagitConfig(
            name="test-project",
            description="A test project",
            kind=models.ProjectKind.APPLICATION,
        )

        config_dict = config.model_dump()

        # Should not contain detection-specific keys
        assert "branches" not in config_dict
        assert "metrics" not in config_dict
        assert "metadata" not in config_dict
        assert "detection_timestamp" not in config_dict
        assert "detection_source" not in config_dict
        assert "detection_version" not in config_dict

    def test_record_save_includes_detection_data(self):
        """Test that MetagitRecord serialization includes detection data."""
        timestamp = datetime.now()
        record = record_models.MetagitRecord(
            name="test-project",
            description="A test project",
            kind=models.ProjectKind.APPLICATION,
            detection_timestamp=timestamp,
            detection_source="github",
            detection_version="1.0.0",
        )

        record_dict = record.model_dump()

        # Should contain detection-specific keys
        assert "branches" in record_dict
        assert "metrics" in record_dict
        assert "metadata" in record_dict
        assert "detection_timestamp" in record_dict
        assert "detection_source" in record_dict
        assert "detection_version" in record_dict

        # Should also contain config keys
        assert "name" in record_dict
        assert "description" in record_dict
        assert "kind" in record_dict
`````

## File: tests/test_utils_common_integration.py
`````python
#!/usr/bin/env python
"""
Integration test for metagit.core.utils.common
"""

from metagit.core.utils import common


def test_common_integration(tmp_path):
    # Create a dict, flatten, merge, and pretty print
    d1 = {"a": {"b": 1}, "c": 2}
    d2 = {"a": {"b": 3}, "d": 4}
    flat1 = common.flatten_dict(d1)
    flat2 = common.flatten_dict(d2)
    merged = common.merge_dicts(d1.copy(), d2)
    pretty_str = common.pretty(merged)
    assert flat1["a.b"] == 1
    assert flat2["a.b"] == 3
    assert merged["a"]["b"] == 3
    assert "a" in pretty_str and "b" in pretty_str and "d" in pretty_str
`````

## File: tests/test_utils_common.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.common
"""

import json
import os

from metagit.core.utils import common


def test_normalize_git_url():
    """Test URL normalization function."""
    # Test removing trailing slashes
    assert (
        common.normalize_git_url("https://github.com/user/repo/")
        == "https://github.com/user/repo"
    )
    assert (
        common.normalize_git_url("https://gitlab.com/user/repo/")
        == "https://gitlab.com/user/repo"
    )
    assert (
        common.normalize_git_url("https://github.com/user/repo.git/")
        == "https://github.com/user/repo.git"
    )

    # Test URLs without trailing slashes (should remain unchanged)
    assert (
        common.normalize_git_url("https://github.com/user/repo")
        == "https://github.com/user/repo"
    )
    assert (
        common.normalize_git_url("https://gitlab.com/user/repo")
        == "https://gitlab.com/user/repo"
    )

    # Test edge cases
    assert common.normalize_git_url("") == ""
    assert common.normalize_git_url(None) is None
    assert common.normalize_git_url("   ") == ""
    assert (
        common.normalize_git_url("https://github.com/user/repo///")
        == "https://github.com/user/repo"
    )


def test_normalize_git_url_with_httpurl_object():
    """Test URL normalization with HttpUrl objects."""
    from pydantic import HttpUrl

    # Test with HttpUrl object
    http_url = HttpUrl("https://github.com/user/repo/")
    result = common.normalize_git_url(http_url)
    assert result == "https://github.com/user/repo"


def test_create_vscode_workspace():
    project_name = "TestProject"
    repo_paths = ["/path/to/repo1", "/path/to/repo2"]
    result = common.create_vscode_workspace(project_name, repo_paths)
    assert isinstance(result, str)
    data = json.loads(result)
    assert data["folders"][0]["name"] == "repo1"
    assert data["folders"][1]["name"] == "repo2"
    assert "settings" in data
    assert "extensions" in data


def test_create_vscode_workspace_error(monkeypatch):
    # Simulate error in json.dumps
    monkeypatch.setattr(
        "json.dumps", lambda *_: (_ for _ in ()).throw(Exception("fail"))
    )
    result = common.create_vscode_workspace("x", ["/bad/path"])
    assert isinstance(result, Exception)


def test_open_editor_file_not_exist(tmp_path):
    result = common.open_editor("echo", str(tmp_path / "nope.txt"))
    assert isinstance(result, Exception)
    assert "does not exist" in str(result)


def test_open_editor_success(tmp_path, monkeypatch):
    file_path = tmp_path / "file.txt"
    file_path.write_text("hi")
    monkeypatch.setattr(
        "subprocess.run", lambda *a, **k: type("R", (), {"returncode": 0})()
    )
    result = common.open_editor("echo", str(file_path))
    assert result is None


def test_open_editor_failure(tmp_path, monkeypatch):
    file_path = tmp_path / "file.txt"
    file_path.write_text("hi")

    class FakeResult:
        returncode = 1
        stderr = "fail"

    monkeypatch.setattr("subprocess.run", lambda *a, **k: FakeResult())
    result = common.open_editor("echo", str(file_path))
    assert isinstance(result, Exception)
    assert "Failed to open editor" in str(result)


def test_flatten_dict():
    d = {"a": {"b": 1}, "c": 2}
    flat = common.flatten_dict(d)
    assert flat == {"a.b": 1, "c": 2}


def test_flatten_dict_error(monkeypatch):
    monkeypatch.setattr(
        common,
        "_flatten_dict_gen",
        lambda *a, **k: (_ for _ in ()).throw(Exception("fail")),
    )
    result = common.flatten_dict({"a": 1})
    assert isinstance(result, Exception)


def test_regex_replace():
    s = "hello world"
    out = common.regex_replace(s, "world", "pytest")
    assert out == "hello pytest"


def test_regex_replace_error(monkeypatch):
    monkeypatch.setattr(
        "re.sub", lambda *a, **k: (_ for _ in ()).throw(Exception("fail"))
    )
    result = common.regex_replace("a", "b", "c")
    assert isinstance(result, Exception)


def test_env_override(monkeypatch):
    monkeypatch.setenv("FOO", "bar")
    assert common.env_override("baz", "FOO") == "bar"
    assert common.env_override("baz", "NOPE") == "baz"


def test_env_override_error(monkeypatch):
    monkeypatch.setattr(
        os, "getenv", lambda *a, **k: (_ for _ in ()).throw(Exception("fail"))
    )
    result = common.env_override("a", "b")
    assert isinstance(result, Exception)


def test_to_yaml_dict():
    d = {"a": 1}
    y = common.to_yaml(d)
    assert isinstance(y, str)
    assert "a:" in y


def test_to_yaml_str():
    s = "already yaml"
    assert common.to_yaml(s) == s


def test_to_yaml_error(monkeypatch):
    # Mock the yaml module that's imported as 'yaml' in common.py
    monkeypatch.setattr(
        common,
        "yaml",
        type(
            "Y",
            (),
            {"dump": staticmethod(lambda *_: (_ for _ in ()).throw(Exception("fail")))},
        )(),
    )
    result = common.to_yaml({"a": 1})
    assert isinstance(result, Exception)


def test_pretty():
    d = {"a": {"b": 1}, "c": 2}
    out = common.pretty(d, indent=2)
    assert isinstance(out, str)
    assert "a" in out and "b" in out and "c" in out


def test_pretty_error(monkeypatch):
    monkeypatch.setattr(
        common, "pretty", lambda *a, **k: (_ for _ in ()).throw(Exception("fail"))
    )
    try:
        result = common.pretty({"a": 1})
    except Exception as e:
        result = e
    assert isinstance(result, Exception) or isinstance(result, str)


def test_merge_dicts():
    a = {"x": 1, "y": {"z": 2}}
    b = {"y": {"z": 3}, "w": 4}
    out = common.merge_dicts(a, b)
    assert out["y"]["z"] == 3
    assert out["w"] == 4


def test_merge_dicts_error(monkeypatch):
    monkeypatch.setattr(
        common, "merge_dicts", lambda *a, **k: (_ for _ in ()).throw(Exception("fail"))
    )
    try:
        result = common.merge_dicts({"a": 1}, {"b": 2})
    except Exception as e:
        result = e
    assert isinstance(result, Exception)


def test_parse_checksum_file(tmp_path):
    file = tmp_path / "checksums.txt"
    file.write_text("abc123  file1.txt\ndef456  file2.txt\n")
    out = common.parse_checksum_file(str(file))
    assert out == {"file1.txt": "abc123", "file2.txt": "def456"}


def test_parse_checksum_file_error(tmp_path):
    out = common.parse_checksum_file(str(tmp_path / "nope.txt"))
    assert isinstance(out, Exception)
`````

## File: tests/test_utils_files.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.files
"""

from metagit.core.utils import files


def test_is_binary_file(tmp_path):
    # Create a text file
    text_file = tmp_path / "file.txt"
    text_file.write_text("hello world")
    assert files.is_binary_file(str(text_file)) is False
    # Create a binary file
    bin_file = tmp_path / "file.bin"
    bin_file.write_bytes(b"\x00\x01\x02\x03")
    assert files.is_binary_file(str(bin_file)) is True


def test_get_file_size(tmp_path):
    f = tmp_path / "a.txt"
    f.write_text("abc")
    assert files.get_file_size(str(f)) == 3
    assert files.get_file_size("/no/such/file") == 0


def test_list_files(tmp_path):
    (tmp_path / "a.txt").write_text("hi")
    (tmp_path / "b.txt").write_text("hi")
    files_list = files.list_files(str(tmp_path))
    assert any("a.txt" in f for f in files_list)
    assert any("b.txt" in f for f in files_list)
    assert files.list_files("/no/such/dir") == []


def test_read_file_lines(tmp_path):
    f = tmp_path / "a.txt"
    f.write_text("line1\nline2\n")
    lines = files.read_file_lines(str(f))
    assert lines == ["line1", "line2"]
    assert files.read_file_lines("/no/such/file") == []


def test_write_file_lines(tmp_path):
    f = tmp_path / "a.txt"
    files.write_file_lines(str(f), ["a", "b"])
    assert f.read_text() == "a\nb\n"


def test_copy_file(tmp_path):
    src = tmp_path / "src.txt"
    dst = tmp_path / "dst.txt"
    src.write_text("hi")
    files.copy_file(str(src), str(dst))
    assert dst.read_text() == "hi"
    # Copy non-existent file
    assert files.copy_file("/no/such/file", str(dst)) is False


def test_remove_file(tmp_path):
    f = tmp_path / "a.txt"
    f.write_text("hi")
    assert files.remove_file(str(f)) is True
    assert files.remove_file(str(f)) is False


def test_make_dir(tmp_path):
    d = tmp_path / "newdir"
    assert files.make_dir(str(d)) is True
    assert d.exists()
    # Already exists
    assert files.make_dir(str(d)) is True


def test_remove_dir(tmp_path):
    d = tmp_path / "d"
    d.mkdir()
    assert files.remove_dir(str(d)) is True
    assert files.remove_dir(str(d)) is False
`````

## File: tests/test_utils_logging.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.logging
"""

from metagit.core.utils import logging as metagit_logging


def test_get_logger_returns_logger():
    logger = metagit_logging.get_logger("test_logger")
    assert hasattr(logger, "info")
    assert hasattr(logger, "error")
    assert hasattr(logger, "debug")


def test_logger_log_levels(monkeypatch):
    logger = metagit_logging.get_logger("test_logger2")
    # Test that the logger has the expected methods
    assert hasattr(logger, "set_level")
    assert hasattr(logger, "debug")
    assert hasattr(logger, "info")
    assert hasattr(logger, "error")

    # Test setting log level
    result = logger.set_level("DEBUG")
    assert result is None

    # Test logging methods (they should not raise exceptions)
    debug_result = logger.debug("debug message")
    info_result = logger.info("info message")
    error_result = logger.error("error message")

    assert debug_result is None
    assert info_result is None
    assert error_result is None
`````

## File: tests/test_utils_userprompt.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.userprompt
"""

from metagit.core.utils import userprompt


def test_yes_no_prompt_yes(monkeypatch):
    monkeypatch.setattr("builtins.input", lambda _: "y")
    assert userprompt.yes_no_prompt("Continue?") is True


def test_yes_no_prompt_no(monkeypatch):
    monkeypatch.setattr("builtins.input", lambda _: "n")
    assert userprompt.yes_no_prompt("Continue?") is False


def test_yes_no_prompt_invalid(monkeypatch):
    responses = iter(["maybe", "y"])
    monkeypatch.setattr("builtins.input", lambda _: next(responses))
    assert userprompt.yes_no_prompt("Continue?") is True
`````

## File: tests/test_utils_yaml_class.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.yaml_class
"""

from metagit.core.utils import yaml_class


def test_yaml_load():
    yaml_str = "a: 1\nb: [2, 3]"
    loaded = yaml_class.load(yaml_str)
    assert loaded == {"a": 1, "b": [2, 3]}


def test_yaml_load_error():
    result = yaml_class.load(": not yaml :")
    assert isinstance(result, Exception)


def test_yaml_load_empty():
    assert yaml_class.load("") is None
`````

## File: .cursorignore
`````
.venv
.vscode
node_modules
`````

## File: .dockerignore
`````
dist/
.github/
tasks/
Taskfile.yml
mkdocs.yml
site/
.venv/
`````

## File: .editorconfig
`````
# EditorConfig is awesome: https://editorconfig.org

# top-most EditorConfig file
root = true

# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true

# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,py}]
charset = utf-8

[*.py]
indent_style = space
indent_size = 2

# Tab indentation (no size specified)
[Makefile]
indent_style = tab

# Indentation override for all JS under lib directory
[lib/**.js]
indent_style = space
indent_size = 2

# Matches the exact files either package.json or .travis.yml
[{package.json,.travis.yml}]
indent_style = space
indent_size = 2
`````

## File: .envrc
`````
set -a
if [ -f .env ]; then
  source .env
fi
set +a
`````

## File: .gitleaksignore
`````
07f8423d068299ff902645172db6468fc2f1f57c:.metagit.yml:slack-webhook-url:99
`````

## File: Dockerfile
`````dockerfile
# Build stage
FROM python:3.13-slim AS builder

# Install curl and uv (recommended install method for uv)
RUN apt-get update && apt-get install -y curl git \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* \
    && pip install uv
ARG DEPLOY_VERSION
# Set environment variables
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    VERSION=$DEPLOY_VERSION

WORKDIR /app

# Copy full source including .git for dynamic versioning
COPY . /app

RUN \
  if [ -n "$VERSION" ]; then \
    SETUPTOOLS_SCM_PRETEND_VERSION=$VERSION; \
  fi && \
  uv sync --frozen && \
  uv build --wheel

# Final stage - only the installed application
FROM python:3.13-slim

RUN apt-get update && apt-get install -y git

# Set environment variables
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Install uv
RUN pip install uv

# Copy the wheel file from builder stage
COPY --from=builder /app/dist/*.whl /tmp/

# Install the wheel file
RUN uv pip install --system /tmp/*.whl && rm -rf /tmp/*.whl

# Create non-root user
RUN useradd --create-home --shell /bin/bash app
USER app

# Run the application as a CLI
ENTRYPOINT ["metagit"]
CMD []
`````

## File: MANIFEST.in
`````
include README.md LICENSE 
recursive-include src/metagit/ *.py
recursive-include src/metagit/data *
exclude tests/*
exclude tasks/*
exclude examples/*
exclude Dockerfile
exclude site/*
exclude .github/*
exclude .vscode/*
exclude .envrc
exclude .env
exclude .gitignore
exclude .gitmodules
exclude .gitignore
exclude .cursor/*
`````

## File: .cursor/skills/gitnexus/gitnexus-cli/SKILL.md
`````markdown
---
name: gitnexus-cli
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
---

# GitNexus CLI Commands

All commands work via `npx` — no global install required.

## Commands

### analyze — Build or refresh the index

```bash
npx gitnexus analyze
```

Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.

| Flag           | Effect                                                           |
| -------------- | ---------------------------------------------------------------- |
| `--force`      | Force full re-index even if up to date                           |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |

**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.

### status — Check index freshness

```bash
npx gitnexus status
```

Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.

### clean — Delete the index

```bash
npx gitnexus clean
```

Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.

| Flag      | Effect                                            |
| --------- | ------------------------------------------------- |
| `--force` | Skip confirmation prompt                          |
| `--all`   | Clean all indexed repos, not just the current one |

### wiki — Generate documentation from the graph

```bash
npx gitnexus wiki
```

Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).

| Flag                | Effect                                    |
| ------------------- | ----------------------------------------- |
| `--force`           | Force full regeneration                   |
| `--model <model>`   | LLM model (default: minimax/minimax-m2.5) |
| `--base-url <url>`  | LLM API base URL                          |
| `--api-key <key>`   | LLM API key                               |
| `--concurrency <n>` | Parallel LLM calls (default: 3)           |
| `--gist`            | Publish wiki as a public GitHub Gist      |

### list — Show all indexed repos

```bash
npx gitnexus list
```

Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.

## After Indexing

1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task

## Troubleshooting

- **"Not inside a git repository"**: Run from a directory inside a git repo
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
`````

## File: .cursor/skills/gitnexus/gitnexus-debugging/SKILL.md
`````markdown
---
name: gitnexus-debugging
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
---

# Debugging with GitNexus

## When to Use

- "Why is this function failing?"
- "Trace where this error comes from"
- "Who calls this method?"
- "This endpoint returns 500"
- Investigating bugs, errors, or unexpected behavior

## Workflow

```
1. gitnexus_query({query: "<error or symptom>"})            → Find related execution flows
2. gitnexus_context({name: "<suspect>"})                    → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name}                → Trace execution flow
4. gitnexus_cypher({query: "MATCH path..."})                 → Custom traces if needed
```

> If "Index is stale" → run `npx gitnexus analyze` in terminal.

## Checklist

```
- [ ] Understand the symptom (error message, unexpected behavior)
- [ ] gitnexus_query for error text or related code
- [ ] Identify the suspect function from returned processes
- [ ] gitnexus_context to see callers and callees
- [ ] Trace execution flow via process resource if applicable
- [ ] gitnexus_cypher for custom call chain traces if needed
- [ ] Read source files to confirm root cause
```

## Debugging Patterns

| Symptom              | GitNexus Approach                                          |
| -------------------- | ---------------------------------------------------------- |
| Error message        | `gitnexus_query` for error text → `context` on throw sites |
| Wrong return value   | `context` on the function → trace callees for data flow    |
| Intermittent failure | `context` → look for external calls, async deps            |
| Performance issue    | `context` → find symbols with many callers (hot paths)     |
| Recent regression    | `detect_changes` to see what your changes affect           |

## Tools

**gitnexus_query** — find code related to error:

```
gitnexus_query({query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```

**gitnexus_context** — full context for a suspect:

```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates (external API!)
→ Processes: CheckoutFlow (step 3/7)
```

**gitnexus_cypher** — custom call chain traces:

```cypher
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
RETURN [n IN nodes(path) | n.name] AS chain
```

## Example: "Payment endpoint returns 500 intermittently"

```
1. gitnexus_query({query: "payment error handling"})
   → Processes: CheckoutFlow, ErrorHandling
   → Symbols: validatePayment, handlePaymentError

2. gitnexus_context({name: "validatePayment"})
   → Outgoing calls: verifyCard, fetchRates (external API!)

3. READ gitnexus://repo/my-app/process/CheckoutFlow
   → Step 3: validatePayment → calls fetchRates (external)

4. Root cause: fetchRates calls external API without proper timeout
```
`````

## File: .cursor/skills/gitnexus/gitnexus-exploring/SKILL.md
`````markdown
---
name: gitnexus-exploring
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
---

# Exploring Codebases with GitNexus

## When to Use

- "How does authentication work?"
- "What's the project structure?"
- "Show me the main components"
- "Where is the database logic?"
- Understanding code you haven't seen before

## Workflow

```
1. READ gitnexus://repos                          → Discover indexed repos
2. READ gitnexus://repo/{name}/context             → Codebase overview, check staleness
3. gitnexus_query({query: "<what you want to understand>"})  → Find related execution flows
4. gitnexus_context({name: "<symbol>"})            → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name}      → Trace full execution flow
```

> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.

## Checklist

```
- [ ] READ gitnexus://repo/{name}/context
- [ ] gitnexus_query for the concept you want to understand
- [ ] Review returned processes (execution flows)
- [ ] gitnexus_context on key symbols for callers/callees
- [ ] READ process resource for full execution traces
- [ ] Read source files for implementation details
```

## Resources

| Resource                                | What you get                                            |
| --------------------------------------- | ------------------------------------------------------- |
| `gitnexus://repo/{name}/context`        | Stats, staleness warning (~150 tokens)                  |
| `gitnexus://repo/{name}/clusters`       | All functional areas with cohesion scores (~300 tokens) |
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens)              |
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens)              |

## Tools

**gitnexus_query** — find execution flows related to a concept:

```
gitnexus_query({query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```

**gitnexus_context** — 360-degree view of a symbol:

```
gitnexus_context({name: "validateUser"})
→ Incoming calls: loginHandler, apiMiddleware
→ Outgoing calls: checkToken, getUserById
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
```

## Example: "How does payment processing work?"

```
1. READ gitnexus://repo/my-app/context       → 918 symbols, 45 processes
2. gitnexus_query({query: "payment processing"})
   → CheckoutFlow: processPayment → validateCard → chargeStripe
   → RefundFlow: initiateRefund → calculateRefund → processRefund
3. gitnexus_context({name: "processPayment"})
   → Incoming: checkoutHandler, webhookHandler
   → Outgoing: validateCard, chargeStripe, saveTransaction
4. Read src/payments/processor.ts for implementation details
```
`````

## File: .cursor/skills/gitnexus/gitnexus-guide/SKILL.md
`````markdown
---
name: gitnexus-guide
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
---

# GitNexus Guide

Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.

## Always Start Here

For any task involving code understanding, debugging, impact analysis, or refactoring:

1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**

> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.

## Skills

| Task                                         | Skill to read       |
| -------------------------------------------- | ------------------- |
| Understand architecture / "How does X work?" | `gitnexus-exploring`         |
| Blast radius / "What breaks if I change X?"  | `gitnexus-impact-analysis`   |
| Trace bugs / "Why is X failing?"             | `gitnexus-debugging`         |
| Rename / extract / split / refactor          | `gitnexus-refactoring`       |
| Tools, resources, schema reference           | `gitnexus-guide` (this file) |
| Index, status, clean, wiki CLI commands      | `gitnexus-cli`               |

## Tools Reference

| Tool             | What it gives you                                                        |
| ---------------- | ------------------------------------------------------------------------ |
| `query`          | Process-grouped code intelligence — execution flows related to a concept |
| `context`        | 360-degree symbol view — categorized refs, processes it participates in  |
| `impact`         | Symbol blast radius — what breaks at depth 1/2/3 with confidence         |
| `detect_changes` | Git-diff impact — what do your current changes affect                    |
| `rename`         | Multi-file coordinated rename with confidence-tagged edits               |
| `cypher`         | Raw graph queries (read `gitnexus://repo/{name}/schema` first)           |
| `list_repos`     | Discover indexed repos                                                   |

## Resources Reference

Lightweight reads (~100-500 tokens) for navigation:

| Resource                                       | Content                                   |
| ---------------------------------------------- | ----------------------------------------- |
| `gitnexus://repo/{name}/context`               | Stats, staleness check                    |
| `gitnexus://repo/{name}/clusters`              | All functional areas with cohesion scores |
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members                              |
| `gitnexus://repo/{name}/processes`             | All execution flows                       |
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace                        |
| `gitnexus://repo/{name}/schema`                | Graph schema for Cypher                   |

## Graph Schema

**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS

```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
RETURN caller.name, caller.filePath
```
`````

## File: .cursor/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
`````markdown
---
name: gitnexus-impact-analysis
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
---

# Impact Analysis with GitNexus

## When to Use

- "Is it safe to change this function?"
- "What will break if I modify X?"
- "Show me the blast radius"
- "Who uses this code?"
- Before making non-trivial code changes
- Before committing — to understand what your changes affect

## Workflow

```
1. gitnexus_impact({target: "X", direction: "upstream"})  → What depends on this
2. READ gitnexus://repo/{name}/processes                   → Check affected execution flows
3. gitnexus_detect_changes()                               → Map current git changes to affected flows
4. Assess risk and report to user
```

> If "Index is stale" → run `npx gitnexus analyze` in terminal.

## Checklist

```
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
- [ ] Review d=1 items first (these WILL BREAK)
- [ ] Check high-confidence (>0.8) dependencies
- [ ] READ processes to check affected execution flows
- [ ] gitnexus_detect_changes() for pre-commit check
- [ ] Assess risk level and report to user
```

## Understanding Output

| Depth | Risk Level       | Meaning                  |
| ----- | ---------------- | ------------------------ |
| d=1   | **WILL BREAK**   | Direct callers/importers |
| d=2   | LIKELY AFFECTED  | Indirect dependencies    |
| d=3   | MAY NEED TESTING | Transitive effects       |

## Risk Assessment

| Affected                       | Risk     |
| ------------------------------ | -------- |
| <5 symbols, few processes      | LOW      |
| 5-15 symbols, 2-5 processes    | MEDIUM   |
| >15 symbols or many processes  | HIGH     |
| Critical path (auth, payments) | CRITICAL |

## Tools

**gitnexus_impact** — the primary tool for symbol blast radius:

```
gitnexus_impact({
  target: "validateUser",
  direction: "upstream",
  minConfidence: 0.8,
  maxDepth: 3
})

→ d=1 (WILL BREAK):
  - loginHandler (src/auth/login.ts:42) [CALLS, 100%]
  - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]

→ d=2 (LIKELY AFFECTED):
  - authRouter (src/routes/auth.ts:22) [CALLS, 95%]
```

**gitnexus_detect_changes** — git-diff based impact analysis:

```
gitnexus_detect_changes({scope: "staged"})

→ Changed: 5 symbols in 3 files
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
→ Risk: MEDIUM
```

## Example: "What breaks if I change validateUser?"

```
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
   → d=1: loginHandler, apiMiddleware (WILL BREAK)
   → d=2: authRouter, sessionManager (LIKELY AFFECTED)

2. READ gitnexus://repo/my-app/processes
   → LoginFlow and TokenRefresh touch validateUser

3. Risk: 2 direct callers, 2 processes = MEDIUM
```
`````

## File: .cursor/skills/gitnexus/gitnexus-refactoring/SKILL.md
`````markdown
---
name: gitnexus-refactoring
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
---

# Refactoring with GitNexus

## When to Use

- "Rename this function safely"
- "Extract this into a module"
- "Split this service"
- "Move this to a new file"
- Any task involving renaming, extracting, splitting, or restructuring code

## Workflow

```
1. gitnexus_impact({target: "X", direction: "upstream"})  → Map all dependents
2. gitnexus_query({query: "X"})                            → Find execution flows involving X
3. gitnexus_context({name: "X"})                           → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```

> If "Index is stale" → run `npx gitnexus analyze` in terminal.

## Checklists

### Rename Symbol

```
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
- [ ] gitnexus_detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
```

### Extract Module

```
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
- [ ] Define new module interface
- [ ] Extract code, update imports
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```

### Split Function/Service

```
- [ ] gitnexus_context({name: target}) — understand all callees
- [ ] Group callees by responsibility
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
- [ ] Create new functions/services
- [ ] Update callers
- [ ] gitnexus_detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```

## Tools

**gitnexus_rename** — automated multi-file rename:

```
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 ast_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```

**gitnexus_impact** — map all dependents first:

```
gitnexus_impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware, testUtils
→ Affected Processes: LoginFlow, TokenRefresh
```

**gitnexus_detect_changes** — verify your changes after refactoring:

```
gitnexus_detect_changes({scope: "all"})
→ Changed: 8 files, 12 symbols
→ Affected processes: LoginFlow, TokenRefresh
→ Risk: MEDIUM
```

**gitnexus_cypher** — custom reference queries:

```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
RETURN caller.name, caller.filePath ORDER BY caller.filePath
```

## Risk Rules

| Risk Factor         | Mitigation                                |
| ------------------- | ----------------------------------------- |
| Many callers (>5)   | Use gitnexus_rename for automated updates |
| Cross-area refs     | Use detect_changes after to verify scope  |
| String/dynamic refs | gitnexus_query to find them               |
| External/public API | Version and deprecate properly            |

## Example: Rename `validateUser` to `authenticateUser`

```
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
   → 12 edits: 10 graph (safe), 2 ast_search (review)
   → Files: validator.ts, login.ts, middleware.ts, config.json...

2. Review ast_search edits (config.json: dynamic reference!)

3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
   → Applied 12 edits across 8 files

4. gitnexus_detect_changes({scope: "all"})
   → Affected: LoginFlow, TokenRefresh
   → Risk: MEDIUM — run tests for these flows
```
`````

## File: .cursor/skills/humanizer/SKILL.md
`````markdown
---
name: humanizer
version: 2.5.1
description: |
  Remove signs of AI-generated writing from text. Use when editing or reviewing
  text to make it sound more natural and human-written. Based on Wikipedia's
  comprehensive "Signs of AI writing" guide. Detects and fixes patterns including:
  inflated symbolism, promotional language, superficial -ing analyses, vague
  attributions, em dash overuse, rule of three, AI vocabulary words, passive
  voice, negative parallelisms, and filler phrases.
license: MIT
compatibility: claude-code opencode
allowed-tools:
  - Read
  - Write
  - Edit
  - Grep
  - Glob
  - AskUserQuestion
---

# Humanizer: Remove AI Writing Patterns

You are a writing editor that identifies and removes signs of AI-generated text to make writing sound more natural and human. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup.

## Your Task

When given text to humanize:

1. **Identify AI patterns** - Scan for the patterns listed below
2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives
3. **Preserve meaning** - Keep the core message intact
4. **Maintain voice** - Match the intended tone (formal, casual, technical, etc.)
5. **Add soul** - Don't just remove bad patterns; inject actual personality
6. **Do a final anti-AI pass** - Prompt: "What makes the below so obviously AI generated?" Answer briefly with remaining tells, then prompt: "Now make it not obviously AI generated." and revise


## Voice Calibration (Optional)

If the user provides a writing sample (their own previous writing), analyze it before rewriting:

1. **Read the sample first.** Note:
   - Sentence length patterns (short and punchy? Long and flowing? Mixed?)
   - Word choice level (casual? academic? somewhere between?)
   - How they start paragraphs (jump right in? Set context first?)
   - Punctuation habits (lots of dashes? Parenthetical asides? Semicolons?)
   - Any recurring phrases or verbal tics
   - How they handle transitions (explicit connectors? Just start the next point?)

2. **Match their voice in the rewrite.** Don't just remove AI patterns - replace them with patterns from the sample. If they write short sentences, don't produce long ones. If they use "stuff" and "things," don't upgrade to "elements" and "components."

3. **When no sample is provided,** fall back to the default behavior (natural, varied, opinionated voice from the PERSONALITY AND SOUL section below).

### How to provide a sample
- Inline: "Humanize this text. Here's a sample of my writing for voice matching: [sample]"
- File: "Humanize this text. Use my writing style from [file path] as a reference."


## PERSONALITY AND SOUL

Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it.

### Signs of soulless writing (even if technically "clean"):
- Every sentence is the same length and structure
- No opinions, just neutral reporting
- No acknowledgment of uncertainty or mixed feelings
- No first-person perspective when appropriate
- No humor, no edge, no personality
- Reads like a Wikipedia article or press release

### How to add voice:

**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons.

**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up.

**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive."

**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking.

**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human.

**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching."

### Before (clean but soulless):
> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear.

### After (has a pulse):
> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night.


## CONTENT PATTERNS

### 1. Undue Emphasis on Significance, Legacy, and Broader Trends

**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted

**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic.

**Before:**
> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance.

**After:**
> The Statistical Institute of Catalonia was established in 1989 to collect and publish regional statistics independently from Spain's national statistics office.


### 2. Undue Emphasis on Notability and Media Coverage

**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence

**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context.

**Before:**
> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers.

**After:**
> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods.


### 3. Superficial Analyses with -ing Endings

**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing...

**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth.

**Before:**
> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land.

**After:**
> The temple uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast.


### 4. Promotional and Advertisement-like Language

**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning

**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics.

**Before:**
> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty.

**After:**
> Alamata Raya Kobo is a town in the Gonder region of Ethiopia, known for its weekly market and 18th-century church.


### 5. Vague Attributions and Weasel Words

**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited)

**Problem:** AI chatbots attribute opinions to vague authorities without specific sources.

**Before:**
> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem.

**After:**
> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences.


### 6. Outline-like "Challenges and Future Prospects" Sections

**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook

**Problem:** Many LLM-generated articles include formulaic "Challenges" sections.

**Before:**
> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth.

**After:**
> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods.


## LANGUAGE AND GRAMMAR PATTERNS

### 7. Overused "AI Vocabulary" Words

**High-frequency AI words:** Actually, additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant

**Problem:** These words appear far more frequently in post-2023 text. They often co-occur.

**Before:**
> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet.

**After:**
> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south.


### 8. Avoidance of "is"/"are" (Copula Avoidance)

**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a]

**Problem:** LLMs substitute elaborate constructions for simple copulas.

**Before:**
> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet.

**After:**
> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet.


### 9. Negative Parallelisms and Tailing Negations

**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused. So are clipped tailing-negation fragments such as "no guessing" or "no wasted motion" tacked onto the end of a sentence instead of written as a real clause.

**Before:**
> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement.

**After:**
> The heavy beat adds to the aggressive tone.

**Before (tailing negation):**
> The options come from the selected item, no guessing.

**After:**
> The options come from the selected item without forcing the user to guess.


### 10. Rule of Three Overuse

**Problem:** LLMs force ideas into groups of three to appear comprehensive.

**Before:**
> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights.

**After:**
> The event includes talks and panels. There's also time for informal networking between sessions.


### 11. Elegant Variation (Synonym Cycling)

**Problem:** AI has repetition-penalty code causing excessive synonym substitution.

**Before:**
> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home.

**After:**
> The protagonist faces many challenges but eventually triumphs and returns home.


### 12. False Ranges

**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale.

**Before:**
> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter.

**After:**
> The book covers the Big Bang, star formation, and current theories about dark matter.


### 13. Passive Voice and Subjectless Fragments

**Problem:** LLMs often hide the actor or drop the subject entirely with lines like "No configuration file needed" or "The results are preserved automatically." Rewrite these when active voice makes the sentence clearer and more direct.

**Before:**
> No configuration file needed. The results are preserved automatically.

**After:**
> You do not need a configuration file. The system preserves the results automatically.


## STYLE PATTERNS

### 14. Em Dash Overuse

**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing. In practice, most of these can be rewritten more cleanly with commas, periods, or parentheses.

**Before:**
> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents.

**After:**
> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" aress, yet this mislabeling continues in official documents.


### 15. Overuse of Boldface

**Problem:** AI chatbots emphasize phrases in boldface mechanically.

**Before:**
> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**.

**After:**
> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard.


### 16. Inline-Header Vertical Lists

**Problem:** AI outputs lists where items start with bolded headers followed by colons.

**Before:**
> - **User Experience:** The user experience has been significantly improved with a new interface.
> - **Performance:** Performance has been enhanced through optimized algorithms.
> - **Security:** Security has been strengthened with end-to-end encryption.

**After:**
> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption.


### 17. Title Case in Headings

**Problem:** AI chatbots capitalize all main words in headings.

**Before:**
> ## Strategic Negotiations And Global Partnerships

**After:**
> ## Strategic negotiations and global partnerships


### 18. Emojis

**Problem:** AI chatbots often decorate headings or bullet points with emojis.

**Before:**
> 🚀 **Launch Phase:** The product launches in Q3
> 💡ey Insight:** Users prefer simplicity
> ✅ **Next Steps:** Schedule follow-up meeting

**After:**
> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting.


### 19. Curly Quotation Marks

**Problem:** ChatGPT uses curly quotes (“...”) instead of straight quotes ("...").

**Before:**
> He said “the project is on track” but others disagreed.

**After:**
> He said "the project is on track" but others disagreed.


## COMMUNICATION PATTERNS

### 20. Collaborative Communication Artifacts

**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely rld you like..., let me know, here is a...

**Problem:** Text meant as chatbot correspondence gets pasted as content.

**Before:**
> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section.

**After:**
> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest.


### 21. Knowledge-Cutoff Disclaimers

**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information...

**Problem:** AI disclaimers about incomplete information get left in text.

**Before:**
> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s.

**After:**
> The company was founded in 1994, according to its registration documents.


### 22. Sycophantic/Servile Tone

**Problem:** Overly positive, people-pleasing language.

**Before:**
> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors.

**After:**
> The economic factors you mentioned are relevant here.


## FILLER AND HEDGING

### 23. Filler Phrases

**Before → After:**
- "In order to achieve this goal" → "To achieve this"
- "Due to the fact  it was raining" → "Because it was raining"
- "At this point in time" → "Now"
- "In the event that you need help" → "If you need help"
- "The system has the ability to process" → "The system can process"
- "It is important to note that the data shows" → "The data shows"


### 24. Excessive Hedging

**Problem:** Over-qualifying statements.

**Before:**
> It could potentially possibly be argued that the policy might have some effect on outcomes.

**After:**
> The policy may affect outcomes.


### 25. Generic Positive Conclusions

**Problem:** Vague ungs.

**Before:**
> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction.

**After:**
> The company plans to open two more locations next year.


### 26. Hyphenated Word Pair Overuse

**Words to watch:** third-party, cross-functional, client-facing, data-driven, decision-making, well-known, high-quality, real-time, long-term, end-to-end

**Problem:** AI hyphenates common word pairs with perfect consistency. Humans rarely hyphenate these uniformly, and when they do, it's inconsistent. Less common or technical compound modifiers are fine to hyphenate.

**Before:**
> The cross-functional team delivered a high-quality, data-driven report on our client-facing tools. Their decision-making process was well-known for being thorough and detail-oriented.

**After:**
> The cross functional team delivered a high quality, data driven report on our client facing tools. Their decision making process was known for being thorough and detail oriented.


### 27. Persuasive Authority Tropes

**Phrases to watch:** The real question is, at its core, in reality, what really matters, fundamentally, the deeper issue, the heart of the matter

**Problem:** LLMs use these phrases to pretend they are cutting through noise to some deeper truth, when the sentence that follows usually just restates an ordinary point with extra ceremony.

**Before:**
> The real question is whether teams can adapt. At its core, what really matters is organizational readiness.

**After:**
> The question is whether teams can adapt. That mostly depends on whether the organization is ready to change its habits.


### 28. Signposting and Announcements

**Phrases to watch:** Let's dive in, let's explore, let's break this down, here's what you need to know, now let's look at, without further ado

**Problem:** LLMs announce what they are about to do instead of doing it. This meta-commentary slows the writing down and gives it a tutorial-script feel.

**Before:**
> Let's dive into how caching works in Next.js. Here's what you need to know.

**After:**
> Next.js caches data at multiple layers, including request memoization, the data cache, and the router cache.


### 29. Fragmented Headers

**Signs to watch:** A heading followed by a one-line paragraph that simply restates the heading before the real content begins.

**Problem:** LLMs often add a generic sentence after a heading as a rhetorical warm-up. It usually adds nothing and makes the prose feel padded.

**Before:**
> ## Performance
>
> Speed matters.
>
> When users hit a slow page, they leave.

**After:**
> ## Performance
>
> When users hit a slow page, they leave.

---

## Process

1. Read the input text carefully
2. Identify all instances of the patterns above
3. Rewrite each problematic section
4. Ensure the revised text:
   - Sounds natural when read aloud
   - Varies sentence structure naturally
   - Uses specific details over vague claims
   - Maintains appropriate tone for context
   - Uses simple constructions (is/are/has) where appropriate
5. Present a draft humanized version
6. Prompt: "What makes the below so obviously AI generated?"
7. Answer briefly with the remaining tells (if any)
8. Prompt: "Now make it not obviously AI generated."
9. Present the final version (revised after the audit)

## Output Format

Provide:
1. Draft rewrite
2. "What makes the below so obviously AI generated?" (brief bullets)
3. Final rewrite
4. A brief summary of changes made (optional, if helpful)


## Full Example

**Before (AI-sounding):**
> Great question! Here is an essay on this topic. I hope this helps!
>
> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
>
> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation.
>
> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The techgy has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment.
>
> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers.
> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards.
> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends.
>
> While specific details are limited based on available information, it could potentially be argued that these tools mave some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices.
>
> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if you’d like me to expand on any section!

**Draft rewrite:**
> AI coding assistants speed up some tasks. In a 2024 study by , developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
>
> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention.
>
> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library.
>
> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants.
>
> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right.

**What makes the below so obviously AI generated?**
- The rhythm is still a bit too tidy (clean contrasts, evenly paced paragraphs).
- The named people and study citations can read like plausible-but-made-up placeholders unless they're real and sourced.
- The closer leans a touch slogan-y ("If you do not have tests...") rather than sounding like a person talking.

**Now make it not obviously AI generated.**
> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture.
>
> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention.
>
> People I talk to tend to land in two camps. Some use it like autocomplete for chores and review every line. Others disable it after it keeps suggesting patterns they don't want. Both feel reasonable.
>
> The productivity metrics are slippery. GitHub can say Copilot users "accept 30% of suggestions," but acceptance isn't correctness, and correctness isn't value. If you don't have tests, you're basically guessing.

**Changes made:**
- Removed chatbot artifacts ("Great question!", "I hope this helps!", "Let me know if...")
- Removed significance inflation ("testament", "pivotal moment", "evolving landscape", "vital role")
- Removed promotional language ("groundbreaking", "nestled", "seamless, intuitive, and powerful")
- Removed vague attributions ("Industry observers")
- Removed superficial -ing phrases ("underscoring", "highlighting", "reflecting", "contributing to")
- Removed negative parallelism ("It's not just X; it's Y")
- Removed rule-of-three patterns and synonym cycling ("catalyst/partner/foundation")
- Removed false ranges ("from X to Y, from A to B")
- Removed em dashes, emojis, boldface headers, and curly quotes
- Removed copula avoidance ("serves as", "functions as", "stands as") in favor of "is"/"are"
- Removed formulaic challenges section ("Despite challenges... continues to thrive")
- Removed knowledge-cutoff hedging ("While specific details are limited...")
- Removed excessive hedging ("could potentially be argued that... might have some")
- Removed filler phrases and persuasive framing ("In order to", "At its core")
- Removed generic positive conclusion ("the future looks bright", "exciting times lie ahead")
- Made the voice more personal and less "assembled" (varied rhythm, fewer placeholders)


## Reference

This skill is based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia.

Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."
`````

## File: .cursor/skills/metagit-bootstrap/scripts/bootstrap-config.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
FORCE="${2:-false}"
TARGET="$ROOT/.metagit.yml"

uv run python - "$ROOT" "$TARGET" "$FORCE" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import create_metagit_config
from metagit.core.config.manager import MetagitConfigManager

root = Path(sys.argv[1]).resolve()
target = Path(sys.argv[2]).resolve()
force = sys.argv[3].lower() in {"1", "true", "yes", "force"}

if target.exists() and not force:
    mgr = MetagitConfigManager(config_path=target)
    result = mgr.load_config()
    state = "valid" if not isinstance(result, Exception) else "invalid"
    print(f"status=exists\tvalidity={state}\tpath={target}")
    raise SystemExit(0)

yaml_out = create_metagit_config(name=root.name, kind="application", as_yaml=True)
if isinstance(yaml_out, Exception):
    print(f"status=error\tmessage={yaml_out}")
    raise SystemExit(1)

target.write_text(yaml_out, encoding="utf-8")
mgr = MetagitConfigManager(config_path=target)
result = mgr.load_config()
state = "valid" if not isinstance(result, Exception) else "invalid"
print(f"status=written\tvalidity={state}\tpath={target}")
PY
`````

## File: .cursor/skills/metagit-bootstrap/SKILL.md
`````markdown
---
name: metagit-bootstrap
description: Use when generating or refining local .metagit.yml files using deterministic discovery plus MCP sampling.
---

# Metagit MCP Bootstrap Skill

Use this skill to create a local `.metagit.yml` using discovery-driven prompts and MCP sampling.

## Purpose

Generate schema-compliant `.metagit.yml` files with high contextual quality while preserving safety and explicit user control.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for local bootstrap tasks:
- `./scripts/bootstrap-config.zsh [root_path] [force]`

Behavior:
- Writes `.metagit.yml` when missing
- Validates via Metagit config models
- Returns a compact status line for agents

## Workflow

1. Gather deterministic discovery data from the target repository:
   - source language/framework indicators
   - package/lock/build files
   - Dockerfiles and CI workflows
   - terraform files and module usage
2. Build a strict prompt package:
   - output format contract: valid YAML only
   - required schema fields and constraints
   - extracted discovery evidence
3. If sampling is supported, call `sampling/createMessage`.
4. Validate generated YAML with Metagit config models.
5. Retry with validation feedback up to a fixed max attempt count.
6. Return draft output and write only on explicit confirmation.

## Output Modes

- **Plan-only mode**: return prompt + discovery summary if sampling unavailable.
- **Draft mode**: return `.metagit.generated.yml` content.
- **Confirmed write mode**: write to `.metagit.yml` only with explicit parameter (`confirm_write=true`).

## Quality Bar

- Preserve discovered evidence in structured fields.
- Include workspace project and related repo entries where detectable.
- Avoid invented repositories or unverifiable dependencies.

## Safety Rules

- Never overwrite `.metagit.yml` silently.
- Never emit secrets in cleartext.
- Prefer placeholders for credentials or tokens.
`````

## File: .cursor/skills/metagit-cli/metagit-cli/SKILL.md
`````markdown
---
name: metagit-cli
description: CLI-only shortcuts for metagit agents — workspace catalog, discovery, prompts, sync, layout, and config. Use instead of MCP or HTTP API when operating from a shell or agent_mode session.
---

# Metagit CLI (agent shortcuts)

Use this skill when an agent should drive metagit **only through the `metagit` command**. Do not call MCP tools or `metagit api` from workflows covered here unless the user explicitly asks.

Set non-interactive defaults when automating:

```bash
export METAGIT_AGENT_MODE=true
```

Global flags (most commands):

- `-c path/to/metagit.config.yaml` — app config (default `metagit.config.yaml`)
- Workspace manifest: `--definition` / `-c` on catalog commands (default `.metagit.yml`)

---

## Prompt commands (all kinds)

List built-in prompt kinds:

```bash
metagit prompt list
metagit prompt list --json
```

Emit prompts (`--text-only` for paste into agent context; `--json` for structured output; `--no-instructions` to omit manifest layers):

| Scope | Command | Default kind |
|-------|---------|--------------|
| Workspace | `metagit prompt workspace -c <definition> -k <kind>` | `instructions` |
| Project | `metagit prompt project -p <project> -c <definition> -k <kind>` | `instructions` |
| Repo | `metagit prompt repo -p <project> -n <repo> -c <definition> -k <kind>` | `instructions` |

### Prompt kinds by scope

| Kind | Workspace | Project | Repo | Purpose |
|------|:---------:|:-------:|:----:|---------|
| `instructions` | yes | yes | yes | Composed `agent_instructions` from manifest layers |
| `session-start` | yes | — | — | Session bootstrap checklist |
| `catalog-edit` | yes | yes | — | Search-before-create; catalog registration |
| `health-preflight` | yes | yes | — | Pre-work workspace/repo status pass |
| `sync-safe` | yes | yes | yes | Guarded sync rules |
| `subagent-handoff` | — | yes | yes | Delegate single-repo work |
| `layout-change` | yes | yes | yes | Rename/move dry-run workflow |
| `repo-enrich` | — | — | yes | **Discover + merge** workspace repo entry |

### Prompt shortcuts (copy-paste)

```bash
# Session bootstrap
metagit prompt workspace -k session-start --text-only -c .metagit.yml

# Composed instructions at each level
metagit prompt workspace -k instructions --text-only -c .metagit.yml
metagit prompt project -p default -k instructions --text-only -c .metagit.yml
metagit prompt repo -p default -n my-api -k instructions --text-only -c .metagit.yml

# Repo catalog enrichment (detect + merge manifest entry)
metagit prompt repo -p default -n my-api -k repo-enrich --text-only -c .metagit.yml

# Catalog registration discipline
metagit prompt workspace -k catalog-edit --text-only -c .metagit.yml

# Safe sync reminder
metagit prompt repo -p default -n my-api -k sync-safe --text-only -c .metagit.yml

# Subagent handoff
metagit prompt repo -p default -n my-api -k subagent-handoff --text-only -c .metagit.yml
```

---

## Repo enrich workflow (`repo-enrich`)

Run the prompt, then execute its steps:

```bash
metagit prompt repo -p <project> -n <repo> -k repo-enrich --text-only -c .metagit.yml
```

Typical discovery chain on the checkout:

```bash
cd "$(metagit search '<repo>' -c .metagit.yml --path-only)"
metagit detect repository -p . -o json
metagit detect repo -p . -o yaml
metagit detect repo_map -p . -o json
```

Provider metadata (dry-run):

```bash
metagit project source sync --provider github --org <org> --mode discover --no-apply
```

After merging fields into `workspace.projects[].repos[]`:

```bash
metagit config validate -c .metagit.yml
metagit workspace repo list --project <project> --json
```

---

## Workspace and catalog

Per-project dedupe override in `.metagit.yml` (overrides `workspace.dedupe.enabled` in `metagit.config.yaml` for that project only):

```yaml
workspace:
  projects:
    - name: local
      dedupe:
        enabled: false
      repos: []
```

```bash
metagit appconfig show --format json
metagit config info -c .metagit.yml
metagit config show -c .metagit.yml
metagit config validate -c .metagit.yml

metagit workspace list -c .metagit.yml --json
metagit workspace project list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --project <name> --json

metagit workspace project add --name <name> --json
metagit workspace repo add --project <name> --name <repo> --url <url> --json
metagit workspace project remove --name <name> --json
metagit workspace repo remove --project <name> --name <repo> --json
```

Search managed repos (always before creating entries):

```bash
metagit search "<query>" -c .metagit.yml --json
metagit search "<query>" -c .metagit.yml --path-only
metagit search "<query>" -c .metagit.yml --tag tier=1 --project <name>
```

---

## Project operations

```bash
metagit project list --config .metagit.yml --all --json
metagit project add --name <name> --json
metagit project remove --name <name> --json
metagit project rename --name <old> --new-name <new> --dry-run --json
metagit project select
metagit project sync

metagit project repo list --json
metagit project repo add --project <name> --name <repo> --url <url>
metagit project repo remove --name <repo> --json
metagit project repo rename --name <old> --new-name <new> --dry-run --json
metagit project repo move --name <repo> --to-project <other> --dry-run --json
metagit project repo prune --project <name> --dry-run

metagit project source sync --provider github --org <org> --mode discover --no-apply
metagit project source sync --provider github --org <org> --mode additive --apply
```

Layout (manifest + disk; always dry-run first):

```bash
metagit workspace project rename --name <old> --new-name <new> --dry-run --json
metagit workspace repo rename --project <p> --name <old> --new-name <new> --dry-run --json
metagit workspace repo move --project <p> --name <repo> --to-project <other> --dry-run --json
```

---

## Discovery and local metadata

```bash
metagit detect project -p <path> -o yaml
metagit detect repo -p <path> -o yaml
metagit detect repo_map -p <path> -o json
metagit detect repository -p <path> -o json
metagit detect repository -p <path> -o metagit
# --save only with operator approval (blocked in agent_mode)
```

Bootstrap new trees:

```bash
metagit init --kind application
metagit init --kind umbrella --template hermes-orchestrator
```

---

## Selection and scope

```bash
metagit workspace select --project <name>
metagit project select
metagit project repo select
```

---

## Config and appconfig

```bash
metagit appconfig validate
metagit appconfig get workspace.path
metagit config example
metagit config schema
metagit config providers
```

---

## Records, skills, version

```bash
metagit record search "<query>"
metagit skills list
metagit skills show metagit-cli
metagit skills install --skill metagit-cli
metagit version
metagit info
```

---

## Agent habits

1. **Search before create** — `metagit search` then catalog add.
2. **Validate after manifest edits** — `metagit config validate`.
3. **Emit prompts instead of rewriting playbooks** — `metagit prompt … --text-only`.
4. **Enrich stale repo entries** — `metagit prompt repo … -k repo-enrich` then detect + merge.
5. **Dry-run layout** — always `--dry-run --json` before apply.
6. **Prefer `METAGIT_AGENT_MODE=true`** in CI and agent loops to skip fuzzy finder and confirm dialogs.

## Related bundled skills

Use topic skills when you need deeper playbooks (some mention MCP): `metagit-projects`, `metagit-workspace-scope`, `metagit-workspace-sync`, `metagit-config-refresh`. This skill is the **CLI-only** index and prompt reference.
`````

## File: .cursor/skills/metagit-config-refresh/SKILL.md
`````markdown
---
name: metagit-config-refresh
description: Refresh or bootstrap `.metagit.yml` using deterministic discovery and validation flows. Use when configuration is missing, stale, or incomplete for workspace operations.
---

# Refreshing Project Config

Use this skill to keep `.metagit.yml` accurate and operational.

## Workflow

1. Check workspace activation and config validity.
2. Run bootstrap plan mode first.
3. Review generated changes against expected workspace topology.
4. Apply config updates and validate schema before continuing.

## Command Wrapper

- `zsh ./skills/metagit-bootstrap/scripts/bootstrap-config.zsh [root_path] [mode] [seed_context]`

## Output Contract

Return:
- config health state before/after
- generated update summary
- any manual follow-up needed

## Safety

- Prefer plan/dry-run first for large config updates.
- Keep changes bounded to target workspace intent.
`````

## File: .cursor/skills/metagit-gating/scripts/gate-status.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"

uv run python - "$ROOT" <<'PY'
import os
import sys
from metagit.core.mcp.gate import WorkspaceGate
from metagit.core.mcp.root_resolver import WorkspaceRootResolver
from metagit.core.mcp.tool_registry import ToolRegistry

root = sys.argv[1]
resolver = WorkspaceRootResolver()
gate = WorkspaceGate()
registry = ToolRegistry()

resolved = resolver.resolve(cwd=os.path.abspath(root), cli_root=root)
status = gate.evaluate(root_path=resolved)
tools = registry.list_tools(status)
print(f"state={status.state.value}\troot={status.root_path or 'none'}\ttools={len(tools)}")
PY
`````

## File: .cursor/skills/metagit-gating/SKILL.md
`````markdown
---
name: metagit-gating
description: Use when implementing or operating Metagit MCP server activation and tool exposure rules based on .metagit.yml presence and validity.
---

# Metagit MCP Gating Skill

Use this skill whenever you need to control whether Metagit MCP tools/resources are exposed.

## Purpose

Ensure high-risk tooling and multi-repo context are only available when a valid `.metagit.yml` exists at the resolved workspace root.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for all gating checks:
- `./scripts/gate-status.zsh [root_path]`

Expected output (single line, tab-delimited):
- `state=<value>\troot=<path|none>\ttools=<count>`

## Activation Workflow

1. Resolve workspace root:
   - `METAGIT_WORKSPACE_ROOT`
   - CLI `--root`
   - upward directory walk
2. Check for `.metagit.yml` in resolved root.
3. Validate config through existing Metagit config models.
4. Derive activation state: missing, invalid, or active.
5. Register tool surface based on state.

## Tool Exposure Contract

### Inactive (missing or invalid config)
Expose only:
- `metagit_workspace_status`
- `metagit_bootstrap_config_plan_only`

### Active (valid config)
Expose full set:
- `metagit_workspace_status`
- `metagit_workspace_index`
- `metagit_workspace_search`
- `metagit_upstream_hints`
- `metagit_repo_inspect`
- `metagit_repo_sync`
- `metagit_bootstrap_config`

## Error Handling

- Return explicit, machine-readable state and reason.
- Avoid stack traces in user-facing outputs.
- Log parser/validation errors with enough detail for debugging.

## Safety Rules

- Never expose mutation-capable tools in inactive state.
- Never operate outside validated workspace boundaries.
- Keep defaults read-only unless user/agent explicitly opts in.
`````

## File: .cursor/skills/metagit-gitnexus/scripts/analyze-targets.zsh
`````zsh
#!/usr/bin/env zsh

set -euo pipefail

workspace_root="${1:-.}"
project_name="${2:-default}"

if [[ ! -f ".metagit.yml" ]]; then
  echo "ERROR: .metagit.yml not found in current directory"
  exit 2
fi

echo "analyze repo=$(pwd)"
npx gitnexus analyze

tmp_output="$(mktemp)"
uv run python - "$workspace_root" "$project_name" <<'PY' > "$tmp_output"
import sys
from pathlib import Path
import yaml

workspace_root = Path(sys.argv[1]).expanduser().resolve()
project_name = sys.argv[2]
cfg = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8")) or {}
workspace = (cfg.get("workspace") or {})
projects = workspace.get("projects") or []
target = next((p for p in projects if p.get("name") == project_name), None)
if not target:
    print(f"warn project_not_found={project_name}")
    raise SystemExit(0)

for repo in target.get("repos") or []:
    name = repo.get("name")
    if not name:
        continue
    repo_path = workspace_root / project_name / name
    if repo_path.exists() and repo_path.is_dir():
        print(f"repo_path={repo_path}")
    else:
        print(f"skip_missing={repo_path}")
PY

while IFS= read -r line; do
  case "$line" in
    repo_path=*)
      path="${line#repo_path=}"
      echo "analyze repo=${path}"
      (cd "$path" && npx gitnexus analyze) || echo "fail repo=${path}"
      ;;
    *)
      echo "$line"
      ;;
  esac
done < "$tmp_output"

rm -f "$tmp_output"
`````

## File: .cursor/skills/metagit-multi-repo/SKILL.md
`````markdown
---
name: metagit-multi-repo
description: Coordinate implementation tasks across multiple repositories using metagit status, search, and scoped sync workflows. Use when one objective spans several repositories.
---

# Coordinating Multi-Repo Implementation

Use this skill for cross-repository feature or fix delivery.

## Workflow

1. Define objective and affected repositories.
2. Verify workspace scope and dependency hints.
3. Sequence work by dependency order.
4. Sync only required repositories.
5. Track progress and blockers per repository.

## Command Wrapper

- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- objective-to-repository map
- execution order
- current blocker + next step

## Safety

- Keep scope bounded to configured workspace repositories.
- Prefer deterministic evidence for cross-repo assumptions.
`````

## File: .cursor/skills/metagit-projects/SKILL.md
`````markdown
---
name: metagit-projects
description: Ongoing workspace and project management for OpenClaw and Hermes agents. Use when starting work, organizing repos, or before creating a new project folder so existing metagit projects are reused instead of duplicated.
---

# Ongoing Project Management

Use this skill when the user starts new work, reorganizes repositories, or asks you to create a project folder. Metagit is the source of truth for what already exists in the workspace.

## Concepts

Metagit uses a three-level hierarchy (see project terminology docs):

| Level | Meaning |
|-------|---------|
| **Workspace** | Root folder where projects are synced (from app config `workspace.path`, often `./.metagit/`). Holds many projects. |
| **Project** | Named group of one or more Git repositories. Multi-repo products are one project; unrelated repos can also share a workspace under different project names. |
| **Repo** | A single Git repository entry under `workspace.projects[].repos` in `.metagit.yml`. |

A **project** is not always “one product.” It is whatever grouping helps the user and agents reason about related (or intentionally grouped) repositories. A workspace may contain unrelated projects side by side (for example `default`, `client-a`, `experiments`).

The umbrella `.metagit.yml` (workspace definition, often `kind: umbrella`) lives in a coordinating repository or central config checkout. Application repos may have their own `.metagit.yml` for metadata mode.

## Mandatory: check before creating folders

**Never** create a new project directory or clone into the workspace until you have checked metagit for an existing match.

1. **Locate the workspace definition**
   - Prefer the user’s umbrella `.metagit.yml` if known.
   - Otherwise use `.metagit.yml` in the current repo with `--definition /path/to/.metagit.yml`.

2. **List configured projects and repo counts**
   ```bash
   metagit config info --config-path /path/to/.metagit.yml
   metagit project list --config /path/to/.metagit.yml --project default
   ```
   Repeat `--project` for each project name returned by `config info`.

3. **Search managed repos by name, URL fragment, or tag**
   ```bash
   metagit search "<proposed-name-or-url>" --definition /path/to/.metagit.yml
   metagit search "<name>" --definition /path/to/.metagit.yml --json
   ```

4. **Inspect on disk** (workspace path from app config, default `./.metagit/`)
   - Expected layout: `{workspace.path}/{project_name}/{repo_name}/`
   - If the directory already exists, **reuse it**; do not create a parallel tree.

5. **Decide**
   - **Match found** → use existing project/repo; run `metagit project sync` only if the user wants checkouts refreshed.
   - **No match** → proceed with registration steps below (still add to workspace; do not leave orphan folders).

## Registering new work in the workspace

### New repository in an existing project

From the directory containing the workspace `.metagit.yml` (or pass `--config`):

```bash
metagit project repo add --project <project_name> --prompt
# or non-interactive:
metagit project repo add --project <project_name> --name <repo> --url <git-url>
metagit config validate --config-path .metagit.yml
metagit project sync --project <project_name>
```

In the new application repo (if applicable):

```bash
cd /path/to/new/repo
metagit init
metagit detect repo --force   # optional: enrich .metagit.yml
```

### New project group (new `workspace.projects[]` entry)

There is no separate `project create` CLI today. Add a project block to `.metagit.yml`:

```yaml
workspace:
  projects:
    - name: my-new-project
      description: Short purpose for agents and humans
      repos: []
```

Then validate, add repos, and sync:

```bash
metagit config validate --config-path .metagit.yml
metagit project repo add --project my-new-project --prompt
metagit project sync --project my-new-project
```

Choose a **distinct project name**; avoid duplicating an existing `workspace.projects[].name`.

### New umbrella workspace

When bootstrapping a workspace coordinator repo:

```bash
metagit init --kind umbrella
metagit project repo add --project default --prompt
metagit project sync
```

## Ongoing session habits

At the start of sustained work:

1. Run **metagit-workspace-scope** (or `metagit mcp serve --status-once` when MCP is available).
2. Confirm **active project** matches the user’s intent (`metagit workspace select --project <name>` when switching).
3. Use **`metagit search`** before assuming a repo is missing or lives elsewhere.
4. For multi-repo tasks, prefer **metagit-control-center** or **metagit-multi-repo** over ad-hoc cloning.

When the user names a target folder:

- Resolve it against managed config first.
- If unmanaged but present on disk under the project sync folder, report it and offer to add via `metagit project repo add` rather than recreating.

## OpenClaw and Hermes setup

Install bundled skills (including this one) for agent hosts:

```bash
metagit skills list
metagit skills install --scope user --target openclaw --target hermes
metagit mcp install --scope user --target openclaw --target hermes
```

Use `--scope project` when installing into a specific umbrella repository checkout.

## Output contract

After project-management actions, report:

- workspace definition path used
- whether the target was **existing** or **newly registered**
- project name and repo name(s) affected
- sync status if `project sync` was run
- recommended next command (`workspace select`, `project select`, or `detect`)

## Safety

- Do not clone, delete, or overwrite sync directories without explicit user approval.
- Do not edit `.metagit.yml` without validating afterward (`metagit config validate`).
- Prefer reusing configured repos over creating duplicate checkouts.
- Keep unrelated experiments in separate `workspace.projects` entries when the user wants clear boundaries.
`````

## File: .cursor/skills/metagit-upstream-scan/scripts/upstream-scan.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-}"
MAX_RESULTS="${4:-20}"

if [[ -z "$QUERY" ]]; then
  echo "status=error\tmessage=query-required"
  exit 1
fi

uv run python - "$ROOT" "$QUERY" "$PRESET" "$MAX_RESULTS" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.mcp.services.workspace_search import WorkspaceSearchService
from metagit.core.mcp.services.upstream_hints import UpstreamHintService

root = Path(sys.argv[1]).resolve()
query = sys.argv[2]
preset = sys.argv[3] or None
max_results = int(sys.argv[4])

manager = MetagitConfigManager(config_path=root / ".metagit.yml")
cfg = manager.load_config()
if isinstance(cfg, Exception):
    print(f"status=error\tmessage=config-invalid\tdetail={cfg}")
    raise SystemExit(1)

index = WorkspaceIndexService().build_index(config=cfg, workspace_root=str(root))
repo_paths = [row["repo_path"] for row in index if row.get("exists")]
search_hits = WorkspaceSearchService().search(query=query, repo_paths=repo_paths, preset=preset, max_results=max_results)
ranked = UpstreamHintService().rank(blocker=query, repo_context=index)[:5]

print(f"status=ok\trepos={len(index)}\thits={len(search_hits)}")
for row in ranked:
    print(f"hint\trepo={row['repo_name']}\tscore={row['score']}")
for hit in search_hits[:5]:
    print(f"hit\tfile={hit['file_path']}\tline={hit['line_number']}")
PY
`````

## File: .cursor/skills/metagit-upstream-scan/SKILL.md
`````markdown
---
name: metagit-upstream-scan
description: Use when a coding agent encounters likely upstream blockers and must find related workspace repositories, files, and probable root causes.
---

# Metagit Upstream Discovery Skill

Use this skill when the current repo does not appear to contain the full fix and related repositories may hold the source issue.

## Supported Use Cases

- Missing Terraform input in a shared module
- Docker base image/version mismatch across repos
- Shared infrastructure definitions causing local failures
- CI pipeline breakages tied to upstream templates/workflows

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for upstream discovery tasks:
- `./scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

Output format:
- compact status line
- top ranked repo hints (`hint`)
- top search file hits (`hit`)

## Workflow

1. Read workspace repository map from active `.metagit.yml`.
2. Run `metagit_workspace_index` to verify repo availability and sync state.
3. Use `metagit_workspace_search` with category preset (`terraform`, `docker`, `infra`, `ci`).
4. Use `metagit_upstream_hints` to rank candidate repositories and files.
5. Return a concise action plan:
   - top candidate repos
   - likely files/definitions
   - whether sync is needed before deeper analysis

## Search Strategy

- Start narrow with issue-specific terms (error, module, variable, image tag).
- Expand to broader shared terms if no hits.
- Prefer repositories referenced by workspace metadata before searching unknown repos.

## Output Contract

Return:
- ranked candidates with rationale
- suggested next file openings
- confidence level and unresolved assumptions

## Safety Rules

- Restrict search to configured workspace repositories.
- Cap result size and duration.
- Keep this workflow read-only unless an explicit sync action is requested.
`````

## File: .cursor/skills/metagit-upstream-triage/SKILL.md
`````markdown
---
name: metagit-upstream-triage
description: Triage cross-repository blockers by ranking likely upstream repositories and files with metagit search and hinting tools. Use when local fixes appear incomplete.
---

# Triaging Upstream Blockers

Use this skill for failures likely rooted in another repository.

## Workflow

1. Run workspace index and search with issue-specific terms.
2. Run upstream hint ranking to prioritize repositories/files.
3. Open the top candidates and validate root-cause evidence.
4. Return a short fix path (repo, file, next action).

## Command Wrapper

- `zsh ./skills/metagit-upstream-scan/scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

## Output Contract

Return:
- ranked candidate repositories
- probable root-cause files
- confidence and assumptions

## Safety

- Keep this flow read-only unless sync is explicitly requested.
`````

## File: .github/workflows/docker.yaml
`````yaml
name: Docker Image

on:
  push:
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository_owner }}/metagit-cli

jobs:
  image-deployment:
    name: Publish Docker Image - ${{ github.ref_name }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
      
    steps:
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Set version
        id: version
        run: |
          if [[ "${{ github.ref_type }}" == "tag" ]]; then
            VERSION=${GITHUB_REF#refs/tags/}
          else
            VERSION=dev-$(git rev-parse --short HEAD)
          fi
          echo "VERSION=$VERSION" >> $GITHUB_ENV
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "Version: $VERSION"

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
      
      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=semver,pattern={{major}}
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push Docker image
        uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
        id: build
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' && github.ref_type == 'tag' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: DEPLOY_VERSION=${{ steps.version.outputs.version }}


      - name: Generate artifact attestation
        if: github.event_name != 'pull_request' && github.ref_type == 'tag'
        uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
        with:
          subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

      - name: Logout from Docker Registry
        run: docker logout ${{ env.REGISTRY }}
`````

## File: .github/workflows/docs.yaml
`````yaml
name: Docs

on:
  push:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  build-and-deploy-docs:
    name: Build and Deploy Docs - ${{ github.ref_name }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.13"

      - name: Install uv
        run: |
          curl -LsSf https://astral.sh/uv/install.sh | sh
          source "$HOME/.cargo/env"
          
      - name: Install dependencies
        run: |
          uv venv
          uv pip install --system -e ".[docs]"

      - name: Build docs
        run: mkdocs build

      - name: Upload artifact
        uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
        with:
          path: site

      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
`````

## File: .mex/context/decisions.md
`````markdown
---
name: decisions
description: Key architectural and technical decisions with reasoning. Load when making design choices or understanding why something is built a certain way.
triggers:
  - "why do we"
  - "why is it"
  - "decision"
  - "alternative"
  - "we chose"
edges:
  - target: context/architecture.md
    condition: when a decision relates to system structure
  - target: context/stack.md
    condition: when a decision relates to technology choice
  - target: context/mcp-runtime.md
    condition: when decisions involve MCP capability scope, transport, or gating
last_updated: 2026-05-05
---

# Decisions

## Decision Log

### Keep project intelligence in repository-local `.metagit.yml`
**Date:** 2026-05-05  
**Status:** Active  
**Decision:** Project/workspace situational awareness is modeled in `.metagit.yml` and related schema/model files.  
**Reasoning:** Keeps context version-controlled, shareable, and inspectable by both humans and agents without external state dependencies.  
**Alternatives considered:** External-only metadata store (rejected — higher setup burden and weaker local/offline UX), ad-hoc markdown notes (rejected — no schema/validation guarantees).  
**Consequences:** Changes to project/workspace metadata must preserve model/schema compatibility and validation paths.

### Use Python CLI + core service modules as primary architecture
**Date:** 2026-05-05  
**Status:** Active  
**Decision:** Core functionality stays in Python modules under `src/metagit/core/*`, with Click command wrappers under `src/metagit/cli/commands/*`.  
**Reasoning:** Separates UX routing from business logic and keeps features testable in isolation.  
**Alternatives considered:** Fat command handlers (rejected — logic duplication and weaker test boundaries), full web-service-first architecture (rejected — project is CLI-first).  
**Consequences:** New features should be implemented in core services/managers with thin command glue.

### Gate MCP capabilities by valid workspace configuration
**Date:** 2026-05-05  
**Status:** Active  
**Decision:** MCP tool/resource surface is state-aware and only fully enabled when a valid `.metagit.yml` is present at resolved workspace root.  
**Reasoning:** Prevents unsafe or context-poor actions and aligns multi-repo operations with explicit workspace declarations.  
**Alternatives considered:** Always-on toolset (rejected — increases misuse risk), hard failure when config missing (rejected — poorer diagnostic/bootstrapping UX).  
**Consequences:** MCP runtime must keep gate checks, limited inactive tooling, and explicit mutation guardrails for sync operations.
`````

## File: .mex/context/setup.md
`````markdown
---
name: setup
description: Dev environment setup and commands. Load when setting up the project for the first time or when environment issues arise.
triggers:
  - "setup"
  - "install"
  - "environment"
  - "getting started"
  - "how do I run"
  - "local development"
edges:
  - target: context/stack.md
    condition: when specific technology versions or library details are needed
  - target: context/architecture.md
    condition: when understanding how components connect during setup
  - target: context/conventions.md
    condition: when setup issues are caused by project-specific command/style rules
last_updated: 2026-05-05
---

# Setup

## Prerequisites
- Python 3.12+ (project requires `>=3.12`).
- `uv` package/environment manager (used for install/run/test/lint commands).
- `task` CLI (Taskfile runner used for standard project workflows).
- Git (required for repository features and many metagit operations).

## First-time Setup
1. `./configure.sh`
2. `task install`
3. `uv pip install -e ".[test]"`
4. `task lint`
5. `task test`

## Environment Variables
- `GITHUB_PERSONAL_ACCESS_TOKEN` (conditionally required) — used by `task start:mcp` for GitHub MCP server container.
- `LEGACY_YAML_LOADER` (optional) — toggles legacy duplicate-key behavior in custom YAML loader.
- `.env` / `.SECRETS.env` (optional/conditional) — loaded by Taskfile; values depend on enabled workflows/providers.

## Common Commands
- `task lint` — runs `ruff check` and `ruff format --check`.
- `task format` — formats code with `ruff format`.
- `task test` — installs test extras and runs pytest suite.
- `uv run metagit --help` — verifies CLI entrypoint and available commands.
- `uv run pytest tests/core/mcp -v` — focused MCP runtime/service regression suite.
- `task docs` — builds docs with mkdocs after schema generation.

## Common Issues
**`task lint` fails after runtime edits:** run `uv run ruff format src/metagit/core/mcp/runtime.py` then rerun `task lint`.  
**MCP command blocks in tests/manual checks:** use `metagit mcp serve --status-once` for non-blocking state diagnostics.  
**Config validation failures during workspace/MCP flows:** validate and inspect `.metagit.yml` shape via `uv run metagit config validate` and model-driven fixes.  
**Tooling mismatch after dependency changes:** run `uv sync` then rerun `task install` to restore expected environment.
`````

## File: .mex/context/stack.md
`````markdown
---
name: stack
description: Technology stack, library choices, and the reasoning behind them. Load when working with specific technologies or making decisions about libraries and tools.
triggers:
  - "library"
  - "package"
  - "dependency"
  - "which tool"
  - "technology"
edges:
  - target: context/decisions.md
    condition: when the reasoning behind a tech choice is needed
  - target: context/conventions.md
    condition: when understanding how to use a technology in this codebase
  - target: context/setup.md
    condition: when stack choices affect local tooling or command execution
  - target: context/mcp-runtime.md
    condition: when working with MCP JSON-RPC transport, tools, and resources
last_updated: 2026-05-05
---

# Stack

## Core Technologies
- **Python 3.12** — primary language runtime (`requires-python >=3.12` in `pyproject.toml`).
- **Click** — CLI framework used by `metagit.cli.main` and command modules.
- **Pydantic v2** — schema and model validation for config/app/workspace/records.
- **uv** — package/environment and command runner used across Taskfile and setup.
- **Taskfile (`task`)** — command orchestration layer for lint/test/build/docs workflows.

## Key Libraries
- **`pydantic`** — all structured config/workspace records are modeled and validated with explicit models.
- **`PyYAML` + custom loader (`metagit.core.utils.yaml_class`)** — YAML parsing with include/envvar and duplicate-key behavior.
- **`GitPython`** — repository metadata and sync operations instead of raw shell git calls in core logic.
- **`pytest`** — primary testing framework for unit/integration tests in `tests/`.
- **`ruff`** — primary lint + format tool for current local workflow (`task lint`, `task format`).
- **`litellm` / `crewai`** — agent-oriented integrations present in dependencies and examples for AI-assisted flows.

## What We Deliberately Do NOT Use
- No JavaScript/TypeScript runtime as the primary execution path; core implementation is Python CLI/service modules.
- No monolithic web framework dependency (e.g., Django/FastAPI app server) for core product behavior.
- No direct subprocess-heavy git orchestration as the primary pattern where GitPython is available.

## Version Constraints
- Python must be 3.12+ for supported runtime behavior.
- Pydantic is v2-style; legacy v1 patterns should not be introduced.
- MCP protocol version emitted by runtime initialize response is `2024-11-05`.
`````

## File: .mex/patterns/add-cli-command.md
`````markdown
---
name: add-cli-command
description: Add or extend a metagit CLI command with core logic boundaries and test coverage.
triggers:
  - "add command"
  - "click command"
  - "new subcommand"
edges:
  - target: context/architecture.md
    condition: when deciding where command logic belongs vs core service logic
  - target: context/conventions.md
    condition: when implementing command/module naming and verify checklist items
  - target: patterns/debug-mcp-runtime.md
    condition: when the new command affects MCP runtime behavior and tests fail
last_updated: 2026-05-05
---

# Add CLI Command

## Context
Load `context/architecture.md` and `context/conventions.md` first. Confirm whether the task is a new command file under `src/metagit/cli/commands/` or an extension of existing command behavior.

## Steps
1. Create or update command module in `src/metagit/cli/commands/`.
2. Keep handler thin: instantiate/call core service/manager classes in `src/metagit/core/*`.
3. Register command in `src/metagit/cli/main.py` if it is a top-level subcommand.
4. Add command tests under `tests/cli/commands/` and integration tests if behavior crosses modules.
5. Run focused tests first, then project lint.

## Gotchas
- Putting substantial logic directly in Click handlers increases duplication and weakens testing boundaries.
- Forgetting registration in `main.py` makes command exist in code but unavailable in CLI.
- Command output contracts should remain stable (especially in automation-facing flows).

## Verify
- [ ] Command appears in `uv run metagit --help` or relevant group help output.
- [ ] Unit/CLI tests cover new path.
- [ ] Core behavior test exists if command bridges multiple services.
- [ ] `task lint` passes.

## Debug
- If command is missing: check `cli.add_command(...)` registration in `src/metagit/cli/main.py`.
- If context/logger missing: verify `@click.pass_context` and `ctx.obj` usage path.
- If tests hang on MCP command: use `--status-once` in test paths.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
`````

## File: .mex/patterns/add-managed-repo-search.md
`````markdown
---
name: add-managed-repo-search
description: Extend or debug managed-only repo lookup across CLI, MCP, and the local JSON API.
triggers:
  - "managed repo search"
  - "metagit_repo_search"
  - "repos/search"
  - "ManagedRepoSearchService"
edges:
  - target: context/architecture.md
    condition: when changing how search flows connect CLI, MCP, and HTTP surfaces
  - target: context/mcp-runtime.md
    condition: when adjusting MCP tool schema or dispatch for managed repo search
  - target: patterns/add-mcp-tool.md
    condition: when only the MCP tool contract or registry changes
last_updated: 2026-05-12
---

# Add or change managed repo search

## Context
Managed repo search is **only** the repos declared under `workspace.projects[].repos` in `.metagit.yml`. It does not scan the filesystem for unmanaged clones. Shared logic lives in `src/metagit/core/project/search_service.py` (`ManagedRepoSearchService`) and index rows from `WorkspaceIndexService.build_index`.

## Steps
1. Confirm ranking/filter behavior in `ManagedRepoSearchService` (`search` / `resolve_one`) and adjust tests in `tests/test_project_search_service.py`.
2. **CLI:** `src/metagit/cli/commands/search.py` — keep thin; delegate to the service + `MetagitConfigManager`.
3. **MCP:** register in `tool_registry.py`, add `inputSchema` + dispatch in `runtime.py` (`metagit_repo_search`); extend `tests/core/mcp/test_runtime.py` (and integration if gating changes).
4. **HTTP API:** `src/metagit/core/api/server.py` — GET handlers only; keep JSON stable; add tests under `tests/api/`.
5. Regenerate schema if `.metagit.yml` / models affecting tags change (`task generate:schema`).

## Gotchas
- Workspace root for path resolution is the **directory containing** `.metagit.yml`, not the file path itself.
- `metagit_workspace_search` (MCP) searches **paths on disk** inside the workspace; `metagit_repo_search` searches **configured managed repos** — different semantics; do not merge without updating docs and clients.
- HTTP `409` on `/v1/repos/resolve` is expected for `ambiguous_match`; clients must handle it (e.g. `urllib` raises `HTTPError`).

## Verify
- [ ] `uv run pytest tests/test_project_search_service.py tests/cli/commands/test_search.py tests/core/mcp/test_runtime.py tests/api/test_repo_search_api.py -q`
- [ ] `task lint` and `task test` green after broader changes.

## Debug
- Empty MCP/API results: gate inactive, invalid config, or `workspace_root` mismatch vs repo `path:` entries.
- CLI vs API mismatch: compare `--definition` parent directory to API `--root`.

## Update Scaffold
- [ ] Update `docs/cli_reference.md` if flags or endpoints change.
- [ ] Update `.mex/ROUTER.md` if user-visible surfaces change materially.
`````

## File: .mex/patterns/add-mcp-tool.md
`````markdown
---
name: add-mcp-tool
description: Add a new MCP tool to metagit runtime with schema, dispatch, gating, and tests.
triggers:
  - "add mcp tool"
  - "tools/list"
  - "tools/call"
edges:
  - target: context/mcp-runtime.md
    condition: when implementing runtime method handlers, schemas, and dispatch logic
  - target: context/conventions.md
    condition: when validating guardrails and verify checklist compliance
  - target: patterns/debug-mcp-runtime.md
    condition: when framed message flow or tool calls produce protocol errors
last_updated: 2026-05-05
---

# Add MCP Tool

## Context
Load `context/mcp-runtime.md` first. The source of truth is `src/metagit/core/mcp/runtime.py` plus supporting services under `src/metagit/core/mcp/services/`.

## Steps
1. Define tool name and argument contract.
2. Add `inputSchema` entry to runtime `_tool_schemas`.
3. Ensure tool name is included in state-appropriate registry in `tool_registry.py`.
4. Implement dispatch branch in `_dispatch_tool` (or call a new dedicated service method).
5. Add tests in `tests/core/mcp/test_runtime.py` for list + call + invalid args.
6. Add/adjust integration tests if state-gating behavior changes.

## Gotchas
- If schema and dispatcher validation drift, clients may pass checks but fail at runtime.
- New tools must respect gate state; inactive state should not expose high-risk operations.
- Return content should be JSON-serializable and stable for agent consumption.

## Verify
- [ ] Tool is visible in `tools/list` only in intended states.
- [ ] `tools/call` succeeds with valid args and returns structured content.
- [ ] Invalid args map to `-32602` with `invalid_arguments` data.
- [ ] `task lint` and MCP test suite pass.

## Debug
- For missing tool in list: check `ToolRegistry` and state snapshot logic.
- For call rejection: inspect gate state and allowed tool set.
- For protocol mismatch: inspect framed read/write handling and JSON envelope fields.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
`````

## File: .mex/patterns/bootstrap-metagit-config.md
`````markdown
---
name: bootstrap-metagit-config
description: Create or repair `.metagit.yml` using model validation and optional MCP sampling.
triggers:
  - "bootstrap config"
  - "generate .metagit.yml"
  - "metagit config invalid"
edges:
  - target: context/setup.md
    condition: when environment/command prerequisites impact config generation
  - target: context/stack.md
    condition: when config/schema model constraints need confirmation
  - target: context/mcp-runtime.md
    condition: when bootstrap is performed through MCP tool calls and sampling flow
last_updated: 2026-05-05
---

# Bootstrap Metagit Config

## Context
Use this when `.metagit.yml` is missing or invalid, or when onboarding a new workspace. Relevant modules are `metagit.core.config.manager` and MCP bootstrap services.

## Steps
1. Check for existing `.metagit.yml` and validate (`uv run metagit config validate`).
2. If missing/invalid, create baseline config using manager/create flow or bootstrap wrapper.
3. Re-validate using model-driven load path (not string-only checks).
4. If running through MCP, use `metagit_bootstrap_config` and verify returned mode (`plan_only` vs `sampled`).
5. Keep writes explicit when replacing existing config.

## Gotchas
- Writing guessed YAML without model validation creates downstream gate failures.
- Overwriting a valid `.metagit.yml` silently can erase workspace repo mappings.
- Sampling output must still pass strict config model validation before use.

## Verify
- [ ] `.metagit.yml` loads through `MetagitConfigManager.load_config()` without exception.
- [ ] Workspace section shape is valid for project/repo operations.
- [ ] MCP gate moves to active when expected.
- [ ] `task lint` and relevant tests pass.

## Debug
- If validation fails: inspect exact model error path and adjust YAML fields/types.
- If gate remains inactive: verify resolver root points to intended workspace directory.
- If sampling output fails repeatedly: run plan-only mode and patch fields manually.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
`````

## File: .mex/patterns/debug-mcp-runtime.md
`````markdown
---
name: debug-mcp-runtime
description: Diagnose MCP runtime failures across framing, initialize capabilities, tool dispatch, and resource reads.
triggers:
  - "mcp error"
  - "tools/call failed"
  - "resources/read failed"
  - "stdio framing"
edges:
  - target: context/mcp-runtime.md
    condition: when tracing runtime internals and protocol method handling
  - target: context/architecture.md
    condition: when failure source may be outside runtime (service/config layer)
  - target: patterns/add-mcp-tool.md
    condition: when bug appears while adding/changing tool schemas or dispatch behavior
last_updated: 2026-05-05
---

# Debug MCP Runtime

## Context
Primary files: `src/metagit/core/mcp/runtime.py`, `tool_registry.py`, gate/root resolver, and MCP service modules. Primary tests: `tests/core/mcp/test_runtime.py` and MCP integration tests.

## Steps
1. Reproduce with smallest path first (`metagit mcp serve --status-once` for gate snapshot).
2. Check gate state and allowed tool set before debugging individual tool behavior.
3. Validate request envelope fields (`jsonrpc`, `id`, `method`, `params`) and framed transport boundaries.
4. Run focused runtime tests, then full MCP suite.
5. If method-specific failure persists, add/adjust regression tests before patching.

## Gotchas
- Mistaking inactive-gate tool denial for runtime logic failure.
- Schema/dispatcher mismatch causing valid-looking requests to fail.
- Framing bugs can masquerade as random parse/protocol errors.

## Verify
- [ ] Runtime methods (`initialize`, `tools/list`, `tools/call`, `resources/*`) pass tests.
- [ ] Invalid arguments consistently return `-32602`.
- [ ] Sampling capability detection reflects initialize input.
- [ ] MCP suite and lint pass after fix.

## Debug
- Use `tests/core/mcp/test_runtime.py` as first failure boundary.
- For tool visibility bugs, inspect `ToolRegistry.list_tools` with active/inactive statuses.
- For resource failures, inspect `ResourcePublisher` payload path and `uri` handling.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
`````

## File: .mex/patterns/debug-workspace-discovery.md
`````markdown
---
name: debug-workspace-discovery
description: Diagnose missing/incorrect upstream repo discovery results in workspace index/search/hints flows.
triggers:
  - "upstream discovery"
  - "workspace search no results"
  - "hints look wrong"
edges:
  - target: context/architecture.md
    condition: when discovery issues involve config/workspace structure assumptions
  - target: context/mcp-runtime.md
    condition: when failures occur through MCP tool calls rather than direct service tests
  - target: patterns/bootstrap-metagit-config.md
    condition: when root issue is invalid or incomplete `.metagit.yml`
last_updated: 2026-05-05
---

# Debug Workspace Discovery

## Context
Discovery path spans `WorkspaceIndexService`, `WorkspaceSearchService`, and `UpstreamHintService`, usually driven by valid workspace config entries.

## Steps
1. Validate `.metagit.yml` and confirm `workspace.projects[].repos` is populated as expected.
2. Build index and inspect resolved `repo_path`, `exists`, and `sync` fields.
3. Check search inputs (`query`, `preset`, `max_results`) and whether target repo paths actually exist locally.
4. Inspect upstream hint scoring inputs (repo metadata + blocker text).
5. Run focused service tests and patch deterministic scoring/search behavior with regression tests.

## Gotchas
- Empty or invalid workspace repo definitions produce zero-index and cascade into no search/hints.
- Query term selection can be too strict or too broad depending on preset merge behavior.
- Local clone state (`exists=False`) can silently remove repos from search scope.

## Verify
- [ ] Index rows match expected repo definitions and path resolution.
- [ ] Search returns bounded hits from intended repositories/files.
- [ ] Hint ranking surfaces expected top candidates for blocker text.
- [ ] MCP tool path returns equivalent outputs when invoked via runtime.

## Debug
- If index is empty: fix config shape/path first.
- If hints rank poorly: inspect term overlap and score contributions.
- If MCP and direct service outputs differ: compare runtime dispatch argument parsing.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
`````

## File: .mex/patterns/mcp-cross-project-dependencies.md
`````markdown
---
name: mcp-cross-project-dependencies
description: Map or extend MCP cross-project dependency graph tooling.
triggers:
  - "cross project dependencies"
  - "metagit_cross_project_dependencies"
edges:
  - target: context/mcp-runtime.md
    condition: when changing runtime schemas or services
  - target: patterns/mcp-project-context.md
    condition: when combining with project context switch workflows
last_updated: 2026-05-15
---

# MCP Cross-Project Dependencies

## Context
Load `context/mcp-runtime.md`. Core logic: `cross_project_dependencies.py`, `import_hint_scanner.py`, `gitnexus_registry.py`.

## Layers
1. **declared/ref** — `.metagit.yml` tags, `ProjectPath.ref`, root `dependencies` / `components`
2. **shared_config/url_match** — identical URLs or `configured_path` across projects
3. **imports** — `package.json`, `pyproject.toml`, `go.mod`, terraform module paths between local repos
4. **GitNexus status** — `~/.gitnexus/registry.json` + optional `npx gitnexus status` per repo (not full graph export)

## Steps
1. Extend collectors in `CrossProjectDependencyService._collect_edges`.
2. Add MCP schema + dispatch in `runtime.py` and `tool_registry.py`.
3. Mock `GitNexusRegistryAdapter` in unit tests to avoid slow `npx` calls.
4. Update `metagit-repo-impact` skill when agent workflow changes.

## Verify
- [ ] `uv run pytest tests/core/mcp/services/test_cross_project_dependencies.py -q`
- [ ] `docs/cli_reference.md` documents tool parameters
`````

## File: .mex/patterns/mcp-project-context.md
`````markdown
---
name: mcp-project-context
description: Add or operate MCP project context switch, session store, and workspace snapshot tools.
triggers:
  - "project context switch"
  - "workspace snapshot"
  - "metagit_project_context_switch"
edges:
  - target: context/mcp-runtime.md
    condition: when changing runtime schemas, dispatch, or services
  - target: patterns/add-mcp-tool.md
    condition: when adding another MCP tool in the same area
last_updated: 2026-05-15
---

# MCP Project Context

## Context
Load `context/mcp-runtime.md`. Core code lives in `session_store.py`, `project_context.py`, `workspace_snapshot.py`, and `context_models.py`.

## Steps
1. Extend Pydantic models in `context_models.py` for any new persisted fields.
2. Update `SessionStore` paths under `.metagit/sessions/` — never store secret values.
3. Implement behavior in `ProjectContextService` / `WorkspaceSnapshotService`; keep git restore out of scope (metadata only).
4. Register tool schema + dispatch in `runtime.py` and `tool_registry.py`.
5. Add unit tests under `tests/core/mcp/services/` and runtime tests in `test_runtime.py`.

## Gotchas
- `metagit_workspace_state_restore` does not checkout branches or reset dirty repos.
- `project_name` session filenames must match `^[\w.-]+$`.
- Env exports skip sensitive variable refs and session overrides are validated.

## Verify
- [ ] `uv run pytest tests/core/mcp/services/test_project_context.py tests/core/mcp/services/test_workspace_snapshot.py tests/core/mcp/test_runtime.py -q`
- [ ] Skills and `docs/cli_reference.md` mention new tools when behavior is user-visible.
`````

## File: .mex/patterns/README.md
`````markdown
# Patterns

This folder contains task-specific guidance — the things you would tell your agent if you were sitting next to it. Not generic instructions. Project-specific accumulated wisdom.

## How patterns get created

**During setup:** After the context/ files are populated, the agent generates starter patterns based on the project's actual stack, architecture, and conventions. These are stack-specific — a Flask API project gets different patterns than a React SPA or a CLI tool.

**Over time:** You or your agent add patterns as they emerge from real work — when something breaks, when a task has a non-obvious gotcha, when you've explained the same thing twice.

## What belongs here

A pattern file is worth creating when:
- A task type is common in this project and has a repeatable workflow
- There are integration gotchas between components that aren't obvious from code
- Something broke and you want to prevent it from breaking the same way again
- A verify checklist specific to one type of task would catch mistakes early

## When to skip a pattern

Default to generating a pattern. Only skip if:
- The exact same guidance is already in `context/conventions.md` with concrete examples
- The task truly has no project-specific gotchas (e.g. "how to write a for loop")

If in doubt, generate the pattern. A pattern that turns out to be obvious costs nothing. A missing pattern costs a broken codebase.

## Format

### Single-task pattern (one file = one task)

```markdown
---
name: [pattern-name]
description: [one line — what this pattern covers and when to use it]
triggers:
  - "[keyword that should trigger loading this file]"
edges:
  - target: "[related file path, e.g. context/conventions.md]"
    condition: "[when to follow this edge]"
last_updated: [YYYY-MM-DD]
---

# [Pattern Name]

## Context
[What to load or know before starting this task type]

## Steps
[The workflow — what to do, in what order]

## Gotchas
[The things that go wrong. What to watch out for.]

## Verify
[Checklist to run after completing this task type]

## Debug
[What to check when this task type breaks]

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
```

### Multi-section pattern (one file = multiple related tasks)

Use this when tasks share context but differ in steps. Each task gets its own
`## Task: ...` heading with sub-sections. The Context section is shared at the top.

```markdown
---
name: [pattern-name]
description: [one line — what this pattern file covers]
triggers:
  - "[keyword]"
edges:
  - target: "[related file path]"
    condition: "[when to follow this edge]"
last_updated: [YYYY-MM-DD]
---

# [Pattern Name]

## Context
[Shared context for all tasks in this file]

## Task: [First Task Name]

### Steps
[...]

### Gotchas
[...]

### Verify
[...]

## Task: [Second Task Name]

### Steps
[...]

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [ ] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
```

Do NOT combine unrelated tasks into one file just to reduce file count.
Only group tasks that genuinely share context.

## How many patterns to generate

Do not use a fixed number. Generate one pattern per:
- Each major task type a developer does repeatedly in this project
- Each external dependency with non-obvious integration gotchas
- Each major failure boundary in the architecture flow

For a simple project this may be 3-4 files. For a complex project this may be 10-15.
Do not cap based on a number — cap based on whether the pattern adds real value.

## Pattern categories

Walk through each category below. For each one, check the relevant context files
and generate patterns for everything that applies to this project.

### Category 1 — Common task patterns

The repeatable tasks in this project. What does a developer do most often?

Derive from: `context/architecture.md` (what are the major components?) and
`context/conventions.md` (what patterns exist for extending them?)

Examples by project type:
- API: "add new endpoint", "add new model/entity", "add auth to a route"
- Frontend: "add new page/route", "add new component", "add form with validation"
- CLI: "add new command", "add new flag/option"
- Pipeline: "add new pipeline stage", "add new data source"
- SaaS: "add payment flow", "add user-facing feature", "add admin operation"

### Category 2 — Integration patterns

How to work with the external dependencies in this project.

Every entry in `context/stack.md` "Key Libraries" or `context/architecture.md`
"External Dependencies" that has non-obvious setup, gotchas, or failure modes
deserves a pattern. These are the most dangerous areas — the agent will
confidently write integration code that looks right but misses project-specific
configuration, error handling, or rate limiting.

Examples: "calling the payments API", "running database migrations",
"adding a new third-party service client", "configuring auth provider"

### Category 3 — Debug/diagnosis patterns

When something breaks, where do you look?

Derive from the architecture flow — each boundary between components is a
potential failure point. One debug pattern per major boundary.

Examples: "debug webhook failures", "debug pipeline stage failures",
"diagnose auth/permission issues", "debug background job failures"

### Category 4 — Deploy/release patterns

Only generate if `context/setup.md` reveals non-trivial deployment.

Examples: "deploy to staging", "rollback a release", "update environment config",
"run database migration in production"
`````

## File: .mex/patterns/run-graphify-analysis.md
`````markdown
---
name: run-graphify-analysis
description: Run focused `graphify` analysis on a repo or subtree, especially when a full-repo graph would be too large or noisy.
triggers:
  - "graphify"
  - "knowledge graph"
  - "graph report"
edges:
  - target: "context/architecture.md"
    condition: "when naming communities or interpreting cross-command relationships"
  - target: "context/conventions.md"
    condition: "when verifying scaffold updates after the graph run"
last_updated: 2026-05-11
---

# Run Graphify Analysis

## Context
- Use this when a user invokes `/graphify` or asks for a repo knowledge graph.
- Start with corpus detection before committing to a full run; this repo has generated/docs-heavy areas that can drown out the interesting command and core logic relationships.
- Prefer focused subtrees when the corpus is over the graphify warning threshold or when the user only needs one part of the codebase.

## Steps
1. Resolve a stable `graphify` Python interpreter and write it to `graphify-out/.graphify_python`.
2. Run `graphify.detect` first and summarize the corpus by file type.
3. If the corpus is over the graphify threshold, show the busiest subdirectories and ask the user which subtree to analyze.
4. Run structural extraction for code files. If the selected scope is code-only, skip semantic subagents and continue with the AST-only flow.
5. Build the graph, cluster it, then label communities with plain-language names before exporting HTML.
6. If the corpus is large enough for benchmarking, run `graphify benchmark`.
7. Save manifest/cost data, clean temporary graphify files, and surface the report's God Nodes, Surprising Connections, and Suggested Questions.

## Gotchas
- `uv run --with graphifyy` can resolve to a temporary interpreter path; use the installed `graphify` tool's shebang-backed Python for multi-step runs.
- Full-repo runs can be skewed by generated assets like `site/assets/*`; detect first instead of assuming `.` is the right scope.
- Code-only runs still produce useful graph structure and avoid unnecessary semantic extraction cost.
- Community labels matter; leaving generic `Community N` names makes the report and `graph.html` much harder to navigate.

## Verify
- Confirm `graphify-out/graph.html`, `graphify-out/GRAPH_REPORT.md`, and `graphify-out/graph.json` exist in the processed directory.
- Confirm the report includes named communities instead of placeholder community labels.
- If benchmark ran, capture its reduction summary for the user.
- Confirm cleanup removed temp extraction files while preserving the final outputs.

## Debug
- If interpreter resolution breaks between steps, regenerate `graphify-out/.graphify_python` from the installed `graphify` binary.
- If the graph is empty, inspect the detect output first; the scope may contain unsupported files or only skipped content.
- If the corpus crosses the threshold, do not push through the full run blindly; narrow the scope and rerun.
- If community names look mixed or misleading, inspect the top labels per community from `graph.json` before regenerating the report.

## Update Scaffold
- [ ] Update `.mex/ROUTER.md` "Current Project State" if what's working/not built has changed
- [ ] Update any `.mex/context/` files that are now out of date
- [x] If this is a new task type without a pattern, create one in `.mex/patterns/` and add to `INDEX.md`
`````

## File: .mex/patterns/update-release-workflow.md
`````markdown
---
name: update-release-workflow
description: Safely replace or adjust GitHub release automation when post-merge release runs fail.
triggers:
  - "release workflow failing after merge"
  - "replace release automation"
  - "github actions release pipeline"
edges:
  - target: "context/conventions.md"
    condition: "before final verification to ensure required checks are run"
  - target: "ROUTER.md"
    condition: "after completing workflow changes to keep project state current"
last_updated: 2026-05-08
---

# Update Release Workflow

## Context
- Identify the active release workflows under `.github/workflows/`.
- Locate known-good reference workflows (for example `.github/example/`).
- Confirm expected release trigger model: `main` push for tag generation, tag push for publishing.

## Steps
1. Compare current release workflow files against the known-good copies.
2. Replace release orchestration logic first (for example `release-please` to semantic-tag flow).
3. Ensure publish workflow gates package publishing on tag refs only.
4. Ensure artifact lifecycle is intact (`build` -> `publish-test` -> `publish-prod` -> GitHub release).
5. Remove deprecated/conflicting workflows so only one release orchestration path is active.
6. Update `.mex/ROUTER.md` project state to match the new release mechanism.

## Gotchas
- Do not leave both `release-please` and semantic-tag workflows enabled unless intentionally dual-tracked.
- Keep token usage consistent with repo secrets (`PAT_TOKEN` vs `GITHUB_TOKEN`) and branch protection.
- Align tag format expectations between semantic tag creator and publish workflow filters.

## Verify
- Confirm all edited workflow YAML files parse in GitHub Actions syntax.
- Confirm semantic release workflow triggers on `push` to `main`.
- Confirm publish workflow only publishes when `github.ref` is a tag.
- Confirm no obsolete release workflow file remains active.

## Debug
- If release automation fails immediately on merge, inspect missing secret or permissions scope first.
- If publish never runs, inspect tag format mismatch versus `startsWith(github.ref, 'refs/tags/...')`.
- If GitHub release creation fails, confirm release job has `contents: write`.

## Update Scaffold
- [x] Update `.mex/ROUTER.md` "Current Project State" if release behavior changed
- [x] Update relevant `.mex/patterns/*` for this task type
- [x] Add this pattern to `.mex/patterns/INDEX.md`
`````

## File: .mex/config.json
`````json
{
  "aiTools": [
    "cursor"
  ]
}
`````

## File: docs/reference/workspace-layout-api.md
`````markdown
# Workspace layout API (v2)

Rename and move operations for workspace projects and repositories. Intended for agents, CLI, MCP, and a future web UI.

## Sync root

Layout operations apply disk changes under `workspace.path` from app config (`metagit.config.yaml`). The HTTP server’s `--root` is the directory containing `.metagit.yml`.

## Endpoints

| Method | Path | Body |
|--------|------|------|
| POST | `/v2/projects/{from}/rename` | `{ "to_name": "apps" }` |
| POST | `/v2/repos/{project}/{repo}/rename` | `{ "to_name": "new-name" }` |
| POST | `/v2/repos/{project}/{repo}/move` | `{ "to_project": "platform" }` |

Query flags (or JSON body): `dry_run`, `manifest_only`, `force`, `no_update_sessions` (project rename only).

## Response shape

Same as catalog mutations:

```json
{
  "ok": true,
  "entity": "repo",
  "operation": "move",
  "project_name": "platform",
  "repo_name": "svc-a",
  "from_project": "portfolio",
  "to_project": "platform",
  "config_path": "/path/.metagit.yml",
  "data": {
    "dry_run": false,
    "manifest_changes": ["..."],
    "disk_steps": [{"action": "move", "source": "...", "target": "..."}],
    "warnings": [],
    "manifest_updated": true
  }
}
```

## CLI equivalents

```bash
metagit workspace project rename alpha apps --dry-run --json
metagit workspace repo rename -p alpha svc-a svc-b --json
metagit workspace repo move -p alpha -n svc-a --to-project beta --json
```

## MCP tools

- `metagit_workspace_project_rename`
- `metagit_workspace_repo_rename`
- `metagit_workspace_repo_move`

Always pass `dry_run: true` first when an agent is unsure about disk impact.
`````

## File: docs/app.logic.md
`````markdown
# Metagit CLI Application Logic

This document outlines the architecture and logic of the Metagit CLI application. It includes a Mermaid diagram to visualize the interaction between different components.

## Core Components

The application is built around a few core components:

*   **CLI (Click):** The command-line interface is implemented using the `click` library. The main entry point is in `src/metagit/cli/main.py`, which defines the main `cli` group and subcommands.
*   **Configuration:** The application uses a YAML-based configuration file (`metagit.config.yaml`) to manage settings. The schema for this file is defined in `schemas/metagit_config.schema.json`.
*   **Logging:** A unified logging system is implemented in `src/metagit/core/utils/logging.py` to provide consistent logging across the application.
*   **Subcommands:** The application is organized into several subcommands, each with its own module in `src/metagit/cli/commands`.

## Mermaid Diagram

```mermaid
graph TD
    A[User] --> B{metagit CLI};
    B --> C{main.py};
    C --> D{Click Library};
    D --> E{Subcommands};
    E --> F[detect];
    E --> G[appconfig];
    E --> H[project];
    E --> I[workspace];
    E --> J[config];
    E --> K[record];
    E --> L[init];
    C --> M{Configuration};
    M --> N[metagit.config.yaml];
    C --> O{Logging};
    O --> P[UnifiedLogger];
```

## Component Interaction

1.  The user interacts with the application through the `metagit` command-line interface.
2.  The `main.py` script is the entry point, which initializes the `click` CLI application.
3.  The `click` library parses the command-line arguments and invokes the appropriate subcommand.
4.  Each subcommand has its own dedicated module that contains the logic for that command.
5.  The application loads its configuration from `metagit.config.yaml`.
6.  Logging is handled by the `UnifiedLogger` class, which provides a consistent logging format.
`````

## File: docs/architecture-diagram.py
`````python
#! /usr/bin/env python3

# Architecture Diagram Generator: Multi-Agent Git Project Graph
# Requires: uv sync --group diagrams  (optional dependency group in pyproject.toml)
from diagrams import Cluster, Diagram
from diagrams.aws.compute import ECS
from diagrams.aws.database import Neptune
from diagrams.custom import Custom
from diagrams.generic.storage import Storage
from diagrams.onprem.client import User
from diagrams.onprem.inmemory import Redis
from diagrams.onprem.queue import Kafka
from diagrams.onprem.vcs import Github
from diagrams.programming.language import Python

with Diagram("Multi-Agent Git Artifact Relationship System", show=False):
    user = User("Developer / API Client")

    with Cluster("Ingestion Layer"):
        git_sources = [Github("GitHub"), Custom("GitLab", "./icons/gitlab.png")]
        fetcher = Python("Git Fetcher")
        parser = Python("Build Parser / ORT")
        storage = Storage("Repo Cache")
        git_sources >> fetcher >> storage >> parser

    with Cluster("Artifact/Dependency Extraction"):
        extractor = Python("Artifact Extractor")
        normalizer = Python("Normalizer")
        artifact_store = Storage("Artifact Metadata")
        parser >> extractor >> normalizer >> artifact_store

    with Cluster("Graph Layer"):
        graphdb = Neptune("Graph DB (Neo4j / Neptune)")
        artifact_store >> graphdb

    with Cluster("MCP & Agent System"):
        agent_router = Python("Agent Router")
        kafka = Kafka("Event Bus")
        redis = Redis("Agent Memory")

        agents = [
            Python("Scaffolder Agent"),
            Python("Dependency Advisor"),
            Python("Security Checker"),
            Python("Test Generator"),
            Python("PR Validator"),
        ]

        agent_router >> kafka >> agents
        agents >> redis
        redis >> agent_router
        graphdb >> agent_router

    with Cluster("API & Insights"):
        api = Python("FastAPI / GraphQL")
        dashboard = Custom("UI Dashboard", "./icons/webapp.png")

        graphdb >> api
        agent_router >> api
        user >> dashboard >> api
`````

## File: docs/hermes-iac-workspace-guide.md
`````markdown
# Hermes agents and organization-wide IaC

This guide shows how a **Hermes** controller agent uses **Metagit** as the control plane for an entire organization's infrastructure-as-code (IaC) surface: Terraform, OpenTofu, Pulumi, policy repos, modules, and the application repos they provision.

Metagit does not replace Terraform or your cloud provider. It gives Hermes a **single, validated map** of every managed repository, plus MCP tools to search, inspect, sync, triage, and hand **layered instructions** to subagents working in specific repos.

---

## Roles in the stack

| Role | Responsibility |
|------|----------------|
| **Hermes (controller)** | Owns the user objective across many repos; reads workspace manifest; launches subagents with composed `agent_instructions`; never clones blindly. |
| **Metagit** | Source of truth for workspace layout, repo metadata, health, search, and guarded git operations. |
| **Subagents (workers)** | Execute in one repo or path at a time (module bump, policy fix, drift investigation) using instructions from the controller. |
| **Git + CI** | Actual IaC execution (`plan`/`apply`, policy checks) stay in each repository's pipelines. |

```mermaid
flowchart TB
  subgraph human [Human operator]
    U[Platform / SRE lead]
  end

  subgraph hermes [Hermes controller]
    H[Controller agent]
    H -->|composed instructions| S1[Subagent: networking]
    H -->|composed instructions| S2[Subagent: eks-modules]
    H -->|composed instructions| S3[Subagent: policy]
  end

  subgraph metagit [Metagit control plane]
    M[MCP + CLI]
    Y[".metagit.yml"]
    M --> Y
  end

  subgraph surface [IaC surface on disk]
    R1[terraform-live/]
    R2[modules/]
    R3[policy/]
    R4[gitops/]
  end

  U --> H
  H --> M
  M --> R1
  M --> R2
  M --> R3
  M --> R4
  S1 --> R1
  S2 --> R2
  S3 --> R3
```

---

## Mapping org IaC to Metagit

Think of one **umbrella workspace** that models the whole platform engineering estate.

```text
Organization IaC estate
└── Metagit workspace (umbrella .metagit.yml)
    ├── Project: platform-live          # Terragrunt / root modules per env
    │   ├── Repo: terraform-live-prod
    │   └── Repo: terraform-live-staging
    ├── Project: shared-modules         # Reusable TF modules
    │   ├── Repo: terraform-aws-vpc
    │   └── Repo: terraform-aws-eks
    ├── Project: policy                 # OPA, Sentinel, Checkov baselines
    │   └── Repo: org-policy
    └── Project: delivery               # Pipelines, Atlantis, runners
        └── Repo: cicd-terraform
```

On disk (typical layout under app config `workspace.path`, often `.metagit/`):

```text
.metagit/
├── platform-live/
│   ├── terraform-live-prod/
│   └── terraform-live-staging/
├── shared-modules/
│   ├── terraform-aws-vpc/
│   └── terraform-aws-eks/
├── policy/
│   └── org-policy/
└── delivery/
    └── cicd-terraform/
```

**Tags** on each `repos[]` entry (for example `tier: "1"`, `iac: "terraform"`, `env: "prod"`) let Hermes filter with `metagit_repo_search` / `metagit search` without scanning the filesystem.

---

## Layered `agent_instructions`

Instructions are optional at four levels. Hermes composes them when switching project context or launching a subagent focused on a repo.

| Layer | YAML location | Typical IaC content |
|-------|---------------|---------------------|
| **File** | top-level `agent_instructions` | Org-wide rules: naming, approval, no `apply` in prod without ticket. |
| **Workspace** | `workspace.agent_instructions` | How to use this umbrella: backends, state buckets, module sources. |
| **Project** | `workspace.projects[].agent_instructions` | Project boundary: "platform-live = Terragrunt only; never edit modules here." |
| **Repo** | `workspace.projects[].repos[].agent_instructions` | Repo-specific: "Subagent: only `env/prod/**`; run `terraform fmt` before commit." |

Legacy manifests may still use `agent_prompt`; Metagit accepts it on load and writes `agent_instructions` going forward.

**Example manifest excerpt:**

```yaml
name: acme-platform-iac
kind: umbrella
agent_instructions: |
  Controller: You manage Acme's IaC estate. Never run terraform apply.
  Coordinate subagents per repo. Always run plan-only unless user explicitly approves apply.

workspace:
  agent_instructions: |
    Remote state: s3://acme-tf-state/{project}/{env}.
    Module sources: git::ssh://git@github.com/acme/terraform-modules.git//...
  projects:
    - name: platform-live
      description: Live infrastructure roots per environment
      agent_instructions: |
        Use Terragrunt. Respect dependency order: network before compute.
      repos:
        - name: terraform-live-prod
          path: platform-live/terraform-live-prod
          url: git@github.com:acme/terraform-live-prod.git
          sync: true
          tags:
            iac: terraform
            env: prod
          agent_instructions: |
            Subagent scope: env/prod only. Required checks: fmt, validate, tflint.
        - name: terraform-live-staging
          path: platform-live/terraform-live-staging
          sync: true
          tags:
            iac: terraform
            env: staging
    - name: shared-modules
      agent_instructions: |
        Semver tag modules on release. No environment-specific values in modules/.
      repos:
        - name: terraform-aws-vpc
          path: shared-modules/terraform-aws-vpc
          sync: true
          tags:
            iac: terraform
            layer: module
```

**Composed output** (what subagents receive via `effective_agent_instructions`):

```text
[FILE]
Controller: You manage Acme's IaC estate...

---

[WORKSPACE]
Remote state: s3://acme-tf-state/...

---

[PROJECT]
Use Terragrunt. Respect dependency order...

---

[REPO]
Subagent scope: env/prod only...
```

---

## One-time setup for Hermes

Install the CLI and agent integrations on the host where Hermes runs (or in the umbrella repo CI image):

```bash
uv tool install metagit-cli
metagit version

# Bundled skills (control center, multi-repo, gitnexus, projects, …)
metagit skills install --scope user --target hermes

# MCP server for tool calls from Hermes
metagit mcp install --scope user --target hermes
```

Bootstrap the umbrella coordinator (if not already present):

```bash
cd /path/to/platform-umbrella
metagit init --kind umbrella
metagit project repo add --project platform-live --prompt
metagit config validate
metagit project sync
```

Point Hermes MCP at the workspace root (directory containing `.metagit.yml`):

```bash
metagit mcp serve --root /path/to/platform-umbrella
# or: metagit mcp serve --status-once   # verify gate is active
```

!!! note "Gate behavior"
    Until `.metagit.yml` exists and validates, MCP exposes only safe tools (`metagit_workspace_status`, bootstrap plan). Full workspace tools unlock when the gate is **active**.

---

## Controller session lifecycle

```mermaid
sequenceDiagram
  participant U as Operator
  participant H as Hermes controller
  participant M as Metagit MCP
  participant S as Subagent

  U->>H: Objective: bump VPC module in prod roots
  H->>M: metagit_workspace_status
  M-->>H: active gate, workspace root
  H->>M: metagit_workspace_health_check
  M-->>H: missing clones, stale branches, GitNexus
  H->>M: metagit_repo_search (tags/layer/module)
  M-->>H: terraform-aws-vpc, terraform-live-prod
  H->>M: metagit_cross_project_dependencies
  M-->>H: declared + import hints
  H->>M: metagit_project_context_switch (platform-live)
  M-->>H: effective_agent_instructions + repos
  H->>M: metagit_workspace_sync (dry_run / fetch)
  H->>S: Task + effective_agent_instructions (module repo)
  S->>S: Edit module, PR
  H->>S: Task + instructions (live repo, prod paths)
  S->>S: Bump version ref, plan-only
  H->>M: metagit_session_update (notes)
  H->>U: Summary + open PRs
```

### Step-by-step (controller checklist)

1. **Orient** — `metagit_workspace_status`, read `metagit://workspace/config` and `metagit://workspace/repos/status`.
2. **Hygiene** — `metagit_workspace_health_check` (clone missing repos, note drift/stale integration).
3. **Scope** — `metagit_repo_search` / `metagit search` with tags (`iac=terraform`, `env=prod`).
4. **Blast radius** — `metagit_cross_project_dependencies` from the module or live project; optional GitNexus `analyze` + semantic search for unknown callers.
5. **Focus** — `metagit_project_context_switch` with `project_name` and optional `primary_repo` for the first subagent.
6. **Refresh** — `metagit_workspace_sync` with `mode: fetch` (default); `pull`/`clone` only with explicit approval.
7. **Delegate** — Pass `effective_agent_instructions` and repo path to each subagent; use per-repo `agent_instructions` when tasks split across repos.
8. **Remember** — `metagit_session_update` before switching projects; optional `metagit_workspace_state_snapshot` for audit metadata.

---

## IaC-focused Metagit capabilities

### Discover and search

| Need | Tool | Notes |
|------|------|--------|
| Find all `.tf` under managed repos | `metagit_workspace_discover` | `intent: terraform` or `pattern: "**/*.tf"` |
| Grep module sources / backends | `metagit_workspace_search` | `preset: terraform`, `query: "module \""` |
| Concept-level search | `metagit_workspace_semantic_search` | Requires GitNexus index per repo |
| List repos by tag | `metagit_repo_search` | `tags`, `status`, `sync_enabled` |

```mermaid
flowchart LR
  subgraph discover [Discovery]
    D1[workspace_discover]
    D2[workspace_search preset=terraform]
    D3[semantic_search]
  end

  subgraph repos [Managed repos only]
    R[.metagit.yml registry]
  end

  R --> D1
  R --> D2
  R --> D3
```

### Upstream triage

When a subagent hits an error in one repo (provider version, shared module API), the controller uses **`metagit_upstream_hints`** with a blocker string instead of guessing which module repo to open.

Typical flow:

1. Subagent reports: `Error: Unsupported argument "xyz" in module vpc`.
2. Controller runs upstream hints + workspace search for `module "vpc"` / path hints.
3. Controller reassigns or spawns subagent on `terraform-aws-vpc` with module-level `agent_instructions`.

### Health and drift

`metagit_workspace_health_check` surfaces:

- Repos not cloned (`configured_missing` → `clone` recommendation)
- Dirty trees and commits behind origin (`sync`)
- Branch age / merge-base staleness (`review_branch_age`, `reconcile_integration`)
- GitNexus index stale (`analyze`)

For IaC teams, **integration staleness** often means live roots have not merged default branch since a module release.

### Cross-project dependencies

`metagit_cross_project_dependencies` combines:

- Declared edges in `.metagit.yml`
- Import hints from manifests (`package.json`, etc., where relevant)
- URL matches across repos

Use this before org-wide module version bumps to build an ordered rollout: **modules → policy → live roots**.

---

## Subagent launch pattern

Hermes should treat each subagent as a **scoped worker**:

```mermaid
flowchart TD
  C[Hermes controller]
  C -->|1. context_switch + primary_repo| M[Metagit MCP]
  M -->|effective_agent_instructions| C
  C -->|2. task envelope| W[Subagent]
  W -->|3. work only in repo_path| G[Git checkout]

  subgraph envelope [Task envelope]
    E1[effective_agent_instructions]
    E2[repo_path / suggested_cwd]
    E3[objective + constraints]
    E4[forbidden: apply in prod]
  end

  C --> envelope
  envelope --> W
```

**Task envelope fields (recommended):**

- `effective_agent_instructions` — full composed stack from Metagit.
- `repo_path` — absolute path from context bundle (`suggested_cwd` or `primary_repo`).
- `objective` — one sentence the controller owns.
- `evidence` — links to search hits, dependency graph snippet, health row.
- `stop_conditions` — e.g. "stop after plan output; do not push."

Subagents should **not** maintain a parallel repo list; they trust Metagit's index for path resolution.

---

## Example: org-wide provider bump

**Objective:** Bump AWS provider `~> 5.0` across all Terraform roots.

| Phase | Controller action | Metagit tools |
|-------|-------------------|---------------|
| Inventory | List prod/staging roots | `metagit_repo_search`, tags `iac=terraform` |
| Order | Modules before live | `metagit_cross_project_dependencies` |
| Hygiene | Fetch latest | `metagit_workspace_sync`, `only_if: behind_origin`, `dry_run` first |
| Work 1 | Subagent on each module repo | `project_context_switch` + repo instructions |
| Work 2 | Subagent per live repo | `workspace_search` for `required_providers` |
| Verify | Health + notes | `workspace_health_check`, `session_update` |

---

## Skills Hermes should load

Install bundled skills with `metagit skills install --target hermes`. High-value skills for IaC controllers:

| Skill | Use when |
|-------|----------|
| `metagit-control-center` | Every multi-repo session |
| `metagit-projects` | Before creating folders or registering repos |
| `metagit-workspace-scope` | Session start |
| `metagit-multi-repo` | Features spanning repos |
| `metagit-workspace-sync` | Refreshing checkouts |
| `metagit-upstream-triage` | Errors that may originate in another repo |
| `metagit-gitnexus` | Before semantic search or impact questions |
| `metagit-repo-impact` | Planning module or API changes |
| `metagit-gating` | MCP inactive / missing config |
| `metagit-release-audit` | Before hand-off (`task qa:prepush` on metagit-cli itself) |

---

## Safety rules (IaC)

- **No `apply` in controller or subagent prompts** unless the human explicitly requests it; prefer plan-only language in `agent_instructions`.
- **No unscoped sync** — use `repos: ["project/repo"]`, not `all`, unless the objective requires it.
- **Mutations** — `pull` / `clone` require `allow_mutation: true` in MCP; default to `fetch`.
- **Secrets** — never put credentials in `agent_instructions` or `env_overrides`; use existing secrets tooling and variable refs in `.metagit.yml`.
- **Config edits** — validate after every manifest change: `metagit config validate`.
- **Register before clone** — follow `metagit-projects`: search managed config before creating directories.

---

## Related documentation

- [Terminology](terminology.md) — workspace, project, repo definitions
- [CLI Reference](cli_reference.md) — MCP tool parameters
- [Installation](install.md) — CLI and skills install
- [Application Logic](app.logic.md) — component overview

For local JSON automation without MCP, `metagit api serve` exposes managed-repo search under `/v1/repos/search` (read-only); the Hermes-oriented MCP surface remains the primary control-plane integration.
`````

## File: docs/hermes-orchestrator-workspace.md
`````markdown
# Hermes orchestrator workspace

Use this guide when a **Hermes** controller agent should act as the DevOps and project-management
entrypoint for a portfolio of repositories and local publish paths. Metagit holds the manifest;
Hermes holds the objective across projects.

For Terraform-heavy estates, also read [Hermes & org IaC guide](hermes-iac-workspace-guide.md).

## Quick start

1. Create or choose an umbrella coordinator repository.
2. Initialize from the bundled template (interactive prompts or an answers file):

```bash
metagit init --list-templates
metagit init ./hermes-control-plane --create --template hermes-orchestrator
# non-interactive:
metagit init --target ./hermes-control-plane --create --template hermes-orchestrator \
  --answers-file examples/hermes-orchestrator/answers.example.yml \
  --no-prompt
```

   Or copy [examples/hermes-orchestrator/.metagit.yml](../examples/hermes-orchestrator/.metagit.yml)
   manually and adjust projects, repos, and instructions.

3. Enable workspace dedupe in app config when the same URL appears in multiple projects:

```yaml
config:
  workspace:
    path: ./.metagit
    dedupe:
      enabled: true
      scope: workspace
```

4. Validate and sync:

```bash
metagit config validate
metagit project sync --project portfolio
metagit project sync --project local
```

5. Serve MCP for Hermes:

```bash
metagit mcp serve --root /path/to/coordinator
```

## Projects in the example manifest

| Project | Purpose |
|---------|---------|
| `portfolio` | Git-backed services and applications |
| `local` | Non-git `path` repos for static sites and local publish workflows |
| `platform` | Optional IaC / shared infra (empty until you add repos) |

The `local` project is the pattern for “publish a folder on disk” without a remote. Each entry
uses `path` + `sync: true`; metagit symlinks into `workspace.path` (or a canonical store when
dedupe is enabled).

## Controller responsibilities

The root `agent_instructions` in the example manifest define the controller loop:

- Orient with workspace status and health.
- Search before creating directories or clones.
- Register work in `.metagit.yml` and validate.
- Switch project context; delegate single-repo work to subagents.
- Sync conservatively (fetch by default).
- Keep descriptions and per-repo instructions accurate.

## Template apply

To copy helper files into an existing synced project folder:

```bash
metagit mcp serve --root /path/to/coordinator
# Tool: metagit_project_template_apply
#   template: hermes-orchestrator
#   target_projects: ["portfolio"]
#   dry_run: true
```

Or use the MCP tool from your agent host with `confirm_apply` when ready.

## Related docs

- [Hermes & org IaC guide](hermes-iac-workspace-guide.md) — Terraform / module rollout patterns
- [Configuration exemplar](reference/metagit-config.md) — full `.metagit.yml` field reference sample
- [Skills](skills.md) — `metagit-projects`, `metagit-control-center`, workspace sync skills
`````

## File: docs/secrets.analysis.md
`````markdown
# Secrets and Variables Analysis

This document outlines the secrets and variables used in the `metagit-detect` project, their sources, and their roles in the application's runtime and CI/CD workflows.

## Runtime Secrets and Variables

These secrets and variables are sourced from `.env` files and are used by the application at runtime. The `.env.example` file provides a template for these variables.

-   **`GITHUB_TOKEN`**:
    -   **Description**: A GitHub Personal Access Token (PAT) used to authenticate with the GitHub API. This is required for analyzing repositories, fetching metadata, and other interactions with GitHub.
    -   **Source**: `.env` file.
    -   **Usage**: Used by the `github` provider in the application.

-   **`GITLAB_TOKEN`**:
    -   **Description**: A GitLab Personal Access Token (PAT) used to authenticate with the GitLab API. This is necessary for interacting with GitLab repositories.
    -   **Source**: `.env` file.
    -   **Usage**: Used by the `gitlab` provider in the application.

-   **`METAGIT_LLM_TOKEN`**:
    -   **Description**: The API token for the configured Large Language Model (LLM) provider. This is used for features that leverage LLMs.
    -   **Source**: `.env` file.
    -   **Usage**: Used by the LLM client in the application.

-   **`OPENROUTER_API_KEY`**:
    -   **Description**: The API key for the OpenRouter service. This is used when `openrouter` is configured as the LLM provider.
    -   **Source**: `.env` file.
    -   **Usage**: Used by the LLM client when the provider is set to `openrouter`.

## CI/CD Secrets and Variables

These secrets are configured in the GitHub repository's secrets and are used in the CI/CD workflows.

-   **`secrets.GITHUB_TOKEN`**:
    -   **Description**: A GitHub token that is automatically generated by GitHub Actions. It is used to authenticate with the GitHub API for various tasks within the CI/CD pipelines.
    -   **Source**: GitHub repository secrets.
    -   **Usage**:
        -   Publishing Docker images to the GitHub Container Registry (`ghcr.io`).
        -   Creating and managing GitHub releases.

-   **`secrets.PYPI_API_TOKEN`**:
    -   **Description**: An API token for PyPI, used to authenticate and publish the Python package to the Python Package Index.
    -   **Source**: GitHub repository secrets, configured as a trusted publisher.
    -   **Usage**: Publishing the package to PyPI in the `release.yaml` workflow.
`````

## File: docs/secrets.definitions.yml
`````yaml
# Secrets and Variables Definitions

secrets:
  - name: GITHUB_TOKEN
    description: "GitHub Personal Access Token for API authentication."
    provenance: "User-provided, stored in .env file."
    placement: "runtime"
    type: "string"

  - name: GITLAB_TOKEN
    description: "GitLab Personal Access Token for API authentication."
    provenance: "User-provided, stored in .env file."
    placement: "runtime"
    type: "string"

  - name: METAGIT_LLM_TOKEN
    description: "API token for the configured LLM provider."
    provenance: "User-provided, stored in .env file."
    placement: "runtime"
    type: "string"

  - name: OPENROUTER_API_KEY
    description: "API key for the OpenRouter service."
    provenance: "User-provided, stored in .env file."
    placement: "runtime"
    type: "string"

  - name: CI_GITHUB_TOKEN
    description: "GitHub token for CI/CD operations."
    provenance: "GitHub repository secrets (secrets.GITHUB_TOKEN)."
    placement: "cicd"
    type: "string"

  - name: CI_PYPI_API_TOKEN
    description: "PyPI API token for publishing the package."
    provenance: "GitHub repository secrets (secrets.PYPI_API_TOKEN)."
    placement: "cicd"
    type: "string"
`````

## File: examples/hermes-orchestrator/.metagit.yml
`````yaml
name: hermes-control-plane
description: |
  Umbrella workspace for a Hermes controller agent that orchestrates DevOps and
  project-management work across many repositories and local publish paths.
kind: umbrella
agent_instructions: |
  You are the Hermes controller for this workspace: the DevOps and project-management
  entrypoint for the operator. You do not improvise workspace layout.

  Session start (every time):
  1. metagit_workspace_status — confirm gate is active and note workspace root.
  2. metagit_workspace_health_check — surface missing clones, broken mounts, duplicate URLs.
  3. Read metagit://workspace/config when you need the full manifest.

  New or continued work:
  - Search before create: metagit_repo_search / `metagit search` for names, URLs, tags.
  - Reuse existing workspace.projects[] and repos[] entries; never clone into ad-hoc folders.
  - Register changes in .metagit.yml (catalog tools or validated YAML), then metagit config validate.
  - metagit_project_context_switch to focus one project; pass effective_agent_instructions to subagents.
  - Stay controller for cross-project objectives; delegate single-repo implementation to subagents.
  - metagit_workspace_sync: fetch by default; pull/clone only with explicit operator approval.
  - metagit_session_update before switching projects or ending the session.

  Documentation duty:
  - Every repo or path must have a clear description in this manifest.
  - Record build, deploy, and publish steps in per-repo agent_instructions when non-obvious.
  - When paths, URLs, or ownership change, update .metagit.yml before claiming work is done.

workspace:
  description: |
    Portfolio registry: git-backed services plus local-only publish paths. Metagit is the
    source of truth for what exists on disk under the configured workspace.path.
  agent_instructions: |
    Validate after manifest edits (`metagit config validate`). Prefer metagit catalog and
    MCP tools over hand-editing repo lists without validation. Enable workspace.dedupe in
    app config when the same remote URL appears in multiple projects.
  projects:
    - name: portfolio
      description: Git-backed applications and shared services under active development.
      agent_instructions: |
        Git operations are allowed per repo policy. Launch subagents with repo-scoped
        effective_agent_instructions for implementation; you coordinate sequencing and PRs.
      repos:
        - name: example-api
          description: Sample HTTP API service (replace with your repository).
          url: https://github.com/example-org/example-api.git
          sync: true
          kind: service
          tags:
            tier: application
          agent_instructions: |
            Run unit tests and lint before opening PRs. Note breaking API changes in PR text.

    - name: local
      description: |
        Local-only paths (no git remotes). Used to build and publish static web apps and
        personal sites for the operator.
      agent_instructions: |
        Repos here use `path`, not `url`. Do not git clone or pull. Sync creates symlinks
        into the workspace. Document publish targets in each repo's agent_instructions.
      repos:
        - name: example-site
          description: Sample static site published from a folder on disk.
          path: ~/Sites/example-site
          sync: true
          kind: website
          tags:
            publish: static
          agent_instructions: |
            Build: npm run build (or project README). Publish: copy dist/ to the host named
            in README. Update `path` in .metagit.yml if the site directory moves.

    - name: platform
      description: Optional shared infrastructure (Terraform, modules, policy). See docs/hermes-iac-workspace-guide.md.
      agent_instructions: |
        Treat IaC changes as high blast-radius. Use metagit_cross_project_dependencies and
        GitNexus before module version bumps. Subagents run plan-only unless operator approves apply.
      repos: []
`````

## File: examples/hermes-orchestrator/answers.example.yml
`````yaml
# Example answers for non-interactive init:
#   metagit init --template hermes-orchestrator --answers-file examples/hermes-orchestrator/answers.example.yml --no-prompt
name: hermes-control-plane
description: Umbrella workspace for a Hermes controller agent.
url: ""
portfolio_repo_name: example-api
portfolio_repo_url: https://github.com/example-org/example-api.git
local_site_name: example-site
local_site_path: ~/Sites/example-site
`````

## File: examples/metagit.umbrella.yml
`````yaml
description: Umbrella workspace for metagit projects
kind: umbrella
name: example_workspace
url: https://github.com/metagit-io/metagit_umbrella
workspace:
  projects:
  - name: default
    repos:
    - name: taskfiles
      description: "Shared taskfiles"
      url: git@github.com:zloeber/taskfiles.git
    - name: dotfiles
      description: "Personal dotfiles"
      url: git@github.com:zloeber/dotfiles.git
    - name: k8s-lab-terraform-libvirt
      description: "Kubernetes lab with Terraform and Libvirt"
      url: git@github.com:zloeber/k8s-lab-terraform-libvirt.git
    - name: crewai-openrouter-lab
      description: "Openrouter lab for crewai"
      url: git@github.com:zloeber/crewai-openrouter-lab.git
    - name: metagit_cli
      description: "Metagit CLI project (symlinked)"
      path: ./src/metagit/cli
`````

## File: scripts/prepush-gate.zsh
`````zsh
#!/usr/bin/env zsh

set -euo pipefail

if command -v uv >/dev/null 2>&1; then
  exec uv run python "./scripts/prepush-gate.py" "$@"
fi

exec python3 "./scripts/prepush-gate.py" "$@"
`````

## File: scripts/validate_skills.py
`````python
#!/usr/bin/env python3
"""
Validate every SKILL.md under skills/ against Claude Code skill conventions.

Usage:
    python3 scripts/validate_skills.py

Checks performed on each skills/<name>/SKILL.md:
  - YAML frontmatter is present and parseable
  - Required fields ``name`` and ``description`` are present
  - ``name`` is lowercase-hyphenated and matches the directory name
  - ``description`` is a non-empty string within the length budget
  - SKILL.md body has substantive content

Exit codes:
  0  all skills passed
  1  one or more skills failed validation
  2  configuration error (skills directory missing, PyYAML not installed)
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

try:
    import yaml
except ImportError:
    print(
        "ERROR: PyYAML is required. Install with: pip install pyyaml",
        file=sys.stderr,
    )
    sys.exit(2)

REPO_ROOT = Path(__file__).parent.parent
SKILLS_DIR = REPO_ROOT / "skills"

NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]*[a-z0-9]$")
MAX_NAME_LEN = 64
MAX_DESCRIPTION_LEN = 1024
MIN_DESCRIPTION_LEN = 20
MIN_BODY_LEN = 200

REQUIRED_FIELDS = {"name", "description"}
KNOWN_FIELDS = {"name", "description", "license", "allowed-tools", "metadata"}

FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)


def validate_skill(skill_dir: Path) -> list[str]:
    errors: list[str] = []
    skill_file = skill_dir / "SKILL.md"

    if not skill_file.is_file():
        rel = skill_file.relative_to(REPO_ROOT)
        return [f"missing SKILL.md (expected at {rel})"]

    text = skill_file.read_text(encoding="utf-8")
    match = FRONTMATTER_RE.match(text)
    if not match:
        return [
            "missing or malformed YAML frontmatter (must open and close with '---')"
        ]

    frontmatter_raw, body = match.group(1), match.group(2)
    try:
        frontmatter = yaml.safe_load(frontmatter_raw)
    except yaml.YAMLError as exc:
        return [f"invalid YAML frontmatter: {exc}"]

    if not isinstance(frontmatter, dict):
        return ["YAML frontmatter must be a mapping of key/value pairs"]

    missing = REQUIRED_FIELDS - frontmatter.keys()
    if missing:
        errors.append(
            "missing required frontmatter field(s): " + ", ".join(sorted(missing))
        )

    unknown = set(frontmatter.keys()) - KNOWN_FIELDS
    if unknown:
        errors.append("unknown frontmatter field(s): " + ", ".join(sorted(unknown)))

    name = frontmatter.get("name")
    if name is not None:
        if not isinstance(name, str):
            errors.append(f"'name' must be a string, got {type(name).__name__}")
        else:
            if not NAME_PATTERN.match(name):
                errors.append(
                    f"'name' must match {NAME_PATTERN.pattern} "
                    f"(lowercase, hyphen-separated, no leading/trailing hyphen), got {name!r}"
                )
            if len(name) > MAX_NAME_LEN:
                errors.append(f"'name' exceeds {MAX_NAME_LEN} chars (got {len(name)})")
            if name != skill_dir.name:
                errors.append(
                    f"'name' ({name!r}) must match the directory name ({skill_dir.name!r})"
                )

    desc = frontmatter.get("description")
    if desc is not None:
        if not isinstance(desc, str):
            errors.append(f"'description' must be a string, got {type(desc).__name__}")
        else:
            stripped = desc.strip()
            if not stripped:
                errors.append("'description' must not be empty")
            elif len(stripped) > MAX_DESCRIPTION_LEN:
                errors.append(
                    f"'description' exceeds {MAX_DESCRIPTION_LEN} chars (got {len(stripped)})"
                )
            elif len(stripped) < MIN_DESCRIPTION_LEN:
                errors.append(
                    f"'description' is suspiciously short ({len(stripped)} chars); "
                    "it should describe when to invoke the skill"
                )

    body_stripped = body.strip()
    if len(body_stripped) < MIN_BODY_LEN:
        errors.append(
            f"SKILL.md body is too short ({len(body_stripped)} chars, "
            f"minimum {MIN_BODY_LEN}); skill instructions should be substantive"
        )

    return errors


def main() -> int:
    if not SKILLS_DIR.is_dir():
        print(
            f"ERROR: skills directory not found at {SKILLS_DIR.relative_to(REPO_ROOT)}/",
            file=sys.stderr,
        )
        return 2

    skill_dirs = sorted(
        p for p in SKILLS_DIR.iterdir() if p.is_dir() and not p.name.startswith(".")
    )
    if not skill_dirs:
        print(
            f"ERROR: no skill subdirectories found under {SKILLS_DIR.relative_to(REPO_ROOT)}/",
            file=sys.stderr,
        )
        return 2

    print(
        f"Validating {len(skill_dirs)} skill(s) in {SKILLS_DIR.relative_to(REPO_ROOT)}/"
    )
    print()

    failed = 0
    for skill_dir in skill_dirs:
        errs = validate_skill(skill_dir)
        if errs:
            failed += 1
            print(f"  FAIL  {skill_dir.name}")
            for err in errs:
                print(f"          - {err}")
        else:
            print(f"  OK    {skill_dir.name}")

    total = len(skill_dirs)
    print()
    print(f"{total - failed}/{total} skills passed validation")
    return 0 if failed == 0 else 1


if __name__ == "__main__":
    sys.exit(main())
`````

## File: skills/metagit-bootstrap/scripts/bootstrap-config.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
FORCE="${2:-false}"
TARGET="$ROOT/.metagit.yml"

uv run python - "$ROOT" "$TARGET" "$FORCE" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import create_metagit_config
from metagit.core.config.manager import MetagitConfigManager

root = Path(sys.argv[1]).resolve()
target = Path(sys.argv[2]).resolve()
force = sys.argv[3].lower() in {"1", "true", "yes", "force"}

if target.exists() and not force:
    mgr = MetagitConfigManager(config_path=target)
    result = mgr.load_config()
    state = "valid" if not isinstance(result, Exception) else "invalid"
    print(f"status=exists\tvalidity={state}\tpath={target}")
    raise SystemExit(0)

yaml_out = create_metagit_config(name=root.name, kind="application", as_yaml=True)
if isinstance(yaml_out, Exception):
    print(f"status=error\tmessage={yaml_out}")
    raise SystemExit(1)

target.write_text(yaml_out, encoding="utf-8")
mgr = MetagitConfigManager(config_path=target)
result = mgr.load_config()
state = "valid" if not isinstance(result, Exception) else "invalid"
print(f"status=written\tvalidity={state}\tpath={target}")
PY
`````

## File: skills/metagit-bootstrap/SKILL.md
`````markdown
---
name: metagit-bootstrap
description: Use when generating or refining local .metagit.yml files using deterministic discovery plus MCP sampling.
---

# Metagit MCP Bootstrap Skill

Use this skill to create a local `.metagit.yml` using discovery-driven prompts and MCP sampling.

## Purpose

Generate schema-compliant `.metagit.yml` files with high contextual quality while preserving safety and explicit user control.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for local bootstrap tasks:
- `./scripts/bootstrap-config.zsh [root_path] [force]`

Behavior:
- Writes `.metagit.yml` when missing
- Validates via Metagit config models
- Returns a compact status line for agents

## Workflow

1. Gather deterministic discovery data from the target repository:
   - source language/framework indicators
   - package/lock/build files
   - Dockerfiles and CI workflows
   - terraform files and module usage
2. Build a strict prompt package:
   - output format contract: valid YAML only
   - required schema fields and constraints
   - extracted discovery evidence
3. If sampling is supported, call `sampling/createMessage`.
4. Validate generated YAML with Metagit config models.
5. Retry with validation feedback up to a fixed max attempt count.
6. Return draft output and write only on explicit confirmation.

## Output Modes

- **Plan-only mode**: return prompt + discovery summary if sampling unavailable.
- **Draft mode**: return `.metagit.generated.yml` content.
- **Confirmed write mode**: write to `.metagit.yml` only with explicit parameter (`confirm_write=true`).

## Quality Bar

- Preserve discovered evidence in structured fields.
- Include workspace project and related repo entries where detectable.
- Avoid invented repositories or unverifiable dependencies.

## Safety Rules

- Never overwrite `.metagit.yml` silently.
- Never emit secrets in cleartext.
- Prefer placeholders for credentials or tokens.
`````

## File: skills/metagit-cli/metagit-cli/SKILL.md
`````markdown
---
name: metagit-cli
description: CLI-only shortcuts for metagit agents — workspace catalog, discovery, prompts, sync, layout, and config. Use instead of MCP or HTTP API when operating from a shell or agent_mode session.
---

# Metagit CLI (agent shortcuts)

Use this skill when an agent should drive metagit **only through the `metagit` command**. Do not call MCP tools or `metagit api` from workflows covered here unless the user explicitly asks.

Set non-interactive defaults when automating:

```bash
export METAGIT_AGENT_MODE=true
```

Global flags (most commands):

- `-c path/to/metagit.config.yaml` — app config (default `metagit.config.yaml`)
- Workspace manifest: `--definition` / `-c` on catalog commands (default `.metagit.yml`)

---

## Prompt commands (all kinds)

List built-in prompt kinds:

```bash
metagit prompt list
metagit prompt list --json
```

Emit prompts (`--text-only` for paste into agent context; `--json` for structured output; `--no-instructions` to omit manifest layers):

| Scope | Command | Default kind |
|-------|---------|--------------|
| Workspace | `metagit prompt workspace -c <definition> -k <kind>` | `instructions` |
| Project | `metagit prompt project -p <project> -c <definition> -k <kind>` | `instructions` |
| Repo | `metagit prompt repo -p <project> -n <repo> -c <definition> -k <kind>` | `instructions` |

### Prompt kinds by scope

| Kind | Workspace | Project | Repo | Purpose |
|------|:---------:|:-------:|:----:|---------|
| `instructions` | yes | yes | yes | Composed `agent_instructions` from manifest layers |
| `session-start` | yes | — | — | Session bootstrap checklist |
| `catalog-edit` | yes | yes | — | Search-before-create; catalog registration |
| `health-preflight` | yes | yes | — | Pre-work workspace/repo status pass |
| `sync-safe` | yes | yes | yes | Guarded sync rules |
| `subagent-handoff` | — | yes | yes | Delegate single-repo work |
| `layout-change` | yes | yes | yes | Rename/move dry-run workflow |
| `repo-enrich` | — | — | yes | **Discover + merge** workspace repo entry |

### Prompt shortcuts (copy-paste)

```bash
# Session bootstrap
metagit prompt workspace -k session-start --text-only -c .metagit.yml

# Composed instructions at each level
metagit prompt workspace -k instructions --text-only -c .metagit.yml
metagit prompt project -p default -k instructions --text-only -c .metagit.yml
metagit prompt repo -p default -n my-api -k instructions --text-only -c .metagit.yml

# Repo catalog enrichment (detect + merge manifest entry)
metagit prompt repo -p default -n my-api -k repo-enrich --text-only -c .metagit.yml

# Catalog registration discipline
metagit prompt workspace -k catalog-edit --text-only -c .metagit.yml

# Safe sync reminder
metagit prompt repo -p default -n my-api -k sync-safe --text-only -c .metagit.yml

# Subagent handoff
metagit prompt repo -p default -n my-api -k subagent-handoff --text-only -c .metagit.yml
```

---

## Repo enrich workflow (`repo-enrich`)

Run the prompt, then execute its steps:

```bash
metagit prompt repo -p <project> -n <repo> -k repo-enrich --text-only -c .metagit.yml
```

Typical discovery chain on the checkout:

```bash
cd "$(metagit search '<repo>' -c .metagit.yml --path-only)"
metagit detect repository -p . -o json
metagit detect repo -p . -o yaml
metagit detect repo_map -p . -o json
```

Provider metadata (dry-run):

```bash
metagit project source sync --provider github --org <org> --mode discover --no-apply
```

After merging fields into `workspace.projects[].repos[]`:

```bash
metagit config validate -c .metagit.yml
metagit workspace repo list --project <project> --json
```

---

## Workspace and catalog

Per-project dedupe override in `.metagit.yml` (overrides `workspace.dedupe.enabled` in `metagit.config.yaml` for that project only):

```yaml
workspace:
  projects:
    - name: local
      dedupe:
        enabled: false
      repos: []
```

```bash
metagit appconfig show --format json
metagit config info -c .metagit.yml
metagit config show -c .metagit.yml
metagit config validate -c .metagit.yml

metagit workspace list -c .metagit.yml --json
metagit workspace project list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --project <name> --json

metagit workspace project add --name <name> --json
metagit workspace repo add --project <name> --name <repo> --url <url> --json
metagit workspace project remove --name <name> --json
metagit workspace repo remove --project <name> --name <repo> --json
```

Search managed repos (always before creating entries):

```bash
metagit search "<query>" -c .metagit.yml --json
metagit search "<query>" -c .metagit.yml --path-only
metagit search "<query>" -c .metagit.yml --tag tier=1 --project <name>
```

---

## Project operations

```bash
metagit project list --config .metagit.yml --all --json
metagit project add --name <name> --json
metagit project remove --name <name> --json
metagit project rename --name <old> --new-name <new> --dry-run --json
metagit project select
metagit project sync

metagit project repo list --json
metagit project repo add --project <name> --name <repo> --url <url>
metagit project repo remove --name <repo> --json
metagit project repo rename --name <old> --new-name <new> --dry-run --json
metagit project repo move --name <repo> --to-project <other> --dry-run --json
metagit project repo prune --project <name> --dry-run

metagit project source sync --provider github --org <org> --mode discover --no-apply
metagit project source sync --provider github --org <org> --mode additive --apply
```

Layout (manifest + disk; always dry-run first):

```bash
metagit workspace project rename --name <old> --new-name <new> --dry-run --json
metagit workspace repo rename --project <p> --name <old> --new-name <new> --dry-run --json
metagit workspace repo move --project <p> --name <repo> --to-project <other> --dry-run --json
```

---

## Discovery and local metadata

```bash
metagit detect project -p <path> -o yaml
metagit detect repo -p <path> -o yaml
metagit detect repo_map -p <path> -o json
metagit detect repository -p <path> -o json
metagit detect repository -p <path> -o metagit
# --save only with operator approval (blocked in agent_mode)
```

Bootstrap new trees:

```bash
metagit init --kind application
metagit init --kind umbrella --template hermes-orchestrator
```

---

## Selection and scope

```bash
metagit workspace select --project <name>
metagit project select
metagit project repo select
```

---

## Config and appconfig

```bash
metagit appconfig validate
metagit appconfig get workspace.path
metagit config example
metagit config schema
metagit config providers
```

---

## Records, skills, version

```bash
metagit record search "<query>"
metagit skills list
metagit skills show metagit-cli
metagit skills install --skill metagit-cli
metagit version
metagit info
```

---

## Agent habits

1. **Search before create** — `metagit search` then catalog add.
2. **Validate after manifest edits** — `metagit config validate`.
3. **Emit prompts instead of rewriting playbooks** — `metagit prompt … --text-only`.
4. **Enrich stale repo entries** — `metagit prompt repo … -k repo-enrich` then detect + merge.
5. **Dry-run layout** — always `--dry-run --json` before apply.
6. **Prefer `METAGIT_AGENT_MODE=true`** in CI and agent loops to skip fuzzy finder and confirm dialogs.

## Related bundled skills

Use topic skills when you need deeper playbooks (some mention MCP): `metagit-projects`, `metagit-workspace-scope`, `metagit-workspace-sync`, `metagit-config-refresh`. This skill is the **CLI-only** index and prompt reference.
`````

## File: skills/metagit-config-refresh/SKILL.md
`````markdown
---
name: metagit-config-refresh
description: Refresh or bootstrap `.metagit.yml` using deterministic discovery and validation flows. Use when configuration is missing, stale, or incomplete for workspace operations.
---

# Refreshing Project Config

Use this skill to keep `.metagit.yml` accurate and operational.

## Workflow

1. Check workspace activation and config validity.
2. Run bootstrap plan mode first.
3. Review generated changes against expected workspace topology.
4. Apply config updates and validate schema before continuing.

## Command Wrapper

- `zsh ./skills/metagit-bootstrap/scripts/bootstrap-config.zsh [root_path] [mode] [seed_context]`

## Output Contract

Return:
- config health state before/after
- generated update summary
- any manual follow-up needed

## Safety

- Prefer plan/dry-run first for large config updates.
- Keep changes bounded to target workspace intent.
`````

## File: skills/metagit-gating/scripts/gate-status.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"

uv run python - "$ROOT" <<'PY'
import os
import sys
from metagit.core.mcp.gate import WorkspaceGate
from metagit.core.mcp.root_resolver import WorkspaceRootResolver
from metagit.core.mcp.tool_registry import ToolRegistry

root = sys.argv[1]
resolver = WorkspaceRootResolver()
gate = WorkspaceGate()
registry = ToolRegistry()

resolved = resolver.resolve(cwd=os.path.abspath(root), cli_root=root)
status = gate.evaluate(root_path=resolved)
tools = registry.list_tools(status)
print(f"state={status.state.value}\troot={status.root_path or 'none'}\ttools={len(tools)}")
PY
`````

## File: skills/metagit-gating/SKILL.md
`````markdown
---
name: metagit-gating
description: Use when implementing or operating Metagit MCP server activation and tool exposure rules based on .metagit.yml presence and validity.
---

# Metagit MCP Gating Skill

Use this skill whenever you need to control whether Metagit MCP tools/resources are exposed.

## Purpose

Ensure high-risk tooling and multi-repo context are only available when a valid `.metagit.yml` exists at the resolved workspace root.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for all gating checks:
- `./scripts/gate-status.zsh [root_path]`

Expected output (single line, tab-delimited):
- `state=<value>\troot=<path|none>\ttools=<count>`

## Activation Workflow

1. Resolve workspace root:
   - `METAGIT_WORKSPACE_ROOT`
   - CLI `--root`
   - upward directory walk
2. Check for `.metagit.yml` in resolved root.
3. Validate config through existing Metagit config models.
4. Derive activation state: missing, invalid, or active.
5. Register tool surface based on state.

## Tool Exposure Contract

### Inactive (missing or invalid config)
Expose only:
- `metagit_workspace_status`
- `metagit_bootstrap_config_plan_only`

### Active (valid config)
Expose full set:
- `metagit_workspace_status`
- `metagit_workspace_index`
- `metagit_workspace_search`
- `metagit_upstream_hints`
- `metagit_repo_inspect`
- `metagit_repo_sync`
- `metagit_bootstrap_config`

## Error Handling

- Return explicit, machine-readable state and reason.
- Avoid stack traces in user-facing outputs.
- Log parser/validation errors with enough detail for debugging.

## Safety Rules

- Never expose mutation-capable tools in inactive state.
- Never operate outside validated workspace boundaries.
- Keep defaults read-only unless user/agent explicitly opts in.
`````

## File: skills/metagit-gitnexus/scripts/analyze-targets.zsh
`````zsh
#!/usr/bin/env zsh

set -euo pipefail

workspace_root="${1:-.}"
project_name="${2:-default}"

if [[ ! -f ".metagit.yml" ]]; then
  echo "ERROR: .metagit.yml not found in current directory"
  exit 2
fi

echo "analyze repo=$(pwd)"
npx gitnexus analyze

tmp_output="$(mktemp)"
uv run python - "$workspace_root" "$project_name" <<'PY' > "$tmp_output"
import sys
from pathlib import Path
import yaml

workspace_root = Path(sys.argv[1]).expanduser().resolve()
project_name = sys.argv[2]
cfg = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8")) or {}
workspace = (cfg.get("workspace") or {})
projects = workspace.get("projects") or []
target = next((p for p in projects if p.get("name") == project_name), None)
if not target:
    print(f"warn project_not_found={project_name}")
    raise SystemExit(0)

for repo in target.get("repos") or []:
    name = repo.get("name")
    if not name:
        continue
    repo_path = workspace_root / project_name / name
    if repo_path.exists() and repo_path.is_dir():
        print(f"repo_path={repo_path}")
    else:
        print(f"skip_missing={repo_path}")
PY

while IFS= read -r line; do
  case "$line" in
    repo_path=*)
      path="${line#repo_path=}"
      echo "analyze repo=${path}"
      (cd "$path" && npx gitnexus analyze) || echo "fail repo=${path}"
      ;;
    *)
      echo "$line"
      ;;
  esac
done < "$tmp_output"

rm -f "$tmp_output"
`````

## File: skills/metagit-multi-repo/SKILL.md
`````markdown
---
name: metagit-multi-repo
description: Coordinate implementation tasks across multiple repositories using metagit status, search, and scoped sync workflows. Use when one objective spans several repositories.
---

# Coordinating Multi-Repo Implementation

Use this skill for cross-repository feature or fix delivery.

## Workflow

1. Define objective and affected repositories.
2. Verify workspace scope and dependency hints.
3. Sequence work by dependency order.
4. Sync only required repositories.
5. Track progress and blockers per repository.

## Command Wrapper

- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- objective-to-repository map
- execution order
- current blocker + next step

## Safety

- Keep scope bounded to configured workspace repositories.
- Prefer deterministic evidence for cross-repo assumptions.
`````

## File: skills/metagit-projects/SKILL.md
`````markdown
---
name: metagit-projects
description: Ongoing workspace and project management for OpenClaw and Hermes agents. Use when starting work, organizing repos, or before creating a new project folder so existing metagit projects are reused instead of duplicated.
---

# Ongoing Project Management

Use this skill when the user starts new work, reorganizes repositories, or asks you to create a project folder. Metagit is the source of truth for what already exists in the workspace.

## Concepts

Metagit uses a three-level hierarchy (see project terminology docs):

| Level | Meaning |
|-------|---------|
| **Workspace** | Root folder where projects are synced (from app config `workspace.path`, often `./.metagit/`). Holds many projects. |
| **Project** | Named group of one or more Git repositories. Multi-repo products are one project; unrelated repos can also share a workspace under different project names. |
| **Repo** | A single Git repository entry under `workspace.projects[].repos` in `.metagit.yml`. |

A **project** is not always “one product.” It is whatever grouping helps the user and agents reason about related (or intentionally grouped) repositories. A workspace may contain unrelated projects side by side (for example `default`, `client-a`, `experiments`).

The umbrella `.metagit.yml` (workspace definition, often `kind: umbrella`) lives in a coordinating repository or central config checkout. Application repos may have their own `.metagit.yml` for metadata mode.

## Mandatory: check before creating folders

**Never** create a new project directory or clone into the workspace until you have checked metagit for an existing match.

1. **Locate the workspace definition**
   - Prefer the user’s umbrella `.metagit.yml` if known.
   - Otherwise use `.metagit.yml` in the current repo with `--definition /path/to/.metagit.yml`.

2. **List configured projects and repo counts**
   ```bash
   metagit config info --config-path /path/to/.metagit.yml
   metagit project list --config /path/to/.metagit.yml --project default
   ```
   Repeat `--project` for each project name returned by `config info`.

3. **Search managed repos by name, URL fragment, or tag**
   ```bash
   metagit search "<proposed-name-or-url>" --definition /path/to/.metagit.yml
   metagit search "<name>" --definition /path/to/.metagit.yml --json
   ```

4. **Inspect on disk** (workspace path from app config, default `./.metagit/`)
   - Expected layout: `{workspace.path}/{project_name}/{repo_name}/`
   - If the directory already exists, **reuse it**; do not create a parallel tree.

5. **Decide**
   - **Match found** → use existing project/repo; run `metagit project sync` only if the user wants checkouts refreshed.
   - **No match** → proceed with registration steps below (still add to workspace; do not leave orphan folders).

## Registering new work in the workspace

### New repository in an existing project

From the directory containing the workspace `.metagit.yml` (or pass `--config`):

```bash
metagit project repo add --project <project_name> --prompt
# or non-interactive:
metagit project repo add --project <project_name> --name <repo> --url <git-url>
metagit config validate --config-path .metagit.yml
metagit project sync --project <project_name>
```

In the new application repo (if applicable):

```bash
cd /path/to/new/repo
metagit init
metagit detect repo --force   # optional: enrich .metagit.yml
```

### New project group (new `workspace.projects[]` entry)

There is no separate `project create` CLI today. Add a project block to `.metagit.yml`:

```yaml
workspace:
  projects:
    - name: my-new-project
      description: Short purpose for agents and humans
      repos: []
```

Then validate, add repos, and sync:

```bash
metagit config validate --config-path .metagit.yml
metagit project repo add --project my-new-project --prompt
metagit project sync --project my-new-project
```

Choose a **distinct project name**; avoid duplicating an existing `workspace.projects[].name`.

### New umbrella workspace

When bootstrapping a workspace coordinator repo:

```bash
metagit init --kind umbrella
metagit project repo add --project default --prompt
metagit project sync
```

## Ongoing session habits

At the start of sustained work:

1. Run **metagit-workspace-scope** (or `metagit mcp serve --status-once` when MCP is available).
2. Confirm **active project** matches the user’s intent (`metagit workspace select --project <name>` when switching).
3. Use **`metagit search`** before assuming a repo is missing or lives elsewhere.
4. For multi-repo tasks, prefer **metagit-control-center** or **metagit-multi-repo** over ad-hoc cloning.

When the user names a target folder:

- Resolve it against managed config first.
- If unmanaged but present on disk under the project sync folder, report it and offer to add via `metagit project repo add` rather than recreating.

## OpenClaw and Hermes setup

Install bundled skills (including this one) for agent hosts:

```bash
metagit skills list
metagit skills install --scope user --target openclaw --target hermes
metagit mcp install --scope user --target openclaw --target hermes
```

Use `--scope project` when installing into a specific umbrella repository checkout.

## Output contract

After project-management actions, report:

- workspace definition path used
- whether the target was **existing** or **newly registered**
- project name and repo name(s) affected
- sync status if `project sync` was run
- recommended next command (`workspace select`, `project select`, or `detect`)

## Safety

- Do not clone, delete, or overwrite sync directories without explicit user approval.
- Do not edit `.metagit.yml` without validating afterward (`metagit config validate`).
- Prefer reusing configured repos over creating duplicate checkouts.
- Keep unrelated experiments in separate `workspace.projects` entries when the user wants clear boundaries.
`````

## File: skills/metagit-upstream-scan/scripts/upstream-scan.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-}"
MAX_RESULTS="${4:-20}"

if [[ -z "$QUERY" ]]; then
  echo "status=error\tmessage=query-required"
  exit 1
fi

uv run python - "$ROOT" "$QUERY" "$PRESET" "$MAX_RESULTS" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.mcp.services.workspace_search import WorkspaceSearchService
from metagit.core.mcp.services.upstream_hints import UpstreamHintService

root = Path(sys.argv[1]).resolve()
query = sys.argv[2]
preset = sys.argv[3] or None
max_results = int(sys.argv[4])

manager = MetagitConfigManager(config_path=root / ".metagit.yml")
cfg = manager.load_config()
if isinstance(cfg, Exception):
    print(f"status=error\tmessage=config-invalid\tdetail={cfg}")
    raise SystemExit(1)

index = WorkspaceIndexService().build_index(config=cfg, workspace_root=str(root))
repo_paths = [row["repo_path"] for row in index if row.get("exists")]
search_hits = WorkspaceSearchService().search(query=query, repo_paths=repo_paths, preset=preset, max_results=max_results)
ranked = UpstreamHintService().rank(blocker=query, repo_context=index)[:5]

print(f"status=ok\trepos={len(index)}\thits={len(search_hits)}")
for row in ranked:
    print(f"hint\trepo={row['repo_name']}\tscore={row['score']}")
for hit in search_hits[:5]:
    print(f"hit\tfile={hit['file_path']}\tline={hit['line_number']}")
PY
`````

## File: skills/metagit-upstream-scan/SKILL.md
`````markdown
---
name: metagit-upstream-scan
description: Use when a coding agent encounters likely upstream blockers and must find related workspace repositories, files, and probable root causes.
---

# Metagit Upstream Discovery Skill

Use this skill when the current repo does not appear to contain the full fix and related repositories may hold the source issue.

## Supported Use Cases

- Missing Terraform input in a shared module
- Docker base image/version mismatch across repos
- Shared infrastructure definitions causing local failures
- CI pipeline breakages tied to upstream templates/workflows

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for upstream discovery tasks:
- `./scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

Output format:
- compact status line
- top ranked repo hints (`hint`)
- top search file hits (`hit`)

## Workflow

1. Read workspace repository map from active `.metagit.yml`.
2. Run `metagit_workspace_index` to verify repo availability and sync state.
3. Use `metagit_workspace_search` with category preset (`terraform`, `docker`, `infra`, `ci`).
4. Use `metagit_upstream_hints` to rank candidate repositories and files.
5. Return a concise action plan:
   - top candidate repos
   - likely files/definitions
   - whether sync is needed before deeper analysis

## Search Strategy

- Start narrow with issue-specific terms (error, module, variable, image tag).
- Expand to broader shared terms if no hits.
- Prefer repositories referenced by workspace metadata before searching unknown repos.

## Output Contract

Return:
- ranked candidates with rationale
- suggested next file openings
- confidence level and unresolved assumptions

## Safety Rules

- Restrict search to configured workspace repositories.
- Cap result size and duration.
- Keep this workflow read-only unless an explicit sync action is requested.
`````

## File: skills/metagit-upstream-triage/SKILL.md
`````markdown
---
name: metagit-upstream-triage
description: Triage cross-repository blockers by ranking likely upstream repositories and files with metagit search and hinting tools. Use when local fixes appear incomplete.
---

# Triaging Upstream Blockers

Use this skill for failures likely rooted in another repository.

## Workflow

1. Run workspace index and search with issue-specific terms.
2. Run upstream hint ranking to prioritize repositories/files.
3. Open the top candidates and validate root-cause evidence.
4. Return a short fix path (repo, file, next action).

## Command Wrapper

- `zsh ./skills/metagit-upstream-scan/scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

## Output Contract

Return:
- ranked candidate repositories
- probable root-cause files
- confidence and assumptions

## Safety

- Keep this flow read-only unless sync is explicitly requested.
`````

## File: src/metagit/cli/commands/api.py
`````python
#!/usr/bin/env python
"""
Local JSON HTTP API command group.
"""

from pathlib import Path

import click

from metagit.core.api.server import build_server


@click.group()
def api() -> None:
    """Local JSON API commands."""


@api.command("serve")
@click.option(
    "--root",
    default=".",
    show_default=True,
    help="Workspace root containing `.metagit.yml`.",
)
@click.option("--host", default="127.0.0.1", show_default=True)
@click.option("--port", default=7878, type=int, show_default=True)
@click.option(
    "--status-once",
    is_flag=True,
    default=False,
    help="Bind once, print ready line with resolved port, and exit.",
)
def serve(root: str, host: str, port: int, status_once: bool) -> None:
    """Serve managed-repo search JSON endpoints on localhost."""
    root_abs = str(Path(root).resolve())
    server = build_server(root=root_abs, host=host, port=port)
    bound_port = server.server_address[1]
    if status_once:
        click.echo(f"api_state=ready host={host} port={bound_port}")
        server.server_close()
        return
    try:
        click.echo(
            f"Serving metagit API on http://{host}:{bound_port}/ "
            f"(workspace root {root_abs})"
        )
        server.serve_forever()
    except KeyboardInterrupt:
        click.echo("Shutting down.")
    finally:
        server.server_close()
`````

## File: src/metagit/cli/commands/project_source.py
`````python
#!/usr/bin/env python
"""
Project source sync subcommand.
"""

from typing import Optional

import click

from metagit.core.appconfig.models import AppConfig
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.project.source_models import SourceSpec, SourceSyncMode
from metagit.core.project.source_sync import SourceSyncService
from metagit.core.utils.logging import UnifiedLogger
from metagit.core.workspace.models import WorkspaceProject


@click.group(name="source")
@click.pass_context
def source(ctx: click.Context) -> None:
    """Source-backed project sync operations."""
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())


@source.command("sync")
@click.option(
    "--provider",
    type=click.Choice(["github", "gitlab"]),
    required=True,
    help="Source provider",
)
@click.option("--org", help="GitHub organization to discover repositories from")
@click.option("--user", help="GitHub user to discover repositories from")
@click.option("--group", help="GitLab group path to discover repositories from")
@click.option(
    "--mode",
    type=click.Choice([mode.value for mode in SourceSyncMode]),
    default=SourceSyncMode.DISCOVER.value,
    show_default=True,
    help="Sync mode",
)
@click.option(
    "--recursive/--no-recursive",
    default=True,
    show_default=True,
    help="Recursive traversal when supported by provider",
)
@click.option(
    "--include-archived/--no-include-archived",
    default=False,
    show_default=True,
    help="Include archived repositories",
)
@click.option(
    "--include-forks/--no-include-forks",
    default=False,
    show_default=True,
    help="Include forks",
)
@click.option(
    "--path-prefix", default=None, help="Optional namespace/repo prefix filter"
)
@click.option(
    "--apply/--no-apply",
    default=False,
    show_default=True,
    help="Apply computed changes to .metagit.yml",
)
@click.option(
    "--yes",
    is_flag=True,
    default=False,
    help="Confirm destructive reconcile removals",
)
@click.pass_context
def source_sync(
    ctx: click.Context,
    provider: str,
    org: Optional[str],
    user: Optional[str],
    group: Optional[str],
    mode: str,
    recursive: bool,
    include_archived: bool,
    include_forks: bool,
    path_prefix: Optional[str],
    apply: bool,
    yes: bool,
) -> None:
    """Discover and sync repositories from provider sources."""
    logger: UnifiedLogger = ctx.obj["logger"]
    app_config: AppConfig = ctx.obj["config"]
    project_name: str = ctx.obj["project"]
    local_config: MetagitConfig = ctx.obj["local_config"]
    config_path: str = ctx.obj["config_path"]

    try:
        spec = SourceSpec(
            provider=provider,
            org=org,
            user=user,
            group=group,
            recursive=recursive,
            include_archived=include_archived,
            include_forks=include_forks,
            path_prefix=path_prefix,
        )
    except Exception as exc:
        raise click.UsageError(str(exc)) from exc

    if not local_config.workspace:
        raise click.UsageError("No workspace configuration found in .metagit.yml")

    workspace_project: Optional[WorkspaceProject] = next(
        (item for item in local_config.workspace.projects if item.name == project_name),
        None,
    )
    if not workspace_project:
        raise click.UsageError(
            f"Project '{project_name}' not found in workspace configuration"
        )

    service = SourceSyncService(app_config, logger)
    discovered_result = service.discover(spec)
    if isinstance(discovered_result, Exception):
        logger.error(f"Source discovery failed: {discovered_result}")
        ctx.abort()
    discovered = discovered_result

    sync_mode = SourceSyncMode(mode)
    plan = service.plan(spec, workspace_project, discovered, sync_mode)

    logger.info(f"Discovered repositories: {plan.discovered_count}")
    logger.info(f"Planned add: {len(plan.to_add)}")
    logger.info(f"Planned update: {len(plan.to_update)}")
    logger.info(f"Planned remove: {len(plan.to_remove)}")
    logger.info(f"Unchanged: {plan.unchanged}")

    if len(plan.to_add) > 0:
        logger.info(
            "Add candidates: " + ", ".join(repo.name for repo in plan.to_add[:20])
        )
    if len(plan.to_update) > 0:
        logger.info(
            "Update candidates: " + ", ".join(repo.name for repo in plan.to_update[:20])
        )
    if len(plan.to_remove) > 0:
        logger.warning(
            "Remove candidates: " + ", ".join(repo.name for repo in plan.to_remove[:20])
        )

    if not apply or sync_mode == SourceSyncMode.DISCOVER:
        logger.info("Dry-run complete (no config changes applied).")
        return

    if sync_mode == SourceSyncMode.RECONCILE and len(plan.to_remove) > 0 and not yes:
        raise click.UsageError(
            "Reconcile mode has removals. Re-run with --yes to confirm destructive changes."
        )

    updated_project = service.apply_plan(workspace_project, plan, sync_mode)
    for index, item in enumerate(local_config.workspace.projects):
        if item.name == updated_project.name:
            local_config.workspace.projects[index] = updated_project
            break

    config_manager = MetagitConfigManager(config_path=config_path)
    save_result = config_manager.save_config(local_config, config_path)
    if isinstance(save_result, Exception):
        logger.error(f"Failed to save updated config: {save_result}")
        ctx.abort()

    logger.success("Source sync applied successfully.")
`````

## File: src/metagit/cli/commands/prompt.py
`````python
#!/usr/bin/env python
"""
Emit metagit prompts for workspace, project, and repo scopes.
"""

from __future__ import annotations

from pathlib import Path

import click

from metagit.cli.json_output import emit_json
from metagit.core.appconfig import AppConfig
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.prompt.catalog import kinds_for_scope
from metagit.core.prompt.service import PromptService, PromptServiceError


def _load_manifest(definition_path: str) -> MetagitConfig:
    manager = MetagitConfigManager(definition_path)
    loaded = manager.load_config()
    if isinstance(loaded, Exception):
        raise click.ClickException(str(loaded))
    return loaded


def _prompt_ctx(
    ctx: click.Context,
    definition_path: str,
) -> tuple[MetagitConfig, str, str, AppConfig]:
    app_config: AppConfig = ctx.obj["config"]
    config = _load_manifest(definition_path)
    workspace_root = str(Path(app_config.workspace.path).expanduser().resolve())
    return config, definition_path, workspace_root, app_config


def _kind_choice(scope: str) -> click.Choice:
    return click.Choice(
        [str(item) for item in kinds_for_scope(scope)],  # type: ignore[arg-type]
        case_sensitive=False,
    )


def _run_emit(
    ctx: click.Context,
    *,
    scope: str,
    definition_path: str,
    kind: str,
    project_name: str | None,
    repo_name: str | None,
    no_instructions: bool,
    as_json: bool,
    text_only: bool,
) -> None:
    config, def_path, workspace_root, app_config = _prompt_ctx(ctx, definition_path)
    try:
        result = PromptService().emit(
            config,
            kind=kind,  # type: ignore[arg-type]
            scope=scope,  # type: ignore[arg-type]
            definition_path=def_path,
            workspace_root=workspace_root,
            project_name=project_name,
            repo_name=repo_name,
            include_instructions=not no_instructions,
            workspace_dedupe=app_config.workspace.dedupe,
        )
    except PromptServiceError as exc:
        raise click.ClickException(str(exc)) from exc

    if text_only:
        click.echo(result.text, nl=result.text.endswith("\n"))
        return
    if as_json:
        emit_json(result)
        return
    click.echo(result.text)
    if result.instruction_layers and kind != "instructions":
        click.echo("\n---\n")
        click.echo(f"({len(result.instruction_layers)} instruction layer(s) included)")


@click.group(name="prompt", invoke_without_command=True)
@click.pass_context
def prompt(ctx: click.Context) -> None:
    """Emit metagit prompts for workspace, project, and repo scopes."""
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())


@prompt.command("list")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def prompt_list(ctx: click.Context, as_json: bool) -> None:
    """List available prompt kinds and valid scopes."""
    _ = ctx
    entries = PromptService().list_entries()
    if as_json:
        emit_json({"prompts": [item.model_dump(mode="json") for item in entries]})
        return
    for entry in entries:
        scopes = ", ".join(entry.scopes)
        click.echo(f"{entry.kind}\t{entry.title}\t(scopes: {scopes})")
        click.echo(f"  {entry.description}")


@prompt.command("workspace")
@click.option(
    "--definition",
    "-c",
    "definition_path",
    default=".metagit.yml",
    show_default=True,
    help="Path to the workspace .metagit.yml definition file",
)
@click.option(
    "--kind",
    "-k",
    "kind",
    type=_kind_choice("workspace"),
    default="instructions",
    show_default=True,
    help="Prompt kind to emit",
)
@click.option(
    "--no-instructions",
    is_flag=True,
    default=False,
    help="Omit composed manifest instructions from operational prompts",
)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.option(
    "--text-only",
    is_flag=True,
    default=False,
    help="Print prompt text only (no JSON wrapper)",
)
@click.pass_context
def prompt_workspace(
    ctx: click.Context,
    definition_path: str,
    kind: str,
    no_instructions: bool,
    as_json: bool,
    text_only: bool,
) -> None:
    """Emit a prompt for the whole workspace manifest."""
    _run_emit(
        ctx,
        scope="workspace",
        definition_path=definition_path,
        kind=kind,
        project_name=None,
        repo_name=None,
        no_instructions=no_instructions,
        as_json=as_json,
        text_only=text_only,
    )


@prompt.command("project")
@click.option(
    "--project", "-p", "project_name", required=True, help="Workspace project name"
)
@click.option(
    "--definition",
    "-c",
    "definition_path",
    default=".metagit.yml",
    show_default=True,
)
@click.option(
    "--kind",
    "-k",
    "kind",
    type=_kind_choice("project"),
    default="instructions",
    show_default=True,
)
@click.option("--no-instructions", is_flag=True, default=False)
@click.option("--json", "as_json", is_flag=True, default=False)
@click.option("--text-only", is_flag=True, default=False)
@click.pass_context
def prompt_project(
    ctx: click.Context,
    project_name: str,
    definition_path: str,
    kind: str,
    no_instructions: bool,
    as_json: bool,
    text_only: bool,
) -> None:
    """Emit a prompt scoped to one workspace project."""
    _run_emit(
        ctx,
        scope="project",
        definition_path=definition_path,
        kind=kind,
        project_name=project_name,
        repo_name=None,
        no_instructions=no_instructions,
        as_json=as_json,
        text_only=text_only,
    )


@prompt.command("repo")
@click.option(
    "--project", "-p", "project_name", required=True, help="Workspace project name"
)
@click.option("--repo", "-n", "repo_name", required=True, help="Repository name")
@click.option(
    "--definition",
    "-c",
    "definition_path",
    default=".metagit.yml",
    show_default=True,
)
@click.option(
    "--kind",
    "-k",
    "kind",
    type=_kind_choice("repo"),
    default="instructions",
    show_default=True,
)
@click.option("--no-instructions", is_flag=True, default=False)
@click.option("--json", "as_json", is_flag=True, default=False)
@click.option("--text-only", is_flag=True, default=False)
@click.pass_context
def prompt_repo(
    ctx: click.Context,
    project_name: str,
    repo_name: str,
    definition_path: str,
    kind: str,
    no_instructions: bool,
    as_json: bool,
    text_only: bool,
) -> None:
    """Emit a prompt scoped to one repository entry."""
    _run_emit(
        ctx,
        scope="repo",
        definition_path=definition_path,
        kind=kind,
        project_name=project_name,
        repo_name=repo_name,
        no_instructions=no_instructions,
        as_json=as_json,
        text_only=text_only,
    )
`````

## File: src/metagit/cli/commands/web.py
`````python
#!/usr/bin/env python
"""Local web UI command group."""

from __future__ import annotations

import webbrowser
from pathlib import Path

import click

from metagit.core.web.server import build_web_server


@click.group()
def web() -> None:
    """Local web UI commands."""


@web.command("serve")
@click.option(
    "--root",
    default=".",
    show_default=True,
    help="Directory containing `.metagit.yml`.",
)
@click.option(
    "--appconfig",
    default=None,
    help="App config path override.",
)
@click.option("--host", default="127.0.0.1", show_default=True)
@click.option("--port", default=8787, type=int, show_default=True)
@click.option("--open/--no-open", default=False, help="Open the UI in a browser.")
@click.option(
    "--status-once",
    is_flag=True,
    default=False,
    help="Bind once, print ready line with resolved port, and exit.",
)
@click.pass_context
def serve(
    ctx: click.Context,
    root: str,
    appconfig: str | None,
    host: str,
    port: int,
    open: bool,
    status_once: bool,
) -> None:
    """Serve the metagit web UI and workspace API on localhost."""
    root_abs = str(Path(root).resolve())
    appconfig_path = appconfig
    if appconfig_path is None:
        if ctx.obj is None:
            raise click.ClickException(
                "Missing app config; pass --appconfig or run via metagit CLI."
            )
        appconfig_path = str(ctx.obj["config_path"])
    server = build_web_server(
        root=root_abs,
        appconfig_path=appconfig_path,
        host=host,
        port=port,
    )
    bound_port = server.server_address[1]
    url = f"http://{host}:{bound_port}/"
    if status_once:
        click.echo(f"web_state=ready host={host} port={bound_port} url={url}")
        server.server_close()
        return
    try:
        click.echo(f"Serving metagit web UI on {url} (workspace root {root_abs})")
        if open:
            webbrowser.open(url)
        server.serve_forever()
    except KeyboardInterrupt:
        click.echo("Shutting down.")
    finally:
        server.server_close()
`````

## File: src/metagit/cli/config_patch_ops.py
`````python
#!/usr/bin/env python
"""Shared helpers for config patch/preview CLI commands."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import click

from metagit.core.config.patch_service import PatchResult, PreviewResult, TreeResult
from metagit.core.web.models import ConfigOpKind, ConfigOperation, ConfigPatchRequest
from metagit.core.utils.logging import UnifiedLogger


def parse_cli_value(raw: str) -> Any:
    """Parse --value as JSON when possible, otherwise return the raw string."""
    stripped = raw.strip()
    if not stripped:
        return ""
    if stripped[0] in '{["':
        try:
            return json.loads(stripped)
        except json.JSONDecodeError as exc:
            raise click.ClickException(f"Invalid JSON value: {exc}") from exc
    if stripped.lower() in {"true", "false"}:
        return stripped.lower() == "true"
    if stripped.isdigit():
        return int(stripped)
    try:
        if "." in stripped:
            return float(stripped)
    except ValueError:
        pass
    return raw


def load_operations_file(path: str) -> list[ConfigOperation]:
    """Load operations from a JSON file (ConfigPatchRequest or operations array)."""
    file_path = Path(path)
    if not file_path.is_file():
        raise click.ClickException(f"Operations file not found: {path}")
    try:
        payload = json.loads(file_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise click.ClickException(f"Invalid JSON in {path}: {exc}") from exc
    if isinstance(payload, list):
        return [ConfigOperation.model_validate(item) for item in payload]
    if isinstance(payload, dict):
        if "operations" in payload:
            request = ConfigPatchRequest.model_validate(payload)
            return list(request.operations)
        raise click.ClickException(
            f"{path} must be a JSON array of operations or an object with 'operations'"
        )
    raise click.ClickException(f"{path} must contain a JSON object or array")


def resolve_operations(
    *,
    operations_file: str | None,
    op: str | None,
    path: str | None,
    value: str | None,
) -> list[ConfigOperation]:
    """Resolve operations from --file or a single --op/--path/--value triplet."""
    if operations_file:
        if op or path or value is not None:
            raise click.ClickException(
                "Use either --file or --op/--path/--value, not both"
            )
        return load_operations_file(operations_file)
    if not op or not path:
        raise click.ClickException(
            "Provide --file <path.json> or --op <kind> --path <field.path>"
        )
    try:
        op_kind = ConfigOpKind(op.lower())
    except ValueError as exc:
        allowed = ", ".join(item.value for item in ConfigOpKind)
        raise click.ClickException(
            f"Invalid --op '{op}'; must be one of: {allowed}"
        ) from exc
    parsed_value = parse_cli_value(value) if value is not None else None
    if op_kind == ConfigOpKind.SET and parsed_value is None:
        raise click.ClickException("--op set requires --value")
    return [
        ConfigOperation(op=op_kind, path=path, value=parsed_value),
    ]


def emit_patch_result(
    result: PatchResult,
    *,
    as_json: bool,
    logger: UnifiedLogger,
) -> None:
    """Print patch outcome and abort on failure when not saving with errors."""
    if as_json:
        click.echo(
            json.dumps(
                result.model_dump(mode="json", exclude_none=True),
                indent=2,
            )
        )
        if not result.ok:
            raise SystemExit(1)
        return
    if result.validation_errors:
        logger.error("Validation failed after applying operations:")
        for item in result.validation_errors:
            logger.error(f"  {item.get('path', '')}: {item.get('message', '')}")
    if result.saved:
        logger.success(f"Saved {result.config_path}")
    elif result.ok:
        logger.info("Operations applied (dry run; use --save to write)")
    if not result.ok:
        raise SystemExit(1)


def emit_preview_result(
    result: PreviewResult,
    *,
    as_json: bool,
    logger: UnifiedLogger,
    output_path: str | None,
) -> None:
    """Print or write YAML preview."""
    if output_path:
        Path(output_path).write_text(result.yaml, encoding="utf-8")
        logger.success(f"Preview written to {output_path}")
    if as_json:
        payload = result.model_dump(mode="json", exclude_none=True)
        if output_path:
            payload["written_to"] = output_path
        click.echo(json.dumps(payload, indent=2))
    else:
        if result.validation_errors:
            logger.warning("Preview has validation errors:")
            for item in result.validation_errors:
                logger.warning(f"  {item.get('path', '')}: {item.get('message', '')}")
        click.echo(result.yaml, nl=result.yaml.endswith("\n"))
    if not result.ok:
        raise SystemExit(1)


def emit_tree_result(
    result: TreeResult,
    *,
    as_json: bool,
) -> None:
    """Print schema tree."""
    if as_json:
        click.echo(json.dumps(result.model_dump(mode="json"), indent=2))
        return
    click.echo(f"config: {result.config_path}")
    _print_tree_node(result.tree, indent=0)


def _print_tree_node(node: Any, *, indent: int) -> None:
    prefix = "  " * indent
    label = node.type_label or node.type
    enabled = "on" if node.enabled else "off"
    path = node.path or "(root)"
    if node.type not in {"object", "array"}:
        value = node.value
        click.echo(f"{prefix}{path} [{label}, {enabled}] = {value!r}")
    else:
        extra = ""
        if node.item_count is not None:
            extra = f", items={node.item_count}"
        if node.can_append:
            extra = f"{extra}, appendable"
        click.echo(f"{prefix}{path} [{label}, {enabled}{extra}]")
    for child in node.children:
        _print_tree_node(child, indent=indent + 1)
`````

## File: src/metagit/core/api/__init__.py
`````python
#!/usr/bin/env python
"""Local HTTP JSON API helpers for Metagit."""

from metagit.core.api.server import build_server

__all__ = ["build_server"]
`````

## File: src/metagit/core/api/catalog_handler.py
`````python
#!/usr/bin/env python
"""
HTTP handlers for workspace catalog list and mutation (v2 API).
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Callable
from urllib.parse import parse_qs, unquote, urlparse

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.workspace.catalog_models import CatalogError, CatalogMutationResult
from metagit.core.workspace.catalog_service import WorkspaceCatalogService


JsonResponder = Callable[[int, dict[str, Any]], None]


class CatalogApiHandler:
    """Route catalog operations for the local JSON HTTP API."""

    def __init__(self, workspace_root: str, config_path: str) -> None:
        self._workspace_root = str(Path(workspace_root).resolve())
        self._config_path = str(Path(config_path).resolve())
        self._service = WorkspaceCatalogService()

    def handle(
        self,
        method: str,
        path: str,
        query: str,
        body: bytes,
        respond: JsonResponder,
    ) -> bool:
        """Dispatch catalog routes; return True when handled."""
        parsed_path = urlparse(path).path
        params = parse_qs(query, keep_blank_values=True)
        config = self._load_config(respond)
        if config is None:
            return True

        if method == "GET" and parsed_path == "/v2/workspace":
            result = self._service.list_workspace(
                config,
                self._config_path,
                self._workspace_root,
            )
            respond(200, result.model_dump(mode="json"))
            return True

        if method == "GET" and parsed_path == "/v2/projects":
            result = self._service.list_projects(config)
            respond(200, result.model_dump(mode="json"))
            return True

        if method == "POST" and parsed_path == "/v2/projects":
            payload = self._parse_body(body, respond)
            if payload is None:
                return True
            name = str(payload.get("name", "")).strip()
            mutation = self._service.add_project(
                config,
                self._config_path,
                name=name,
                description=payload.get("description"),
                agent_instructions=payload.get("agent_instructions"),
            )
            self._respond_mutation(mutation, respond)
            return True

        if method == "DELETE" and parsed_path.startswith("/v2/projects/"):
            name = unquote(parsed_path.removeprefix("/v2/projects/").strip("/"))
            mutation = self._service.remove_project(
                config,
                self._config_path,
                name=name,
            )
            self._respond_mutation(mutation, respond)
            return True

        if method == "GET" and parsed_path == "/v2/repos":
            project = self._first(params, "project")
            result = self._service.list_repos(
                config,
                self._workspace_root,
                project_name=project,
            )
            respond(200, result.model_dump(mode="json"))
            return True

        if method == "POST" and parsed_path == "/v2/repos":
            payload = self._parse_body(body, respond)
            if payload is None:
                return True
            project_name = str(payload.get("project", "")).strip()
            built = self._service.build_repo_from_fields(
                name=str(payload.get("name", "")),
                description=payload.get("description"),
                kind=payload.get("kind"),
                path=payload.get("path"),
                url=payload.get("url"),
                sync=payload.get("sync"),
                agent_instructions=payload.get("agent_instructions"),
                tags=payload.get("tags")
                if isinstance(payload.get("tags"), dict)
                else None,
            )
            if isinstance(built, CatalogError):
                respond(400, {"ok": False, "error": built.model_dump(mode="json")})
                return True
            mutation = self._service.add_repo(
                config,
                self._config_path,
                project_name=project_name,
                repo=built,
            )
            self._respond_mutation(mutation, respond)
            return True

        if method == "DELETE" and parsed_path.startswith("/v2/repos/"):
            remainder = parsed_path.removeprefix("/v2/repos/").strip("/")
            if "/" not in remainder:
                respond(
                    400,
                    {
                        "ok": False,
                        "error": {
                            "kind": "invalid_path",
                            "message": "use /v2/repos/{project}/{repo}",
                        },
                    },
                )
                return True
            project_name, repo_name = remainder.split("/", 1)
            mutation = self._service.remove_repo(
                config,
                self._config_path,
                project_name=unquote(project_name),
                repo_name=unquote(repo_name),
            )
            self._respond_mutation(mutation, respond)
            return True

        return False

    def _load_config(self, respond: JsonResponder) -> MetagitConfig | None:
        manager = MetagitConfigManager(self._config_path)
        loaded = manager.load_config()
        if isinstance(loaded, Exception):
            respond(
                500,
                {
                    "ok": False,
                    "error": {"kind": "config_error", "message": str(loaded)},
                },
            )
            return None
        return loaded

    def _parse_body(self, body: bytes, respond: JsonResponder) -> dict[str, Any] | None:
        if not body:
            respond(
                400,
                {
                    "ok": False,
                    "error": {"kind": "invalid_body", "message": "JSON body required"},
                },
            )
            return None
        try:
            parsed = json.loads(body.decode("utf-8"))
        except json.JSONDecodeError as exc:
            respond(
                400,
                {"ok": False, "error": {"kind": "invalid_json", "message": str(exc)}},
            )
            return None
        if not isinstance(parsed, dict):
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_body",
                        "message": "expected JSON object",
                    },
                },
            )
            return None
        return parsed

    def _respond_mutation(
        self,
        mutation: CatalogMutationResult,
        respond: JsonResponder,
    ) -> None:
        status = 200 if mutation.ok else 409
        if mutation.error and mutation.error.kind == "not_found":
            status = 404
        if mutation.error and mutation.error.kind in {
            "invalid_name",
            "invalid_repo",
            "invalid_body",
        }:
            status = 400
        respond(status, mutation.model_dump(mode="json"))

    @staticmethod
    def _first(params: dict[str, list[str]], key: str) -> str | None:
        values = params.get(key)
        if not values:
            return None
        first = values[0].strip()
        return first or None
`````

## File: src/metagit/core/api/layout_handler.py
`````python
#!/usr/bin/env python
"""
HTTP handlers for workspace layout rename and move (v2 API).
"""

from __future__ import annotations

import json
from typing import Any, Callable
from urllib.parse import parse_qs, unquote, urlparse

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.workspace.layout_context import resolve_sync_context
from metagit.core.workspace.layout_models import LayoutMutationResult
from metagit.core.workspace.layout_service import WorkspaceLayoutService

JsonResponder = Callable[[int, dict[str, Any]], None]


class LayoutApiHandler:
    """Route layout rename/move operations for the local JSON HTTP API."""

    def __init__(self, definition_root: str, config_path: str) -> None:
        self._definition_root = definition_root
        self._config_path = config_path
        self._service = WorkspaceLayoutService()

    def handle(
        self,
        method: str,
        path: str,
        query: str,
        body: bytes,
        respond: JsonResponder,
    ) -> bool:
        """Dispatch layout routes; return True when handled."""
        parsed_path = urlparse(path).path
        params = parse_qs(query, keep_blank_values=True)
        config = self._load_config(respond)
        if config is None:
            return True

        sync_root, dedupe = resolve_sync_context(self._definition_root)

        if method == "POST" and parsed_path.endswith("/rename"):
            flags = self._layout_flags(params, body, respond)
            if flags is None:
                return True
            if parsed_path.startswith("/v2/projects/"):
                from_name = unquote(
                    parsed_path.removeprefix("/v2/projects/")
                    .removesuffix("/rename")
                    .strip("/")
                )
                payload = flags.get("body") or {}
                to_name = str(
                    payload.get("to_name") or self._first(params, "to_name") or ""
                ).strip()
                result = self._service.rename_project(
                    config,
                    self._config_path,
                    sync_root,
                    from_name=from_name,
                    to_name=to_name,
                    dedupe=dedupe,
                    dry_run=flags["dry_run"],
                    move_disk=flags["move_disk"],
                    update_sessions=flags["update_sessions"],
                    force=flags["force"],
                )
                self._respond_layout(result, respond)
                return True

            if "/v2/repos/" in parsed_path and parsed_path.endswith("/rename"):
                remainder = (
                    parsed_path.removeprefix("/v2/repos/")
                    .removesuffix("/rename")
                    .strip("/")
                )
                if "/" not in remainder:
                    respond(
                        400,
                        {
                            "ok": False,
                            "error": {
                                "kind": "invalid_path",
                                "message": "use /v2/repos/{project}/{repo}/rename",
                            },
                        },
                    )
                    return True
                project_name, repo_name = remainder.split("/", 1)
                if flags is None:
                    return True
                payload = flags.get("body") or {}
                result = self._service.rename_repo(
                    config,
                    self._config_path,
                    sync_root,
                    project_name=unquote(project_name),
                    from_name=unquote(repo_name),
                    to_name=str(payload.get("to_name", "")).strip(),
                    dedupe=dedupe,
                    dry_run=flags["dry_run"],
                    move_disk=flags["move_disk"],
                    force=flags["force"],
                )
                self._respond_layout(result, respond)
                return True

        if method == "POST" and parsed_path.endswith("/move"):
            flags = self._layout_flags(params, body, respond)
            if flags is None:
                return True
            if "/v2/repos/" in parsed_path:
                remainder = (
                    parsed_path.removeprefix("/v2/repos/")
                    .removesuffix("/move")
                    .strip("/")
                )
                if "/" not in remainder:
                    respond(
                        400,
                        {
                            "ok": False,
                            "error": {
                                "kind": "invalid_path",
                                "message": "use /v2/repos/{project}/{repo}/move",
                            },
                        },
                    )
                    return True
                project_name, repo_name = remainder.split("/", 1)
                if flags is None:
                    return True
                payload = flags.get("body") or {}
                result = self._service.move_repo(
                    config,
                    self._config_path,
                    sync_root,
                    repo_name=unquote(repo_name),
                    from_project=unquote(project_name),
                    to_project=str(payload.get("to_project", "")).strip(),
                    dedupe=dedupe,
                    dry_run=flags["dry_run"],
                    move_disk=flags["move_disk"],
                    force=flags["force"],
                )
                self._respond_layout(result, respond)
                return True

        return False

    def _layout_flags(
        self,
        params: dict[str, list[str]],
        body: bytes,
        respond: JsonResponder,
    ) -> dict[str, Any] | None:
        payload: dict[str, Any] = {}
        if body:
            try:
                parsed = json.loads(body.decode("utf-8"))
            except json.JSONDecodeError as exc:
                respond(
                    400,
                    {
                        "ok": False,
                        "error": {"kind": "invalid_json", "message": str(exc)},
                    },
                )
                return None
            if not isinstance(parsed, dict):
                respond(
                    400,
                    {
                        "ok": False,
                        "error": {
                            "kind": "invalid_body",
                            "message": "expected JSON object",
                        },
                    },
                )
                return None
            payload = parsed

        def _bool_param(key: str, default: bool = False) -> bool:
            raw = params.get(key, [str(payload.get(key, default)).lower()])
            value = raw[0] if raw else str(default).lower()
            return value.lower() in {"1", "true", "yes"}

        manifest_only = _bool_param("manifest_only") or bool(
            payload.get("manifest_only", False)
        )
        return {
            "body": payload,
            "dry_run": _bool_param("dry_run") or bool(payload.get("dry_run", False)),
            "move_disk": not manifest_only,
            "update_sessions": not _bool_param("no_update_sessions"),
            "force": _bool_param("force") or bool(payload.get("force", False)),
        }

    def _load_config(self, respond: JsonResponder) -> MetagitConfig | None:
        manager = MetagitConfigManager(self._config_path)
        loaded = manager.load_config()
        if isinstance(loaded, Exception):
            respond(
                500,
                {
                    "ok": False,
                    "error": {"kind": "config_error", "message": str(loaded)},
                },
            )
            return None
        return loaded

    def _respond_layout(
        self,
        mutation: LayoutMutationResult,
        respond: JsonResponder,
    ) -> None:
        status = 200 if mutation.ok else 409
        if mutation.error and mutation.error.kind == "not_found":
            status = 404
        if mutation.error and mutation.error.kind in {
            "invalid_name",
            "noop",
            "unsupported",
        }:
            status = 400
        if mutation.error and mutation.error.kind == "protected":
            status = 403
        respond(status, mutation.model_dump(mode="json"))

    @staticmethod
    def _first(params: dict[str, list[str]], key: str) -> str | None:
        values = params.get(key)
        if not values:
            return None
        first = values[0].strip()
        return first or None
`````

## File: src/metagit/core/appconfig/agent_mode.py
`````python
#!/usr/bin/env python
"""
Agent-mode detection for non-interactive, agent-optimized interfaces.
"""

from __future__ import annotations

import os

from metagit.core.appconfig.models import AppConfig

_ENV_VAR = "METAGIT_AGENT_MODE"
_TRUTHY = frozenset({"1", "true", "yes", "on"})


def env_agent_mode_enabled() -> bool | None:
    """
    Read METAGIT_AGENT_MODE from the environment.

    Returns None when the variable is unset (caller should use file config).
    """
    raw = os.getenv(_ENV_VAR)
    if raw is None:
        return None
    return raw.strip().lower() in _TRUTHY


def resolve_agent_mode(config: AppConfig) -> bool:
    """Effective agent mode: METAGIT_AGENT_MODE overrides appconfig.agent_mode."""
    from_env = env_agent_mode_enabled()
    if from_env is not None:
        return from_env
    return bool(config.agent_mode)


def apply_agent_mode_override(config: AppConfig) -> AppConfig:
    """Apply METAGIT_AGENT_MODE to config after load."""
    from_env = env_agent_mode_enabled()
    if from_env is not None:
        config.agent_mode = from_env
    return config
`````

## File: src/metagit/core/appconfig/display.py
`````python
#!/usr/bin/env python
"""
Render full application configuration for CLI display.
"""

from __future__ import annotations

import json
from typing import Any, Literal

from metagit.core.appconfig.agent_mode import resolve_agent_mode
from metagit.core.appconfig.models import AppConfig
from metagit.core.config.yaml_display import dump_config_dict

OutputFormat = Literal["yaml", "json", "minimal-yaml"]


def build_appconfig_payload(
    config: AppConfig,
    *,
    config_path: str,
    minimal: bool = False,
) -> dict[str, Any]:
    """Build the document shown by `metagit appconfig show`."""
    if minimal:
        config_body = config.model_dump(
            exclude_none=True,
            exclude_defaults=True,
            mode="json",
        )
    else:
        config_body = config.model_dump(mode="json")
    return {
        "config_path": config_path,
        "agent_mode": resolve_agent_mode(config),
        "config": config_body,
    }


def render_appconfig_show(
    config: AppConfig,
    *,
    config_path: str,
    output_format: OutputFormat = "yaml",
    minimal: bool = False,
) -> str:
    """Serialize appconfig show output for the requested format."""
    payload = build_appconfig_payload(
        config,
        config_path=config_path,
        minimal=minimal,
    )
    if output_format == "json":
        return json.dumps(payload, indent=2, default=str) + "\n"
    if output_format == "minimal-yaml":
        return dump_config_dict({"config": payload["config"]})
    return dump_config_dict(payload)
`````

## File: src/metagit/core/config/__init__.py
`````python
#!/usr/bin/env python
"""
Project package for metagit.

This package provides Pydantic models and methods for parsing and validating
.metagit.yml configuration files.
"""

from .manager import MetagitConfigManager
from .documentation_models import DocumentationSource, normalize_documentation_entries
from .graph_models import GraphEndpoint, GraphRelationship, WorkspaceGraph
from .models import (
    CICD,
    AlertingChannel,
    AlertingChannelType,
    Artifact,
    ArtifactType,
    Branch,
    BranchNaming,
    BranchStrategy,
    CICDPlatform,
    ComponentKind,
    Dashboard,
    DependencyKind,
    Deployment,
    DeploymentStrategy,
    Environment,
    Hosting,
    Infrastructure,
    License,
    LicenseKind,
    LoggingProvider,
    Maintainer,
    MetagitConfig,
    MonitoringProvider,
    Observability,
    Pipeline,
    ProjectKind,
    ProjectPath,
    ProvisioningTool,
    Secret,
    SecretKind,
    Tasker,
    TaskerKind,
    Variable,
    VariableKind,
    VersionStrategy,
    Workspace,
    WorkspaceProject,
)

__all__ = [
    # Main configuration model
    "MetagitConfigManager",
    "MetagitConfig",
    # Enums
    "ProjectKind",
    "LicenseKind",
    "BranchStrategy",
    "TaskerKind",
    "ArtifactType",
    "VersionStrategy",
    "SecretKind",
    "VariableKind",
    "CICDPlatform",
    "DeploymentStrategy",
    "ProvisioningTool",
    "Hosting",
    "LoggingProvider",
    "MonitoringProvider",
    "AlertingChannelType",
    "ComponentKind",
    "DependencyKind",
    # Models
    "Maintainer",
    "License",
    "Tasker",
    "BranchNaming",
    "Branch",
    "Artifact",
    "Secret",
    "Variable",
    "Pipeline",
    "CICD",
    "Environment",
    "Infrastructure",
    "Deployment",
    "AlertingChannel",
    "Dashboard",
    "Observability",
    "ProjectPath",
    "Dependency",
    "Component",
    "WorkspaceProject",
    "Workspace",
    "DocumentationSource",
    "normalize_documentation_entries",
    "WorkspaceGraph",
    "GraphEndpoint",
    "GraphRelationship",
]
`````

## File: src/metagit/core/config/documentation_models.py
`````python
#!/usr/bin/env python
"""
Documentation source models for .metagit.yml knowledge-graph ingestion.
"""

from __future__ import annotations

from typing import Any, Optional, Union

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


def _looks_like_url(value: str) -> bool:
    trimmed = value.strip().lower()
    return trimmed.startswith("http://") or trimmed.startswith("https://")


def normalize_documentation_entries(
    value: object,
) -> Optional[list[dict[str, Any]]]:
    """
    Accept documentation as strings or dicts; normalize to DocumentationSource dicts.

    - Bare strings: markdown path or web URL (inferred from prefix).
    - Dicts: explicit kind/path/url/tags/metadata for graph pipelines.
    """
    if value is None:
        return None
    if not isinstance(value, list):
        raise ValueError("documentation must be a list")
    normalized: list[dict[str, Any]] = []
    for item in value:
        if isinstance(item, str):
            trimmed = item.strip()
            if not trimmed:
                continue
            if _looks_like_url(trimmed):
                normalized.append({"kind": "web", "url": trimmed})
            else:
                normalized.append({"kind": "markdown", "path": trimmed})
            continue
        if isinstance(item, dict):
            normalized.append(dict(item))
            continue
        raise ValueError(
            "documentation entries must be strings or objects with kind/path/url"
        )
    return normalized


class DocumentationSource(BaseModel):
    """One documentation source for agents and knowledge-graph ingestion."""

    model_config = ConfigDict(extra="forbid", use_enum_values=True)

    kind: str = Field(
        ...,
        description=(
            "Source type (markdown, web, confluence, sharepoint, wiki, api, other)"
        ),
    )
    path: Optional[str] = Field(
        None,
        description="Repo-relative or absolute path to a documentation file",
    )
    url: Optional[str] = Field(None, description="Remote documentation URL")
    title: Optional[str] = Field(None, description="Human-readable title")
    description: Optional[str] = Field(
        None,
        description="Short summary for indexing and graph nodes",
    )
    tags: dict[str, str] = Field(
        default_factory=dict,
        description="Flat metadata tags for filtering and graph edges",
    )
    metadata: dict[str, Any] = Field(
        default_factory=dict,
        description="Extensible key-value payload for downstream graph ingestors",
    )

    @field_validator("kind", mode="before")
    @classmethod
    def _normalize_kind(cls, value: object) -> str:
        if value is None:
            raise ValueError("documentation kind is required")
        return str(value).strip().lower()

    @field_validator("tags", mode="before")
    @classmethod
    def _normalize_tags(cls, value: object) -> dict[str, str]:
        if value is None:
            return {}
        if isinstance(value, dict):
            return {str(key): str(item) for key, item in value.items()}
        if isinstance(value, list):
            return {str(item): "true" for item in value}
        raise ValueError("documentation tags must be a list of strings or a map")

    @model_validator(mode="after")
    def _require_path_or_url(self) -> DocumentationSource:
        if not self.path and not self.url:
            raise ValueError("documentation source requires path or url")
        return self

    def graph_node_payload(self) -> dict[str, Any]:
        """Serialize for knowledge-graph or export pipelines."""
        payload: dict[str, Any] = {
            "kind": self.kind,
            "tags": dict(self.tags),
            "metadata": dict(self.metadata),
        }
        if self.path:
            payload["path"] = self.path
        if self.url:
            payload["url"] = str(self.url)
        if self.title:
            payload["title"] = self.title
        if self.description:
            payload["description"] = self.description
        return payload


DocumentationEntry = Union[str, DocumentationSource]
`````

## File: src/metagit/core/config/graph_cypher_export.py
`````python
#!/usr/bin/env python
"""Export .metagit.yml workspace graph data as GitNexus-ingestible Cypher."""

from __future__ import annotations

import json
from typing import Any, Literal, Optional

from pydantic import BaseModel, Field

from metagit.core.config.graph_resolver import resolve_graph_endpoint_id
from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService


class GraphCypherNode(BaseModel):
    """Workspace graph node for export."""

    id: str
    kind: Literal["workspace", "project", "repo", "documentation"]
    label: str
    workspace: Optional[str] = None
    project: Optional[str] = None
    repo: Optional[str] = None
    path: Optional[str] = None
    properties: dict[str, Any] = Field(default_factory=dict)


class GraphCypherEdge(BaseModel):
    """Workspace graph edge for export."""

    id: str
    from_id: str
    to_id: str
    type: str
    source: Literal["manual", "structure", "documentation"] = "manual"
    label: Optional[str] = None
    properties: dict[str, Any] = Field(default_factory=dict)


class GraphCypherToolCall(BaseModel):
    """MCP-shaped call for gitnexus_cypher."""

    tool: str = "gitnexus_cypher"
    arguments: dict[str, Any] = Field(default_factory=dict)


class GraphCypherExportResult(BaseModel):
    """Cypher export bundle for CLI, MCP, and agent pipelines."""

    ok: bool = True
    workspace_name: str = ""
    gitnexus_repo: str = ""
    schema_statements: list[str] = Field(default_factory=list)
    statements: list[str] = Field(default_factory=list)
    tool_calls: list[GraphCypherToolCall] = Field(default_factory=list)
    nodes: list[GraphCypherNode] = Field(default_factory=list)
    edges: list[GraphCypherEdge] = Field(default_factory=list)
    warnings: list[str] = Field(default_factory=list)


def _escape_cypher_string(value: str) -> str:
    return value.replace("\\", "\\\\").replace("'", "\\'")


def _literal_string(value: str) -> str:
    return f"'{_escape_cypher_string(value)}'"


def _literal_json(payload: dict[str, Any]) -> str:
    return _literal_string(json.dumps(payload, sort_keys=True))


class GraphCypherExportService:
    """Build Metagit workspace overlay nodes/edges and Cypher ingest statements."""

    _schema_statements: tuple[str, ...] = (
        "CREATE NODE TABLE IF NOT EXISTS MetagitEntity ("
        "id STRING, kind STRING, label STRING, workspace STRING, "
        "project STRING, repo STRING, path STRING, properties STRING, "
        "PRIMARY KEY (id)"
        ");",
        "CREATE REL TABLE IF NOT EXISTS MetagitLink ("
        "FROM MetagitEntity TO MetagitEntity, "
        "type STRING, source STRING, label STRING, rel_id STRING, properties STRING"
        ");",
    )

    def __init__(
        self,
        index_service: Optional[WorkspaceIndexService] = None,
    ) -> None:
        self._index = index_service or WorkspaceIndexService()

    def export(
        self,
        config: MetagitConfig,
        workspace_root: str,
        *,
        gitnexus_repo: Optional[str] = None,
        include_structure: bool = False,
        include_documentation: bool = False,
        manual_only: bool = False,
        with_schema: bool = True,
    ) -> GraphCypherExportResult:
        """Export manifest graph data as Cypher statements and MCP tool calls."""
        rows = self._index.build_index(config=config, workspace_root=workspace_root)
        project_names = {
            project.name
            for project in (config.workspace.projects if config.workspace else [])
        }
        workspace_name = config.name or "workspace"
        target_repo = gitnexus_repo or workspace_name

        nodes: dict[str, GraphCypherNode] = {}
        edges: list[GraphCypherEdge] = []
        warnings: list[str] = []

        self._ensure_workspace_node(nodes, workspace_name)
        if not manual_only:
            self._add_structure_nodes(nodes, project_names, rows)
            if include_structure:
                self._add_structure_edges(edges, rows)
            if include_documentation:
                self._add_documentation_nodes(config, nodes, edges, warnings)

        manual_added = self._add_manual_edges(
            config=config,
            rows=rows,
            project_names=project_names,
            nodes=nodes,
            edges=edges,
            warnings=warnings,
        )
        if manual_only and manual_added == 0:
            warnings.append("manual_only=true but graph.relationships is empty")

        node_list = sorted(nodes.values(), key=lambda item: item.id)
        schema = list(self._schema_statements) if with_schema else []
        statements = self._build_statements(node_list, edges)
        tool_calls = [
            GraphCypherToolCall(
                arguments={"query": statement, "repo": target_repo},
            )
            for statement in schema + statements
        ]

        return GraphCypherExportResult(
            ok=True,
            workspace_name=workspace_name,
            gitnexus_repo=target_repo,
            schema_statements=schema,
            statements=statements,
            tool_calls=tool_calls,
            nodes=node_list,
            edges=edges,
            warnings=warnings,
        )

    def _ensure_workspace_node(
        self,
        nodes: dict[str, GraphCypherNode],
        workspace_name: str,
    ) -> None:
        node_id = f"workspace:{workspace_name}"
        nodes[node_id] = GraphCypherNode(
            id=node_id,
            kind="workspace",
            label=workspace_name,
            workspace=workspace_name,
        )

    def _add_structure_nodes(
        self,
        nodes: dict[str, GraphCypherNode],
        project_names: set[str],
        rows: list[dict[str, Any]],
    ) -> None:
        for project_name in sorted(project_names):
            node_id = f"project:{project_name}"
            nodes[node_id] = GraphCypherNode(
                id=node_id,
                kind="project",
                label=project_name,
                project=project_name,
            )
        for row in rows:
            node_id = f"repo:{row['project_name']}/{row['repo_name']}"
            nodes[node_id] = GraphCypherNode(
                id=node_id,
                kind="repo",
                label=str(row.get("repo_name", "")),
                project=str(row.get("project_name", "")),
                repo=str(row.get("repo_name", "")),
                path=str(row.get("configured_path") or row.get("repo_path") or ""),
                properties={
                    "url": row.get("url"),
                    "sync": row.get("sync"),
                },
            )

    def _add_structure_edges(
        self,
        edges: list[GraphCypherEdge],
        rows: list[dict[str, Any]],
    ) -> None:
        for row in rows:
            project_id = f"project:{row['project_name']}"
            repo_id = f"repo:{row['project_name']}/{row['repo_name']}"
            edges.append(
                GraphCypherEdge(
                    id=f"structure:{project_id}->{repo_id}",
                    from_id=project_id,
                    to_id=repo_id,
                    type="contains",
                    source="structure",
                    label="contains",
                )
            )

    def _add_documentation_nodes(
        self,
        config: MetagitConfig,
        nodes: dict[str, GraphCypherNode],
        edges: list[GraphCypherEdge],
        warnings: list[str],
    ) -> None:
        if not config.documentation:
            return
        for index, entry in enumerate(config.documentation):
            payload = entry.graph_node_payload()
            doc_id = f"documentation:{index}"
            label = entry.title or entry.path or entry.url or doc_id
            nodes[doc_id] = GraphCypherNode(
                id=doc_id,
                kind="documentation",
                label=str(label),
                path=entry.path,
                properties=payload,
            )
            edges.append(
                GraphCypherEdge(
                    id=f"documentation:{index}:documents",
                    from_id=doc_id,
                    to_id=f"workspace:{config.name}",
                    type="documents",
                    source="documentation",
                    label=entry.kind,
                    properties=payload,
                )
            )

    def _add_manual_edges(
        self,
        *,
        config: MetagitConfig,
        rows: list[dict[str, Any]],
        project_names: set[str],
        nodes: dict[str, GraphCypherNode],
        edges: list[GraphCypherEdge],
        warnings: list[str],
    ) -> int:
        if config.graph is None or not config.graph.relationships:
            return 0
        added = 0
        for rel in config.graph.relationships:
            from_id = resolve_graph_endpoint_id(
                rel.from_endpoint,
                rows=rows,
                project_names=project_names,
            )
            to_id = resolve_graph_endpoint_id(
                rel.to,
                rows=rows,
                project_names=project_names,
            )
            if not from_id or not to_id:
                warnings.append(
                    f"skipped relationship {rel.id or rel.type}: unresolved endpoint"
                )
                continue
            self._ensure_endpoint_nodes(from_id, nodes, project_names, rows)
            self._ensure_endpoint_nodes(to_id, nodes, project_names, rows)
            rel_id = rel.id or f"manual:{from_id}->{to_id}:{rel.type}"
            edges.append(
                GraphCypherEdge(
                    id=rel_id,
                    from_id=from_id,
                    to_id=to_id,
                    type=rel.type,
                    source="manual",
                    label=rel.label or rel.type,
                    properties={
                        "description": rel.description,
                        "tags": dict(rel.tags),
                        "metadata": dict(rel.metadata),
                    },
                )
            )
            added += 1
        return added

    def _ensure_endpoint_nodes(
        self,
        node_id: str,
        nodes: dict[str, GraphCypherNode],
        project_names: set[str],
        rows: list[dict[str, Any]],
    ) -> None:
        if node_id in nodes:
            return
        if node_id.startswith("project:"):
            project = node_id.split(":", 1)[1]
            if project in project_names:
                nodes[node_id] = GraphCypherNode(
                    id=node_id,
                    kind="project",
                    label=project,
                    project=project,
                )
            return
        if node_id.startswith("repo:"):
            body = node_id.split(":", 1)[1]
            if "/" not in body:
                return
            project, repo = body.split("/", 1)
            row = next(
                (
                    item
                    for item in rows
                    if item.get("project_name") == project
                    and item.get("repo_name") == repo
                ),
                None,
            )
            nodes[node_id] = GraphCypherNode(
                id=node_id,
                kind="repo",
                label=repo,
                project=project,
                repo=repo,
                path=str(
                    (row or {}).get("configured_path")
                    or (row or {}).get("repo_path")
                    or ""
                ),
            )

    def _build_statements(
        self,
        nodes: list[GraphCypherNode],
        edges: list[GraphCypherEdge],
    ) -> list[str]:
        statements: list[str] = []
        for node in nodes:
            statements.append(self._merge_node_statement(node))
        for edge in edges:
            statements.append(self._create_edge_statement(edge))
        return statements

    def _merge_node_statement(self, node: GraphCypherNode) -> str:
        props = dict(node.properties)
        return (
            f"MERGE (n:MetagitEntity {{id: {_literal_string(node.id)}}}) "
            f"SET n.kind = {_literal_string(node.kind)}, "
            f"n.label = {_literal_string(node.label)}, "
            f"n.workspace = {_literal_string(node.workspace or '')}, "
            f"n.project = {_literal_string(node.project or '')}, "
            f"n.repo = {_literal_string(node.repo or '')}, "
            f"n.path = {_literal_string(node.path or '')}, "
            f"n.properties = {_literal_json(props)};"
        )

    def _create_edge_statement(self, edge: GraphCypherEdge) -> str:
        rel_props = {
            key: value for key, value in edge.properties.items() if value is not None
        }
        return (
            "MATCH "
            f"(a:MetagitEntity {{id: {_literal_string(edge.from_id)}}}), "
            f"(b:MetagitEntity {{id: {_literal_string(edge.to_id)}}}) "
            f"CREATE (a)-[:MetagitLink {{"
            f"type: {_literal_string(edge.type)}, "
            f"source: {_literal_string(edge.source)}, "
            f"label: {_literal_string(edge.label or '')}, "
            f"rel_id: {_literal_string(edge.id)}, "
            f"properties: {_literal_json(rel_props)}"
            f"}}]->(b);"
        )
`````

## File: src/metagit/core/config/graph_models.py
`````python
#!/usr/bin/env python
"""
Manual workspace graph relationships for cross-repo knowledge graphs.
"""

from __future__ import annotations

from typing import Any, Optional

from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator


class GraphEndpoint(BaseModel):
    """Endpoint for a manual cross-repo relationship."""

    model_config = ConfigDict(extra="forbid")

    project: Optional[str] = Field(
        None,
        description="Workspace project name",
    )
    repo: Optional[str] = Field(
        None,
        description="Repository name under the project",
    )
    path: Optional[str] = Field(
        None,
        description="Optional file or directory path within the repo",
    )


class GraphRelationship(BaseModel):
    """Manually declared edge between workspace projects or repos."""

    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    id: Optional[str] = Field(
        None,
        description="Stable identifier for exports and graph merges",
    )
    from_endpoint: GraphEndpoint = Field(
        ...,
        validation_alias=AliasChoices("from", "from_endpoint"),
        serialization_alias="from",
        description="Relationship source",
    )
    to: GraphEndpoint = Field(..., description="Relationship target")
    type: str = Field(
        default="depends_on",
        description=(
            "Relationship type (depends_on, documents, consumes, owns, related, …)"
        ),
    )
    label: Optional[str] = Field(None, description="Short label for graph UIs")
    description: Optional[str] = Field(
        None,
        description="Longer explanation for agents and exports",
    )
    tags: dict[str, str] = Field(
        default_factory=dict,
        description="Flat tags for filtering graph exports",
    )
    metadata: dict[str, Any] = Field(
        default_factory=dict,
        description="Extensible payload for GitNexus or other graph ingestors",
    )

    @field_validator("type", mode="before")
    @classmethod
    def _normalize_type(cls, value: object) -> str:
        if value is None:
            return "depends_on"
        return str(value).strip().lower()


class WorkspaceGraph(BaseModel):
    """Top-level manual graph data on a .metagit.yml manifest."""

    model_config = ConfigDict(extra="forbid")

    relationships: list[GraphRelationship] = Field(
        default_factory=list,
        description="Manually entered cross-repo or cross-project edges",
    )
    metadata: dict[str, Any] = Field(
        default_factory=dict,
        description="Graph-level metadata for export pipelines",
    )
`````

## File: src/metagit/core/config/graph_resolver.py
`````python
#!/usr/bin/env python
"""
Resolve manual graph endpoints to workspace dependency node ids.
"""

from __future__ import annotations

from typing import Any, Optional

from metagit.core.config.graph_models import GraphEndpoint


def resolve_graph_endpoint_id(
    endpoint: GraphEndpoint,
    *,
    rows: list[dict[str, Any]],
    project_names: set[str],
) -> Optional[str]:
    """
    Map a graph endpoint to a dependency node id (project:… or repo:…/…).

    Requires project when repo is set. Repo-only matches the first indexed row.
    """
    if endpoint.project and endpoint.project not in project_names:
        return None
    if endpoint.repo:
        project = endpoint.project
        for row in rows:
            if row.get("repo_name") != endpoint.repo:
                continue
            if project and row.get("project_name") != project:
                continue
            return f"repo:{row['project_name']}/{row['repo_name']}"
        return None
    if endpoint.project:
        return f"project:{endpoint.project}"
    return None
`````

## File: src/metagit/core/config/patch_service.py
`````python
#!/usr/bin/env python
"""Apply schema-tree config operations for CLI and web consumers."""

from __future__ import annotations

from pathlib import Path
from typing import Literal

from pydantic import BaseModel, Field

from metagit.core.appconfig import load_config as load_appconfig
from metagit.core.appconfig import save_config as save_appconfig
from metagit.core.appconfig.models import AppConfig
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.web.config_preview import (
    PreviewStyle,
    read_disk_text,
    render_appconfig_yaml,
    render_metagit_yaml,
)
from metagit.core.web.models import ConfigOperation, SchemaFieldNode
from metagit.core.web.schema_tree import SchemaTreeService

ConfigTarget = Literal["metagit", "appconfig"]


class PatchResult(BaseModel):
    """Outcome of applying config patch operations."""

    ok: bool
    target: ConfigTarget
    config_path: str
    validation_errors: list[dict[str, str]] = Field(default_factory=list)
    saved: bool = False
    tree: SchemaFieldNode | None = None


class PreviewResult(BaseModel):
    """YAML preview after optional draft operations."""

    ok: bool
    target: ConfigTarget
    config_path: str
    style: PreviewStyle
    yaml: str
    draft: bool = False
    validation_errors: list[dict[str, str]] = Field(default_factory=list)


class TreeResult(BaseModel):
    """Schema tree for a loaded config."""

    ok: bool
    target: ConfigTarget
    config_path: str
    tree: SchemaFieldNode
    validation_errors: list[dict[str, str]] = Field(default_factory=list)


class ConfigPatchService:
    """Load, mutate, preview, and save metagit and appconfig via schema operations."""

    def __init__(self) -> None:
        self._schema = SchemaTreeService()

    def build_tree(
        self,
        target: ConfigTarget,
        config_path: str,
        *,
        mask_secrets: bool = False,
    ) -> TreeResult | Exception:
        """Build schema tree for the config at config_path."""
        resolved = str(Path(config_path).resolve())
        if target == "metagit":
            loaded = self._load_metagit(resolved)
        else:
            loaded = self._load_appconfig(resolved)
        if isinstance(loaded, Exception):
            return loaded
        model_class = MetagitConfig if target == "metagit" else AppConfig
        tree = self._schema.build_tree(
            loaded,
            model_class,
            mask_secrets=mask_secrets or target == "appconfig",
        )
        return TreeResult(
            ok=True,
            target=target,
            config_path=resolved,
            tree=tree,
        )

    def preview(
        self,
        target: ConfigTarget,
        config_path: str,
        operations: list[ConfigOperation],
        *,
        style: PreviewStyle = "normalized",
    ) -> PreviewResult | Exception:
        """Render YAML preview with optional draft operations."""
        resolved = str(Path(config_path).resolve())
        if style == "disk" and operations:
            return ValueError("disk preview cannot include draft operations")
        if target == "metagit":
            loaded = self._load_metagit(resolved)
            model_class = MetagitConfig
        else:
            loaded = self._load_appconfig(resolved)
            model_class = AppConfig
        if isinstance(loaded, Exception):
            return loaded
        config = loaded
        validation_errors: list[dict[str, str]] = []
        draft = bool(operations)
        if draft:
            config, validation_errors = self._schema.apply_operations(
                loaded,
                model_class,
                operations,
            )
        if style == "disk":
            yaml_text = read_disk_text(resolved)
        elif target == "metagit":
            yaml_text = render_metagit_yaml(config, style=style)
        else:
            yaml_text = render_appconfig_yaml(
                config,
                config_path=resolved,
                style=style,
                mask_secrets=True,
            )
        return PreviewResult(
            ok=len(validation_errors) == 0,
            target=target,
            config_path=resolved,
            style=style,
            yaml=yaml_text,
            draft=draft,
            validation_errors=validation_errors,
        )

    def patch(
        self,
        target: ConfigTarget,
        config_path: str,
        operations: list[ConfigOperation],
        *,
        save: bool = False,
        include_tree: bool = False,
        mask_secrets: bool = False,
    ) -> PatchResult | Exception:
        """Apply operations; optionally persist when save=True and validation passes."""
        resolved = str(Path(config_path).resolve())
        if target == "metagit":
            loaded = self._load_metagit(resolved)
            model_class = MetagitConfig
        else:
            loaded = self._load_appconfig(resolved)
            model_class = AppConfig
        if isinstance(loaded, Exception):
            return loaded
        updated, validation_errors = self._schema.apply_operations(
            loaded,
            model_class,
            operations,
        )
        saved = False
        if save and not validation_errors:
            if target == "metagit":
                save_result = MetagitConfigManager(resolved).save_config(updated)
            else:
                save_result = save_appconfig(resolved, updated)
            if isinstance(save_result, Exception):
                return save_result
            saved = True
        tree = None
        if include_tree:
            tree = self._schema.build_tree(
                updated,
                model_class,
                mask_secrets=mask_secrets or target == "appconfig",
            )
        return PatchResult(
            ok=len(validation_errors) == 0,
            target=target,
            config_path=resolved,
            validation_errors=validation_errors,
            saved=saved,
            tree=tree,
        )

    def _load_metagit(self, config_path: str) -> MetagitConfig | Exception:
        manager = MetagitConfigManager(config_path=config_path)
        loaded = manager.load_config()
        if isinstance(loaded, Exception):
            return loaded
        return loaded

    def _load_appconfig(self, config_path: str) -> AppConfig | Exception:
        loaded = load_appconfig(config_path)
        if isinstance(loaded, Exception):
            return loaded
        return loaded
`````

## File: src/metagit/core/config/yaml_display.py
`````python
#!/usr/bin/env python
"""
Human-readable YAML serialization for Metagit config display.
"""

from __future__ import annotations

from typing import Any

import yaml


def _represent_str(dumper: yaml.Dumper, value: str) -> yaml.nodes.ScalarNode:
    """Use literal block style for multiline strings; preserve Unicode."""
    if "\n" in value:
        return dumper.represent_scalar(
            "tag:yaml.org,2002:str",
            value.rstrip("\n"),
            style="|",
        )
    return dumper.represent_scalar("tag:yaml.org,2002:str", value)


class _ReadableYamlDumper(yaml.SafeDumper):
    """Dumper tuned for terminal-friendly config output."""


_ReadableYamlDumper.add_representer(str, _represent_str)


def dump_config_dict(payload: dict[str, Any]) -> str:
    """
    Serialize a config dict for `metagit config show --normalized`.

    Multiline fields use `|` blocks; Unicode is not escaped.
    """
    return yaml.dump(
        payload,
        Dumper=_ReadableYamlDumper,
        default_flow_style=False,
        sort_keys=False,
        allow_unicode=True,
        indent=2,
    )
`````

## File: src/metagit/core/detect/detectors/git.py
`````python
#!/usr/bin/env python3
"""
Git repository detection plugin.

This module provides functionality to detect Git repository information
including branch checksum, tags, last commit timestamp, origin branch count,
and local dirty status.
"""

from datetime import datetime
from typing import Optional
from git import Repo, InvalidGitRepositoryError, NoSuchPathError

from metagit.core.detect.models import Detector, ProjectScanContext, DiscoveryResult


class GitDetector(Detector):
    """Git repository detection plugin."""

    name = "git"

    def should_run(self, ctx: ProjectScanContext) -> bool:
        """
        Determine if this detector should run on the given context.

        Args:
            ctx: Project scan context

        Returns:
            bool: True if the path is a Git repository
        """
        try:
            # Check if the path is a Git repository
            _ = Repo(str(ctx.root_path))
            return True
        except (InvalidGitRepositoryError, NoSuchPathError):
            return False

    def run(self, ctx: ProjectScanContext) -> Optional[DiscoveryResult]:
        """
        Run Git repository detection.

        Args:
            ctx: Project scan context

        Returns:
            DiscoveryResult with Git repository information
        """

        if not self.should_run(ctx):
            return None

        repo = Repo(str(ctx.root_path))

        # Get current branch
        try:
            current_branch = repo.active_branch.name
        except TypeError:
            # Handle detached HEAD state
            current_branch = "HEAD"

        # Get current branch checksum (commit hash)
        checksum = repo.head.commit.hexsha

        # Get last commit timestamp
        last_commit_timestamp = datetime.fromtimestamp(repo.head.commit.committed_date)

        # Get tags
        tags = [tag.name for tag in repo.tags]

        # Get remote branch count
        origin_branch_count = 0
        try:
            if repo.remotes:
                origin = repo.remotes.origin
                origin.fetch()
                origin_branch_count = len(list(origin.refs))
        except (AttributeError, IndexError):
            # No origin remote or no refs
            pass

        # Check for local dirty status
        is_dirty = repo.is_dirty()

        # Create structured data
        data = {
            "current_branch": current_branch,
            "checksum": checksum,
            "last_commit_timestamp": last_commit_timestamp.isoformat(),
            "tags": tags,
            "origin_branch_count": origin_branch_count,
            "is_dirty": is_dirty,
            "total_branches": len(repo.branches),
            "total_remotes": len(repo.remotes),
        }

        # Create tags for the discovery result
        discovery_tags = ["git", "vcs", "repository"]
        if is_dirty:
            discovery_tags.append("dirty")
        if tags:
            discovery_tags.append("tagged")

        return DiscoveryResult(
            name="Git Repository",
            description=f"Git repository on branch '{current_branch}'",
            tags=discovery_tags,
            confidence=1.0,
            data=data,
        )
`````

## File: src/metagit/core/detect/__init__.py
`````python
#!/usr/bin/env python3
"""
Detection module for metagit.

This module provides comprehensive repository detection and analysis capabilities,
including language detection, project classification, branch analysis, CI/CD detection,
and metrics collection.
"""

# from .manager import DetectionManager
# from .models import (
#     BranchInfo,
#     CIConfigAnalysis,
#     DetectionManagerConfig,
#     GitBranchAnalysis,
#     LanguageDetection,
#     ProjectTypeDetection,
# )

# __all__ = [
#     "DetectionManager",
#     "DetectionManagerConfig",
#     "LanguageDetection",
#     "ProjectTypeDetection",
#     "GitBranchAnalysis",
#     "CIConfigAnalysis",
#     "BranchInfo",
# ]
`````

## File: src/metagit/core/init/__init__.py
`````python
#!/usr/bin/env python
"""Metagit project initialization (templates and minimal profiles)."""

from metagit.core.init.service import InitService, InitWriteResult

__all__ = ["InitService", "InitWriteResult"]
`````

## File: src/metagit/core/init/models.py
`````python
#!/usr/bin/env python
"""Pydantic models for metagit init templates."""

from __future__ import annotations

from typing import Literal, Optional

from pydantic import BaseModel, ConfigDict, Field


class InitPromptSpec(BaseModel):
    """One copier-style prompt for template variable collection."""

    model_config = ConfigDict(extra="forbid")

    name: str = Field(..., description="Template variable name")
    label: str = Field(..., description="Human-readable prompt label")
    default: Optional[str] = Field(None, description="Static default value")
    default_from: Optional[Literal["directory_name", "git_remote_url"]] = Field(
        None,
        description="Built-in default resolver when default is not set",
    )
    required: bool = Field(
        default=True, description="Whether a non-empty value is required"
    )
    secret: bool = Field(default=False, description="Hide input in interactive prompts")


class InitTemplateFileSpec(BaseModel):
    """Rendered file mapping for a template."""

    model_config = ConfigDict(extra="forbid")

    template: str = Field(
        ..., description="Source filename inside the template directory"
    )
    output: str = Field(
        ..., description="Relative output path from init target directory"
    )
    optional: bool = Field(
        default=False,
        description="When true, skip writing if rendered content is empty",
    )


class InitTemplateManifest(BaseModel):
    """Manifest for a bundled init template (copier-style)."""

    model_config = ConfigDict(extra="forbid")

    id: str = Field(..., description="Template identifier for --template")
    label: str = Field(..., description="Short label for --list-templates")
    description: str = Field(..., description="Longer description of the template")
    kind: str = Field(..., description="Default MetagitConfig kind for this template")
    prompts: list[InitPromptSpec] = Field(default_factory=list)
    files: list[InitTemplateFileSpec] = Field(default_factory=list)
`````

## File: src/metagit/core/init/prompts.py
`````python
#!/usr/bin/env python
"""Collect init template answers from defaults, files, and interactive prompts."""

from __future__ import annotations

from pathlib import Path
from typing import Callable, Optional

import click

from metagit.core.init.models import InitPromptSpec, InitTemplateManifest
from metagit.core.utils.yaml_class import yaml


PromptFn = Callable[..., str]


def load_answers_file(path: Path) -> dict[str, str]:
    """Load answers from a YAML or JSON mapping file."""
    text = path.read_text(encoding="utf-8")
    if path.suffix.lower() == ".json":
        import json

        loaded = json.loads(text)
    else:
        loaded = yaml.safe_load(text)
    if not isinstance(loaded, dict):
        raise ValueError("answers file must be a mapping of variable names to values")
    return {
        str(key): "" if value is None else str(value) for key, value in loaded.items()
    }


def build_builtin_defaults(
    target_dir: Path,
    *,
    directory_name: str,
    git_remote_url: Optional[str],
) -> dict[str, str]:
    """Built-in default resolvers for template prompts."""
    return {
        "directory_name": directory_name,
        "git_remote_url": git_remote_url or "",
    }


def resolve_prompt_default(
    prompt: InitPromptSpec,
    builtins: dict[str, str],
) -> str:
    """Resolve the default value for one prompt spec."""
    if prompt.default is not None:
        return prompt.default
    if prompt.default_from:
        return builtins.get(prompt.default_from, "")
    return ""


def collect_answers(
    manifest: InitTemplateManifest,
    *,
    target_dir: Path,
    directory_name: str,
    git_remote_url: Optional[str],
    answers: Optional[dict[str, str]] = None,
    overrides: Optional[dict[str, str]] = None,
    no_prompt: bool = False,
    prompt_fn: Optional[PromptFn] = None,
) -> dict[str, str]:
    """
    Merge answers file, overrides, and interactive prompts.

    Raises click.UsageError when required values are missing in no_prompt mode.
    """
    merged: dict[str, str] = {}
    if answers:
        merged.update(answers)
    if overrides:
        merged.update(
            {key: value for key, value in overrides.items() if value is not None}
        )

    builtins = build_builtin_defaults(
        target_dir,
        directory_name=directory_name,
        git_remote_url=git_remote_url,
    )
    ask = prompt_fn or click.prompt

    for prompt in manifest.prompts:
        if prompt.name in merged and merged[prompt.name] != "":
            continue
        default = resolve_prompt_default(prompt, builtins)
        if no_prompt:
            if prompt.required and not default:
                raise click.UsageError(
                    f"Missing required init answer {prompt.name!r} "
                    f"(provide --answers-file or drop --no-prompt)"
                )
            merged[prompt.name] = default
            continue
        value = ask(
            prompt.label,
            default=default or None,
            hide_input=prompt.secret,
            show_default=True,
        )
        merged[prompt.name] = str(value).strip()

    for prompt in manifest.prompts:
        if prompt.required and not merged.get(prompt.name):
            raise click.UsageError(f"Required init answer {prompt.name!r} is empty")

    merged.setdefault("kind", manifest.kind)
    return merged
`````

## File: src/metagit/core/init/registry.py
`````python
#!/usr/bin/env python
"""Load bundled init templates from package data."""

from __future__ import annotations

from pathlib import Path
from typing import Optional

from metagit import DATA_PATH
from metagit.core.init.models import InitTemplateManifest
from metagit.core.utils.yaml_class import yaml


class InitTemplateRegistry:
    """Discover and load init templates shipped under data/init-templates."""

    def __init__(self, root: Optional[Path] = None) -> None:
        self._root = root or Path(DATA_PATH) / "init-templates"

    @property
    def root(self) -> Path:
        return self._root

    def list_templates(self) -> list[InitTemplateManifest]:
        """Return all valid template manifests sorted by id."""
        manifests: list[InitTemplateManifest] = []
        if not self._root.is_dir():
            return manifests
        for entry in sorted(self._root.iterdir()):
            if not entry.is_dir() or entry.name.startswith("."):
                continue
            manifest = self.load_manifest(entry.name)
            if manifest is not None:
                manifests.append(manifest)
        return manifests

    def load_manifest(self, template_id: str) -> Optional[InitTemplateManifest]:
        """Load template.yaml for a template id."""
        manifest_path = self._safe_template_path(template_id) / "template.yaml"
        if not manifest_path.is_file():
            return None
        with manifest_path.open("r", encoding="utf-8") as handle:
            raw = yaml.safe_load(handle)
        if not isinstance(raw, dict):
            return None
        return InitTemplateManifest.model_validate(raw)

    def template_dir(self, template_id: str) -> Path:
        """Return the directory containing template sources."""
        return self._safe_template_path(template_id)

    def _safe_template_path(self, template_id: str) -> Path:
        if not template_id or ".." in template_id or "/" in template_id:
            raise ValueError(f"invalid template id: {template_id!r}")
        return self._root / template_id
`````

## File: src/metagit/core/init/renderer.py
`````python
#!/usr/bin/env python
"""Render init template files and validate Metagit manifests."""

from __future__ import annotations

import re
from pathlib import Path
from typing import Any

from metagit.core.config.models import MetagitConfig
from metagit.core.init.models import InitTemplateFileSpec, InitTemplateManifest
from metagit.core.utils.yaml_class import yaml

_PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}")


def render_placeholders(content: str, context: dict[str, str]) -> str:
    """Replace ``{{ name }}`` placeholders (copier-style, no extra deps)."""

    def _replace(match: re.Match[str]) -> str:
        key = match.group(1)
        return context.get(key, "")

    return _PLACEHOLDER.sub(_replace, content)


def clean_manifest_payload(payload: dict[str, Any]) -> dict[str, Any]:
    """Remove empty optional keys before validation."""
    cleaned = dict(payload)
    for key in ("url", "agent_instructions"):
        value = cleaned.get(key)
        if value is None or value == "":
            cleaned.pop(key, None)
    return cleaned


def validate_metagit_yaml(content: str) -> MetagitConfig:
    """Parse and validate rendered .metagit.yml content."""
    loaded = yaml.safe_load(content)
    if not isinstance(loaded, dict):
        raise ValueError("rendered manifest is not a YAML mapping")
    return MetagitConfig.model_validate(clean_manifest_payload(loaded))


class InitTemplateRenderer:
    """Render template files for a target directory."""

    def render_file(
        self,
        template_dir: Path,
        file_spec: InitTemplateFileSpec,
        context: dict[str, str],
    ) -> str:
        source = template_dir / file_spec.template
        if not source.is_file():
            raise FileNotFoundError(f"template file not found: {source}")
        raw = source.read_text(encoding="utf-8")
        return render_placeholders(raw, context)

    def render_manifest(
        self,
        template_dir: Path,
        manifest: InitTemplateManifest,
        context: dict[str, str],
    ) -> list[tuple[InitTemplateFileSpec, str]]:
        """Render all files declared in the manifest."""
        rendered: list[tuple[InitTemplateFileSpec, str]] = []
        for file_spec in manifest.files:
            content = self.render_file(template_dir, file_spec, context)
            if file_spec.optional and not content.strip():
                continue
            if file_spec.output == ".metagit.yml":
                validate_metagit_yaml(content)
            rendered.append((file_spec, content))
        return rendered
`````

## File: src/metagit/core/init/service.py
`````python
#!/usr/bin/env python
"""Orchestrate metagit init from templates or minimal kind profiles."""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

import click

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.init.models import InitTemplateManifest
from metagit.core.init.prompts import collect_answers, load_answers_file
from metagit.core.init.registry import InitTemplateRegistry
from metagit.core.init.renderer import InitTemplateRenderer, validate_metagit_yaml
from metagit.core.project.models import ProjectKind
from metagit.core.utils.yaml_class import yaml


@dataclass
class InitWriteResult:
    """Files written during init."""

    metagit_yml: Path
    extra_files: list[Path] = field(default_factory=list)


@dataclass
class InitService:
    """Create .metagit.yml and optional companion files from templates or kinds."""

    registry: InitTemplateRegistry = field(default_factory=InitTemplateRegistry)
    renderer: InitTemplateRenderer = field(default_factory=InitTemplateRenderer)

    def list_templates(self) -> list[InitTemplateManifest]:
        return self.registry.list_templates()

    def resolve_template_id(self, template: Optional[str], kind: Optional[str]) -> str:
        """Map --template or --kind to a bundled template id when a bundle exists."""
        if template:
            return template
        if kind:
            candidate = kind.strip().lower()
            if self.registry.load_manifest(candidate) is not None:
                return candidate
        return "application"

    def initialize(
        self,
        target_dir: Path,
        *,
        template_id: str,
        directory_name: str,
        git_remote_url: Optional[str],
        answers_file: Optional[Path] = None,
        answers: Optional[dict[str, str]] = None,
        overrides: Optional[dict[str, str]] = None,
        no_prompt: bool = False,
        force: bool = False,
        dry_run: bool = False,
    ) -> InitWriteResult:
        manifest = self.registry.load_manifest(template_id)
        if manifest is None:
            raise click.ClickException(f"Unknown init template: {template_id!r}")

        file_answers: dict[str, str] = {}
        if answers_file is not None:
            file_answers = load_answers_file(answers_file)

        context = collect_answers(
            manifest,
            target_dir=target_dir,
            directory_name=directory_name,
            git_remote_url=git_remote_url,
            answers={**file_answers, **(answers or {})},
            overrides=overrides,
            no_prompt=no_prompt,
        )
        context.setdefault("kind", manifest.kind)

        template_dir = self.registry.template_dir(template_id)
        rendered_files = self.renderer.render_manifest(template_dir, manifest, context)

        metagit_path = target_dir / ".metagit.yml"
        if metagit_path.exists() and not force and not dry_run:
            raise click.ClickException(
                ".metagit.yml already exists (use --force to overwrite)"
            )

        extra_paths: list[Path] = []
        metagit_content: Optional[str] = None

        for file_spec, content in rendered_files:
            destination = target_dir / file_spec.output
            if file_spec.output == ".metagit.yml":
                metagit_content = content
                if not dry_run:
                    destination.write_text(content, encoding="utf-8")
                continue
            if not dry_run:
                destination.parent.mkdir(parents=True, exist_ok=True)
                destination.write_text(content, encoding="utf-8")
            extra_paths.append(destination)

        if metagit_content is None:
            raise click.ClickException("template did not produce .metagit.yml")

        return InitWriteResult(metagit_yml=metagit_path, extra_files=extra_paths)

    def initialize_minimal(
        self,
        target_dir: Path,
        *,
        kind: str,
        name: str,
        description: str,
        url: Optional[str],
        force: bool = False,
        dry_run: bool = False,
    ) -> InitWriteResult:
        """Create a minimal validated manifest without a bundled template directory."""
        metagit_path = target_dir / ".metagit.yml"
        if metagit_path.exists() and not force and not dry_run:
            raise click.ClickException(
                ".metagit.yml already exists (use --force to overwrite)"
            )

        try:
            kind_value = ProjectKind(kind)
        except ValueError as exc:
            allowed = ", ".join(item.value for item in ProjectKind)
            raise click.ClickException(
                f"Invalid kind {kind!r}; expected one of: {allowed}"
            ) from exc

        manager = MetagitConfigManager()
        config_result = manager.create_config(
            name=name,
            description=description,
            url=url,
            kind=kind_value.value,
        )
        if isinstance(config_result, Exception):
            raise click.ClickException(f"Failed to build config: {config_result}")

        payload = config_result.model_dump(
            mode="json",
            exclude_none=True,
        )
        content = yaml.safe_dump(
            payload,
            default_flow_style=False,
            sort_keys=False,
            indent=2,
            line_break=True,
        )
        validate_metagit_yaml(content)
        if not dry_run:
            metagit_path.write_text(content, encoding="utf-8")
        return InitWriteResult(metagit_yml=metagit_path)
`````

## File: src/metagit/core/mcp/services/discovery_context.py
`````python
#!/usr/bin/env python
"""
Deterministic discovery context packaging for config bootstrap.
"""

from pathlib import Path


class DiscoveryContextService:
    """Create deterministic discovery payloads for sampling prompts."""

    def build_context(self, repo_root: str) -> dict[str, str]:
        """Build a minimal deterministic context from repository files."""
        root = Path(repo_root).expanduser().resolve()
        hints: list[str] = []
        for candidate in ["pyproject.toml", "Dockerfile", ".github/workflows"]:
            path = root / candidate
            if path.exists():
                hints.append(candidate)

        return {
            "repo_root": str(root),
            "detected_artifacts": ", ".join(hints) if hints else "none",
            "instruction": "Generate valid .metagit.yml YAML only.",
        }
`````

## File: src/metagit/core/mcp/services/gitnexus_registry.py
`````python
#!/usr/bin/env python
"""
Read GitNexus global registry and per-repo index status.
"""

import json
import subprocess
from pathlib import Path
from typing import Any, Optional


class GitNexusRegistryAdapter:
    """Resolve GitNexus index metadata for local repository paths."""

    def __init__(self, registry_path: Optional[Path] = None) -> None:
        self._registry_path = (
            registry_path or Path.home() / ".gitnexus" / "registry.json"
        )

    def load_entries(self) -> list[dict[str, Any]]:
        """Load registry entries or return an empty list."""
        if not self._registry_path.is_file():
            return []
        try:
            payload = json.loads(self._registry_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            return []
        return payload if isinstance(payload, list) else []

    def lookup_by_path(self, repo_path: str) -> Optional[dict[str, Any]]:
        """Find registry entry for a resolved repository path."""
        target = str(Path(repo_path).expanduser().resolve())
        for entry in self.load_entries():
            entry_path = entry.get("path")
            if not entry_path:
                continue
            if str(Path(str(entry_path)).resolve()) == target:
                return entry
        return None

    def index_status(self, repo_path: str) -> str:
        """Return indexed, stale, missing, or error for a repository path."""
        entry = self.lookup_by_path(repo_path=repo_path)
        if entry is None:
            return "missing"
        if not Path(repo_path).is_dir():
            return "missing"
        cli_status = self._status_via_cli(repo_path=repo_path)
        if cli_status:
            return cli_status
        return "indexed"

    def _status_via_cli(self, repo_path: str) -> Optional[str]:
        """Parse `gitnexus status` output when the CLI is available."""
        try:
            completed = subprocess.run(
                ["npx", "--yes", "gitnexus@1.6.4", "status"],
                cwd=repo_path,
                capture_output=True,
                text=True,
                check=False,
                timeout=90,
            )
        except (OSError, subprocess.TimeoutExpired):
            return None
        output = (completed.stdout or "") + (completed.stderr or "")
        if "stale" in output.lower():
            return "stale"
        if "indexed" in output.lower():
            return "indexed"
        if completed.returncode != 0:
            return "error"
        return "indexed"

    def summarize_for_paths(self, repo_paths: list[str]) -> dict[str, str]:
        """Map repo paths to gitnexus status strings."""
        return {path: self.index_status(repo_path=path) for path in repo_paths}

    def registry_name_for_path(self, repo_path: str) -> Optional[str]:
        """GitNexus registry alias for a checkout path (--repo flag value)."""
        entry = self.lookup_by_path(repo_path=repo_path)
        if entry is None:
            return None
        name = entry.get("name")
        return str(name) if isinstance(name, str) and name else None
`````

## File: src/metagit/core/mcp/services/import_hint_scanner.py
`````python
#!/usr/bin/env python
"""
Lightweight import/reference scanning between workspace repositories.
"""

import json
import re
from pathlib import Path
from typing import Any, Optional

_PATH_DEP_PATTERN = re.compile(
    r"(?:file:|path:)\s*['\"]?([^'\"\s]+)['\"]?", re.IGNORECASE
)
_GO_REPLACE_PATTERN = re.compile(r"^\s*replace\s+[^\s]+\s+=>\s+(.+)$", re.MULTILINE)
_TERRAFORM_MODULE_PATTERN = re.compile(r'source\s*=\s*"([^"]+)"', re.IGNORECASE)


class ImportHintScanner:
    """Detect cross-repo references from common manifest files."""

    def scan_repo(
        self,
        repo_path: str,
        path_to_repo_id: dict[str, str],
    ) -> list[dict[str, Any]]:
        """Return import edges from one repo to other workspace repos."""
        root = Path(repo_path).expanduser().resolve()
        if not root.is_dir():
            return []
        hints: list[dict[str, Any]] = []
        candidates = [
            root / "package.json",
            root / "pyproject.toml",
            root / "go.mod",
            root / "requirements.txt",
        ]
        for candidate in candidates:
            if not candidate.is_file():
                continue
            try:
                text = candidate.read_text(encoding="utf-8")
            except OSError:
                continue
            if candidate.name == "package.json":
                hints.extend(
                    self._scan_package_json(
                        root=root,
                        text=text,
                        file_path=str(candidate),
                        path_to_repo_id=path_to_repo_id,
                    )
                )
            elif candidate.name == "pyproject.toml":
                hints.extend(
                    self._scan_text_paths(
                        root=root,
                        text=text,
                        file_path=str(candidate),
                        path_to_repo_id=path_to_repo_id,
                    )
                )
            elif candidate.name == "go.mod":
                hints.extend(
                    self._scan_go_mod(
                        root=root,
                        text=text,
                        file_path=str(candidate),
                        path_to_repo_id=path_to_repo_id,
                    )
                )
            else:
                hints.extend(
                    self._scan_text_paths(
                        root=root,
                        text=text,
                        file_path=str(candidate),
                        path_to_repo_id=path_to_repo_id,
                    )
                )
        hints.extend(
            self._scan_terraform_modules(
                root=root,
                path_to_repo_id=path_to_repo_id,
            )
        )
        return hints

    def _scan_package_json(
        self,
        root: Path,
        text: str,
        file_path: str,
        path_to_repo_id: dict[str, str],
    ) -> list[dict[str, Any]]:
        """Scan npm package.json for file/workspace path dependencies."""
        hints: list[dict[str, Any]] = []
        try:
            payload = json.loads(text)
        except json.JSONDecodeError:
            return hints
        sections = []
        for key in ("dependencies", "devDependencies", "peerDependencies"):
            value = payload.get(key)
            if isinstance(value, dict):
                sections.append(value)
        for section in sections:
            for dep_name, dep_ref in section.items():
                if not isinstance(dep_ref, str):
                    continue
                target_id = self._resolve_reference(
                    root=root,
                    reference=dep_ref,
                    path_to_repo_id=path_to_repo_id,
                )
                if target_id:
                    hints.append(
                        {
                            "to_id": target_id,
                            "evidence": [
                                f"{file_path}: dependency {dep_name} -> {dep_ref}"
                            ],
                        }
                    )
        return hints

    def _scan_go_mod(
        self,
        root: Path,
        text: str,
        file_path: str,
        path_to_repo_id: dict[str, str],
    ) -> list[dict[str, Any]]:
        """Scan go.mod replace directives for local paths."""
        hints: list[dict[str, Any]] = []
        for match in _GO_REPLACE_PATTERN.finditer(text):
            reference = match.group(1).strip()
            target_id = self._resolve_reference(
                root=root,
                reference=reference,
                path_to_repo_id=path_to_repo_id,
            )
            if target_id:
                hints.append(
                    {
                        "to_id": target_id,
                        "evidence": [f"{file_path}: replace => {reference}"],
                    }
                )
        return hints

    def _scan_text_paths(
        self,
        root: Path,
        text: str,
        file_path: str,
        path_to_repo_id: dict[str, str],
    ) -> list[dict[str, Any]]:
        """Scan generic text for file:/path: references."""
        hints: list[dict[str, Any]] = []
        for match in _PATH_DEP_PATTERN.finditer(text):
            reference = match.group(1).strip()
            target_id = self._resolve_reference(
                root=root,
                reference=reference,
                path_to_repo_id=path_to_repo_id,
            )
            if target_id:
                hints.append(
                    {
                        "to_id": target_id,
                        "evidence": [f"{file_path}: path reference {reference}"],
                    }
                )
        return hints

    def _scan_terraform_modules(
        self,
        root: Path,
        path_to_repo_id: dict[str, str],
    ) -> list[dict[str, Any]]:
        """Scan terraform files for module sources pointing at sibling repos."""
        hints: list[dict[str, Any]] = []
        for tf_file in list(root.rglob("*.tf"))[:40]:
            if not tf_file.is_file():
                continue
            try:
                text = tf_file.read_text(encoding="utf-8")
            except OSError:
                continue
            for match in _TERRAFORM_MODULE_PATTERN.finditer(text):
                reference = match.group(1).strip()
                if reference.startswith(("git::", "http://", "https://", "registry")):
                    continue
                target_id = self._resolve_reference(
                    root=root,
                    reference=reference,
                    path_to_repo_id=path_to_repo_id,
                )
                if target_id:
                    hints.append(
                        {
                            "to_id": target_id,
                            "evidence": [f"{tf_file}: module source {reference}"],
                        }
                    )
        return hints

    def _resolve_reference(
        self,
        root: Path,
        reference: str,
        path_to_repo_id: dict[str, str],
        file_path: str = "",
    ) -> Optional[str]:
        """Resolve a relative reference to another workspace repo node id."""
        _ = file_path
        cleaned = reference.removeprefix("file:").strip()
        if cleaned.startswith((".", "/")):
            resolved = (root / cleaned).resolve()
            for repo_path, node_id in path_to_repo_id.items():
                repo_root = Path(repo_path).resolve()
                if resolved == repo_root or repo_root in resolved.parents:
                    return node_id
        return None
`````

## File: src/metagit/core/mcp/services/ops_log.py
`````python
#!/usr/bin/env python
"""
Bounded operations log for MCP runtime.
"""

from collections import deque
from datetime import datetime, UTC


class OperationsLogService:
    """Store a bounded in-memory operations trail."""

    def __init__(self, capacity: int = 100) -> None:
        self._entries: deque[dict[str, str]] = deque(maxlen=capacity)

    def append(self, action: str, detail: str) -> None:
        """Append an operation log entry."""
        self._entries.append(
            {
                "timestamp": datetime.now(UTC).isoformat(),
                "action": action,
                "detail": detail,
            }
        )

    def list_entries(self) -> list[dict[str, str]]:
        """List operation log entries."""
        return list(self._entries)
`````

## File: src/metagit/core/mcp/services/repo_git_stats.py
`````python
#!/usr/bin/env python
"""
Git statistics helpers for repository inspect and snapshot flows.
"""

from datetime import datetime, timezone
from typing import Optional

from git import Repo
from git.exc import GitCommandError


def inspect_repo_state(repo_path: str) -> dict[str, str | bool | int | float | None]:
    """Return branch, dirty flag, ahead/behind, uncommitted count, and age metrics."""
    try:
        repo = Repo(repo_path)
        branch = (
            str(repo.active_branch.name) if not repo.head.is_detached else "DETACHED"
        )
        dirty = repo.is_dirty(untracked_files=True)
        ahead, behind = _ahead_behind(repo=repo)
        uncommitted = _uncommitted_count(repo=repo, dirty=dirty)
        head_age_days = head_commit_age_days(repo=repo)
        merge_age_days = merge_base_age_days(repo=repo)
        return {
            "ok": True,
            "branch": branch,
            "dirty": dirty,
            "ahead": ahead,
            "behind": behind,
            "uncommitted_count": uncommitted,
            "head_commit_age_days": head_age_days,
            "merge_base_age_days": merge_age_days,
        }
    except Exception as exc:
        return {
            "ok": False,
            "error": str(exc),
            "branch": None,
            "dirty": None,
            "ahead": None,
            "behind": None,
            "uncommitted_count": None,
            "head_commit_age_days": None,
            "merge_base_age_days": None,
        }


def _ahead_behind(repo: Repo) -> tuple[Optional[int], Optional[int]]:
    """Return ahead and behind counts relative to upstream when configured."""
    try:
        if repo.head.is_detached:
            return None, None
        tracking = repo.active_branch.tracking_branch()
        if tracking is None:
            return None, None
        counts = repo.git.rev_list(
            "--left-right",
            "--count",
            f"{tracking.name}...{repo.active_branch.name}",
        ).strip()
        parts = counts.split("\t")
        if len(parts) != 2:
            return None, None
        behind = int(parts[0])
        ahead = int(parts[1])
        return ahead, behind
    except Exception:
        return None, None


def head_commit_age_days(repo: Repo) -> Optional[float]:
    """Return days elapsed since HEAD commit timestamp (committed datetime)."""
    try:
        commit = repo.head.commit
        authored = getattr(commit, "committed_datetime", None)
        if authored is None:
            return None
        if authored.tzinfo is None:
            authored = authored.replace(tzinfo=timezone.utc)
        delta = datetime.now(timezone.utc) - authored.astimezone(timezone.utc)
        return round(delta.total_seconds() / 86400.0, 2)
    except Exception:
        return None


def merge_base_age_days(repo: Repo) -> Optional[float]:
    """
    Return days since merge-base(H, default remote branch) committed.

    Signals how stale the integration point with the remote default branch is.
    """
    try:
        if repo.head.is_detached:
            head_ref = str(repo.head.commit.hexsha)
        else:
            head_ref = "HEAD"
        default_ref = _resolve_origin_default(repo=repo)
        if default_ref is None:
            return None
        bases = repo.git.merge_base(head_ref, default_ref).strip()
        if not bases:
            return None
        base_hex = bases.split()[0].strip()
        commit = repo.commit(base_hex)
        authored = getattr(commit, "committed_datetime", None)
        if authored is None:
            return None
        if authored.tzinfo is None:
            authored = authored.replace(tzinfo=timezone.utc)
        delta = datetime.now(timezone.utc) - authored.astimezone(timezone.utc)
        return round(delta.total_seconds() / 86400.0, 2)
    except GitCommandError:
        return None
    except Exception:
        return None


def _resolve_origin_default(repo: Repo) -> Optional[str]:
    """Return ref like origin/main usable for merge-base, or None."""
    try:
        out = repo.git.symbolic_ref("refs/remotes/origin/HEAD").strip()
        if out.startswith("refs/remotes/"):
            return out[len("refs/remotes/") :].strip()
    except GitCommandError:
        pass
    for candidate in ("origin/main", "origin/master", "origin/develop"):
        try:
            repo.git.rev_parse("--verify", candidate)
            return candidate
        except GitCommandError:
            continue
    return None


def _uncommitted_count(repo: Repo, dirty: bool) -> int:
    """Estimate uncommitted change count."""
    if not dirty:
        return 0
    try:
        staged = len(repo.index.diff("HEAD"))
        unstaged = len(repo.index.diff(None))
        untracked = len(repo.untracked_files)
        return staged + unstaged + untracked
    except Exception:
        return 0
`````

## File: src/metagit/core/mcp/services/workspace_semantic_search.py
`````python
#!/usr/bin/env python
"""
GitNexus semantic workspace search (vector-ranked query per repository).
"""

from __future__ import annotations

import json
import subprocess
from typing import Any, Optional

from metagit.core.mcp.services.gitnexus_registry import GitNexusRegistryAdapter


class WorkspaceSemanticSearchService:
    """Run `gitnexus query` across workspace checkouts with a registry name."""

    _gitnexus_pkg = "gitnexus@1.6.4"

    def __init__(self, registry: Optional[GitNexusRegistryAdapter] = None) -> None:
        self._registry = registry or GitNexusRegistryAdapter()

    def search_across_repos(
        self,
        query: str,
        repo_paths: list[str],
        *,
        task_context: Optional[str] = None,
        goal: Optional[str] = None,
        limit_per_repo: int = 5,
        timeout_seconds: int = 120,
    ) -> dict[str, Any]:
        """Run semantic query for each indexed GitNexus repo path."""
        trimmed = query.strip()
        if not trimmed:
            return {"ok": False, "error": "empty_query", "results": []}

        results: list[dict[str, Any]] = []
        for repo_path in repo_paths:
            name = self._registry.registry_name_for_path(repo_path=repo_path)
            if not name:
                results.append(
                    {
                        "repo_path": repo_path,
                        "registry_name": None,
                        "ok": False,
                        "error": "not_in_gitnexus_registry",
                        "data": None,
                    }
                )
                continue
            payload, err = self._run_query(
                repo_path=repo_path,
                registry_name=name,
                query=trimmed,
                task_context=task_context,
                goal=goal,
                limit=limit_per_repo,
                timeout_seconds=timeout_seconds,
            )
            results.append(
                {
                    "repo_path": repo_path,
                    "registry_name": name,
                    "ok": err is None,
                    "error": err,
                    "data": payload,
                }
            )

        any_ok = any(item.get("ok") for item in results)
        return {
            "ok": any_ok,
            "query": trimmed,
            "results": results,
            "note": "Requires GitNexus index and optional embeddings; "
            "register repos with `gitnexus analyze`.",
        }

    def _run_query(
        self,
        repo_path: str,
        registry_name: str,
        query: str,
        task_context: Optional[str],
        goal: Optional[str],
        limit: int,
        timeout_seconds: int,
    ) -> tuple[Optional[dict[str, Any]], Optional[str]]:
        """Execute gitnexus query and parse JSON payload from stdout."""
        cmd: list[str] = [
            "npx",
            "--yes",
            self._gitnexus_pkg,
            "query",
            "-r",
            registry_name,
            "-l",
            str(limit),
            query,
        ]
        if task_context:
            cmd.extend(["-c", task_context])
        if goal:
            cmd.extend(["-g", goal])
        try:
            completed = subprocess.run(
                cmd,
                cwd=repo_path,
                capture_output=True,
                text=True,
                check=False,
                timeout=max(5, timeout_seconds),
            )
        except subprocess.TimeoutExpired:
            return None, "gitnexus query timed out"
        except OSError as exc:
            return None, str(exc)

        combined = (completed.stdout or "") + "\n" + (completed.stderr or "")
        payload = self._parse_query_json(stdout=combined)
        if payload is None:
            if completed.returncode != 0:
                return (
                    None,
                    f"gitnexus query exit {completed.returncode}: "
                    + (combined[:500] if combined.strip() else "no output"),
                )
            return None, "Could not parse gitnexus query JSON"

        warning = payload.get("warning") if isinstance(payload, dict) else None
        if warning and isinstance(warning, str) and completed.returncode != 0:
            return payload, warning
        return payload, None

    def _parse_query_json(self, stdout: str) -> Optional[dict[str, Any]]:
        """Extract the primary JSON object with processes from CLI output."""
        for line in stdout.splitlines():
            line = line.strip()
            if not line.startswith("{"):
                continue
            if '"processes"' not in line:
                continue
            try:
                decoded = json.loads(line)
            except json.JSONDecodeError:
                continue
            if isinstance(decoded, dict) and "processes" in decoded:
                return decoded
        return None
`````

## File: src/metagit/core/mcp/services/workspace_snapshot.py
`````python
#!/usr/bin/env python
"""
Workspace snapshot create and restore for MCP tools.
"""

import json
import os
import uuid
from pathlib import Path
from typing import Any, Optional

from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.project_context import ProjectContextService
from metagit.core.mcp.services.repo_git_stats import inspect_repo_state
from metagit.core.mcp.services.session_store import SessionStore
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.workspace.context_models import (
    SnapshotRepoState,
    WorkspaceSnapshot,
    WorkspaceSnapshotRestoreResult,
)


class WorkspaceSnapshotService:
    """Capture and restore workspace git-state manifests."""

    def __init__(
        self,
        index_service: Optional[WorkspaceIndexService] = None,
        context_service: Optional[ProjectContextService] = None,
    ) -> None:
        self._index = index_service or WorkspaceIndexService()
        self._context = context_service or ProjectContextService()

    def create(
        self,
        config: MetagitConfig,
        workspace_root: str,
        *,
        label: Optional[str] = None,
        project_name: Optional[str] = None,
        include_all_projects: bool = False,
        include_env_state: bool = True,
        link_session: bool = True,
    ) -> dict[str, Any]:
        """Create a snapshot manifest for scoped repositories."""
        store = SessionStore(workspace_root=workspace_root)
        meta = store.get_workspace_meta()
        active_project = project_name or meta.active_project
        rows = self._index.build_index(config=config, workspace_root=workspace_root)
        scoped_rows = (
            rows
            if include_all_projects
            else [row for row in rows if row["project_name"] == active_project]
            if active_project
            else rows
        )

        snapshot_id = str(uuid.uuid4())
        repo_states: list[SnapshotRepoState] = []
        for row in scoped_rows:
            repo_states.append(self._snapshot_repo_row(row=row))

        env_key_names: list[str] = []
        if include_env_state and active_project:
            env_key_names = self._context.list_env_export_keys(
                config=config,
                workspace_root=workspace_root,
                project_name=active_project,
            )

        session_ref = (
            os.path.join(".metagit", "sessions", f"{active_project}.json")
            if active_project
            else None
        )
        snapshot = WorkspaceSnapshot(
            snapshot_id=snapshot_id,
            active_project=active_project,
            label=label,
            repos=repo_states,
            env_key_names=env_key_names,
            session_ref=session_ref,
        )
        self._write_snapshot(workspace_root=workspace_root, snapshot=snapshot)
        if link_session:
            store.link_snapshot(snapshot_id=snapshot_id, project_name=active_project)
        return snapshot.model_dump(mode="json")

    def restore(
        self,
        config: MetagitConfig,
        workspace_root: str,
        snapshot_id: str,
        *,
        switch_project: bool = True,
        restore_session: bool = True,
    ) -> WorkspaceSnapshotRestoreResult:
        """Restore session metadata from a snapshot; does not mutate git state."""
        snapshot = self._load_snapshot(
            workspace_root=workspace_root, snapshot_id=snapshot_id
        )
        if snapshot is None:
            return WorkspaceSnapshotRestoreResult(
                ok=False,
                error="snapshot_not_found",
                snapshot_id=snapshot_id,
                notes=[
                    "Git branches, dirty state, and uncommitted changes were not modified.",
                ],
            )

        notes = [
            "Restore updated session metadata only.",
            "Git branches, dirty state, and uncommitted changes were not modified.",
        ]
        context = None
        if switch_project and snapshot.active_project:
            context = self._context.switch(
                config=config,
                workspace_root=workspace_root,
                project_name=snapshot.active_project,
                restore_session=restore_session,
                save_previous=True,
            )
        elif restore_session and snapshot.session_ref and snapshot.active_project:
            session_path = Path(workspace_root) / snapshot.session_ref
            self._restore_session_file(
                workspace_root=workspace_root,
                project_name=snapshot.active_project,
                session_path=session_path,
            )

        return WorkspaceSnapshotRestoreResult(
            ok=True,
            snapshot_id=snapshot_id,
            context=context,
            notes=notes,
        )

    def _snapshot_repo_row(self, row: dict[str, Any]) -> SnapshotRepoState:
        """Build snapshot repo state from an index row."""
        exists = bool(row.get("exists"))
        branch: Optional[str] = None
        dirty = False
        ahead: Optional[int] = None
        behind: Optional[int] = None
        uncommitted: Optional[int] = None
        inspect_error: Optional[str] = None
        if exists and row.get("is_git_repo"):
            inspected = inspect_repo_state(repo_path=str(row["repo_path"]))
            if inspected.get("ok"):
                branch = str(inspected["branch"]) if inspected.get("branch") else None
                dirty = bool(inspected.get("dirty", False))
                ahead_val = inspected.get("ahead")
                behind_val = inspected.get("behind")
                ahead = int(ahead_val) if isinstance(ahead_val, int) else None
                behind = int(behind_val) if isinstance(behind_val, int) else None
                uncommitted_val = inspected.get("uncommitted_count")
                uncommitted = (
                    int(uncommitted_val) if isinstance(uncommitted_val, int) else None
                )
            else:
                inspect_error = str(inspected.get("error", "inspect failed"))
        return SnapshotRepoState(
            project_name=str(row.get("project_name", "")),
            repo_name=str(row.get("repo_name", "")),
            repo_path=str(row.get("repo_path", "")),
            branch=branch,
            dirty=dirty,
            ahead=ahead,
            behind=behind,
            uncommitted_count=uncommitted,
            inspect_error=inspect_error,
        )

    def _write_snapshot(self, workspace_root: str, snapshot: WorkspaceSnapshot) -> Path:
        """Write snapshot JSON to workspace .metagit/snapshots."""
        snapshots_dir = Path(workspace_root) / ".metagit" / "snapshots"
        snapshots_dir.mkdir(parents=True, exist_ok=True)
        path = snapshots_dir / f"{snapshot.snapshot_id}.json"
        path.write_text(
            json.dumps(snapshot.model_dump(mode="json"), indent=2) + "\n",
            encoding="utf-8",
        )
        try:
            os.chmod(path, 0o600)
        except OSError:
            pass
        return path

    def _load_snapshot(
        self, workspace_root: str, snapshot_id: str
    ) -> Optional[WorkspaceSnapshot]:
        """Load snapshot by id."""
        path = Path(workspace_root) / ".metagit" / "snapshots" / f"{snapshot_id}.json"
        if not path.is_file():
            return None
        try:
            payload = json.loads(path.read_text(encoding="utf-8"))
            return WorkspaceSnapshot.model_validate(payload)
        except (OSError, json.JSONDecodeError, ValueError):
            return None

    def _restore_session_file(
        self,
        workspace_root: str,
        project_name: str,
        session_path: Path,
    ) -> None:
        """Copy a snapshot-linked session file into the live session store."""
        if not session_path.is_file():
            return
        try:
            payload = json.loads(session_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            return
        from metagit.core.workspace.context_models import ProjectSession

        store = SessionStore(workspace_root=workspace_root)
        session = ProjectSession.model_validate(payload)
        session.project_name = project_name
        store.save_project_session(session=session)
`````

## File: src/metagit/core/mcp/services/workspace_sync.py
`````python
#!/usr/bin/env python
"""
Batch workspace repository synchronization for MCP tools.
"""

from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Optional

from metagit.core.mcp.services.repo_git_stats import inspect_repo_state
from metagit.core.mcp.services.repo_ops import RepoOperationsService


class WorkspaceSyncService:
    """Synchronize many workspace repositories with guardrails."""

    def __init__(self, repo_ops: Optional[RepoOperationsService] = None) -> None:
        self._repo_ops = repo_ops or RepoOperationsService()

    def sync_many(
        self,
        repo_rows: list[dict[str, Any]],
        *,
        repos: Optional[list[str]] = None,
        mode: str = "fetch",
        only_if: str = "any",
        allow_mutation: bool = False,
        max_parallel: int = 4,
        dry_run: bool = False,
    ) -> dict[str, Any]:
        """Sync selected repositories and return per-repo results."""
        selected_rows = self._select_rows(repo_rows=repo_rows, repos=repos)
        normalized_mode = mode.lower()
        normalized_only_if = only_if.lower()
        if normalized_mode not in {"fetch", "pull", "clone"}:
            return {
                "ok": False,
                "error": "Unsupported sync mode.",
                "results": [],
                "summary": {},
            }
        parallel = max(1, min(max_parallel, 16))
        results: list[dict[str, Any]] = []

        def run_row(row: dict[str, Any]) -> dict[str, Any]:
            return self._sync_row(
                row=row,
                mode=normalized_mode,
                only_if=normalized_only_if,
                allow_mutation=allow_mutation,
                dry_run=dry_run,
            )

        with ThreadPoolExecutor(max_workers=parallel) as executor:
            futures = {executor.submit(run_row, row): row for row in selected_rows}
            for future in as_completed(futures):
                results.append(future.result())

        results.sort(
            key=lambda item: (item.get("project_name", ""), item.get("repo_name", ""))
        )
        summary = {
            "total": len(results),
            "ok": sum(1 for item in results if item.get("ok")),
            "skipped": sum(1 for item in results if item.get("skipped")),
            "failed": sum(
                1 for item in results if not item.get("ok") and not item.get("skipped")
            ),
            "dry_run": dry_run,
        }
        return {"ok": summary["failed"] == 0, "results": results, "summary": summary}

    def _select_rows(
        self,
        repo_rows: list[dict[str, Any]],
        repos: Optional[list[str]],
    ) -> list[dict[str, Any]]:
        """Filter index rows by repo selectors."""
        if not repos or repos == ["all"]:
            return list(repo_rows)
        selectors = {item.strip() for item in repos if item.strip()}
        selected: list[dict[str, Any]] = []
        for row in repo_rows:
            repo_path = str(row.get("repo_path", ""))
            repo_name = str(row.get("repo_name", ""))
            project_name = str(row.get("project_name", ""))
            keys = {repo_path, repo_name, f"{project_name}/{repo_name}"}
            if selectors.intersection(keys):
                selected.append(row)
        return selected

    def _sync_row(
        self,
        row: dict[str, Any],
        mode: str,
        only_if: str,
        allow_mutation: bool,
        dry_run: bool,
    ) -> dict[str, Any]:
        """Sync one repository row."""
        base = {
            "project_name": row.get("project_name"),
            "repo_name": row.get("repo_name"),
            "repo_path": row.get("repo_path"),
            "ok": False,
            "skipped": False,
            "mode": mode,
        }
        repo_path = str(row.get("repo_path", ""))
        exists = bool(row.get("exists"))
        is_git_repo = bool(row.get("is_git_repo"))
        should_sync, skip_reason = self._should_sync(
            repo_path=repo_path,
            exists=exists,
            is_git_repo=is_git_repo,
            only_if=only_if,
        )
        if not should_sync:
            base["skipped"] = True
            base["skipped_reason"] = skip_reason
            base["ok"] = True
            return base
        if dry_run:
            base["ok"] = True
            base["dry_run"] = True
            return base
        origin_url = str(row.get("url")) if row.get("url") else None
        if mode == "clone" and not exists and not origin_url:
            base["error"] = "origin_url is required for clone mode."
            return base
        outcome = self._repo_ops.sync(
            repo_path=repo_path,
            mode=mode,
            allow_mutation=allow_mutation,
            origin_url=origin_url,
        )
        base.update(outcome)
        return base

    def _should_sync(
        self,
        repo_path: str,
        exists: bool,
        is_git_repo: bool,
        only_if: str,
    ) -> tuple[bool, Optional[str]]:
        """Determine whether a repository should be synchronized."""
        if only_if == "any":
            return True, None
        if only_if == "missing":
            if exists and is_git_repo:
                return False, "already_present"
            return True, None
        if only_if == "dirty":
            if not exists or not is_git_repo:
                return False, "not_a_git_repo"
            inspected = inspect_repo_state(repo_path=repo_path)
            if inspected.get("ok") and inspected.get("dirty"):
                return True, None
            return False, "clean"
        if only_if == "behind_origin":
            if not exists or not is_git_repo:
                return False, "not_a_git_repo"
            inspected = inspect_repo_state(repo_path=repo_path)
            behind = inspected.get("behind")
            if inspected.get("ok") and isinstance(behind, int) and behind > 0:
                return True, None
            return False, "not_behind_origin"
        return True, None
`````

## File: src/metagit/core/mcp/services/workspace_template.py
`````python
#!/usr/bin/env python
"""
Apply packaged workspace templates to workspace projects.
"""

from __future__ import annotations

import os
import shutil
from pathlib import Path
from typing import Any, Optional

from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService


class WorkspaceTemplateService:
    """Copy template files into workspace project directories."""

    def __init__(self, index_service: Optional[WorkspaceIndexService] = None) -> None:
        self._index = index_service or WorkspaceIndexService()

    def apply(
        self,
        config: MetagitConfig,
        workspace_root: str,
        template: str,
        target_projects: list[str],
        *,
        dry_run: bool = True,
        confirm_apply: bool = False,
    ) -> dict[str, Any]:
        """Preview or apply a template to target workspace projects."""
        template_dir = self._resolve_template_dir(template=template)
        if template_dir is None:
            return {
                "ok": False,
                "error": "template_not_found",
                "template": template,
                "results": [],
            }
        if not target_projects:
            return {
                "ok": False,
                "error": "target_projects_required",
                "template": template,
                "results": [],
            }
        if not dry_run and not confirm_apply:
            return {
                "ok": False,
                "error": "confirm_apply_required",
                "template": template,
                "results": [],
            }

        rows = self._index.build_index(config=config, workspace_root=workspace_root)
        project_names = {project.name for project in (config.workspace.projects or [])}
        results: list[dict[str, Any]] = []
        for project_name in target_projects:
            if project_name not in project_names:
                results.append(
                    {
                        "project_name": project_name,
                        "ok": False,
                        "error": "project_not_found",
                        "files": [],
                    }
                )
                continue
            target_root = self._project_target_root(
                workspace_root=workspace_root,
                project_name=project_name,
                rows=rows,
            )
            if target_root is None:
                results.append(
                    {
                        "project_name": project_name,
                        "ok": False,
                        "error": "no_target_path",
                        "files": [],
                    }
                )
                continue
            planned = self._plan_copy(
                template_dir=template_dir, target_root=target_root
            )
            if dry_run:
                results.append(
                    {
                        "project_name": project_name,
                        "ok": True,
                        "dry_run": True,
                        "target_root": target_root,
                        "files": planned,
                    }
                )
                continue
            written = self._execute_copy(
                template_dir=template_dir,
                target_root=target_root,
                planned=planned,
            )
            results.append(
                {
                    "project_name": project_name,
                    "ok": True,
                    "dry_run": False,
                    "target_root": target_root,
                    "files": written,
                }
            )

        ok = all(item.get("ok") for item in results)
        return {
            "ok": ok,
            "template": template,
            "dry_run": dry_run,
            "results": results,
        }

    def list_templates(self) -> list[str]:
        """Return available template names."""
        root = self._templates_root()
        if not root.is_dir():
            return []
        return sorted(
            [
                path.name
                for path in root.iterdir()
                if path.is_dir() and not path.name.startswith(".")
            ]
        )

    def _templates_root(self) -> Path:
        """Return packaged templates directory."""
        return Path(__file__).resolve().parents[3] / "data" / "templates"

    def _resolve_template_dir(self, template: str) -> Optional[Path]:
        """Resolve template directory if it exists."""
        if not template or ".." in template or "/" in template:
            return None
        candidate = self._templates_root() / template
        return candidate if candidate.is_dir() else None

    def _project_target_root(
        self,
        workspace_root: str,
        project_name: str,
        rows: list[dict[str, Any]],
    ) -> Optional[str]:
        """Choose a directory to receive template files for a project."""
        project_rows = [row for row in rows if row.get("project_name") == project_name]
        for row in project_rows:
            if row.get("exists"):
                return str(row.get("repo_path"))
        candidate = Path(workspace_root) / project_name
        if candidate.is_dir():
            return str(candidate.resolve())
        return None

    def _plan_copy(self, template_dir: Path, target_root: str) -> list[dict[str, str]]:
        """List files that would be copied."""
        planned: list[dict[str, str]] = []
        target = Path(target_root)
        for source in template_dir.rglob("*"):
            if not source.is_file():
                continue
            relative = source.relative_to(template_dir)
            destination = target / relative
            planned.append(
                {
                    "relative_path": str(relative),
                    "destination": str(destination),
                    "exists": "true" if destination.exists() else "false",
                }
            )
        return planned

    def _execute_copy(
        self,
        template_dir: Path,
        target_root: str,
        planned: list[dict[str, str]],
    ) -> list[dict[str, str]]:
        """Copy planned files, skipping destinations that already exist."""
        written: list[dict[str, str]] = []
        target = Path(target_root)
        for item in planned:
            relative = item["relative_path"]
            destination = target / relative
            if destination.exists():
                item["status"] = "skipped_exists"
                written.append(item)
                continue
            destination.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(template_dir / relative, destination)
            try:
                os.chmod(destination, 0o644)
            except OSError:
                pass
            item["status"] = "written"
            written.append(item)
        return written
`````

## File: src/metagit/core/mcp/tools/bootstrap_plan_only.py
`````python
#!/usr/bin/env python
"""
Plan-only bootstrap MCP tool implementation.
"""

from typing import Optional


def metagit_bootstrap_config_plan_only(reason: Optional[str]) -> dict[str, str]:
    """Return bootstrap guidance when MCP sampling is not yet available."""
    details = reason or "Workspace is not active. Generate or fix .metagit.yml first."
    return {
        "mode": "plan_only",
        "message": details,
    }
`````

## File: src/metagit/core/mcp/tools/workspace_status.py
`````python
#!/usr/bin/env python
"""
Workspace status MCP tool implementation.
"""

from metagit.core.mcp.models import WorkspaceStatus


def metagit_workspace_status(status: WorkspaceStatus) -> dict[str, str | None]:
    """Return structured workspace status details."""
    return {
        "state": status.state.value,
        "root_path": status.root_path,
        "reason": status.reason,
    }
`````

## File: src/metagit/core/mcp/__init__.py
`````python
#!/usr/bin/env python
"""
Metagit MCP core package.
"""

from metagit.core.mcp.models import McpActivationState, WorkspaceStatus

__all__ = ["McpActivationState", "WorkspaceStatus"]
`````

## File: src/metagit/core/mcp/gate.py
`````python
#!/usr/bin/env python
"""
Workspace gate evaluation for Metagit MCP runtime.
"""

import os
from pathlib import Path
from typing import Optional

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.mcp.models import McpActivationState, WorkspaceStatus


class WorkspaceGate:
    """Evaluate whether workspace is active for MCP tool exposure."""

    _config_file_name: str = ".metagit.yml"

    def evaluate(self, root_path: Optional[str]) -> WorkspaceStatus:
        """Evaluate root state as active, missing config, or invalid config."""
        if not root_path:
            return WorkspaceStatus(
                state=McpActivationState.INACTIVE_MISSING_CONFIG,
                root_path=None,
                reason="Workspace root could not be resolved.",
            )

        config_path = os.path.join(root_path, self._config_file_name)
        if not os.path.exists(config_path):
            return WorkspaceStatus(
                state=McpActivationState.INACTIVE_MISSING_CONFIG,
                root_path=str(Path(root_path).resolve()),
                reason=f"Configuration file not found: {self._config_file_name}",
            )

        manager = MetagitConfigManager(config_path=Path(config_path))
        result = manager.load_config()
        if isinstance(result, Exception):
            return WorkspaceStatus(
                state=McpActivationState.INACTIVE_INVALID_CONFIG,
                root_path=str(Path(root_path).resolve()),
                reason=str(result),
            )

        return WorkspaceStatus(
            state=McpActivationState.ACTIVE,
            root_path=str(Path(root_path).resolve()),
            reason=None,
        )
`````

## File: src/metagit/core/mcp/models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for Metagit MCP runtime state.
"""

from enum import Enum
from typing import Optional

from pydantic import BaseModel, Field


class McpActivationState(str, Enum):
    """Activation state for MCP workspace gating."""

    ACTIVE = "active"
    INACTIVE_MISSING_CONFIG = "inactive_missing_config"
    INACTIVE_INVALID_CONFIG = "inactive_invalid_config"


class WorkspaceStatus(BaseModel):
    """Current workspace status used by MCP tools and resources."""

    state: McpActivationState = Field(
        ...,
        description="Workspace activation state",
    )
    root_path: Optional[str] = Field(
        default=None,
        description="Resolved workspace root path when available",
    )
    reason: Optional[str] = Field(
        default=None,
        description="Human-readable reason for inactive state",
    )
`````

## File: src/metagit/core/mcp/protocols.py
`````python
#!/usr/bin/env python
"""
Protocol contracts for Metagit MCP components.
"""

from typing import Protocol

from metagit.core.mcp.models import WorkspaceStatus


class WorkspaceRootResolverProtocol(Protocol):
    """Resolve the effective workspace root for the MCP runtime."""

    def resolve(self, cwd: str, cli_root: str | None = None) -> str | None:
        """Resolve and return a workspace root path, if available."""


class WorkspaceGateProtocol(Protocol):
    """Evaluate whether MCP should expose active tooling."""

    def evaluate(self, root_path: str | None) -> WorkspaceStatus:
        """Return the current workspace status for tool gating."""
`````

## File: src/metagit/core/mcp/root_resolver.py
`````python
#!/usr/bin/env python
"""
Workspace root resolution for Metagit MCP runtime.
"""

import os
from pathlib import Path
from typing import Optional


class WorkspaceRootResolver:
    """Resolve workspace root for `.metagit.yml` gating."""

    _config_file_name: str = ".metagit.yml"
    _env_var_name: str = "METAGIT_WORKSPACE_ROOT"

    def resolve(self, cwd: str, cli_root: Optional[str] = None) -> Optional[str]:
        """Resolve workspace root by env var, CLI option, then upward walk."""
        env_root = os.getenv(self._env_var_name)
        if env_root:
            return str(Path(env_root).expanduser().resolve())

        if cli_root:
            return str(Path(cli_root).expanduser().resolve())

        return self._walk_for_config(cwd=cwd)

    def _walk_for_config(self, cwd: str) -> Optional[str]:
        """Walk up the directory tree until `.metagit.yml` is found."""
        current = Path(cwd).expanduser().resolve()
        while True:
            config_path = os.path.join(str(current), self._config_file_name)
            if os.path.exists(config_path):
                return str(current)

            if current.parent == current:
                return None

            current = current.parent
`````

## File: src/metagit/core/project/search_models.py
`````python
#!/usr/bin/env python
"""
Runtime models for managed repository search and resolve results.
"""

from pydantic import BaseModel, Field


class ManagedRepoStatus(BaseModel):
    """Resolved sync state for a managed workspace repository row."""

    resolved_path: str
    exists: bool
    is_git_repo: bool
    sync_enabled: bool
    status: str


class ManagedRepoMatch(BaseModel):
    """One search hit with scoring metadata."""

    project_name: str
    repo_name: str
    url: str | None = None
    configured_path: str | None = None
    tags: dict[str, str] = Field(default_factory=dict)
    status: ManagedRepoStatus
    match_reasons: list[str] = Field(default_factory=list)
    score: int


class ManagedRepoSearchResult(BaseModel):
    """Ordered search hits for a query."""

    query: str
    matches: list[ManagedRepoMatch] = Field(default_factory=list)


class ManagedRepoError(BaseModel):
    """Structured error from resolve_one."""

    kind: str
    message: str
    matches: list[ManagedRepoMatch] = Field(default_factory=list)


class ManagedRepoResolveResult(BaseModel):
    """Single-match resolution or an error."""

    match: ManagedRepoMatch | None = None
    error: ManagedRepoError | None = None
`````

## File: src/metagit/core/project/source_models.py
`````python
#!/usr/bin/env python
"""
Models for provider-based recursive repository discovery and sync planning.
"""

from enum import Enum
from typing import List, Optional

from pydantic import BaseModel, Field, model_validator

from metagit.core.project.models import ProjectPath


class SourceProvider(str, Enum):
    """Supported source providers for recursive discovery."""

    GITHUB = "github"
    GITLAB = "gitlab"


class SourceSyncMode(str, Enum):
    """Supported sync planning modes."""

    DISCOVER = "discover"
    ADDITIVE = "additive"
    RECONCILE = "reconcile"


class SourceSpec(BaseModel):
    """Input specification for source-backed repository discovery."""

    provider: SourceProvider = Field(..., description="Source provider")
    org: Optional[str] = Field(None, description="GitHub organization")
    user: Optional[str] = Field(None, description="GitHub user")
    group: Optional[str] = Field(None, description="GitLab group path")
    recursive: bool = Field(
        True, description="Whether to recurse into nested scopes when supported"
    )
    include_archived: bool = Field(
        False, description="Include archived repositories in discovery"
    )
    include_forks: bool = Field(False, description="Include forked repositories")
    path_prefix: Optional[str] = Field(
        None, description="Optional namespace/repo prefix filter"
    )

    @model_validator(mode="after")
    def validate_scope(self) -> "SourceSpec":
        if self.provider == SourceProvider.GITHUB:
            selectors = [self.org, self.user]
            if sum(1 for value in selectors if value) != 1:
                raise ValueError(
                    "GitHub source requires exactly one of --org or --user"
                )
            if self.group:
                raise ValueError("GitHub source cannot use --group")
        if self.provider == SourceProvider.GITLAB:
            if not self.group:
                raise ValueError("GitLab source requires --group")
            if self.org or self.user:
                raise ValueError("GitLab source cannot use --org or --user")
        return self

    @property
    def namespace_key(self) -> str:
        """Canonical source namespace used for provenance and reconcile boundaries."""
        if self.provider == SourceProvider.GITHUB:
            return self.org if self.org else self.user or ""
        return self.group or ""


class DiscoveredRepo(BaseModel):
    """Normalized repository shape discovered from provider APIs."""

    provider: SourceProvider
    namespace: str
    full_name: str
    name: str
    clone_url: str
    default_branch: Optional[str] = None
    description: Optional[str] = None
    repo_id: Optional[str] = None
    archived: bool = False
    fork: bool = False
    private: Optional[bool] = None


class SourceSyncPlan(BaseModel):
    """Computed workspace changes for source-backed sync modes."""

    discovered_count: int = 0
    unchanged: int = 0
    to_add: List[ProjectPath] = Field(default_factory=list)
    to_update: List[ProjectPath] = Field(default_factory=list)
    to_remove: List[ProjectPath] = Field(default_factory=list)
`````

## File: src/metagit/core/prompt/__init__.py
`````python
#!/usr/bin/env python
"""
Metagit prompt emission for agent workflows.
"""

from metagit.core.prompt.models import (
    PromptCatalogEntry,
    PromptEmitResult,
    PromptKind,
    PromptScope,
)
from metagit.core.prompt.service import PromptService, PromptServiceError

__all__ = [
    "PromptCatalogEntry",
    "PromptEmitResult",
    "PromptKind",
    "PromptScope",
    "PromptService",
    "PromptServiceError",
]
`````

## File: src/metagit/core/prompt/catalog.py
`````python
#!/usr/bin/env python
"""
Built-in operational prompts for metagit agents by scope.
"""

from __future__ import annotations

from metagit.core.prompt.models import PromptCatalogEntry, PromptKind, PromptScope

_CATALOG: list[PromptCatalogEntry] = [
    PromptCatalogEntry(
        kind="instructions",
        title="Composed manifest instructions",
        description=(
            "Layered agent_instructions from .metagit.yml "
            "(file → workspace → project → repo)."
        ),
        scopes=["workspace", "project", "repo"],
    ),
    PromptCatalogEntry(
        kind="session-start",
        title="Workspace session bootstrap",
        description="MCP gate, health check, and manifest discovery checklist.",
        scopes=["workspace"],
    ),
    PromptCatalogEntry(
        kind="catalog-edit",
        title="Catalog registration workflow",
        description="Search-before-create and validate manifest edits.",
        scopes=["workspace", "project"],
    ),
    PromptCatalogEntry(
        kind="health-preflight",
        title="Health preflight",
        description="Surface missing clones, duplicates, and branch age before work.",
        scopes=["workspace", "project"],
    ),
    PromptCatalogEntry(
        kind="sync-safe",
        title="Safe repository sync",
        description="Fetch-first sync rules and operator approval for mutation.",
        scopes=["workspace", "project", "repo"],
    ),
    PromptCatalogEntry(
        kind="subagent-handoff",
        title="Subagent handoff",
        description="Delegate single-repo work with repo-scoped instructions.",
        scopes=["project", "repo"],
    ),
    PromptCatalogEntry(
        kind="layout-change",
        title="Layout rename or move",
        description="Dry-run layout operations before manifest and disk changes.",
        scopes=["workspace", "project", "repo"],
    ),
    PromptCatalogEntry(
        kind="repo-enrich",
        title="Repo catalog enrichment",
        description=(
            "Discover repo metadata on disk and merge into the workspace "
            "manifest entry (detect, source sync, validate)."
        ),
        scopes=["repo"],
    ),
]

_SCOPE_KINDS: dict[PromptScope, frozenset[PromptKind]] = {
    "workspace": frozenset(
        {
            "instructions",
            "session-start",
            "catalog-edit",
            "health-preflight",
            "sync-safe",
            "layout-change",
        }
    ),
    "project": frozenset(
        {
            "instructions",
            "catalog-edit",
            "health-preflight",
            "sync-safe",
            "subagent-handoff",
            "layout-change",
        }
    ),
    "repo": frozenset(
        {
            "instructions",
            "sync-safe",
            "subagent-handoff",
            "layout-change",
            "repo-enrich",
        }
    ),
}


def list_catalog() -> list[PromptCatalogEntry]:
    """Return all registered prompt kinds."""
    return list(_CATALOG)


def kinds_for_scope(scope: PromptScope) -> list[PromptKind]:
    """Prompt kinds valid for a scope level."""
    return sorted(_SCOPE_KINDS[scope], key=lambda item: item)


def is_kind_allowed(kind: PromptKind, scope: PromptScope) -> bool:
    """True when kind can be emitted at scope."""
    return kind in _SCOPE_KINDS[scope]


def template_body(
    kind: PromptKind,
    scope: PromptScope,
    *,
    project_name: str | None = None,
    repo_name: str | None = None,
) -> str:
    """Return built-in prompt text for a kind and scope."""
    project_label = project_name or "<project>"
    repo_label = repo_name or "<repo>"
    templates: dict[PromptKind, str] = {
        "session-start": """You are operating a metagit-managed workspace. Run this checklist before changing code or disk layout.

1. `metagit appconfig show --format json` — workspace.path, dedupe, agent_mode.
2. `metagit workspace list -c <definition> --json` — projects, repos, clone/sync hints from index.
3. `metagit config info -c <definition>` — manifest summary.
4. `metagit search "<name-or-url>" -c <definition> --json` before creating projects or repos.
5. Prefer `metagit workspace project|repo add` over hand-editing repo lists; always `metagit config validate -c <definition>` after edits.""",
        "catalog-edit": """When registering or changing workspace catalog entries:

1. Search first: `metagit search "<name-or-url>" -c <definition> --json`.
2. Reuse existing workspace.projects[] and repos[] entries; do not clone into ad-hoc folders.
3. Add via catalog: `metagit workspace project add`, `metagit workspace repo add`, or `metagit project repo add --project <name>`.
4. Validate: `metagit config validate -c <definition>`.
5. Sync only with explicit approval: `metagit project sync --project <name>` (fetch/pull as operator directs).""",
        "health-preflight": """Before implementation work, run a workspace health pass:

1. `metagit workspace list -c <definition> --json` — missing clones, duplicate URLs, per-repo status in repos_index.
2. `metagit workspace repo list -c <definition> --project <name> --json` to narrow to one project.
3. Resolve blockers (missing clone, broken symlink mount, duplicate URL) before editing application code.
4. Re-run list after catalog or layout changes.""",
        "sync-safe": """Repository sync rules for metagit-managed workspaces:

- Default to fetch-only; use pull or clone only with explicit operator approval.
- Project batch: `metagit project sync --project <name>` after confirming scope with `metagit workspace repo list --json`.
- Inspect before sync: `metagit workspace list --json` for missing or dirty repos.
- Never delete canonical dedupe directories; project mounts are symlinks when workspace.dedupe is enabled.""",
        "subagent-handoff": """Hand off single-repo implementation to a subagent:

1. Controller stays at workspace/project scope; subagent receives repo-scoped instructions only.
2. `metagit workspace select --project <name>` or `metagit project select` when switching focus.
3. Pass the composed [REPO] layer (and project context) — not the full workspace controller stack unless required.
4. Subagent works only inside the resolved repo_path; no cross-project manifest edits unless escalated.""",
        "layout-change": """Rename or move projects/repos only through layout CLI:

1. Dry-run first: `metagit workspace project rename|repo rename|repo move --dry-run --json`.
2. Confirm disk_steps in JSON before applying without --dry-run.
3. `metagit config validate -c <definition>` after manifest updates.
4. `metagit workspace list --json` after layout changes complete.""",
        "repo-enrich": """Review this repository and enrich its workspace manifest entry using metagit CLI discovery only.

## 1. Baseline (manifest)
- `metagit workspace repo list -c <definition> --project <project> --json` — current entry for this repo.
- `metagit search "<repo>" -c <definition> --json` — confirm name, url, path, tags.
- `metagit appconfig show --format json` — resolve sync root (`workspace.path`).

## 2. Discover on disk
From the repo checkout under `{workspace.path}/<project>/<repo>/` (or resolved path from search):
- `metagit detect repository -p . -o json` — full detection payload (language, kind, frameworks, url hints).
- `metagit detect repo -p . -o yaml` — codebase analysis summary.
- `metagit detect repo_map -p . -o json` — directory map for structure-aware agent_instructions.
If the repo has its own `.metagit.yml`: `metagit detect repository -p . -o metagit` for local metadata (do not overwrite workspace file).

## 3. Provider discovery (when remote url is known)
- `metagit project source sync --provider github|gitlab --org|--user|--group ... --mode discover --no-apply`
Use output to fill `url`, `source_provider`, `source_namespace`, `source_repo_id`, and provider tags when missing.

## 4. Merge into workspace.projects[].repos[]
Merge policy for the matching repo entry:
- Never remove or weaken `protected: true`.
- Fill only empty/null fields: `description`, `kind`, `language`, `language_version`, `package_manager`, `frameworks`, `url`, `branches`, `source_*`.
- Merge `tags` (add new keys; keep existing values on conflict unless the existing value is empty).
- Set `agent_instructions` only when blank; prefer repo-layer text from local `.metagit.yml` when appropriate.
- Do not rename the entry or change `path` unless `path` is missing and the on-disk location is canonical.

Persist by editing the umbrella `.metagit.yml` or `metagit workspace repo remove` + `metagit workspace repo add` with merged fields.

## 5. Validate
- `metagit config validate -c <definition>`
- `metagit workspace repo list --project <project> --json` — verify enriched fields.

Use `METAGIT_AGENT_MODE=true` for non-interactive runs; never use `detect repository --save` against the workspace file without explicit approval.""",
    }
    if kind == "instructions":
        return ""
    body = templates.get(kind, "")
    if scope == "project" and kind in {
        "catalog-edit",
        "health-preflight",
        "subagent-handoff",
    }:
        body += f"\n\nFocused project: {project_label}."
    if scope == "repo":
        if kind == "sync-safe":
            body += f"\n\nFocused repo: {project_label}/{repo_label}."
        elif kind == "subagent-handoff":
            body += (
                f"\n\nYou are the subagent for {project_label}/{repo_label}. "
                "Follow [REPO] instructions below; escalate cross-repo issues to the controller."
            )
        elif kind == "layout-change":
            body += f"\n\nTarget: {project_label}/{repo_label}."
        elif kind == "repo-enrich":
            body += (
                f"\n\nTarget repo: {project_label}/{repo_label}. "
                "Emit merged YAML for the repos[] entry when done."
            )
    return body.strip()
`````

## File: src/metagit/core/prompt/models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for metagit prompt emission.
"""

from __future__ import annotations

from typing import Any, Literal, Optional

from pydantic import BaseModel, Field

from metagit.core.workspace.agent_instructions import AgentInstructionLayer

PromptScope = Literal["workspace", "project", "repo"]
PromptKind = Literal[
    "instructions",
    "session-start",
    "catalog-edit",
    "health-preflight",
    "sync-safe",
    "subagent-handoff",
    "layout-change",
    "repo-enrich",
]


class PromptCatalogEntry(BaseModel):
    """Metadata for one built-in prompt kind."""

    kind: PromptKind
    title: str
    description: str
    scopes: list[PromptScope]


class PromptEmitResult(BaseModel):
    """Emitted prompt for agents or humans."""

    ok: bool = True
    kind: PromptKind
    scope: PromptScope
    project_name: Optional[str] = None
    repo_name: Optional[str] = None
    definition_path: str = ""
    instruction_layers: list[AgentInstructionLayer] = Field(default_factory=list)
    text: str = ""
    metadata: dict[str, Any] = Field(default_factory=dict)
`````

## File: src/metagit/core/prompt/service.py
`````python
#!/usr/bin/env python
"""
Emit metagit prompts for workspace, project, and repo scopes.
"""

from __future__ import annotations

from pathlib import Path
from typing import Optional

from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectPath
from metagit.core.prompt.catalog import (
    is_kind_allowed,
    kinds_for_scope,
    list_catalog,
    template_body,
)
from metagit.core.prompt.models import (
    PromptCatalogEntry,
    PromptEmitResult,
    PromptKind,
    PromptScope,
)
from metagit.core.appconfig.models import WorkspaceDedupeConfig
from metagit.core.workspace.agent_instructions import AgentInstructionsResolver
from metagit.core.workspace.dedupe_resolver import resolve_effective_dedupe
from metagit.core.workspace.models import WorkspaceProject


class PromptServiceError(Exception):
    """Raised when prompt emission cannot proceed."""


class PromptService:
    """Resolve and render prompts for agent consumption."""

    def __init__(self) -> None:
        self._resolver = AgentInstructionsResolver()

    def list_entries(self) -> list[PromptCatalogEntry]:
        """List catalog metadata for all prompt kinds."""
        return list_catalog()

    def emit(
        self,
        config: MetagitConfig,
        *,
        kind: PromptKind,
        scope: PromptScope,
        definition_path: str,
        workspace_root: str,
        project_name: Optional[str] = None,
        repo_name: Optional[str] = None,
        include_instructions: bool = True,
        workspace_dedupe: Optional[WorkspaceDedupeConfig] = None,
    ) -> PromptEmitResult:
        """Emit a prompt for the requested kind and scope."""
        if not is_kind_allowed(kind, scope):
            allowed = ", ".join(kinds_for_scope(scope))
            raise PromptServiceError(
                f"prompt kind {kind!r} is not available for scope {scope!r}; "
                f"allowed: {allowed}"
            )

        project, repo = self._resolve_scope_targets(
            config=config,
            scope=scope,
            project_name=project_name,
            repo_name=repo_name,
        )

        composition = self._resolver.resolve(
            config,
            project=project,
            repo=repo,
        )

        if kind == "instructions":
            text = composition.effective
            if not text:
                raise PromptServiceError(
                    f"no agent_instructions configured for scope {scope!r}"
                )
            return PromptEmitResult(
                kind=kind,
                scope=scope,
                project_name=project.name if project else None,
                repo_name=repo.name if repo else None,
                definition_path=str(Path(definition_path).resolve()),
                instruction_layers=composition.layers,
                text=text,
                metadata=self._metadata(
                    config=config,
                    workspace_root=workspace_root,
                    project=project,
                    repo=repo,
                    workspace_dedupe=workspace_dedupe,
                ),
            )

        body = template_body(
            kind,
            scope,
            project_name=project.name if project else project_name,
            repo_name=repo.name if repo else repo_name,
        )
        sections: list[str] = [body]
        if include_instructions and composition.effective:
            sections.append(
                "---\n\n## Manifest instructions (composed)\n\n" + composition.effective
            )
        return PromptEmitResult(
            kind=kind,
            scope=scope,
            project_name=project.name if project else None,
            repo_name=repo.name if repo else None,
            definition_path=str(Path(definition_path).resolve()),
            instruction_layers=composition.layers if include_instructions else [],
            text="\n\n".join(sections).strip(),
            metadata=self._metadata(
                config=config,
                workspace_root=workspace_root,
                project=project,
                repo=repo,
                workspace_dedupe=workspace_dedupe,
            ),
        )

    def _resolve_scope_targets(
        self,
        *,
        config: MetagitConfig,
        scope: PromptScope,
        project_name: Optional[str],
        repo_name: Optional[str],
    ) -> tuple[Optional[WorkspaceProject], Optional[ProjectPath]]:
        if scope == "workspace":
            if project_name or repo_name:
                raise PromptServiceError(
                    "workspace scope does not accept --project or --repo"
                )
            return None, None

        if not project_name or not project_name.strip():
            raise PromptServiceError("project scope requires --project")
        if not config.workspace:
            raise PromptServiceError("no workspace block in manifest")

        project = next(
            (item for item in config.workspace.projects if item.name == project_name),
            None,
        )
        if project is None:
            raise PromptServiceError(f"project {project_name!r} not found")

        if scope == "project":
            if repo_name:
                raise PromptServiceError("project scope does not accept --repo")
            return project, None

        if not repo_name or not repo_name.strip():
            raise PromptServiceError("repo scope requires --repo")
        repo = self._resolver.find_repo(project, repo_name=repo_name)
        if repo is None:
            raise PromptServiceError(
                f"repo {repo_name!r} not found in project {project_name!r}"
            )
        return project, repo

    def _metadata(
        self,
        *,
        config: MetagitConfig,
        workspace_root: str,
        project: Optional[WorkspaceProject],
        repo: Optional[ProjectPath],
        workspace_dedupe: Optional[WorkspaceDedupeConfig] = None,
    ) -> dict[str, str | int | bool | None]:
        project_count = len(config.workspace.projects) if config.workspace else 0
        repo_count = 0
        if config.workspace:
            repo_count = sum(len(item.repos) for item in config.workspace.projects)
        effective_dedupe: Optional[bool] = None
        project_dedupe_override: Optional[bool] = None
        if workspace_dedupe is not None:
            effective = resolve_effective_dedupe(workspace_dedupe, project)
            effective_dedupe = effective is not None
            if project is not None and project.dedupe is not None:
                project_dedupe_override = project.dedupe.enabled
        return {
            "workspace_root": str(Path(workspace_root).resolve()),
            "file_name": config.name,
            "project_count": project_count,
            "repo_count": repo_count,
            "focused_project": project.name if project else None,
            "focused_repo": repo.name if repo else None,
            "workspace_dedupe_enabled": (
                workspace_dedupe.enabled if workspace_dedupe is not None else None
            ),
            "project_dedupe_override": project_dedupe_override,
            "effective_dedupe_enabled": effective_dedupe,
        }
`````

## File: src/metagit/core/utils/logging.py
`````python
#!/usr/bin/env python

"""
logging class that does general logging via loguru and prints to the console via rich.
"""

import json
import logging
import sys
from typing import Any, Literal, Optional, Protocol, Union

from loguru import logger
from pydantic import BaseModel, Field, PrivateAttr
from rich.console import Console
from rich.panel import Panel
from rich.theme import Theme


class LoggerProtocol(Protocol):
    """
    Protocol for the logger interface.
    This includes:
        Standard log methods (info, debug, error, etc.)
        Rich printing methods (print_json, print_info, print_task_status, etc.)
        Optional console formatting methods (header, footer, etc.)
        Return type of Union[None, Exception]
    Notes:
        - All the ... are placeholders for the actual methods per the Protocol definition
        - This is a workaround to allow for the logger to be injected into classes that don't inherit from LoggingModel
    """

    # === Core logging methods ===
    def debug(self, message: str) -> Union[None, Exception]: ...
    def info(self, message: str) -> Union[None, Exception]: ...
    def warning(self, message: str) -> Union[None, Exception]: ...
    def error(self, message: str) -> Union[None, Exception]: ...
    def critical(self, message: str) -> Union[None, Exception]: ...
    def exception(self, message: str) -> Union[None, Exception]: ...

    # === Print helpers ===
    def print_info(self, message: str) -> Union[None, Exception]: ...
    def print_debug(
        self, message: str, title: str = "Debug Information"
    ) -> Union[None, Exception]: ...
    def print_debug_json(
        self, data: dict[str, Any], title: str = "Debug JSON Data"
    ) -> Union[None, Exception]: ...
    def print_json(
        self, data: dict[str, Any], title: str = "JSON Data"
    ) -> Union[None, Exception]: ...
    def print_error(self, error_message: str) -> Union[None, Exception]: ...
    def print_success(self, message: str) -> Union[None, Exception]: ...
    def print_input(self, input_data: dict[str, Any]) -> Union[None, Exception]: ...
    def print_output(self, output_data: Any) -> Union[None, Exception]: ...
    def print_agent_message(
        self, agent_name: str, message: str, style: str = "agent"
    ) -> Union[None, Exception]: ...
    def print_task_status(
        self, task_name: str, status: str, details: Optional[str] = None
    ) -> Union[None, Exception]: ...
    def print_crew_status(
        self, message: str, status_type: str = "info"
    ) -> Union[None, Exception]: ...

    # === Console formatting ===
    def header(
        self, text: str, console: Optional[bool] = None
    ) -> Union[None, Exception]: ...
    def footer(self, text: str, console: bool = True) -> Union[None, Exception]: ...
    def line(self, console: bool = True) -> Union[None, Exception]: ...
    def success(self, text: str, console: bool = True) -> Union[None, Exception]: ...
    def proc_out(self, text: str, console: bool = True) -> Union[None, Exception]: ...
    def echo(
        self, text: str, color: str = "", dim: bool = False, console: bool = True
    ) -> Union[None, Exception]: ...
    def param(
        self, text: str, value: str, status: str, console: bool = True
    ) -> Union[None, Exception]: ...
    def config_element(
        self,
        name: str = "",
        value: str = "",
        separator: str = ": ",
        console: bool = True,
    ) -> Union[None, Exception]: ...

    # Add any others you rely on via injection (e.g. print_json, print_info)


# Logger configuration
class LoggerConfig(BaseModel):
    """
    Pydantic model for unified logger configuration.
    """

    # Logging configuration
    name: Optional[str] = Field(default="metagit", description="Logger name.")
    log_level: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "TRACE"] = (
        Field(default="INFO", description="Logging level.")
    )
    log_to_file: bool = Field(default=False, description="Whether to log to a file.")
    log_file_path: str = Field(default="app.log", description="Path to the log file.")
    json_logs: bool = Field(
        default=False, description="Whether to output logs in JSON format."
    )
    rotation: str = Field(default="10 MB", description="Log file rotation policy.")
    retention: str = Field(default="7 days", description="Log file retention policy.")
    backtrace: bool = Field(default=False, description="Enable backtrace in logs.")
    diagnose: bool = Field(default=False, description="Enable diagnose in logs.")

    # Console output configuration
    minimal_console: bool = Field(
        default=False, description="Use minimal console output format."
    )
    use_rich_console: bool = Field(
        default=True, description="Whether to use rich console formatting."
    )
    terse: bool = Field(
        default=False, description="Use terse output format (no borders or titles)."
    )

    class Config:
        env_prefix = "LOG_"


LOG_LEVEL_MAP: dict[str, int] = {
    "CRITICAL": logging.CRITICAL,
    "ERROR": logging.ERROR,
    "WARNING": logging.WARNING,
    "INFO": logging.INFO,
    "DEBUG": logging.DEBUG,
}

LOG_LEVELS: dict[int, int] = {
    0: logging.NOTSET,
    1: logging.ERROR,
    2: logging.WARNING,
    3: logging.INFO,
    4: logging.DEBUG,
}  #: a mapping of `verbose` option counts to logging levels


class UnifiedLogger(LoggerProtocol):
    def __init__(self, config: LoggerConfig):
        self.config = config
        self.debug_mode = config.log_level in ("DEBUG", "TRACE")
        self._stdout_handler_id = None
        self._file_handler_id = None

        # Initialize rich console if enabled
        self.console = None
        if config.use_rich_console:
            theme = Theme(
                {
                    "info": "cyan",
                    "success": "green",
                    "warning": "yellow",
                    "error": "red",
                    "debug": "dim cyan",
                    "agent": "magenta",
                    "task": "blue",
                    "crew": "bold green",
                    "input": "bold yellow",
                    "output": "bold white",
                    "json": "bold cyan",
                }
            )
            self.console = Console(theme=theme)

        # Remove default loguru handler
        logger.remove()

        # Choose formatting
        if config.json_logs or config.minimal_console:
            log_format = "{message}"
            serialize = config.json_logs
        else:
            log_format = (
                "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
                "<level>{level: <8}</level> | "
                "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
                "<dim>{file}:{module}</dim> - "
                "<level>{message}</level>"
            )
            serialize = False

        # Console sink
        self._stdout_handler_id = logger.add(
            sys.stdout,
            level=config.log_level,
            format=log_format,
            backtrace=config.backtrace,
            diagnose=config.diagnose,
            serialize=serialize,
            enqueue=True,
        )

        # Optional file sink
        if config.log_to_file:
            self._file_handler_id = logger.add(
                config.log_file_path,
                level=config.log_level,
                format=log_format,
                rotation=config.rotation,
                retention=config.retention,
                backtrace=config.backtrace,
                diagnose=config.diagnose,
                serialize=serialize,
                enqueue=True,
            )

        self._intercept_std_logging()

    def set_level(
        self, level: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "TRACE"]
    ) -> Union[None, Exception]:
        """
        Set the logging level for all handlers.
        Args:
            level: The new logging level to set
        """
        try:
            self.config.log_level = level
            self.debug_mode = level == "DEBUG" or level == "TRACE"

            # Update stdout handler
            if self._stdout_handler_id is not None:
                logger.remove(self._stdout_handler_id)
                self._stdout_handler_id = logger.add(
                    sys.stdout,
                    level=level,
                    format=(
                        "{message}"
                        if self.config.json_logs or self.config.minimal_console
                        else (
                            "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
                            "<level>{level: <8}</level> | "
                            "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
                            "<dim>{file}:{module}</dim> - "
                            "<level>{message}</level>"
                        )
                    ),
                    backtrace=self.config.backtrace,
                    diagnose=self.config.diagnose,
                    serialize=self.config.json_logs,
                    enqueue=True,
                )

            # Update file handler if it exists
            if self._file_handler_id is not None and self.config.log_to_file:
                logger.remove(self._file_handler_id)
                self._file_handler_id = logger.add(
                    self.config.log_file_path,
                    level=level,
                    format=(
                        "{message}"
                        if self.config.json_logs or self.config.minimal_console
                        else (
                            "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
                            "<level>{level: <8}</level> | "
                            "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
                            "<dim>{file}:{module}</dim> - "
                            "<level>{message}</level>"
                        )
                    ),
                    rotation=self.config.rotation,
                    retention=self.config.retention,
                    backtrace=self.config.backtrace,
                    diagnose=self.config.diagnose,
                    serialize=self.config.json_logs,
                    enqueue=True,
                )
            return None
        except Exception as e:
            return e

    def _intercept_std_logging(self) -> Union[None, Exception]:
        """Intercept standard logging module output to loguru."""
        try:

            class InterceptHandler(logging.Handler):
                def emit(self, record: logging.LogRecord) -> None:
                    try:
                        level = logger.level(record.levelname).name
                    except ValueError:
                        level = record.levelno
                    frame, depth = logging.currentframe(), 2
                    while frame and frame.f_code.co_filename == logging.__file__:
                        frame = frame.f_back
                        depth += 1
                    logger.opt(depth=depth, exception=record.exc_info).log(
                        level, record.getMessage()
                    )

            logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
            return None
        except Exception as e:
            return e

    def get_logger(self) -> Union[Any, Exception]:
        """Get the underlying loguru logger instance."""
        try:
            return logger
        except Exception as e:
            return e

    def print_debug(
        self, message: str, title: str = "Debug Information"
    ) -> Union[None, Exception]:
        """Print debug messages with rich formatting."""
        try:
            if self.debug_mode:
                self._format_output(message, "debug", title)
                logger.debug(message)
            return None
        except Exception as e:
            return e

    def print_agent_message(
        self, agent_name: str, message: str, style: str = "agent"
    ) -> Union[None, Exception]:
        """Print a message from an agent with rich formatting."""
        try:
            self._format_output(message, style, agent_name)
            logger.info(f"Agent {agent_name}: {message}")
            return None
        except Exception as e:
            return e

    def print_task_status(
        self, task_name: str, status: str, details: Union[str, None] = None
    ) -> Union[None, Exception]:
        """Print task status information with rich formatting."""
        try:
            content = f"{task_name}\nStatus: {status}"
            if details:
                content += f"\n\n{details}"
            self._format_output(content, "task", "Task Update")
            logger.info(
                f"Task {task_name} - Status: {status}"
                + (f" - {details}" if details else "")
            )
            return None
        except Exception as e:
            return e

    def print_crew_status(
        self, message: str, status_type: str = "info"
    ) -> Union[None, Exception]:
        """Print crew status messages with rich formatting."""
        try:
            self._format_output(message, status_type, "Crew Status")
            logger.info(f"Crew Status: {message}")
            return None
        except Exception as e:
            return e

    def print_input(self, input_data: dict[str, Any]) -> Union[None, Exception]:
        """Print input data with rich formatting."""
        try:
            self._format_output(str(input_data), "input", "Input Data")
            logger.info(f"Input: {input_data}")
            return None
        except Exception as e:
            return e

    def print_output(self, output_data: Any) -> Union[None, Exception]:
        """Print output data with rich formatting."""
        try:
            self._format_output(str(output_data), "output", "Output Data")
            logger.info(f"Output: {output_data}")
            return None
        except Exception as e:
            return e

    def print_error(self, error_message: str) -> Union[None, Exception]:
        """Print error messages with rich formatting."""
        try:
            self._format_output(error_message, "error", "Error")
            logger.error(error_message)
            return None
        except Exception as e:
            return e

    def print_success(self, message: str) -> Union[None, Exception]:
        """Print success messages with rich formatting."""
        try:
            self._format_output(message, "success", "Success")
            logger.info(message)
            return None
        except Exception as e:
            return e

    def print_info(self, message: str) -> Union[None, Exception]:
        """Print informational messages with rich formatting."""
        try:
            self._format_output(message, "info", "Info")
            logger.info(message)
            return None
        except Exception as e:
            return e

    def print_json(
        self, data: dict[str, Any], title: str = "JSON Data"
    ) -> Union[None, Exception]:
        """Print JSON data with rich formatting and syntax highlighting."""
        try:
            json_str = json.dumps(data, indent=2)
            self._format_output(json_str, "json", title)
            logger.info(json_str)
            return None
        except Exception as e:
            return e

    def print_debug_json(
        self, data: dict[str, Any], title: str = "Debug JSON Data"
    ) -> Union[None, Exception]:
        """Print JSON data only if in debug mode."""
        try:
            if self.debug_mode:
                self.print_json(data, title)
            return None
        except Exception as e:
            return e

    # Direct loguru methods
    def debug(self, message: str) -> Union[None, Exception]:
        """Log a debug message."""
        try:
            logger.opt(depth=2).debug(message)
            return None
        except Exception as e:
            return e

    def info(self, message: str) -> Union[None, Exception]:
        """Log an info message."""
        try:
            logger.opt(depth=2).info(message)
            return None
        except Exception as e:
            return e

    def warning(self, message: str) -> Union[None, Exception]:
        """Log a warning message."""
        try:
            logger.opt(depth=2).warning(message)
            return None
        except Exception as e:
            return e

    def error(self, message: str) -> Union[None, Exception]:
        """Log an error message."""
        try:
            logger.opt(depth=2).error(message)
            return None
        except Exception as e:
            return e

    def critical(self, message: str) -> Union[None, Exception]:
        """Log a critical message."""
        try:
            logger.opt(depth=2).critical(message)
            return None
        except Exception as e:
            return e

    def exception(self, message: str) -> Union[None, Exception]:
        """Log an exception with traceback."""
        try:
            logger.opt(depth=2).exception(message)
            return None
        except Exception as e:
            return e

    def _format_output(
        self, message: str, style: str, title: Union[str, None] = None
    ) -> Union[None, Exception]:
        """
        Helper to format and print output using rich console.
        Args:
            message (str): The message to print.
            style (str): The rich style to use.
            title (str, optional): The panel title. Defaults to None.
        """
        try:
            if self.console and not self.config.minimal_console:
                if self.config.terse:
                    self.console.print(f"[{style}]{message}[/{style}]")
                else:
                    panel_title = f"[{style}]{title}[/{style}]" if title else None
                    self.console.print(
                        Panel(
                            f"[{style}]{message}[/{style}]",
                            title=panel_title,
                            expand=False,
                        )
                    )
            elif not self.config.json_logs:
                # Fallback for non-rich, non-json logging
                print(f"{title}: {message}" if title else message)
            return None
        except Exception as e:
            return e

    def header(self, text: str, console: bool = None) -> Union[None, Exception]:
        """Prints a header"""
        try:
            if console is None:
                console = self.config.use_rich_console
            if console:
                self.console.rule(f"[bold green]{text}")
            else:
                print(f"### {text} ###")
            return None
        except Exception as e:
            return e

    def param(
        self, text: str, value: str, status: str, console: bool = True
    ) -> Union[None, Exception]:
        """Prints a parameter line"""
        try:
            if console:
                self.console.print(
                    f"[dim] {text} [/dim][bold cyan]{value}[/bold cyan] [bold green]({status})[/bold green]"
                )
            else:
                print(f"{text} {value} ({status})")
            return None
        except Exception as e:
            return e

    def config_element(
        self,
        name: str = "",
        value: str = "",
        separator: str = ": ",
        console: bool = True,
    ) -> Union[None, Exception]:
        """Prints a config element"""
        try:
            if console:
                self.console.print(
                    f"[bold white]  {name}[/bold white]{separator}{value}"
                )
            else:
                print(f"  {name}{separator}{value}")
            return None
        except Exception as e:
            return e

    def footer(self, text: str, console: bool = True) -> Union[None, Exception]:
        """Prints a footer"""
        try:
            if console:
                self.console.rule(f"[bold green]{text}")
            else:
                print(f"### {text} ###")
            return None
        except Exception as e:
            return e

    def proc_out(self, text: str, console: bool = True) -> Union[None, Exception]:
        """Prints a process output"""
        try:
            if console:
                self.console.print(f"[dim]{text}[/dim]")
            else:
                print(text)
            return None
        except Exception as e:
            return e

    def line(self, console: bool = True) -> Union[None, Exception]:
        """Prints a line"""
        try:
            if console:
                self.console.print("")
            else:
                print("")
            return None
        except Exception as e:
            return e

    def success(self, text: str, console: bool = True) -> Union[None, Exception]:
        """Prints a success message"""
        try:
            if console:
                self.console.print(f"[bold green]{text}[/bold green]")
            return None
        except Exception as e:
            return e

    def echo(
        self, text: str, color: str = "", dim: bool = False, console: bool = True
    ) -> Union[None, Exception]:
        """
        Echo text to console with optional color and dimming.
        Args:
            text: Text to echo
            color: Color to use
            dim: Whether to dim the text
            console: Whether to output to console
        """
        try:
            if console and self.console:
                style = f"{color} dim" if dim else color
                self.console.print(text, style=style)
            return None
        except Exception as e:
            return e


def get_logger(name: str = "metagit") -> Any:
    """
    Get a logger instance with default configuration.

    Args:
        name: Logger name

    Returns:
        UnifiedLogger instance
    """
    config = LoggerConfig(name=name)
    return UnifiedLogger(config)


class LoggingModel(BaseModel):
    _logger: Any = PrivateAttr()

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._logger = getattr(self, "logger", None) or logger

    @property
    def logger(self):
        return self._logger

    def set_logger(self, logger):
        self._logger = logger
`````

## File: src/metagit/core/utils/userprompt.py
`````python
#!/usr/bin/env python
"""
UserPrompt utility for dynamically prompting users for Pydantic object properties.
"""

from __future__ import annotations

import json
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Type, TypeVar, Union

from pydantic import BaseModel, ValidationError

T = TypeVar("T", bound=BaseModel)

_pt_cache: Optional[SimpleNamespace] = None
_prompt_style_cache: Any = None


def _promptkit() -> SimpleNamespace:
    """
    Lazily import prompt_toolkit so CLI modules can load without it.

    Interactive commands require the dependency; install with metagit-cli or
    ``pip install 'prompt-toolkit>=3.0'``.
    """
    global _pt_cache
    if _pt_cache is None:
        try:
            from prompt_toolkit import PromptSession
            from prompt_toolkit.formatted_text import FormattedText
            from prompt_toolkit.shortcuts import print_formatted_text
            from prompt_toolkit.styles import Style
            from prompt_toolkit.validation import ValidationError as PTValidationError
            from prompt_toolkit.validation import Validator
        except ImportError as exc:
            raise ImportError(
                "Interactive prompts require 'prompt-toolkit'. "
                "Install: pip install 'prompt-toolkit>=3.0' or reinstall metagit-cli."
            ) from exc
        _pt_cache = SimpleNamespace(
            PromptSession=PromptSession,
            FormattedText=FormattedText,
            print_formatted_text=print_formatted_text,
            Style=Style,
            PTValidationError=PTValidationError,
            Validator=Validator,
        )
    return _pt_cache


def _prompt_style() -> Any:
    global _prompt_style_cache
    if _prompt_style_cache is None:
        pk = _promptkit()
        _prompt_style_cache = pk.Style.from_dict(
            {
                "title": "bold cyan",
                "field": "bold green",
                "description": "italic yellow",
                "default": "blue",
                "error": "bold red",
                "success": "bold green",
                "optional": "white",
                "prompt": "white",
            }
        )
    return _prompt_style_cache


class UserPrompt:
    """
    A utility class for prompting users to input values for Pydantic model properties.

    This class provides methods to interactively collect user input for required
    fields of any Pydantic model, with validation and type conversion using prompt_toolkit.
    """

    def __init__(self) -> None:
        pk = _promptkit()
        self.session = pk.PromptSession(style=_prompt_style())

    @staticmethod
    def prompt_for_model(
        model_class: Type[T],
        existing_data: Optional[Dict[str, Any]] = None,
        title: str = None,
        fields_to_prompt: Optional[List[str]] = None,
    ) -> Union[T, Exception]:
        """
        Prompt the user for fields of a Pydantic model.

        Args:
            model_class: The Pydantic model class to prompt for
            existing_data: Optional existing data to pre-populate fields
            title: Optional title to display at the top of the prompt
            fields_to_prompt: Optional list of field names to prompt for.
                             If None, prompts for all fields. If specified,
                             only prompts for these fields and uses defaults/None for others.

        Returns:
            An instance of the specified Pydantic model

        Raises:
            ValueError: If the model_class is not a valid Pydantic model
        """
        try:
            if not issubclass(model_class, BaseModel):
                return ValueError(f"{model_class} is not a valid Pydantic model")

            pk = _promptkit()
            existing_data = existing_data or {}
            field_data = {}
            prompt_instance = UserPrompt()

            # Get model fields
            model_fields = model_class.model_fields

            if title:
                # Print title
                title_text = pk.FormattedText([("class:title", f"\n=== {title} ===\n")])
                pk.print_formatted_text(title_text)

            for field_name, field_info in model_fields.items():
                # Skip if field already has a value
                if field_name in existing_data:
                    field_data[field_name] = existing_data[field_name]
                    success_text = pk.FormattedText(
                        [
                            ("class:success", f"✓ {field_name}: "),
                            (
                                "class:prompt",
                                f"{existing_data[field_name]} (pre-filled)",
                            ),
                        ]
                    )
                    pk.print_formatted_text(success_text)
                    continue

                # If fields_to_prompt is specified, only prompt for those fields
                if fields_to_prompt is not None and field_name not in fields_to_prompt:
                    # Use default value or None for non-prompted fields
                    try:
                        default_value = field_info.get_default()
                        if str(default_value) == "PydanticUndefined":
                            default_value = None
                        field_data[field_name] = default_value
                    except Exception:
                        field_data[field_name] = None
                    continue

                # Check if field is required
                is_required = field_info.is_required()

                if is_required:
                    value = prompt_instance._prompt_for_field(field_name, field_info)
                    if isinstance(value, Exception):
                        return value
                    field_data[field_name] = value
                else:
                    # For optional fields, prompt directly with [Optional] indicator
                    value = prompt_instance._prompt_for_optional_field(
                        field_name, field_info
                    )
                    if isinstance(value, Exception):
                        return value
                    # Only assign if a value was provided (not None)
                    if value is not None:
                        field_data[field_name] = value

            # Create and validate the model instance
            try:
                return model_class(**field_data)
            except ValidationError as e:
                pk = _promptkit()
                error_text = pk.FormattedText(
                    [("class:error", f"\n❌ Validation error: {e}\n")]
                )
                pk.print_formatted_text(error_text)
                # Retry with corrected data
                return UserPrompt.prompt_for_model(
                    model_class, field_data, title, fields_to_prompt
                )
        except Exception as e:
            return e

    def _prompt_for_field(
        self, field_name: str, field_info: Any
    ) -> Union[Any, Exception]:
        """
        Prompt the user for a specific field value.

        Args:
            field_name: Name of the field
            field_info: Field information from Pydantic

        Returns:
            The user input value, converted to appropriate type
        """
        try:
            pk = _promptkit()
            field_type = field_info.annotation
            description = field_info.description or ""

            # Get the actual default value for Pydantic v2
            try:
                default_value = field_info.get_default()
                # Filter out PydanticUndefined
                if str(default_value) == "PydanticUndefined":
                    default_value = None
            except Exception:
                default_value = None

            # Build formatted prompt message
            prompt_parts = [("class:field", f"\n{field_name}")]

            if description:
                prompt_parts.extend(
                    [
                        ("class:prompt", " ("),
                        ("class:description", description),
                        ("class:prompt", ")"),
                    ]
                )

            if default_value is not None and default_value != ...:
                prompt_parts.extend(
                    [
                        ("class:prompt", " [default: "),
                        ("class:default", str(default_value)),
                        ("class:prompt", ")"),
                    ]
                )

            prompt_parts.append(("class:prompt", ": "))

            prompt_text = pk.FormattedText(prompt_parts)

            # Create validator for the field type
            validator = self._create_field_validator(field_type, field_info)
            if isinstance(validator, Exception):
                return validator

            # Get user input with validation
            while True:
                try:
                    user_input = self.session.prompt(
                        prompt_text, validator=validator
                    ).strip()

                    # Handle default value
                    if (
                        not user_input
                        and default_value is not None
                        and default_value != ...
                    ):
                        return default_value

                    # Handle empty input for required fields
                    if not user_input and field_info.is_required():
                        error_text = pk.FormattedText(
                            [
                                (
                                    "class:error",
                                    "❌ This field is required. Please provide a value.\n",
                                )
                            ]
                        )
                        pk.print_formatted_text(error_text)
                        continue

                    # Convert and return the input
                    converted_value = self._convert_input(user_input, field_type)
                    if isinstance(converted_value, Exception):
                        # This should be caught by the validator, but as a fallback
                        error_text = pk.FormattedText(
                            [("class:error", f"❌ {converted_value}\n")]
                        )
                        pk.print_formatted_text(error_text)
                        continue
                    return converted_value

                except pk.PTValidationError as e:
                    error_text = pk.FormattedText(
                        [("class:error", f"❌ {e.message}\n")]
                    )
                    pk.print_formatted_text(error_text)
                    continue
        except Exception as e:
            return e

    def _prompt_for_optional_field(
        self, field_name: str, field_info: Any
    ) -> Union[Any, Exception]:
        """
        Prompt the user for an optional field value.

        Args:
            field_name: Name of the field
            field_info: Field information from Pydantic

        Returns:
            The user input value (converted to appropriate type) or None if no value provided
        """
        try:
            pk = _promptkit()
            field_type = field_info.annotation
            description = field_info.description or ""

            # Get the actual default value for Pydantic v2
            try:
                default_value = field_info.get_default()
                # Filter out PydanticUndefined
                if str(default_value) == "PydanticUndefined":
                    default_value = None
            except Exception:
                default_value = None

            # Build formatted prompt message with [Optional] indicator
            prompt_parts = [("class:field", f"\n{field_name}")]

            if description:
                prompt_parts.extend(
                    [
                        ("class:prompt", " ("),
                        ("class:description", description),
                        ("class:prompt", ")"),
                    ]
                )

            # Add [Optional] indicator
            prompt_parts.extend(
                [
                    ("class:prompt", " ["),
                    ("class:optional", "Optional"),
                    ("class:prompt", "]"),
                ]
            )

            if default_value is not None and default_value != ...:
                prompt_parts.extend(
                    [
                        ("class:prompt", " [default: "),
                        ("class:default", str(default_value)),
                        ("class:prompt", ")"),
                    ]
                )

            prompt_parts.append(("class:prompt", ": "))

            prompt_text = pk.FormattedText(prompt_parts)

            # Create validator for the field type
            validator = self._create_field_validator(field_type, field_info)
            if isinstance(validator, Exception):
                return validator

            # Get user input with validation
            while True:
                try:
                    user_input = self.session.prompt(
                        prompt_text, validator=validator
                    ).strip()

                    # Handle empty input for optional fields - return None
                    if not user_input:
                        return None

                    # Handle default value
                    if (
                        not user_input
                        and default_value is not None
                        and default_value != ...
                    ):
                        return default_value

                    # Convert and return the input
                    converted_value = self._convert_input(user_input, field_type)
                    if isinstance(converted_value, Exception):
                        # This should be caught by the validator, but as a fallback
                        error_text = pk.FormattedText(
                            [("class:error", f"❌ {converted_value}\n")]
                        )
                        pk.print_formatted_text(error_text)
                        continue
                    return converted_value

                except pk.PTValidationError as e:
                    error_text = pk.FormattedText(
                        [("class:error", f"❌ {e.message}\n")]
                    )
                    pk.print_formatted_text(error_text)
                    continue
        except Exception as e:
            return e

    def _create_field_validator(
        self, field_type: Any, field_info: Any = None
    ) -> Union[Any, Exception]:
        """
        Create a validator for the given field type.

        Args:
            field_type: The type to validate against
            field_info: Field information from Pydantic (optional)
            field_name: Name of the field (optional)

        Returns:
            A prompt_toolkit Validator instance or None
        """
        try:
            pk = _promptkit()

            def validate_type(text: str) -> bool:
                if not text:
                    return True  # Allow empty input for optional fields

                # Check if this is a boolean field
                is_bool_field = False

                # Method 1: Check if field_info.annotation is bool
                if field_info and hasattr(field_info, "annotation"):
                    if (
                        hasattr(field_info.annotation, "__origin__")
                        and field_info.annotation.__origin__ is Union
                    ):
                        # Handle Optional[bool], which is Union[bool, None]
                        if bool in field_info.annotation.__args__:
                            is_bool_field = True
                    elif field_info.annotation is bool:
                        is_bool_field = True

                if is_bool_field:
                    if text.strip().lower() in ["true", "false", "y", "n", "yes", "no"]:
                        return True
                    raise pk.PTValidationError(
                        message="Please enter 'true', 'false', 'y', or 'n'"
                    )

                # For other types, try to convert
                try:
                    self._convert_input(text, field_type)
                    return True
                except (ValueError, TypeError) as exc:
                    raise pk.PTValidationError(message=f"Invalid value: {exc}") from exc

            return pk.Validator.from_callable(validate_type)
        except Exception as e:
            return e

    @staticmethod
    def _convert_input(user_input: str, target_type: Any) -> Union[Any, Exception]:
        """
        Convert user input to the target type, handling various types like lists and JSON.

        Args:
            user_input: Raw user input string
            target_type: Target type to convert to

        Returns:
            The converted value
        """
        try:
            # Handle Optional types
            if hasattr(target_type, "__origin__") and target_type.__origin__ is Union:
                # Get the non-None type from Optional[T] or Union[T, None]
                args = [arg for arg in target_type.__args__ if arg is not type(None)]
                if len(args) == 1:
                    target_type = args[0]
                else:
                    # For complex unions, we can't reliably convert, so just return string
                    return user_input

            # Handle lists
            if hasattr(target_type, "__origin__") and target_type.__origin__ in (
                list,
                List,
            ):
                item_type = (
                    target_type.__args__[0] if target_type.__args__ else str
                )  # Default to list of strings
                # Split by comma and strip whitespace
                items = [item.strip() for item in user_input.split(",")]
                # Convert each item to the target type
                converted_list = [
                    UserPrompt._convert_input(item, item_type) for item in items
                ]
                exception_items = [
                    item for item in converted_list if isinstance(item, Exception)
                ]
                if exception_items:
                    return exception_items[0]
                return converted_list

            # Handle JSON/Dict
            if user_input.startswith("{") and user_input.endswith("}"):
                try:
                    return json.loads(user_input)
                except json.JSONDecodeError as exc:
                    raise ValueError("Invalid JSON format") from exc

            # Handle boolean conversion
            if target_type is bool:
                if user_input.lower() in ["true", "y", "yes"]:
                    return True
                if user_input.lower() in ["false", "n", "no"]:
                    return False
                raise ValueError(f"Cannot convert '{user_input}' to boolean")

            # Default conversion
            try:
                return target_type(user_input)
            except (ValueError, TypeError) as exc:
                raise ValueError(
                    f"Cannot convert '{user_input}' to type {target_type.__name__}"
                ) from exc
        except Exception as e:
            return e

    @staticmethod
    def prompt_for_single_field(
        field_name: str,
        field_type: Type[Any],
        description: str = "",
        default: Any = None,
    ) -> Union[Any, Exception]:
        """
        Prompt the user for a single field value.

        Args:
            field_name: Name of the field
            field_type: Type of the field
            description: Optional description of the field
            default: Optional default value

        Returns:
            The user input value, converted to the specified type
        """
        try:
            prompt_instance = UserPrompt()
            _ = {
                "annotation": field_type,
                "description": description,
                "default": default,
            }
            # Mock field_info object for _prompt_for_field
            mock_field_info = SimpleNamespace(
                annotation=field_type,
                description=description,
                is_required=lambda: default is None,
                get_default=lambda: default,
            )
            return prompt_instance._prompt_for_field(field_name, mock_field_info)
        except Exception as e:
            return e

    @staticmethod
    def confirm_action(message: str = "Continue?") -> Union[bool, Exception]:
        """
        Prompt the user for a yes/no confirmation.

        Args:
            message: The confirmation message

        Returns:
            bool: True if user confirms, False otherwise
        """
        try:
            pk = _promptkit()
            session = pk.PromptSession(style=_prompt_style())
            prompt_text = pk.FormattedText([("class:field", f"\n{message} (y/n): ")])

            while True:
                response = session.prompt(prompt_text).strip().lower()
                if response in ["y", "yes"]:
                    return True
                if response in ["n", "no"]:
                    return False
                error_text = pk.FormattedText(
                    [("class:error", "❌ Please enter 'y' or 'n'.\n")]
                )
                pk.print_formatted_text(error_text)
        except Exception as e:
            return e

    @staticmethod
    def prompt_for_model_fields(
        model_class: Type[T],
        fields_to_prompt: List[str],
        existing_data: Optional[Dict[str, Any]] = None,
        title: str = None,
    ) -> Union[T, Exception]:
        """
        Prompt the user for specific fields of a Pydantic model.

        Args:
            model_class: The Pydantic model class to prompt for
            fields_to_prompt: List of field names to prompt for
            existing_data: Optional existing data to pre-populate fields
            title: Optional title to display at the top of the prompt

        Returns:
            An instance of the specified Pydantic model
        """
        try:
            return UserPrompt.prompt_for_model(
                model_class, existing_data, title, fields_to_prompt
            )
        except Exception as e:
            return e


def yes_no_prompt(message: str = "Continue?") -> bool:
    """
    Simple yes/no prompt function.

    Args:
        message: The message to display

    Returns:
        True for 'y' or 'yes', False for 'n' or 'no'
    """
    try:
        while True:
            response = input(f"{message} (y/n): ").lower().strip()
            if response in ["y", "yes"]:
                return True
            if response in ["n", "no"]:
                return False
            print("Please enter 'y' or 'n'")
    except Exception:
        return False
`````

## File: src/metagit/core/utils/yaml_class.py
`````python
#!/usr/bin/env python

"""
yaml class that can load yaml files with includes and envvars and check for duplicate keys.
"""

import functools
import json
import os
from typing import Any, Union

import yaml
from yaml.constructor import ConstructorError

LegacyYAMLLoader = (os.getenv("LEGACY_YAML_LOADER", "false")).lower() == "true"


def no_duplicates_constructor(
    loader: yaml.Loader, node: yaml.Node, deep: bool = False
) -> Union[Any, Exception]:
    """Check for duplicate keys."""
    try:
        mapping = {}
        for key_node, value_node in node.value:
            key = loader.construct_object(key_node, deep=deep)
            value = loader.construct_object(value_node, deep=deep)
            if (key in mapping) and (not LegacyYAMLLoader):
                return ConstructorError(
                    "While constructing a mapping",
                    node.start_mark,
                    f"found duplicate key ({key})",
                    key_node.start_mark,
                )
            mapping[key] = value

        return loader.construct_mapping(node, deep)
    except Exception as e:
        return e


yaml.add_constructor(
    yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, no_duplicates_constructor
)


class ExtLoaderMeta(type):
    """External yaml loader metadata class."""

    def __new__(
        metacls, __name__: str, __bases__: Any, __dict__: Any
    ) -> Union[Any, Exception]:
        """Add constructers to class."""
        try:
            cls = super().__new__(metacls, __name__, __bases__, __dict__)

            # register the include constructors on the class
            cls.add_constructor("!include", cls.construct_include)
            cls.add_constructor("!envvar", cls.construct_envvar)
            return cls
        except Exception as e:
            return e


class ExtLoader(yaml.Loader, metaclass=ExtLoaderMeta):
    """YAML Loader with additional constructors."""

    def __init__(self, stream: Any) -> None:
        """Initialise Loader."""
        try:
            streamdata = stream if isinstance(stream, str) else stream.name
            self._root = os.path.split(streamdata)[0]
        except AttributeError:
            self._root = os.path.curdir
        super().__init__(stream)

    def construct_include(self, node: yaml.Node) -> Union[Any, Exception]:
        """Include file referenced at node."""
        try:
            file_name = os.path.abspath(
                os.path.join(self._root, self.construct_scalar(node))
            )
            extension = os.path.splitext(file_name)[1].lstrip(".")
            with open(file_name) as f:
                if extension in ("yaml", "yml"):
                    data = yaml.load(  # nosec B506 — trusted workspace includes; custom Loader tags
                        f, Loader=yaml.FullLoader
                    )
                elif extension in ("json",):
                    data = json.load(f)
                else:
                    includedata = []
                    line = f.readline()
                    cnt = 0
                    while line:
                        includedata.append(line.strip())
                        line = f.readline()
                        cnt += 1
                    if cnt == 1:
                        data = "".join(includedata)
                    else:
                        data = '"' + "\\n".join(includedata) + '"'
            return data
        except Exception as e:
            return e

    def construct_envvar(self, node: yaml.Node) -> Union[str, None, Exception]:
        """Expand env variable at node"""
        try:
            return os.getenv((node.value).strip(), "")
        except Exception as e:
            return e


def load(*args: Any, **kwargs: Any) -> Union[Any, Exception]:
    try:
        return functools.partial(yaml.load, Loader=ExtLoader)(*args, **kwargs)
    except Exception as e:
        return e
`````

## File: src/metagit/core/web/__init__.py
`````python
#!/usr/bin/env python
"""Local web UI server components."""
`````

## File: src/metagit/core/web/config_preview.py
`````python
#!/usr/bin/env python
"""Render YAML previews for metagit web config editor."""

from __future__ import annotations

from pathlib import Path
from typing import Any, Literal

from metagit.core.appconfig.agent_mode import resolve_agent_mode
from metagit.core.appconfig.models import AppConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.config.yaml_display import dump_config_dict
from metagit.core.web.schema_tree import SchemaTreeService

PreviewStyle = Literal["normalized", "minimal", "disk"]

_SENSITIVE_KEYS = SchemaTreeService.SENSITIVE_KEYS


def read_disk_text(path: str) -> str:
    """Return on-disk file contents or empty string when missing."""
    file_path = Path(path)
    if not file_path.is_file():
        return ""
    return file_path.read_text(encoding="utf-8")


def redact_secrets(payload: Any) -> Any:
    """Return a copy of nested dict/list data with sensitive string values masked."""
    if isinstance(payload, dict):
        redacted: dict[str, Any] = {}
        for key, value in payload.items():
            if key in _SENSITIVE_KEYS or key.endswith("_token"):
                if isinstance(value, str) and value:
                    suffix = value[-4:] if len(value) > 4 else ""
                    redacted[key] = f"***{suffix}" if suffix else "***"
                else:
                    redacted[key] = value
            else:
                redacted[key] = redact_secrets(value)
        return redacted
    if isinstance(payload, list):
        return [redact_secrets(item) for item in payload]
    return payload


def render_metagit_yaml(
    config: MetagitConfig,
    *,
    style: PreviewStyle,
) -> str:
    """Serialize a metagit manifest for preview."""
    if style == "minimal":
        payload = config.model_dump(
            exclude_none=True, exclude_defaults=True, mode="json"
        )
    else:
        payload = config.model_dump(exclude_none=True, mode="json")
    return dump_config_dict(payload)


def render_appconfig_yaml(
    config: AppConfig,
    *,
    config_path: str,
    style: PreviewStyle,
    mask_secrets: bool,
) -> str:
    """Serialize application config for preview."""
    if style == "minimal":
        config_body = config.model_dump(
            exclude_none=True,
            exclude_defaults=True,
            mode="json",
        )
        payload: dict[str, Any] = {"config": config_body}
    else:
        config_body = config.model_dump(mode="json")
        payload = {
            "config_path": config_path,
            "agent_mode": resolve_agent_mode(config),
            "config": config_body,
        }
    if mask_secrets:
        payload = redact_secrets(payload)
    if style == "minimal":
        return dump_config_dict({"config": payload["config"]})
    return dump_config_dict(payload)
`````

## File: src/metagit/core/web/graph_service.py
`````python
#!/usr/bin/env python
"""
Build unified workspace graph views for the web UI.
"""

from __future__ import annotations

from typing import Any, Literal, Optional

from pydantic import BaseModel, Field

from metagit.core.config.models import MetagitConfig
from metagit.core.config.graph_resolver import resolve_graph_endpoint_id
from metagit.core.mcp.services.cross_project_dependencies import (
    CrossProjectDependencyService,
)
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService


class GraphViewNode(BaseModel):
    """Renderable node in the workspace relationship diagram."""

    id: str
    label: str
    kind: Literal["project", "repo"]
    project_name: Optional[str] = None
    repo_name: Optional[str] = None


class GraphViewEdge(BaseModel):
    """Renderable edge in the workspace relationship diagram."""

    id: str
    from_id: str
    to_id: str
    type: str
    label: Optional[str] = None
    source: Literal["manual", "inferred", "structure"] = "inferred"


class WorkspaceGraphView(BaseModel):
    """Nodes and edges for diagram rendering."""

    ok: bool = True
    nodes: list[GraphViewNode] = Field(default_factory=list)
    edges: list[GraphViewEdge] = Field(default_factory=list)
    manual_edge_count: int = 0
    inferred_edge_count: int = 0
    structure_edge_count: int = 0


class WorkspaceGraphService:
    """Assemble manifest manual graph data with optional inferred dependencies."""

    def __init__(
        self,
        index_service: Optional[WorkspaceIndexService] = None,
        dependency_service: Optional[CrossProjectDependencyService] = None,
    ) -> None:
        self._index = index_service or WorkspaceIndexService()
        self._dependencies = dependency_service or CrossProjectDependencyService()

    def build_view(
        self,
        config: MetagitConfig,
        workspace_root: str,
        *,
        include_inferred: bool = True,
        include_structure: bool = True,
    ) -> WorkspaceGraphView:
        """Return diagram-ready nodes and edges."""
        rows = self._index.build_index(config=config, workspace_root=workspace_root)
        project_names = {
            project.name
            for project in (config.workspace.projects if config.workspace else [])
        }
        nodes: list[GraphViewNode] = []
        node_ids: set[str] = set()

        for project_name in sorted(project_names):
            node_id = f"project:{project_name}"
            nodes.append(
                GraphViewNode(
                    id=node_id,
                    label=project_name,
                    kind="project",
                    project_name=project_name,
                )
            )
            node_ids.add(node_id)

        for row in rows:
            node_id = f"repo:{row['project_name']}/{row['repo_name']}"
            if node_id in node_ids:
                continue
            nodes.append(
                GraphViewNode(
                    id=node_id,
                    label=str(row.get("repo_name", "")),
                    kind="repo",
                    project_name=str(row.get("project_name", "")),
                    repo_name=str(row.get("repo_name", "")),
                )
            )
            node_ids.add(node_id)

        edges: list[GraphViewEdge] = []
        edge_keys: set[tuple[str, str, str]] = set()

        if include_structure:
            for row in rows:
                project_id = f"project:{row['project_name']}"
                repo_id = f"repo:{row['project_name']}/{row['repo_name']}"
                if project_id in node_ids and repo_id in node_ids:
                    self._append_edge(
                        edges,
                        edge_keys,
                        from_id=project_id,
                        to_id=repo_id,
                        edge_type="contains",
                        label="contains",
                        source="structure",
                    )

        manual_count = self._append_manual_edges(
            config=config,
            rows=rows,
            project_names=project_names,
            node_ids=node_ids,
            edges=edges,
            edge_keys=edge_keys,
        )

        inferred_count = 0
        if include_inferred and config.workspace:
            inferred_count = self._append_inferred_edges(
                config=config,
                workspace_root=workspace_root,
                node_ids=node_ids,
                edges=edges,
                edge_keys=edge_keys,
            )

        structure_count = sum(1 for edge in edges if edge.source == "structure")
        return WorkspaceGraphView(
            ok=True,
            nodes=nodes,
            edges=edges,
            manual_edge_count=manual_count,
            inferred_edge_count=inferred_count,
            structure_edge_count=structure_count,
        )

    def _append_manual_edges(
        self,
        *,
        config: MetagitConfig,
        rows: list[dict[str, Any]],
        project_names: set[str],
        node_ids: set[str],
        edges: list[GraphViewEdge],
        edge_keys: set[tuple[str, str, str]],
    ) -> int:
        if config.graph is None or not config.graph.relationships:
            return 0
        added = 0
        for rel in config.graph.relationships:
            from_id = resolve_graph_endpoint_id(
                rel.from_endpoint,
                rows=rows,
                project_names=project_names,
            )
            to_id = resolve_graph_endpoint_id(
                rel.to,
                rows=rows,
                project_names=project_names,
            )
            if not from_id or not to_id:
                continue
            if from_id not in node_ids or to_id not in node_ids:
                continue
            label = rel.label or rel.type
            if self._append_edge(
                edges,
                edge_keys,
                from_id=from_id,
                to_id=to_id,
                edge_type="manual",
                label=label,
                source="manual",
                edge_id=rel.id,
            ):
                added += 1
        return added

    def _append_inferred_edges(
        self,
        *,
        config: MetagitConfig,
        workspace_root: str,
        node_ids: set[str],
        edges: list[GraphViewEdge],
        edge_keys: set[tuple[str, str, str]],
    ) -> int:
        added = 0
        if not config.workspace:
            return 0
        for project in config.workspace.projects:
            result = self._dependencies.map_dependencies(
                config,
                workspace_root,
                project.name,
                dependency_types=None,
                depth=3,
            )
            if not result.ok:
                continue
            for dep_edge in result.edges:
                if dep_edge.from_id not in node_ids or dep_edge.to_id not in node_ids:
                    continue
                if dep_edge.type == "manual":
                    continue
                label = dep_edge.type
                if dep_edge.evidence:
                    label = str(dep_edge.evidence[0])[:48]
                if self._append_edge(
                    edges,
                    edge_keys,
                    from_id=dep_edge.from_id,
                    to_id=dep_edge.to_id,
                    edge_type=dep_edge.type,
                    label=label,
                    source="inferred",
                ):
                    added += 1
        return added

    def _append_edge(
        self,
        edges: list[GraphViewEdge],
        edge_keys: set[tuple[str, str, str]],
        *,
        from_id: str,
        to_id: str,
        edge_type: str,
        label: Optional[str],
        source: Literal["manual", "inferred", "structure"],
        edge_id: Optional[str] = None,
    ) -> bool:
        key = (from_id, to_id, edge_type)
        if key in edge_keys:
            return False
        edge_keys.add(key)
        edge_key = edge_id or f"{from_id}->{to_id}:{edge_type}"
        edges.append(
            GraphViewEdge(
                id=edge_key,
                from_id=from_id,
                to_id=to_id,
                type=edge_type,
                label=label,
                source=source,
            )
        )
        return True
`````

## File: src/metagit/core/web/job_store.py
`````python
#!/usr/bin/env python
"""In-memory sync job tracking for web UI SSE flows."""

from __future__ import annotations

import threading
import uuid
from typing import Any

from metagit.core.web.models import SyncJobStatus


class SyncJobStore:
    """Thread-safe in-memory sync job tracking."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._jobs: dict[str, SyncJobStatus] = {}
        self._pending_events: dict[str, list[dict[str, Any]]] = {}

    def create_job(self) -> str:
        """Create a pending job and return its id (uuid4 hex)."""
        job_id = uuid.uuid4().hex
        with self._lock:
            self._jobs[job_id] = SyncJobStatus(job_id=job_id, state="pending")
            self._pending_events[job_id] = []
        return job_id

    def mark_running(self, job_id: str) -> None:
        """Mark an existing job as running."""
        with self._lock:
            status = self._jobs.get(job_id)
            if status is None:
                return
            self._jobs[job_id] = status.model_copy(update={"state": "running"})

    def append_event(self, job_id: str, event: dict[str, Any]) -> None:
        """Append a server-sent event payload for a job."""
        with self._lock:
            if job_id not in self._jobs:
                return
            self._pending_events.setdefault(job_id, []).append(dict(event))

    def complete(
        self,
        job_id: str,
        *,
        summary: dict[str, Any],
        results: list[dict[str, Any]],
    ) -> None:
        """Mark a job completed with summary and per-repo results."""
        with self._lock:
            status = self._jobs.get(job_id)
            if status is None:
                return
            self._jobs[job_id] = status.model_copy(
                update={
                    "state": "completed",
                    "summary": dict(summary),
                    "results": list(results),
                    "error": None,
                }
            )

    def fail(self, job_id: str, error: str) -> None:
        """Mark a job as failed with an error message."""
        with self._lock:
            status = self._jobs.get(job_id)
            if status is None:
                return
            self._jobs[job_id] = status.model_copy(
                update={"state": "failed", "error": error}
            )

    def get(self, job_id: str) -> SyncJobStatus | None:
        """Return a snapshot of job status, or None if unknown."""
        with self._lock:
            status = self._jobs.get(job_id)
            return None if status is None else status.model_copy(deep=True)

    def drain_events(self, job_id: str) -> list[dict[str, Any]]:
        """Return pending SSE-style events for a job and clear the buffer."""
        with self._lock:
            pending = self._pending_events.pop(job_id, [])
            return [dict(e) for e in pending]
`````

## File: src/metagit/core/web/static_handler.py
`````python
#!/usr/bin/env python
"""Serve bundled static assets for the metagit web UI."""

from __future__ import annotations

import mimetypes
from http.server import BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import unquote, urlparse

from metagit import DATA_PATH

_API_PREFIXES = ("/v1", "/v2", "/v3")


class StaticWebHandler:
    """Serve SPA assets from the packaged web data directory."""

    def __init__(self, web_root: str | None = None) -> None:
        root = Path(web_root) if web_root is not None else Path(DATA_PATH) / "web"
        self._web_root = root.resolve()

    def handle(
        self, method: str, path: str, request_handler: BaseHTTPRequestHandler
    ) -> bool:
        """Serve static files for non-API GET requests."""
        if method != "GET":
            return False
        parsed_path = urlparse(path).path
        if parsed_path.startswith(_API_PREFIXES):
            return False
        file_path = self._resolve_file(parsed_path)
        if file_path is None or not file_path.is_file():
            file_path = self._web_root / "index.html"
        if not file_path.is_file():
            return False
        self._send_file(file_path, request_handler)
        return True

    def _resolve_file(self, parsed_path: str) -> Path | None:
        if parsed_path in ("", "/"):
            return self._web_root / "index.html"
        relative = unquote(parsed_path.lstrip("/"))
        candidate = (self._web_root / relative).resolve()
        web_root_resolved = self._web_root.resolve()
        if (
            candidate != web_root_resolved
            and web_root_resolved not in candidate.parents
        ):
            return None
        return candidate

    def _send_file(
        self, file_path: Path, request_handler: BaseHTTPRequestHandler
    ) -> None:
        content_type, _ = mimetypes.guess_type(str(file_path))
        if content_type is None:
            content_type = "application/octet-stream"
        body = file_path.read_bytes()
        request_handler.send_response(200)
        request_handler.send_header("Content-Type", content_type)
        request_handler.send_header("Content-Length", str(len(body)))
        request_handler.end_headers()
        request_handler.wfile.write(body)

    @staticmethod
    def is_api_path(path: str) -> bool:
        """Return True when the path belongs to a versioned API route."""
        parsed_path = urlparse(path).path
        return parsed_path.startswith(_API_PREFIXES)
`````

## File: src/metagit/core/workspace/agent_instructions.py
`````python
#!/usr/bin/env python
"""
Compose layered agent instructions from .metagit.yml for controller and subagents.
"""

from __future__ import annotations

from typing import Literal, Optional

from pydantic import BaseModel, Field

from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectPath
from metagit.core.workspace.models import WorkspaceProject

_LAYER_HEADERS: dict[str, str] = {
    "file": "[FILE]",
    "workspace": "[WORKSPACE]",
    "project": "[PROJECT]",
    "repo": "[REPO]",
}


class AgentInstructionLayer(BaseModel):
    """One non-empty instruction layer from the manifest."""

    layer: Literal["file", "workspace", "project", "repo"]
    text: str = Field(..., min_length=1)


class AgentInstructionsComposition(BaseModel):
    """Structured and composed agent instructions for a scope."""

    layers: list[AgentInstructionLayer] = Field(default_factory=list)
    effective: str = ""


class AgentInstructionsResolver:
    """Build instruction stacks for workspace, project, and repo scopes."""

    def resolve(
        self,
        config: MetagitConfig,
        *,
        project: Optional[WorkspaceProject] = None,
        repo: Optional[ProjectPath] = None,
    ) -> AgentInstructionsComposition:
        """Compose instructions from file → workspace → project → repo."""
        layers: list[AgentInstructionLayer] = []
        file_text = _normalized(config.agent_instructions)
        if file_text:
            layers.append(AgentInstructionLayer(layer="file", text=file_text))
        if config.workspace:
            workspace_text = _normalized(config.workspace.agent_instructions)
            if workspace_text:
                layers.append(
                    AgentInstructionLayer(layer="workspace", text=workspace_text)
                )
        if project:
            project_text = _normalized(project.agent_instructions)
            if project_text:
                layers.append(AgentInstructionLayer(layer="project", text=project_text))
        if repo:
            repo_text = _normalized(repo.agent_instructions)
            if repo_text:
                layers.append(AgentInstructionLayer(layer="repo", text=repo_text))
        return AgentInstructionsComposition(
            layers=layers,
            effective=_compose_text(layers=layers),
        )

    def find_repo(
        self,
        project: WorkspaceProject,
        *,
        repo_name: Optional[str] = None,
        repo_path: Optional[str] = None,
    ) -> Optional[ProjectPath]:
        """Locate a configured repo entry by name or resolved path."""
        if not repo_name and not repo_path:
            return None
        normalized_path = repo_path.strip() if repo_path else None
        for entry in project.repos:
            if repo_name and entry.name == repo_name:
                return entry
            if normalized_path and entry.path and entry.path == normalized_path:
                return entry
            if normalized_path and entry.path and normalized_path.endswith(entry.path):
                return entry
        return None


def _normalized(value: Optional[str]) -> Optional[str]:
    if value is None:
        return None
    stripped = value.strip()
    return stripped if stripped else None


def _compose_text(layers: list[AgentInstructionLayer]) -> str:
    if not layers:
        return ""
    blocks: list[str] = []
    for item in layers:
        header = _LAYER_HEADERS.get(item.layer, item.layer.upper())
        blocks.append(f"{header}\n{item.text}")
    return "\n\n---\n\n".join(blocks)
`````

## File: src/metagit/core/workspace/dedupe_resolver.py
`````python
#!/usr/bin/env python
"""
Resolve effective workspace dedupe settings with per-project manifest overrides.
"""

from __future__ import annotations

from typing import Optional

from metagit.core.appconfig.models import WorkspaceDedupeConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.workspace.layout_resolver import find_project
from metagit.core.workspace.models import WorkspaceProject


def resolve_effective_dedupe(
    workspace_dedupe: WorkspaceDedupeConfig,
    project: Optional[WorkspaceProject] = None,
) -> Optional[WorkspaceDedupeConfig]:
    """
    Return dedupe config to apply for a project, or None when dedupe is off.

    App-config ``workspace.dedupe`` supplies strategy and canonical_dir. A project
    may set ``dedupe.enabled`` in ``.metagit.yml`` to override only the enabled flag.
    """
    enabled = workspace_dedupe.enabled
    if project is not None and project.dedupe is not None:
        if project.dedupe.enabled is not None:
            enabled = project.dedupe.enabled
    if not enabled:
        return None
    return workspace_dedupe


def resolve_effective_dedupe_for_project(
    workspace_dedupe: WorkspaceDedupeConfig,
    config: MetagitConfig,
    project_name: str,
) -> Optional[WorkspaceDedupeConfig]:
    """Resolve dedupe for a named workspace project."""
    project = find_project(config, project_name)
    return resolve_effective_dedupe(workspace_dedupe, project)


def resolve_dedupe_for_layout(
    app_dedupe: WorkspaceDedupeConfig,
    config: MetagitConfig,
    project_name: Optional[str] = None,
) -> Optional[WorkspaceDedupeConfig]:
    """Resolve dedupe for layout/sync CLI using optional project scope."""
    if not project_name:
        return resolve_effective_dedupe(app_dedupe, None)
    return resolve_effective_dedupe_for_project(app_dedupe, config, project_name)
`````

## File: src/metagit/core/workspace/health_models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for workspace health check results.
"""

from typing import Literal, Optional

from pydantic import BaseModel, Field


class HealthRecommendation(BaseModel):
    """Actionable workspace maintenance recommendation."""

    severity: Literal["info", "warning", "critical"] = "info"
    action: str
    message: str
    project_name: Optional[str] = None
    repo_name: Optional[str] = None
    repo_path: Optional[str] = None


class RepoHealthRow(BaseModel):
    """Per-repository health signals."""

    project_name: str
    repo_name: str
    repo_path: str
    status: str
    exists: bool
    is_git_repo: bool
    branch: Optional[str] = None
    dirty: Optional[bool] = None
    ahead: Optional[int] = None
    behind: Optional[int] = None
    gitnexus_status: Optional[str] = None
    head_commit_age_days: Optional[float] = None
    merge_base_age_days: Optional[float] = None


class WorkspaceHealthResult(BaseModel):
    """Aggregate workspace health report."""

    ok: bool = True
    workspace_root: str = ""
    summary: dict[str, int] = Field(default_factory=dict)
    repos: list[RepoHealthRow] = Field(default_factory=list)
    recommendations: list[HealthRecommendation] = Field(default_factory=list)
`````

## File: src/metagit/core/workspace/hydrate.py
`````python
#!/usr/bin/env python
"""
Materialize symlinked project mounts into full directory trees.
"""

from __future__ import annotations

import os
import shutil
from pathlib import Path
from typing import Optional

from tqdm import tqdm


def collect_file_copy_jobs(source: Path) -> list[tuple[Path, Path]]:
    """Return (source_file, destination_file) pairs for a recursive copy."""
    root = source.resolve()
    if not root.is_dir():
        if root.is_file():
            return [(root, root)]
        return []
    jobs: list[tuple[Path, Path]] = []
    for dirpath, _, filenames in os.walk(root, followlinks=True):
        base = Path(dirpath)
        rel = base.relative_to(root)
        for name in filenames:
            src = base / name
            jobs.append((src, rel / name))
    return jobs


def materialize_symlink_mount(
    mount: Path,
    *,
    position: int = 0,
    repo_label: str = "",
) -> tuple[bool, Optional[str]]:
    """
    Replace a symlink mount with a full copy of its resolved target.

    Returns (changed, error_message). ``changed`` is False when the mount is not
    a symlink (already material or missing).
    """
    if not mount.is_symlink():
        return False, None

    source = mount.resolve()
    if not source.exists():
        return False, f"hydrate target does not exist: {source}"

    label = repo_label or mount.name
    mount.unlink()

    if source.is_dir():
        mount.mkdir(parents=True, exist_ok=True)
        jobs = collect_file_copy_jobs(source)
        if not jobs:
            tqdm.write(f"  💧 {label}: empty directory (materialized)")
            return True, None
        desc = f"  💧 {label}"
        bar_format = (
            "{l_bar}{bar}| {n_fmt}/{total_fmt} files [{elapsed}<{remaining}]{r_bar}"
        )
        with tqdm(
            total=len(jobs),
            desc=desc,
            position=position,
            unit="file",
            bar_format=bar_format,
            leave=True,
        ) as pbar:
            for src_file, rel in jobs:
                dest = mount / rel
                dest.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(src_file, dest, follow_symlinks=True)
                name = rel.name if len(str(rel)) <= 48 else f"…{rel.name}"
                pbar.set_postfix_str(name, refresh=False)
                pbar.update(1)
        return True, None

    mount.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(source, mount, follow_symlinks=True)
    with tqdm(
        total=1,
        desc=f"  💧 {label}",
        position=position,
        bar_format="{l_bar}Copied{r_bar}",
    ) as pbar:
        pbar.update(1)
    return True, None
`````

## File: src/metagit/core/workspace/layout_context.py
`````python
#!/usr/bin/env python
"""
Resolve sync root and dedupe settings for layout operations.
"""

from __future__ import annotations

from pathlib import Path
from typing import Optional

from metagit.core.appconfig.models import AppConfig, WorkspaceDedupeConfig
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.workspace.dedupe_resolver import resolve_effective_dedupe_for_project


def resolve_sync_context(
    definition_root: str,
    *,
    definition_path: Optional[str] = None,
    project_name: Optional[str] = None,
) -> tuple[str, Optional[WorkspaceDedupeConfig]]:
    """
    Return (sync_root, dedupe) for layout operations.

    Uses app config workspace.path when load succeeds; otherwise definition_root.
    When definition_path and project_name are set, applies per-project dedupe override.
    """
    loaded = AppConfig.load()
    if not isinstance(loaded, Exception):
        sync_root = str(Path(loaded.workspace.path).expanduser().resolve())
        dedupe = loaded.workspace.dedupe
        if definition_path and project_name:
            manager = MetagitConfigManager(definition_path)
            manifest = manager.load_config()
            if not isinstance(manifest, Exception):
                return sync_root, resolve_effective_dedupe_for_project(
                    dedupe,
                    manifest,
                    project_name,
                )
        if not dedupe.enabled:
            return sync_root, None
        return sync_root, dedupe
    return str(Path(definition_root).expanduser().resolve()), None
`````

## File: src/metagit/core/workspace/layout_executor.py
`````python
#!/usr/bin/env python
"""
Apply planned workspace layout filesystem steps.
"""

from __future__ import annotations

import os
import shutil
from pathlib import Path

from metagit.core.utils.common import create_vscode_workspace
from metagit.core.workspace.layout_models import LayoutPlan, LayoutStep


def apply_plan(plan: LayoutPlan, *, dry_run: bool) -> list[LayoutStep]:
    """Execute disk steps from a layout plan."""
    applied: list[LayoutStep] = []
    for step in plan.disk_steps:
        if dry_run:
            applied.append(step.model_copy())
            continue
        result = _apply_step(step)
        applied.append(result)
        if result.action != "noop" and not result.applied and result.detail:
            raise LayoutExecutionError(result.detail)
    return applied


class LayoutExecutionError(Exception):
    """Raised when a layout step cannot be applied."""


def _apply_step(step: LayoutStep) -> LayoutStep:
    if step.action == "noop":
        return step.model_copy(update={"applied": True})
    if step.action == "mkdir":
        if step.target:
            Path(step.target).mkdir(parents=True, exist_ok=True)
        return step.model_copy(update={"applied": True})
    if step.action in {"rename", "move"}:
        return _apply_rename_or_move(step)
    if step.action == "unlink":
        return _apply_unlink(step)
    if step.action == "symlink":
        return _apply_symlink(step)
    if step.action == "regenerate_vscode":
        return _apply_vscode(step)
    if step.action == "migrate_session":
        return _apply_session(step)
    return step.model_copy(update={"applied": False, "detail": "unknown action"})


def _apply_rename_or_move(step: LayoutStep) -> LayoutStep:
    if not step.source or not step.target:
        return step.model_copy(
            update={"applied": False, "detail": "missing source or target"}
        )
    source = Path(step.source)
    target = Path(step.target)
    if not source.exists():
        return step.model_copy(update={"applied": True, "detail": "source missing"})
    if target.exists():
        return step.model_copy(
            update={"applied": False, "detail": f"target already exists: {target}"}
        )
    target.parent.mkdir(parents=True, exist_ok=True)
    try:
        os.rename(source, target)
    except OSError:
        shutil.move(str(source), str(target))
    return step.model_copy(update={"applied": True})


def _apply_unlink(step: LayoutStep) -> LayoutStep:
    if not step.source:
        return step.model_copy(update={"applied": False, "detail": "missing source"})
    path = Path(step.source)
    if not path.exists() and not path.is_symlink():
        return step.model_copy(update={"applied": True, "detail": "already absent"})
    if path.is_symlink():
        path.unlink(missing_ok=True)
    elif path.is_dir():
        shutil.rmtree(path)
    else:
        path.unlink(missing_ok=True)
    return step.model_copy(update={"applied": True})


def _apply_symlink(step: LayoutStep) -> LayoutStep:
    if not step.source or not step.target:
        return step.model_copy(
            update={"applied": False, "detail": "missing source or target"}
        )
    from metagit.core.workspace import workspace_dedupe

    mount = Path(step.target)
    target = Path(step.source)
    changed, error = workspace_dedupe.ensure_symlink(mount, target)
    if error:
        return step.model_copy(update={"applied": False, "detail": error})
    detail = "created symlink" if changed else "symlink already correct"
    return step.model_copy(update={"applied": True, "detail": detail})


def _apply_vscode(step: LayoutStep) -> LayoutStep:
    if not step.source or not step.target:
        return step.model_copy(
            update={"applied": False, "detail": "missing project dir or name"}
        )
    project_dir = Path(step.source)
    project_name = step.target
    repo_names = [
        entry.name
        for entry in project_dir.iterdir()
        if entry.name != "workspace.code-workspace"
        and (entry.is_dir() or entry.is_symlink())
    ]
    if not repo_names:
        return step.model_copy(update={"applied": True, "detail": "no repos to index"})
    content = create_vscode_workspace(project_name, repo_names)
    if isinstance(content, Exception):
        return step.model_copy(update={"applied": False, "detail": str(content)})
    out_path = project_dir / "workspace.code-workspace"
    out_path.write_text(content, encoding="utf-8")
    return step.model_copy(update={"applied": True})


def _apply_session(step: LayoutStep) -> LayoutStep:
    if not step.source or not step.target:
        return step.model_copy(
            update={"applied": False, "detail": "missing session paths"}
        )
    source = Path(step.source)
    target = Path(step.target)
    if not source.is_file():
        return step.model_copy(update={"applied": True, "detail": "no session file"})
    target.parent.mkdir(parents=True, exist_ok=True)
    if target.exists():
        target.unlink()
    os.rename(source, target)
    return step.model_copy(update={"applied": True})
`````

## File: src/metagit/core/workspace/layout_models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for workspace layout rename and move operations.
"""

from typing import Any, Literal, Optional

from pydantic import BaseModel, Field

from metagit.core.workspace.catalog_models import CatalogError


class LayoutStep(BaseModel):
    """Single filesystem or auxiliary layout action."""

    action: Literal[
        "rename",
        "move",
        "symlink",
        "unlink",
        "mkdir",
        "regenerate_vscode",
        "migrate_session",
        "noop",
    ]
    source: Optional[str] = None
    target: Optional[str] = None
    detail: Optional[str] = None
    applied: bool = False


class LayoutPlan(BaseModel):
    """Planned manifest and disk changes before execution."""

    operation: Literal["rename_project", "rename_repo", "move_repo"] = "rename_project"
    dry_run: bool = False
    manifest_changes: list[str] = Field(default_factory=list)
    disk_steps: list[LayoutStep] = Field(default_factory=list)
    warnings: list[str] = Field(default_factory=list)


class LayoutMutationResult(BaseModel):
    """Result of rename/move layout operations."""

    ok: bool = True
    error: Optional[CatalogError] = None
    entity: Literal["project", "repo"] = "project"
    operation: Literal["rename", "move"] = "rename"
    project_name: str = ""
    repo_name: Optional[str] = None
    from_project: Optional[str] = None
    to_project: Optional[str] = None
    config_path: str = ""
    data: Optional[dict[str, Any]] = None
`````

## File: src/metagit/core/workspace/layout_resolver.py
`````python
#!/usr/bin/env python
"""
Resolve sync-root paths for workspace layout operations.
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import Optional

from metagit.core.appconfig.models import WorkspaceDedupeConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectPath
from metagit.core.workspace import workspace_dedupe
from metagit.core.workspace.models import WorkspaceProject

_NAME_PATTERN = re.compile(r"^[\w][\w.-]*$")


def validate_layout_name(name: str, *, label: str = "name") -> Optional[str]:
    """Return an error message when name is invalid for paths and sessions."""
    trimmed = name.strip()
    if not trimmed:
        return f"{label} is required"
    if trimmed in {".", ".."} or "/" in trimmed or "\\" in trimmed:
        return f"invalid {label}: {name!r}"
    if not _NAME_PATTERN.match(trimmed):
        return f"invalid {label} (use letters, digits, _, ., -): {name!r}"
    return None


def sync_root_path(workspace_path: str) -> Path:
    """Resolved workspace sync root."""
    return Path(workspace_path).expanduser().resolve()


def project_dir(workspace_path: Path, project_name: str) -> Path:
    """Project folder under the sync root."""
    return workspace_path / project_name


def repo_mount_path(
    workspace_path: Path,
    project_name: str,
    repo_name: str,
) -> Path:
    """Repo mount path under a project folder."""
    return workspace_dedupe.project_mount_path(
        workspace_path,
        project_name,
        repo_name,
    )


def find_project(
    config: MetagitConfig,
    project_name: str,
) -> Optional[WorkspaceProject]:
    """Locate a workspace project by name."""
    if not config.workspace:
        return None
    for project in config.workspace.projects:
        if project.name == project_name:
            return project
    return None


def find_repo(
    project: WorkspaceProject,
    repo_name: str,
) -> Optional[ProjectPath]:
    """Locate a repo entry on a project."""
    for repo in project.repos:
        if repo.name == repo_name:
            return repo
    return None


def dedupe_enabled(dedupe: Optional[WorkspaceDedupeConfig]) -> bool:
    """True when workspace dedupe layout applies."""
    return bool(dedupe and dedupe.enabled)


def canonical_for_repo(
    workspace_path: Path,
    dedupe: WorkspaceDedupeConfig,
    repo: ProjectPath,
) -> Optional[Path]:
    """Canonical checkout path when dedupe applies."""
    identity = workspace_dedupe.build_repo_identity(repo)
    if identity is None:
        return None
    return workspace_dedupe.canonical_path(
        workspace_path,
        dedupe,
        identity.repo_key,
    )
`````

## File: src/metagit/core/workspace/layout_service.py
`````python
#!/usr/bin/env python
"""
Rename and move workspace projects and repositories (manifest + sync layout).
"""

from __future__ import annotations

import copy
from pathlib import Path
from typing import Any, Optional

from metagit.core.appconfig.models import WorkspaceDedupeConfig
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.session_store import SessionStore
from metagit.core.workspace import workspace_dedupe
from metagit.core.workspace.catalog_models import CatalogError
from metagit.core.workspace.layout_executor import LayoutExecutionError, apply_plan
from metagit.core.workspace.layout_models import (
    LayoutMutationResult,
    LayoutPlan,
    LayoutStep,
)
from metagit.core.workspace.layout_resolver import (
    dedupe_enabled,
    find_project,
    find_repo,
    project_dir,
    repo_mount_path,
    sync_root_path,
    validate_layout_name,
)


class WorkspaceLayoutService:
    """Rename/move workspace catalog entries and aligned sync folders."""

    def rename_project(
        self,
        config: MetagitConfig,
        config_path: str,
        workspace_path: str,
        *,
        from_name: str,
        to_name: str,
        dedupe: Optional[WorkspaceDedupeConfig] = None,
        dry_run: bool = False,
        move_disk: bool = True,
        update_sessions: bool = True,
        force: bool = False,
    ) -> LayoutMutationResult:
        """Rename a workspace project in the manifest and on disk."""
        _ = dedupe
        source = from_name.strip()
        target = to_name.strip()
        err = validate_layout_name(source, label="from_name") or validate_layout_name(
            target, label="to_name"
        )
        if err:
            return self._error(
                "rename", "project", source, kind="invalid_name", message=err
            )
        if source == target:
            return self._error(
                "rename",
                "project",
                source,
                kind="noop",
                message="source and target names are the same",
            )
        project = find_project(config, source)
        if project is None:
            return self._error(
                "rename",
                "project",
                source,
                kind="not_found",
                message=f"project '{source}' not found",
            )
        if find_project(config, target) is not None:
            return self._error(
                "rename",
                "project",
                source,
                kind="already_exists",
                message=f"project '{target}' already exists",
            )

        root = sync_root_path(workspace_path)
        plan = LayoutPlan(
            operation="rename_project",
            dry_run=dry_run,
            manifest_changes=[f"project.name: {source} -> {target}"],
        )
        old_dir = project_dir(root, source)
        new_dir = project_dir(root, target)
        if move_disk and old_dir.exists():
            if new_dir.exists() and not force:
                return self._error(
                    "rename",
                    "project",
                    source,
                    kind="target_exists",
                    message=f"sync folder already exists: {new_dir}",
                )
            plan.disk_steps.append(
                LayoutStep(
                    action="rename",
                    source=str(old_dir),
                    target=str(new_dir),
                )
            )
        if move_disk and new_dir.exists():
            plan.disk_steps.append(
                LayoutStep(
                    action="regenerate_vscode",
                    source=str(new_dir),
                    target=target,
                )
            )

        if dry_run:
            return self._success(
                "rename",
                "project",
                target,
                config_path=config_path,
                plan=plan,
            )

        working = copy.deepcopy(config)
        working_project = find_project(working, source)
        if working_project is None:
            return self._error(
                "rename",
                "project",
                source,
                kind="not_found",
                message=f"project '{source}' not found",
            )
        working_project.name = target

        try:
            if move_disk:
                apply_plan(plan, dry_run=False)
            save_err = self._save(working, config_path)
            if save_err:
                return self._error(
                    "rename",
                    "project",
                    source,
                    kind="save_failed",
                    message=str(save_err),
                )
            project.name = target
            if update_sessions:
                store = SessionStore(workspace_root=str(root))
                if store.rename_project_session(from_name=source, to_name=target):
                    plan.disk_steps.append(
                        LayoutStep(
                            action="migrate_session",
                            source=f"{source}.json",
                            target=f"{target}.json",
                            applied=True,
                        )
                    )
                meta = store.get_workspace_meta()
                if meta.active_project == source:
                    store.set_active_project(project_name=target)
        except LayoutExecutionError as exc:
            return self._error(
                "rename",
                "project",
                source,
                kind="disk_failed",
                message=str(exc),
            )

        return self._success(
            "rename",
            "project",
            target,
            config_path=config_path,
            plan=plan,
            manifest_updated=True,
        )

    def rename_repo(
        self,
        config: MetagitConfig,
        config_path: str,
        workspace_path: str,
        *,
        project_name: str,
        from_name: str,
        to_name: str,
        dedupe: Optional[WorkspaceDedupeConfig] = None,
        dry_run: bool = False,
        move_disk: bool = True,
        force: bool = False,
    ) -> LayoutMutationResult:
        """Rename a repository entry and its sync mount when present."""
        project_key = project_name.strip()
        source = from_name.strip()
        target = to_name.strip()
        err = validate_layout_name(source, label="from_name") or validate_layout_name(
            target, label="to_name"
        )
        if err:
            return self._error(
                "rename",
                "repo",
                project_key,
                repo_name=source,
                kind="invalid_name",
                message=err,
            )
        if project_key == "local":
            return self._error(
                "rename",
                "repo",
                project_key,
                repo_name=source,
                kind="unsupported",
                message="the local project does not support layout disk operations",
            )
        if source == target:
            return self._error(
                "rename",
                "repo",
                project_key,
                repo_name=source,
                kind="noop",
                message="source and target names are the same",
            )
        project = find_project(config, project_key)
        if project is None:
            return self._error(
                "rename",
                "repo",
                project_key,
                repo_name=source,
                kind="project_not_found",
                message=f"project '{project_key}' not found",
            )
        repo = find_repo(project, source)
        if repo is None:
            return self._error(
                "rename",
                "repo",
                project_key,
                repo_name=source,
                kind="not_found",
                message=f"repo '{source}' not found in project '{project_key}'",
            )
        if repo.protected and not force:
            return self._error(
                "rename",
                "repo",
                project_key,
                repo_name=source,
                kind="protected",
                message=f"repo '{source}' is protected (use force=True)",
            )
        if find_repo(project, target) is not None:
            return self._error(
                "rename",
                "repo",
                project_key,
                repo_name=source,
                kind="already_exists",
                message=f"repo '{target}' already exists in project '{project_key}'",
            )

        root = sync_root_path(workspace_path)
        plan = LayoutPlan(
            operation="rename_repo",
            dry_run=dry_run,
            manifest_changes=[
                f"{project_key}.repos[].name: {source} -> {target}",
            ],
        )
        plan.warnings.extend(
            self._git_warnings(repo_mount_path(root, project_key, source))
        )

        old_mount = repo_mount_path(root, project_key, source)
        new_mount = repo_mount_path(root, project_key, target)
        if move_disk and old_mount.exists():
            if new_mount.exists() and not force:
                return self._error(
                    "rename",
                    "repo",
                    project_key,
                    repo_name=source,
                    kind="target_exists",
                    message=f"mount already exists: {new_mount}",
                )
            if dedupe_enabled(dedupe) and old_mount.is_symlink():
                identity = workspace_dedupe.build_repo_identity(repo)
                if identity and dedupe:
                    canonical = workspace_dedupe.canonical_path(
                        root, dedupe, identity.repo_key
                    )
                    plan.disk_steps.append(
                        LayoutStep(action="unlink", source=str(old_mount))
                    )
                    plan.disk_steps.append(
                        LayoutStep(
                            action="symlink",
                            source=str(canonical),
                            target=str(new_mount),
                        )
                    )
                else:
                    plan.disk_steps.append(
                        LayoutStep(
                            action="rename",
                            source=str(old_mount),
                            target=str(new_mount),
                        )
                    )
            else:
                plan.disk_steps.append(
                    LayoutStep(
                        action="rename",
                        source=str(old_mount),
                        target=str(new_mount),
                    )
                )

        proj_path = project_dir(root, project_key)
        if move_disk and proj_path.exists():
            plan.disk_steps.append(
                LayoutStep(
                    action="regenerate_vscode",
                    source=str(proj_path),
                    target=project_key,
                )
            )

        if dry_run:
            return self._success(
                "rename",
                "repo",
                project_key,
                repo_name=target,
                config_path=config_path,
                plan=plan,
            )

        repo.name = target
        try:
            if move_disk:
                apply_plan(plan, dry_run=False)
            save_err = self._save(config, config_path)
            if save_err:
                repo.name = source
                return self._error(
                    "rename",
                    "repo",
                    project_key,
                    repo_name=source,
                    kind="save_failed",
                    message=str(save_err),
                )
        except LayoutExecutionError as exc:
            repo.name = source
            return self._error(
                "rename",
                "repo",
                project_key,
                repo_name=source,
                kind="disk_failed",
                message=str(exc),
            )

        return self._success(
            "rename",
            "repo",
            project_key,
            repo_name=target,
            config_path=config_path,
            plan=plan,
            manifest_updated=True,
        )

    def move_repo(
        self,
        config: MetagitConfig,
        config_path: str,
        workspace_path: str,
        *,
        repo_name: str,
        from_project: str,
        to_project: str,
        dedupe: Optional[WorkspaceDedupeConfig] = None,
        dry_run: bool = False,
        move_disk: bool = True,
        force: bool = False,
    ) -> LayoutMutationResult:
        """Move a repository entry from one project to another."""
        source_project_name = from_project.strip()
        target_project_name = to_project.strip()
        repo_key = repo_name.strip()
        if source_project_name == "local" or target_project_name == "local":
            return self._error(
                "move",
                "repo",
                source_project_name,
                repo_name=repo_key,
                kind="unsupported",
                message="the local project does not support layout disk operations",
            )
        err = validate_layout_name(repo_key, label="repo_name")
        if err:
            return self._error(
                "move",
                "repo",
                source_project_name,
                repo_name=repo_key,
                kind="invalid_name",
                message=err,
            )
        if source_project_name == target_project_name:
            return self._error(
                "move",
                "repo",
                source_project_name,
                repo_name=repo_key,
                kind="noop",
                message="source and target projects are the same",
            )

        source_project = find_project(config, source_project_name)
        if source_project is None:
            return self._error(
                "move",
                "repo",
                source_project_name,
                repo_name=repo_key,
                kind="project_not_found",
                message=f"project '{source_project_name}' not found",
            )
        target_project = find_project(config, target_project_name)
        if target_project is None:
            return self._error(
                "move",
                "repo",
                source_project_name,
                repo_name=repo_key,
                kind="project_not_found",
                message=f"project '{target_project_name}' not found",
            )
        repo = find_repo(source_project, repo_key)
        if repo is None:
            return self._error(
                "move",
                "repo",
                source_project_name,
                repo_name=repo_key,
                kind="not_found",
                message=(
                    f"repo '{repo_key}' not found in project '{source_project_name}'"
                ),
            )
        if repo.protected and not force:
            return self._error(
                "move",
                "repo",
                source_project_name,
                repo_name=repo_key,
                kind="protected",
                message=f"repo '{repo_key}' is protected (use force=True)",
            )

        existing_target = find_repo(target_project, repo_key)
        if existing_target is not None and not force:
            return self._error(
                "move",
                "repo",
                source_project_name,
                repo_name=repo_key,
                kind="already_exists",
                message=(
                    f"repo '{repo_key}' already exists in project '{target_project_name}'"
                ),
            )

        root = sync_root_path(workspace_path)
        plan = LayoutPlan(
            operation="move_repo",
            dry_run=dry_run,
            manifest_changes=[
                f"move {repo_key}: {source_project_name} -> {target_project_name}",
            ],
        )
        old_mount = repo_mount_path(root, source_project_name, repo_key)
        new_mount = repo_mount_path(root, target_project_name, repo_key)
        plan.warnings.extend(self._git_warnings(old_mount))

        if move_disk and old_mount.exists():
            if new_mount.exists() and not force:
                return self._error(
                    "move",
                    "repo",
                    source_project_name,
                    repo_name=repo_key,
                    kind="target_exists",
                    message=f"mount already exists: {new_mount}",
                )
            if dedupe_enabled(dedupe) and old_mount.is_symlink() and dedupe:
                identity = workspace_dedupe.build_repo_identity(repo)
                if identity:
                    canonical = workspace_dedupe.canonical_path(
                        root, dedupe, identity.repo_key
                    )
                    plan.disk_steps.append(
                        LayoutStep(action="unlink", source=str(old_mount))
                    )
                    plan.disk_steps.append(
                        LayoutStep(
                            action="mkdir",
                            target=str(new_mount.parent),
                        )
                    )
                    plan.disk_steps.append(
                        LayoutStep(
                            action="symlink",
                            source=str(canonical),
                            target=str(new_mount),
                        )
                    )
            else:
                plan.disk_steps.append(
                    LayoutStep(
                        action="move",
                        source=str(old_mount),
                        target=str(new_mount),
                    )
                )

        for proj_name in (source_project_name, target_project_name):
            proj_path = project_dir(root, proj_name)
            if move_disk and proj_path.exists():
                plan.disk_steps.append(
                    LayoutStep(
                        action="regenerate_vscode",
                        source=str(proj_path),
                        target=proj_name,
                    )
                )

        if dry_run:
            return self._success(
                "move",
                "repo",
                target_project_name,
                repo_name=repo_key,
                from_project=source_project_name,
                to_project=target_project_name,
                config_path=config_path,
                plan=plan,
            )

        if existing_target is not None and force:
            target_project.repos = [
                item for item in target_project.repos if item.name != repo_key
            ]

        pop_index = next(
            index
            for index, item in enumerate(source_project.repos)
            if item.name == repo_key
        )
        moved_repo = source_project.repos.pop(pop_index)
        target_project.repos.append(moved_repo)

        try:
            if move_disk:
                apply_plan(plan, dry_run=False)
            save_err = self._save(config, config_path)
            if save_err:
                target_project.repos.pop()
                source_project.repos.append(moved_repo)
                return self._error(
                    "move",
                    "repo",
                    source_project_name,
                    repo_name=repo_key,
                    kind="save_failed",
                    message=str(save_err),
                )
        except LayoutExecutionError as exc:
            target_project.repos = [
                item for item in target_project.repos if item.name != repo_key
            ]
            source_project.repos.append(moved_repo)
            return self._error(
                "move",
                "repo",
                source_project_name,
                repo_name=repo_key,
                kind="disk_failed",
                message=str(exc),
            )

        return self._success(
            "move",
            "repo",
            target_project_name,
            repo_name=repo_key,
            from_project=source_project_name,
            to_project=target_project_name,
            config_path=config_path,
            plan=plan,
            manifest_updated=True,
        )

    def _git_warnings(self, mount: Path) -> list[str]:
        """Warn when a git checkout under mount has a dirty working tree."""
        warnings: list[str] = []
        git_dir = mount / ".git"
        if not git_dir.exists():
            return warnings
        try:
            import git

            repo = git.Repo(str(mount))
            if repo.is_dirty(untracked_files=True):
                warnings.append(f"git working tree has uncommitted changes: {mount}")
        except Exception as exc:
            warnings.append(f"could not inspect git status at {mount}: {exc}")
        return warnings

    def _save(self, config: MetagitConfig, config_path: str) -> Optional[Exception]:
        manager = MetagitConfigManager(metagit_config=config)
        result = manager.save_config(config, Path(config_path))
        if isinstance(result, Exception):
            return result
        return None

    def _error(
        self,
        operation: str,
        entity: str,
        project_name: str,
        *,
        kind: str,
        message: str,
        repo_name: Optional[str] = None,
    ) -> LayoutMutationResult:
        return LayoutMutationResult(
            ok=False,
            error=CatalogError(kind=kind, message=message),
            entity="repo" if entity == "repo" else "project",
            operation="move" if operation == "move" else "rename",
            project_name=project_name,
            repo_name=repo_name,
            config_path="",
        )

    def _success(
        self,
        operation: str,
        entity: str,
        project_name: str,
        *,
        config_path: str,
        plan: LayoutPlan,
        repo_name: Optional[str] = None,
        from_project: Optional[str] = None,
        to_project: Optional[str] = None,
        manifest_updated: bool = False,
    ) -> LayoutMutationResult:
        data: dict[str, Any] = {
            "dry_run": plan.dry_run,
            "manifest_changes": plan.manifest_changes,
            "disk_steps": [step.model_dump(mode="json") for step in plan.disk_steps],
            "warnings": plan.warnings,
            "manifest_updated": manifest_updated,
        }
        return LayoutMutationResult(
            ok=True,
            entity="repo" if entity == "repo" else "project",
            operation="move" if operation == "move" else "rename",
            project_name=project_name,
            repo_name=repo_name,
            from_project=from_project,
            to_project=to_project,
            config_path=config_path,
            data=data,
        )
`````

## File: src/metagit/core/workspace/workspace_dedupe.py
`````python
#!/usr/bin/env python
"""
Workspace-scoped repository deduplication helpers (canonical store + symlinks).
"""

from __future__ import annotations

import hashlib
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

from metagit.core.appconfig.models import WorkspaceDedupeConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectPath
from metagit.core.utils.common import normalize_git_url


@dataclass(frozen=True)
class RepoIdentity:
    """Stable identity for a workspace repo entry."""

    repo_key: str
    url: Optional[str] = None
    local_path: Optional[str] = None


def build_repo_identity(repo: ProjectPath) -> Optional[RepoIdentity]:
    """
    Build a stable repo key for deduplication within one workspace manifest.

    Branch-specific entries (ref or branches) receive distinct keys.
    """
    branch_suffix = _branch_suffix(repo)
    if repo.url:
        normalized = normalize_git_url(str(repo.url)) or ""
        if not normalized:
            return None
        base_key = _slugify(normalized, digest_prefix="url")
        return RepoIdentity(
            repo_key=f"{base_key}{branch_suffix}",
            url=normalized,
        )
    if repo.path:
        resolved = str(Path(repo.path).expanduser().resolve())
        base_key = _slugify(resolved, digest_prefix="path")
        return RepoIdentity(
            repo_key=f"{base_key}{branch_suffix}",
            local_path=resolved,
        )
    return None


def find_duplicate_identities(
    config: MetagitConfig,
    repo: ProjectPath,
    *,
    exclude_project: Optional[str] = None,
    exclude_repo_name: Optional[str] = None,
) -> list[tuple[str, str]]:
    """Return (project_name, repo_name) pairs sharing the same identity as repo."""
    target = build_repo_identity(repo)
    if target is None or not config.workspace:
        return []
    matches: list[tuple[str, str]] = []
    for project in config.workspace.projects:
        for existing in project.repos:
            if exclude_project == project.name and exclude_repo_name == existing.name:
                continue
            existing_identity = build_repo_identity(existing)
            if (
                existing_identity is not None
                and existing_identity.repo_key == target.repo_key
            ):
                matches.append((project.name, existing.name))
    return matches


def canonical_path(
    workspace_path: Path,
    dedupe: WorkspaceDedupeConfig,
    repo_key: str,
) -> Path:
    """Absolute path to the canonical checkout directory for repo_key."""
    return (workspace_path / dedupe.canonical_dir / repo_key).resolve()


def project_mount_path(
    workspace_path: Path,
    project_name: str,
    repo_name: str,
) -> Path:
    """Absolute path where a project exposes a repo (symlink or directory)."""
    return (workspace_path / project_name / repo_name).resolve()


def ensure_symlink(mount: Path, target: Path) -> tuple[bool, Optional[str]]:
    """
    Ensure mount is a symlink to target.

    Returns (changed, error_message).
    """
    target_resolved = target.resolve()
    if mount.is_symlink():
        try:
            current = Path(os.readlink(mount))
            if not current.is_absolute():
                current = (mount.parent / current).resolve()
            else:
                current = current.resolve()
            if current == target_resolved:
                return False, None
        except OSError:
            pass
        mount.unlink(missing_ok=True)
    elif mount.exists():
        return False, f"mount exists and is not a symlink: {mount}"

    mount.parent.mkdir(parents=True, exist_ok=True)
    try:
        os.symlink(target_resolved, mount, target_is_directory=target_resolved.is_dir())
        return True, None
    except OSError as exc:
        return False, str(exc)


def list_canonical_references(
    config: MetagitConfig,
    workspace_path: Path,
    dedupe: WorkspaceDedupeConfig,
) -> dict[str, list[tuple[str, str]]]:
    """
    Map repo_key -> list of (project_name, repo_name) manifest entries referencing it.
    """
    references: dict[str, list[tuple[str, str]]] = {}
    if not config.workspace:
        return references
    for project in config.workspace.projects:
        for repo in project.repos:
            identity = build_repo_identity(repo)
            if identity is None:
                continue
            references.setdefault(identity.repo_key, []).append(
                (project.name, repo.name)
            )
    return references


def list_orphan_canonical_dirs(
    workspace_path: Path,
    dedupe: WorkspaceDedupeConfig,
    references: dict[str, list[tuple[str, str]]],
) -> list[Path]:
    """Canonical directories with no manifest reference (by repo_key)."""
    root = workspace_path / dedupe.canonical_dir
    if not root.is_dir():
        return []
    referenced = set(references.keys())
    orphans: list[Path] = []
    for entry in root.iterdir():
        if entry.is_dir() and entry.name not in referenced:
            orphans.append(entry.resolve())
    return sorted(orphans, key=lambda item: item.name)


def _branch_suffix(repo: ProjectPath) -> str:
    if repo.ref:
        return f"--ref-{_slugify(str(repo.ref), digest_prefix='ref')}"
    if repo.branches:
        joined = "-".join(sorted(str(branch) for branch in repo.branches))
        return f"--branches-{_slugify(joined, digest_prefix='br')}"
    return ""


def _slugify(value: str, *, digest_prefix: str) -> str:
    compact = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip().lower()).strip("-")
    if len(compact) <= 80 and compact:
        return compact
    digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
    short = compact[:40].strip("-") if compact else digest_prefix
    return f"{short}-{digest}"
`````

## File: src/metagit/data/init-templates/application/.metagit.yml.tpl
`````
name: {{ name }}
description: |
  {{ description }}
kind: application
url: {{ url }}
`````

## File: src/metagit/data/init-templates/application/template.yaml
`````yaml
id: application
label: Application (single repo)
description: Minimal .metagit.yml for one application repository.
kind: application
prompts:
  - name: name
    label: Project name
    default_from: directory_name
  - name: description
    label: Short description
    default: Application project managed by metagit.
  - name: url
    label: Git remote URL (optional)
    default_from: git_remote_url
    required: false
files:
  - template: .metagit.yml.tpl
    output: .metagit.yml
`````

## File: src/metagit/data/init-templates/hermes-orchestrator/.metagit.yml.tpl
`````
name: {{ name }}
description: |
  {{ description }}
kind: umbrella
url: {{ url }}
agent_instructions: |
  You are the Hermes controller for this workspace: the DevOps and project-management
  entrypoint for the operator. You do not improvise workspace layout.

  Session start (every time):
  1. metagit_workspace_status — confirm gate is active and note workspace root.
  2. metagit_workspace_health_check — surface missing clones, broken mounts, duplicate URLs.
  3. Read metagit://workspace/config when you need the full manifest.

  New or continued work:
  - Search before create: metagit_repo_search / `metagit search` for names, URLs, tags.
  - Reuse existing workspace.projects[] and repos[] entries; never clone into ad-hoc folders.
  - Register changes in .metagit.yml (catalog tools or validated YAML), then metagit config validate.
  - metagit_project_context_switch to focus one project; pass effective_agent_instructions to subagents.
  - Stay controller for cross-project objectives; delegate single-repo implementation to subagents.
  - metagit_workspace_sync: fetch by default; pull/clone only with explicit operator approval.
  - metagit_session_update before switching projects or ending the session.

  Documentation duty:
  - Every repo or path must have a clear description in this manifest.
  - Record build, deploy, and publish steps in per-repo agent_instructions when non-obvious.
  - When paths, URLs, or ownership change, update .metagit.yml before claiming work is done.

workspace:
  description: |
    Portfolio registry: git-backed services plus local-only publish paths. Metagit is the
    source of truth for what exists on disk under the configured workspace.path.
  agent_instructions: |
    Validate after manifest edits (`metagit config validate`). Prefer metagit catalog and
    MCP tools over hand-editing repo lists without validation. Enable workspace.dedupe in
    app config when the same remote URL appears in multiple projects.
  projects:
    - name: portfolio
      description: Git-backed applications and shared services under active development.
      agent_instructions: |
        Git operations are allowed per repo policy. Launch subagents with repo-scoped
        effective_agent_instructions for implementation; you coordinate sequencing and PRs.
      repos:
        - name: {{ portfolio_repo_name }}
          description: Sample HTTP API service (replace with your repository).
          url: {{ portfolio_repo_url }}
          sync: true
          kind: service
          tags:
            tier: application
          agent_instructions: |
            Run unit tests and lint before opening PRs. Note breaking API changes in PR text.

    - name: local
      description: |
        Local-only paths (no git remotes). Used to build and publish static web apps and
        personal sites for the operator.
      agent_instructions: |
        Repos here use `path`, not `url`. Do not git clone or pull. Sync creates symlinks
        into the workspace. Document publish targets in each repo's agent_instructions.
      repos:
        - name: {{ local_site_name }}
          description: Sample static site published from a folder on disk.
          path: {{ local_site_path }}
          sync: true
          kind: website
          tags:
            publish: static
          agent_instructions: |
            Build: npm run build (or project README). Publish: copy dist/ to the host named
            in README. Update `path` in .metagit.yml if the site directory moves.

    - name: platform
      description: Optional shared infrastructure (Terraform, modules, policy). See docs/hermes-iac-workspace-guide.md.
      agent_instructions: |
        Treat IaC changes as high blast-radius. Use metagit_cross_project_dependencies and
        GitNexus before module version bumps. Subagents run plan-only unless operator approves apply.
      repos: []
`````

## File: src/metagit/data/init-templates/hermes-orchestrator/AGENTS.md.tpl
`````
# Hermes orchestrator coordinator

This repository was initialized with the `hermes-orchestrator` metagit template.

## Controller role

You are the DevOps and project-management entrypoint for workspace **{{ name }}**.
Read root and layered `agent_instructions` in `.metagit.yml` before changing workspace layout.

## Session checklist

1. `metagit_workspace_status` and `metagit_workspace_health_check`
2. Search before create (`metagit search` / `metagit_repo_search`)
3. `metagit config validate` after manifest edits
4. `metagit_project_context_switch` when focusing a project
5. Delegate single-repo work to subagents with `effective_agent_instructions`
6. `metagit_session_update` on handoff

## Docs

- [Hermes orchestrator workspace](https://metagit-ai.github.io/metagit-cli/hermes-orchestrator-workspace/)
- [Hermes & org IaC guide](https://metagit-ai.github.io/metagit-cli/hermes-iac-workspace-guide/)
`````

## File: src/metagit/data/init-templates/hermes-orchestrator/template.yaml
`````yaml
id: hermes-orchestrator
label: Hermes orchestrator
description: |
  DevOps / project-management controller workspace with portfolio, local path,
  and platform projects plus AGENTS.md for the coordinator repo.
kind: umbrella
prompts:
  - name: name
    label: Workspace / manifest name
    default: hermes-control-plane
  - name: description
    label: Manifest description
    default: Umbrella workspace for a Hermes controller agent.
  - name: url
    label: Coordinator git URL (optional)
    default_from: git_remote_url
    required: false
  - name: portfolio_repo_name
    label: Example portfolio repo entry name
    default: example-api
  - name: portfolio_repo_url
    label: Example portfolio git URL
    default: https://github.com/example-org/example-api.git
  - name: local_site_name
    label: Local site entry name (non-git)
    default: example-site
  - name: local_site_path
    label: Local site filesystem path
    default: ~/Sites/example-site
files:
  - template: .metagit.yml.tpl
    output: .metagit.yml
  - template: AGENTS.md.tpl
    output: AGENTS.md
`````

## File: src/metagit/data/init-templates/umbrella/.metagit.yml.tpl
`````
name: {{ name }}
description: |
  {{ description }}
kind: umbrella
url: {{ url }}
workspace:
  description: Workspace projects synced under the configured workspace.path.
  projects:
    - name: default
      description: Default project group for managed repositories.
      repos: []
`````

## File: src/metagit/data/init-templates/umbrella/template.yaml
`````yaml
id: umbrella
label: Umbrella workspace
description: Coordinator manifest with an empty default workspace project.
kind: umbrella
prompts:
  - name: name
    label: Workspace / manifest name
    default_from: directory_name
  - name: description
    label: Short description
    default: Umbrella workspace coordinating multiple repositories.
  - name: url
    label: Coordinator git URL (optional)
    default_from: git_remote_url
    required: false
files:
  - template: .metagit.yml.tpl
    output: .metagit.yml
`````

## File: src/metagit/data/prompts/gemini_prompt_example.md
`````markdown
## Prompt 1

Create a yaml file named .metagit.example.yml in the root of this project that adheres to the jsonschema file
./schemas/metagit_config.schema.json that is based on the folders and files within this project. Be certain to adhere to
.gitignore when processing files. Only actually read in the file contents for any discovered CICD files, docker image files,
and other files that may have external dependency references. Do your best to infer directory purpose without reading in
everything. For example, tests with several dozen .py files would be unit tests and not valuable. Intelligently trace for
important files and project structure by using the build_files.important list found in ./src/metagit/data/build-files.yaml
as a compass. The end goal will be to create the file as instructed so that it accurately represents the languages, used
frameworks, dependencies, and other project data.

╭──────────────────────────────────╮
│                                  │
│  Agent powering down. Goodbye!   │
│                                  │
│                                  │
│  Cumulative Stats (1 Turns)      │
│                                  │
│  Input Tokens           134,416  │
│  Output Tokens            1,607  │
│  Thoughts Tokens          2,186  │
│  ──────────────────────────────  │
│  Total Tokens           138,209  │
│                                  │
│  Total duration (API)     48.1s  │
│  Total duration (wall)  16m 57s  │
│                                  │
╰──────────────────────────────────╯

## Prompt 2

Perform a comprehensive analysis of the files in this project. Update the yaml file named .metagit.example.yml in the root of this project in a manner that adheres to the jsonschema file located at ./schemas/metagit_config.schema.json. Ensure you skip files matching the patterns in .gitignore. Do your best to update even optional elements of this schema. Pay special attention to dependencies found in Dockerfiles, CICD files, and mcp definitions. When complete also create a project.metagit.md file with additional documentation on the project components that includes a mermaid diagram of how they interact.

╭─────────────────────────────────╮
│                                 │
│  Agent powering down. Goodbye!  │
│                                 │
│                                 │
│  Cumulative Stats (1 Turns)     │
│                                 │
│  Input Tokens          134,880  │
│  Output Tokens           2,037  │
│  Thoughts Tokens         1,790  │
│  ─────────────────────────────  │
│  Total Tokens          138,707  │
│                                 │
│  Total duration (API)    49.4s  │
│  Total duration (wall)  2m 26s  │
│                                 │
╰─────────────────────────────────╯

## Prompt 3

You are an expert devops and software engineer. Intelligently explore this project for its use of secrets and variables paying special attention to only those affecting the runtime behavior of the code and the cicd workflows used to release the project's artifacts. Create a report in ./docs/secrets.analysis.md on each of the secrets found and were they are sourced from. Using this data create a yaml file at ./docs/secrets.definitions.yml that represents the secrets, their provenance, and where they should be placed.

## Prompt 4 - Simplified Project Docs

Perform a comprehensive analysis of the files in this project. Update the yaml file named .metagit.example.yml in the root of this project in a manner that adheres to the jsonschema file located at ./schemas/metagit_config.schema.json. Ensure you skip files matching the patterns in .gitignore. Do your best to update even optional elements of this schema. Pay special attention to dependencies found in Dockerfiles, CICD files, and mcp definitions. When complete also create ./docs/app.logic.md with additional documentation on the project components that includes a mermaid diagram of how they interact.
`````

## File: src/metagit/data/skills/metagit-bootstrap/scripts/bootstrap-config.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
FORCE="${2:-false}"
TARGET="$ROOT/.metagit.yml"

uv run python - "$ROOT" "$TARGET" "$FORCE" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import create_metagit_config
from metagit.core.config.manager import MetagitConfigManager

root = Path(sys.argv[1]).resolve()
target = Path(sys.argv[2]).resolve()
force = sys.argv[3].lower() in {"1", "true", "yes", "force"}

if target.exists() and not force:
    mgr = MetagitConfigManager(config_path=target)
    result = mgr.load_config()
    state = "valid" if not isinstance(result, Exception) else "invalid"
    print(f"status=exists\tvalidity={state}\tpath={target}")
    raise SystemExit(0)

yaml_out = create_metagit_config(name=root.name, kind="application", as_yaml=True)
if isinstance(yaml_out, Exception):
    print(f"status=error\tmessage={yaml_out}")
    raise SystemExit(1)

target.write_text(yaml_out, encoding="utf-8")
mgr = MetagitConfigManager(config_path=target)
result = mgr.load_config()
state = "valid" if not isinstance(result, Exception) else "invalid"
print(f"status=written\tvalidity={state}\tpath={target}")
PY
`````

## File: src/metagit/data/skills/metagit-bootstrap/SKILL.md
`````markdown
---
name: metagit-bootstrap
description: Use when generating or refining local .metagit.yml files using deterministic discovery plus MCP sampling.
---

# Metagit MCP Bootstrap Skill

Use this skill to create a local `.metagit.yml` using discovery-driven prompts and MCP sampling.

## Purpose

Generate schema-compliant `.metagit.yml` files with high contextual quality while preserving safety and explicit user control.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for local bootstrap tasks:
- `./scripts/bootstrap-config.zsh [root_path] [force]`

Behavior:
- Writes `.metagit.yml` when missing
- Validates via Metagit config models
- Returns a compact status line for agents

## Workflow

1. Gather deterministic discovery data from the target repository:
   - source language/framework indicators
   - package/lock/build files
   - Dockerfiles and CI workflows
   - terraform files and module usage
2. Build a strict prompt package:
   - output format contract: valid YAML only
   - required schema fields and constraints
   - extracted discovery evidence
3. If sampling is supported, call `sampling/createMessage`.
4. Validate generated YAML with Metagit config models.
5. Retry with validation feedback up to a fixed max attempt count.
6. Return draft output and write only on explicit confirmation.

## Output Modes

- **Plan-only mode**: return prompt + discovery summary if sampling unavailable.
- **Draft mode**: return `.metagit.generated.yml` content.
- **Confirmed write mode**: write to `.metagit.yml` only with explicit parameter (`confirm_write=true`).

## Quality Bar

- Preserve discovered evidence in structured fields.
- Include workspace project and related repo entries where detectable.
- Avoid invented repositories or unverifiable dependencies.

## Safety Rules

- Never overwrite `.metagit.yml` silently.
- Never emit secrets in cleartext.
- Prefer placeholders for credentials or tokens.
`````

## File: src/metagit/data/skills/metagit-config-refresh/SKILL.md
`````markdown
---
name: metagit-config-refresh
description: Refresh or bootstrap `.metagit.yml` using deterministic discovery and validation flows. Use when configuration is missing, stale, or incomplete for workspace operations.
---

# Refreshing Project Config

Use this skill to keep `.metagit.yml` accurate and operational.

## Workflow

1. Check workspace activation and config validity.
2. Run bootstrap plan mode first.
3. Review generated changes against expected workspace topology.
4. Apply config updates and validate schema before continuing.

## Command Wrapper

- `zsh ./skills/metagit-bootstrap/scripts/bootstrap-config.zsh [root_path] [mode] [seed_context]`

## Output Contract

Return:
- config health state before/after
- generated update summary
- any manual follow-up needed

## Safety

- Prefer plan/dry-run first for large config updates.
- Keep changes bounded to target workspace intent.
`````

## File: src/metagit/data/skills/metagit-gating/scripts/gate-status.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"

uv run python - "$ROOT" <<'PY'
import os
import sys
from metagit.core.mcp.gate import WorkspaceGate
from metagit.core.mcp.root_resolver import WorkspaceRootResolver
from metagit.core.mcp.tool_registry import ToolRegistry

root = sys.argv[1]
resolver = WorkspaceRootResolver()
gate = WorkspaceGate()
registry = ToolRegistry()

resolved = resolver.resolve(cwd=os.path.abspath(root), cli_root=root)
status = gate.evaluate(root_path=resolved)
tools = registry.list_tools(status)
print(f"state={status.state.value}\troot={status.root_path or 'none'}\ttools={len(tools)}")
PY
`````

## File: src/metagit/data/skills/metagit-gating/SKILL.md
`````markdown
---
name: metagit-gating
description: Use when implementing or operating Metagit MCP server activation and tool exposure rules based on .metagit.yml presence and validity.
---

# Metagit MCP Gating Skill

Use this skill whenever you need to control whether Metagit MCP tools/resources are exposed.

## Purpose

Ensure high-risk tooling and multi-repo context are only available when a valid `.metagit.yml` exists at the resolved workspace root.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for all gating checks:
- `./scripts/gate-status.zsh [root_path]`

Expected output (single line, tab-delimited):
- `state=<value>\troot=<path|none>\ttools=<count>`

## Activation Workflow

1. Resolve workspace root:
   - `METAGIT_WORKSPACE_ROOT`
   - CLI `--root`
   - upward directory walk
2. Check for `.metagit.yml` in resolved root.
3. Validate config through existing Metagit config models.
4. Derive activation state: missing, invalid, or active.
5. Register tool surface based on state.

## Tool Exposure Contract

### Inactive (missing or invalid config)
Expose only:
- `metagit_workspace_status`
- `metagit_bootstrap_config_plan_only`

### Active (valid config)
Expose full set:
- `metagit_workspace_status`
- `metagit_workspace_index`
- `metagit_workspace_search`
- `metagit_upstream_hints`
- `metagit_repo_inspect`
- `metagit_repo_sync`
- `metagit_bootstrap_config`

## Error Handling

- Return explicit, machine-readable state and reason.
- Avoid stack traces in user-facing outputs.
- Log parser/validation errors with enough detail for debugging.

## Safety Rules

- Never expose mutation-capable tools in inactive state.
- Never operate outside validated workspace boundaries.
- Keep defaults read-only unless user/agent explicitly opts in.
`````

## File: src/metagit/data/skills/metagit-gitnexus/scripts/analyze-targets.zsh
`````zsh
#!/usr/bin/env zsh

set -euo pipefail

workspace_root="${1:-.}"
project_name="${2:-default}"

if [[ ! -f ".metagit.yml" ]]; then
  echo "ERROR: .metagit.yml not found in current directory"
  exit 2
fi

echo "analyze repo=$(pwd)"
npx gitnexus analyze

tmp_output="$(mktemp)"
uv run python - "$workspace_root" "$project_name" <<'PY' > "$tmp_output"
import sys
from pathlib import Path
import yaml

workspace_root = Path(sys.argv[1]).expanduser().resolve()
project_name = sys.argv[2]
cfg = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8")) or {}
workspace = (cfg.get("workspace") or {})
projects = workspace.get("projects") or []
target = next((p for p in projects if p.get("name") == project_name), None)
if not target:
    print(f"warn project_not_found={project_name}")
    raise SystemExit(0)

for repo in target.get("repos") or []:
    name = repo.get("name")
    if not name:
        continue
    repo_path = workspace_root / project_name / name
    if repo_path.exists() and repo_path.is_dir():
        print(f"repo_path={repo_path}")
    else:
        print(f"skip_missing={repo_path}")
PY

while IFS= read -r line; do
  case "$line" in
    repo_path=*)
      path="${line#repo_path=}"
      echo "analyze repo=${path}"
      (cd "$path" && npx gitnexus analyze) || echo "fail repo=${path}"
      ;;
    *)
      echo "$line"
      ;;
  esac
done < "$tmp_output"

rm -f "$tmp_output"
`````

## File: src/metagit/data/skills/metagit-multi-repo/SKILL.md
`````markdown
---
name: metagit-multi-repo
description: Coordinate implementation tasks across multiple repositories using metagit status, search, and scoped sync workflows. Use when one objective spans several repositories.
---

# Coordinating Multi-Repo Implementation

Use this skill for cross-repository feature or fix delivery.

## Workflow

1. Define objective and affected repositories.
2. Verify workspace scope and dependency hints.
3. Sequence work by dependency order.
4. Sync only required repositories.
5. Track progress and blockers per repository.

## Command Wrapper

- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- objective-to-repository map
- execution order
- current blocker + next step

## Safety

- Keep scope bounded to configured workspace repositories.
- Prefer deterministic evidence for cross-repo assumptions.
`````

## File: src/metagit/data/skills/metagit-upstream-scan/scripts/upstream-scan.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-}"
MAX_RESULTS="${4:-20}"

if [[ -z "$QUERY" ]]; then
  echo "status=error\tmessage=query-required"
  exit 1
fi

uv run python - "$ROOT" "$QUERY" "$PRESET" "$MAX_RESULTS" <<'PY'
import sys
from pathlib import Path
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.mcp.services.workspace_search import WorkspaceSearchService
from metagit.core.mcp.services.upstream_hints import UpstreamHintService

root = Path(sys.argv[1]).resolve()
query = sys.argv[2]
preset = sys.argv[3] or None
max_results = int(sys.argv[4])

manager = MetagitConfigManager(config_path=root / ".metagit.yml")
cfg = manager.load_config()
if isinstance(cfg, Exception):
    print(f"status=error\tmessage=config-invalid\tdetail={cfg}")
    raise SystemExit(1)

index = WorkspaceIndexService().build_index(config=cfg, workspace_root=str(root))
repo_paths = [row["repo_path"] for row in index if row.get("exists")]
search_hits = WorkspaceSearchService().search(query=query, repo_paths=repo_paths, preset=preset, max_results=max_results)
ranked = UpstreamHintService().rank(blocker=query, repo_context=index)[:5]

print(f"status=ok\trepos={len(index)}\thits={len(search_hits)}")
for row in ranked:
    print(f"hint\trepo={row['repo_name']}\tscore={row['score']}")
for hit in search_hits[:5]:
    print(f"hit\tfile={hit['file_path']}\tline={hit['line_number']}")
PY
`````

## File: src/metagit/data/skills/metagit-upstream-scan/SKILL.md
`````markdown
---
name: metagit-upstream-scan
description: Use when a coding agent encounters likely upstream blockers and must find related workspace repositories, files, and probable root causes.
---

# Metagit Upstream Discovery Skill

Use this skill when the current repo does not appear to contain the full fix and related repositories may hold the source issue.

## Supported Use Cases

- Missing Terraform input in a shared module
- Docker base image/version mismatch across repos
- Shared infrastructure definitions causing local failures
- CI pipeline breakages tied to upstream templates/workflows

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for upstream discovery tasks:
- `./scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

Output format:
- compact status line
- top ranked repo hints (`hint`)
- top search file hits (`hit`)

## Workflow

1. Read workspace repository map from active `.metagit.yml`.
2. Run `metagit_workspace_index` to verify repo availability and sync state.
3. Use `metagit_workspace_search` with category preset (`terraform`, `docker`, `infra`, `ci`).
4. Use `metagit_upstream_hints` to rank candidate repositories and files.
5. Return a concise action plan:
   - top candidate repos
   - likely files/definitions
   - whether sync is needed before deeper analysis

## Search Strategy

- Start narrow with issue-specific terms (error, module, variable, image tag).
- Expand to broader shared terms if no hits.
- Prefer repositories referenced by workspace metadata before searching unknown repos.

## Output Contract

Return:
- ranked candidates with rationale
- suggested next file openings
- confidence level and unresolved assumptions

## Safety Rules

- Restrict search to configured workspace repositories.
- Cap result size and duration.
- Keep this workflow read-only unless an explicit sync action is requested.
`````

## File: src/metagit/data/skills/metagit-upstream-triage/SKILL.md
`````markdown
---
name: metagit-upstream-triage
description: Triage cross-repository blockers by ranking likely upstream repositories and files with metagit search and hinting tools. Use when local fixes appear incomplete.
---

# Triaging Upstream Blockers

Use this skill for failures likely rooted in another repository.

## Workflow

1. Run workspace index and search with issue-specific terms.
2. Run upstream hint ranking to prioritize repositories/files.
3. Open the top candidates and validate root-cause evidence.
4. Return a short fix path (repo, file, next action).

## Command Wrapper

- `zsh ./skills/metagit-upstream-scan/scripts/upstream-scan.zsh [root_path] "<query>" [preset] [max_results]`

## Output Contract

Return:
- ranked candidate repositories
- probable root-cause files
- confidence and assumptions

## Safety

- Keep this flow read-only unless sync is explicitly requested.
`````

## File: src/metagit/data/templates/agent-standard/AGENTS.md.fragment
`````
# Agent workspace conventions

- Read `.metagit.yml` before making cross-repository changes.
- Use `metagit_project_context_switch` to scope work to one workspace project.
- Run `metagit_workspace_health_check` when the workspace feels out of date.
`````

## File: src/metagit/data/templates/hermes-orchestrator/AGENTS.md.fragment
`````
# Hermes orchestrator (workspace coordinator)

This directory received the `hermes-orchestrator` workspace template.

1. Copy or merge `examples/hermes-orchestrator/.metagit.yml` into your umbrella coordinator repository.
2. Run `metagit config validate` on the manifest.
3. Register real repos with `metagit project repo add` or MCP catalog tools.
4. Point Hermes MCP at the coordinator root: `metagit mcp serve --root <path>`.

Controller agents should follow root `agent_instructions` in `.metagit.yml`, not this file alone.
`````

## File: src/metagit/data/templates/hermes-orchestrator/README.md
`````markdown
# hermes-orchestrator template

Files here are copied into workspace project directories via `metagit_project_template_apply`
(template id: `hermes-orchestrator`).

The umbrella manifest example lives at `examples/hermes-orchestrator/.metagit.yml` in the
metagit-cli repository. See `docs/hermes-orchestrator-workspace.md` for the full guide.
`````

## File: src/metagit/data/web/assets/index-B315j_NF.css
`````css
:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--font-sans:system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--font-mono:ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;--radius-sm:.25rem;--radius-md:.5rem;--radius-lg:.75rem;--shadow-sm:0 1px 2px #0f172a0f;--shadow-md:0 4px 12px #0f172a14;--transition-theme:color .2s ease, background-color .2s ease, border-color .2s ease, box-shadow .2s ease}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root,[data-theme=light]{--color-bg:#f6f7fb;--color-bg-elevated:#fff;--color-bg-muted:#eef1f6;--color-border:#d8dee9;--color-border-strong:#c5cedb;--color-text:#0f172a;--color-text-muted:#5b6475;--color-text-subtle:#7c8798;--color-accent:#4f46e5;--color-accent-hover:#4338ca;--color-accent-soft:#4f46e51f;--color-danger:#dc2626;--color-danger-soft:#dc26261a;--color-success:#059669;--color-focus:#4f46e559;--color-tree-hover:#4f46e514;--color-tree-selected:#4f46e529;--graph-edge-manual:#7c3aed;--graph-edge-inferred:#0ea5e9;--graph-edge-structure:#94a3b8}[data-theme=dark]{--color-bg:#0f1117;--color-bg-elevated:#171a22;--color-bg-muted:#1f2430;--color-border:#2a3140;--color-border-strong:#3a4356;--color-text:#ffffffeb;--color-text-muted:#ffffffa6;--color-text-subtle:#ffffff73;--color-accent:#818cf8;--color-accent-hover:#a5b4fc;--color-accent-soft:#818cf82e;--color-danger:#f87171;--color-danger-soft:#f8717126;--color-success:#34d399;--color-focus:#818cf866;--color-tree-hover:#818cf81f;--color-tree-selected:#818cf838;--graph-edge-manual:#a78bfa;--graph-edge-inferred:#38bdf8;--graph-edge-structure:#64748b}@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--color-bg:#0f1117;--color-bg-elevated:#171a22;--color-bg-muted:#1f2430;--color-border:#2a3140;--color-border-strong:#3a4356;--color-text:#ffffffeb;--color-text-muted:#ffffffa6;--color-text-subtle:#ffffff73;--color-accent:#818cf8;--color-accent-hover:#a5b4fc;--color-accent-soft:#818cf82e;--color-danger:#f87171;--color-danger-soft:#f8717126;--color-success:#34d399;--color-focus:#818cf866;--color-tree-hover:#818cf81f;--color-tree-selected:#818cf838;--graph-edge-manual:#a78bfa;--graph-edge-inferred:#38bdf8;--graph-edge-structure:#64748b}}*{box-sizing:border-box}html{transition:var(--transition-theme)}body{min-width:320px;min-height:100vh;font-family:var(--font-sans);color:var(--color-text);background-color:var(--color-bg);font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;transition:var(--transition-theme);margin:0;font-weight:400;line-height:1.5}#root{min-height:100vh}button,input,select,textarea{font:inherit}._shell_1gsdz_1{min-height:100vh;transition:var(--transition-theme);flex-direction:column;display:flex}._header_1gsdz_8{border-bottom:1px solid var(--color-border);background:var(--color-bg-elevated);transition:var(--transition-theme);flex-wrap:wrap;align-items:center;gap:1rem 1.5rem;padding:1rem 1.5rem;display:flex}._title_1gsdz_19{color:var(--color-text);margin:0;font-size:1.25rem;font-weight:600}._nav_1gsdz_26{flex-wrap:wrap;flex:1;gap:.5rem;display:flex}._navLink_1gsdz_33{color:var(--color-text-muted);border-radius:var(--radius-md);transition:var(--transition-theme);padding:.35rem .65rem;text-decoration:none}._navLink_1gsdz_33:hover{color:var(--color-text);background:var(--color-tree-hover)}._navLinkActive_1gsdz_46{color:var(--color-text);background:var(--color-accent-soft)}._themeToggle_1gsdz_51{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-muted);width:2.25rem;height:2.25rem;color:var(--color-text);cursor:pointer;transition:var(--transition-theme);justify-content:center;align-items:center;margin-left:auto;display:inline-flex}._themeToggle_1gsdz_51:hover{border-color:var(--color-border-strong);background:var(--color-tree-hover)}._themeIcon_1gsdz_71{font-size:1.1rem;line-height:1}._main_1gsdz_76{flex:1;width:100%;max-width:80rem;margin:0 auto;padding:1.5rem}._previewPanel_tee41_1{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);min-height:12rem;max-height:calc(100vh - 12rem);transition:var(--transition-theme);flex-direction:column;gap:.75rem;padding:1rem;display:flex}._header_tee41_15{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem 1rem;display:flex}._title_tee41_23{text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-subtle);margin:0;font-size:.8rem;font-weight:600}._controls_tee41_32{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}._select_tee41_39{color:var(--color-text);background:var(--color-bg);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:.35rem .5rem;font-size:.85rem}._badge_tee41_48{text-transform:uppercase;letter-spacing:.04em;border-radius:var(--radius-sm);background:var(--color-accent-muted);color:var(--color-accent);padding:.15rem .45rem;font-size:.7rem;font-weight:600}._badgeInvalid_tee41_59{background:color-mix(in srgb, var(--color-danger) 18%, transparent);color:var(--color-danger)}._codeWrap_tee41_64{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-code,var(--color-bg));flex:1;min-height:0;overflow:auto}._code_tee41_64{font-family:var(--font-mono);white-space:pre;color:var(--color-text);margin:0;padding:.85rem 1rem;font-size:.78rem;line-height:1.45}._state_tee41_83{color:var(--color-text-muted);margin:0;font-size:.9rem}._errors_tee41_89{color:var(--color-danger);margin:0;padding-left:1.1rem;font-size:.85rem}._panel_4l6gj_1{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);transition:var(--transition-theme);flex-direction:column;gap:1rem;padding:1.25rem;display:flex}._empty_4l6gj_13{color:var(--color-text-muted);margin:0}._header_4l6gj_18{flex-direction:column;gap:.35rem;display:flex}._title_4l6gj_24{margin:0;font-size:1.1rem;font-weight:600}._path_4l6gj_30{font-family:var(--font-mono);color:var(--color-text-subtle);word-break:break-all;margin:0;font-size:.8rem}._meta_4l6gj_38{color:var(--color-text-muted);flex-wrap:wrap;gap:.5rem;font-size:.8rem;display:flex}._badge_4l6gj_46{border-radius:var(--radius-sm);background:var(--color-bg-muted);border:1px solid var(--color-border);font-family:var(--font-mono);padding:.15rem .45rem}._description_4l6gj_54{color:var(--color-text-muted);margin:0;font-size:.9rem;line-height:1.5}._hint_4l6gj_61{background:var(--color-bg-muted);border-radius:var(--radius-md);color:var(--color-text-muted);margin:0;padding:.75rem 1rem;font-size:.9rem}._field_4l6gj_70{flex-direction:column;gap:.35rem;display:flex}._label_4l6gj_76{color:var(--color-text-muted);font-size:.85rem;font-weight:500}._input_4l6gj_82,._select_4l6gj_83,._textarea_4l6gj_84{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg);width:100%;color:var(--color-text);font:inherit;transition:var(--transition-theme);padding:.55rem .75rem}._input_4l6gj_82:focus,._select_4l6gj_83:focus,._textarea_4l6gj_84:focus{border-color:var(--color-accent);box-shadow:0 0 0 3px var(--color-focus);outline:none}._input_4l6gj_82:disabled,._select_4l6gj_83:disabled{opacity:.6;cursor:not-allowed}._actions_4l6gj_109{flex-wrap:wrap;gap:.5rem;padding-top:.25rem;display:flex}._button_4l6gj_116{border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-bg-muted);color:var(--color-text);font:inherit;cursor:pointer;transition:var(--transition-theme);padding:.5rem 1rem;font-size:.875rem}._button_4l6gj_116:hover:not(:disabled){border-color:var(--color-border-strong)}._button_4l6gj_116:disabled{opacity:.55;cursor:not-allowed}._buttonPrimary_4l6gj_137{background:var(--color-accent);border-color:var(--color-accent);color:#fff}._buttonPrimary_4l6gj_137:hover:not(:disabled){background:var(--color-accent-hover);border-color:var(--color-accent-hover)}._errors_4l6gj_148{background:var(--color-danger-soft);border:1px solid var(--color-danger);border-radius:var(--radius-md);margin:0;padding:.75rem 1rem;list-style:none}._errors_4l6gj_148 li{color:var(--color-danger);margin:.25rem 0;font-size:.85rem}._errors_4l6gj_148 li:first-child{margin-top:0}._errors_4l6gj_148 li:last-child{margin-bottom:0}._status_4l6gj_171{color:var(--color-text-subtle);font-size:.8rem}._tree_1q1u5_1{margin:0;padding:0;list-style:none}._nested_1q1u5_7{border-left:1px solid var(--color-border);margin-left:1rem;padding-left:.5rem}._row_1q1u5_13{border-radius:var(--radius-sm);cursor:pointer;transition:var(--transition-theme);align-items:center;gap:.5rem;padding:.35rem .5rem;display:flex}._row_1q1u5_13:hover{background:var(--color-tree-hover)}._rowSelected_1q1u5_27{background:var(--color-tree-selected)}._rowDisabled_1q1u5_31{opacity:.5}._checkbox_1q1u5_35{width:1rem;height:1rem;accent-color:var(--color-accent);flex-shrink:0}._checkboxPlaceholder_1q1u5_42{flex-shrink:0;width:1rem}._label_1q1u5_47{flex:1;align-items:baseline;gap:.5rem;min-width:0;display:flex}._key_1q1u5_55{color:var(--color-text);font-weight:500}._type_1q1u5_60{color:var(--color-text-subtle);font-size:.75rem;font-family:var(--font-mono)}._required_1q1u5_66{color:var(--color-accent);text-transform:uppercase;letter-spacing:.04em;font-size:.65rem}._state_1q1u5_73{color:var(--color-text-muted);margin:1rem 0}._error_1q1u5_78{color:var(--color-danger)}._expandBtn_1q1u5_82{border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg-muted);width:1.5rem;height:1.5rem;color:var(--color-text-muted);cursor:pointer;flex-shrink:0;padding:0;font-size:.85rem;line-height:1}._expandBtn_1q1u5_82:hover{border-color:var(--color-border-strong);color:var(--color-text)}._listBtn_1q1u5_101{border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-bg-muted);width:1.5rem;height:1.5rem;color:var(--color-accent);cursor:pointer;flex-shrink:0;padding:0;font-size:.95rem;line-height:1}._listBtn_1q1u5_101:hover{border-color:var(--color-accent)}._listBtnDanger_1q1u5_119{color:var(--color-danger)}._listBtnDanger_1q1u5_119:hover{border-color:var(--color-danger)}._count_1q1u5_127{color:var(--color-text-subtle);font-size:.7rem}._page_2nlim_1{flex-direction:column;gap:1rem;display:flex}._header_2nlim_7{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem 1.5rem;display:flex}._title_2nlim_15{margin:0;font-size:1.5rem;font-weight:600}._subtitle_2nlim_21{color:var(--color-text-muted);font-size:.9rem;font-family:var(--font-mono);word-break:break-all;margin:.25rem 0 0}._layout_2nlim_29{grid-template-columns:minmax(14rem,.9fr) minmax(16rem,1fr) minmax(16rem,1fr);align-items:start;gap:1.25rem;display:grid}@media (width<=1200px){._layout_2nlim_29{grid-template-columns:minmax(14rem,1fr) minmax(16rem,1.1fr)}._layout_2nlim_29>:last-child{grid-column:1/-1}}@media (width<=900px){._layout_2nlim_29{grid-template-columns:1fr}._layout_2nlim_29>:last-child{grid-column:auto}}._treePanel_2nlim_56{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);max-height:calc(100vh - 12rem);transition:var(--transition-theme);padding:1rem;overflow:auto}._treeHeading_2nlim_67{text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-subtle);margin:0 0 .75rem;font-size:.8rem;font-weight:600}._wrap_6qkdh_1{flex-direction:column;gap:.75rem;display:flex}._legend_6qkdh_7{color:var(--color-text-muted);flex-wrap:wrap;gap:.75rem 1.25rem;font-size:.8rem;display:flex}._legendItem_6qkdh_15{align-items:center;gap:.4rem;display:inline-flex}._legendSwatch_6qkdh_21{border-radius:2px;width:1.25rem;height:.2rem}._canvasScroll_6qkdh_27{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-elevated);min-height:14rem;max-height:28rem;overflow:auto}._canvas_6qkdh_27{min-width:100%;height:auto;display:block}._nodeProject_6qkdh_42{fill:var(--color-accent-soft);stroke:var(--color-accent);stroke-width:1.5px}._nodeRepo_6qkdh_48{fill:var(--color-bg);stroke:var(--color-border-strong);stroke-width:1px}._nodeLabel_6qkdh_54{fill:var(--color-text);font-size:12px;font-family:var(--font-mono);pointer-events:none}._edgeLabel_6qkdh_61{fill:var(--color-text-muted);font-size:10px;font-family:var(--font-sans)}._empty_6qkdh_67{text-align:center;color:var(--color-text-muted);border:1px dashed var(--color-border);border-radius:var(--radius-md);margin:0;padding:1.5rem}._panel_uwnxw_1{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);transition:var(--transition-theme);flex-direction:column;gap:1rem;padding:1.25rem;display:flex}._heading_uwnxw_13{text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-subtle);margin:0;font-size:.8rem;font-weight:600}._section_uwnxw_22{flex-direction:column;gap:.65rem;display:flex}._sectionTitle_uwnxw_28{margin:0;font-size:.95rem;font-weight:600}._field_uwnxw_34{flex-direction:column;gap:.35rem;display:flex}._label_uwnxw_40{color:var(--color-text-muted);font-size:.85rem;font-weight:500}._select_uwnxw_46{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg);width:100%;color:var(--color-text);padding:.55rem .75rem}._button_uwnxw_55{border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-bg-muted);color:var(--color-text);cursor:pointer;transition:var(--transition-theme);padding:.5rem 1rem}._button_uwnxw_55:hover:not(:disabled){border-color:var(--color-border-strong)}._button_uwnxw_55:disabled{opacity:.55;cursor:not-allowed}._buttonPrimary_uwnxw_74{background:var(--color-accent);border-color:var(--color-accent);color:#fff}._buttonPrimary_uwnxw_74:hover:not(:disabled){background:var(--color-accent-hover);border-color:var(--color-accent-hover)}._buttonDanger_uwnxw_85{background:var(--color-danger);border-color:var(--color-danger);color:#fff}._buttonDanger_uwnxw_85:hover:not(:disabled){opacity:.9}._hint_uwnxw_95{color:var(--color-text-muted);margin:0;font-size:.8rem}._candidateList_uwnxw_101{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg);max-height:10rem;margin:0;padding:0;list-style:none;overflow:auto}._candidateList_uwnxw_101 li{border-bottom:1px solid var(--color-border);font-family:var(--font-mono);word-break:break-all;padding:.45rem .65rem;font-size:.75rem}._candidateList_uwnxw_101 li:last-child{border-bottom:none}._checkboxRow_uwnxw_124{color:var(--color-text-muted);align-items:flex-start;gap:.5rem;font-size:.85rem;display:flex}._status_uwnxw_132{color:var(--color-text-subtle);margin:0;font-size:.8rem}._statusError_uwnxw_138{color:var(--color-danger)}._divider_uwnxw_142{background:var(--color-border);height:1px;margin:.25rem 0}._overlay_uwnxw_148{z-index:100;background:#0f172a73;justify-content:center;align-items:center;padding:1rem;display:flex;position:fixed;inset:0}._modal_uwnxw_159{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);width:min(40rem,100%);max-height:calc(100vh - 2rem);box-shadow:var(--shadow-md);padding:1.25rem;overflow:auto}._modalTitle_uwnxw_170{margin:0 0 .75rem;font-size:1.15rem;font-weight:600}._summaryGrid_uwnxw_176{grid-template-columns:repeat(auto-fill,minmax(6rem,1fr));gap:.5rem;margin-bottom:1rem;display:grid}._summaryChip_uwnxw_183{border-radius:var(--radius-md);background:var(--color-bg-muted);padding:.5rem .65rem;font-size:.8rem}._summaryChip_uwnxw_183 strong{font-size:1rem;display:block}._recommendations_uwnxw_195{margin:0 0 1rem;padding:0;list-style:none}._recommendations_uwnxw_195 li{border-radius:var(--radius-md);background:var(--color-bg-muted);margin:.35rem 0;padding:.5rem .65rem;font-size:.85rem}._severityCritical_uwnxw_209{border-left:3px solid var(--color-danger)}._severityWarning_uwnxw_213{border-left:3px solid #d97706}._severityInfo_uwnxw_217{border-left:3px solid var(--color-accent)}._repoTable_uwnxw_221{border-collapse:collapse;width:100%;font-size:.8rem}._repoTable_uwnxw_221 th,._repoTable_uwnxw_221 td{text-align:left;border-bottom:1px solid var(--color-border);padding:.4rem .5rem}._repoTable_uwnxw_221 th{color:var(--color-text-subtle);text-transform:uppercase;font-size:.72rem}._modalActions_uwnxw_240{justify-content:flex-end;margin-top:1rem;display:flex}._tableWrap_1nm7q_1{border:1px solid var(--color-border);border-radius:var(--radius-lg);background:var(--color-bg-elevated);box-shadow:var(--shadow-sm);transition:var(--transition-theme);overflow:auto}._table_1nm7q_1{border-collapse:collapse;width:100%;font-size:.875rem}._table_1nm7q_1 th{text-align:left;text-transform:uppercase;letter-spacing:.05em;color:var(--color-text-subtle);background:var(--color-bg-muted);border-bottom:1px solid var(--color-border);padding:.65rem .85rem;font-size:.75rem;font-weight:600}._table_1nm7q_1 td{border-bottom:1px solid var(--color-border);vertical-align:middle;padding:.55rem .85rem}._projectRow_1nm7q_34 td{background:var(--color-bg-muted);border-bottom:1px solid var(--color-border-strong)}._projectHeader_1nm7q_39{flex-wrap:wrap;align-items:center;gap:.5rem 1rem;display:flex}._expandButton_1nm7q_46{color:var(--color-text);font:inherit;cursor:pointer;background:0 0;border:none;align-items:center;gap:.35rem;padding:0;font-weight:600;display:inline-flex}._expandButton_1nm7q_46:hover{color:var(--color-accent)}._projectMeta_1nm7q_63{color:var(--color-text-muted);font-size:.8rem;font-weight:400}._repoName_1nm7q_69{font-weight:500}._path_1nm7q_73{font-family:var(--font-mono);color:var(--color-text-muted);word-break:break-all;font-size:.78rem}._badge_1nm7q_80{border-radius:var(--radius-sm);text-transform:uppercase;letter-spacing:.04em;padding:.15rem .5rem;font-size:.75rem;font-weight:600;display:inline-block}._badgeSynced_1nm7q_90{color:var(--color-success);background:#0596691f}._badgeMissing_1nm7q_95{color:#d97706;background:#d9770624}[data-theme=dark] ._badgeMissing_1nm7q_95{color:#fbbf24}._actions_1nm7q_104{flex-wrap:wrap;gap:.35rem;display:flex}._button_1nm7q_110{border-radius:var(--radius-sm);border:1px solid var(--color-border);background:var(--color-bg);color:var(--color-text);cursor:pointer;transition:var(--transition-theme);padding:.35rem .65rem;font-size:.8rem}._button_1nm7q_110:hover{border-color:var(--color-border-strong);background:var(--color-bg-muted)}._buttonPrimary_1nm7q_126{background:var(--color-accent-soft);border-color:var(--color-accent);color:var(--color-accent)}._buttonPrimary_1nm7q_126:hover{background:var(--color-accent);color:#fff}._empty_1nm7q_137{text-align:center;color:var(--color-text-muted);margin:0;padding:2rem 1rem}._overlay_1lfb6_1{z-index:100;background:#0f172a73;justify-content:center;align-items:center;padding:1rem;display:flex;position:fixed;inset:0}._dialog_1lfb6_12{background:var(--color-bg-elevated);border:1px solid var(--color-border);border-radius:var(--radius-lg);width:min(32rem,100%);max-height:calc(100vh - 2rem);box-shadow:var(--shadow-md);transition:var(--transition-theme);padding:1.25rem;overflow:auto}._title_1lfb6_24{margin:0 0 .25rem;font-size:1.15rem;font-weight:600}._subtitle_1lfb6_30{color:var(--color-text-muted);margin:0 0 1rem;font-size:.875rem}._field_1lfb6_36{flex-direction:column;gap:.35rem;margin-bottom:.85rem;display:flex}._label_1lfb6_43{color:var(--color-text-muted);font-size:.85rem;font-weight:500}._select_1lfb6_49{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg);width:100%;color:var(--color-text);padding:.55rem .75rem}._checkboxRow_1lfb6_58{color:var(--color-text-muted);align-items:center;gap:.5rem;margin-bottom:1rem;font-size:.9rem;display:flex}._actions_1lfb6_67{flex-wrap:wrap;justify-content:flex-end;gap:.5rem;display:flex}._button_1lfb6_74{border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-bg-muted);color:var(--color-text);cursor:pointer;transition:var(--transition-theme);padding:.5rem 1rem}._button_1lfb6_74:hover:not(:disabled){border-color:var(--color-border-strong)}._button_1lfb6_74:disabled{opacity:.55;cursor:not-allowed}._buttonPrimary_1lfb6_93{background:var(--color-accent);border-color:var(--color-accent);color:#fff}._buttonPrimary_1lfb6_93:hover:not(:disabled){background:var(--color-accent-hover);border-color:var(--color-accent-hover)}._status_1lfb6_104{background:var(--color-bg-muted);border-radius:var(--radius-md);color:var(--color-text-muted);margin:0 0 1rem;padding:.75rem 1rem;font-size:.875rem}._statusError_1lfb6_113{background:var(--color-danger-soft);color:var(--color-danger)}._summary_1lfb6_118{color:var(--color-text-muted);margin:0 0 1rem;padding:0;font-size:.85rem;list-style:none}._summary_1lfb6_118 li{font-family:var(--font-mono);margin:.2rem 0}._page_uygcw_1{flex-direction:column;gap:1rem;display:flex}._header_uygcw_7{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem 1.5rem;display:flex}._title_uygcw_15{margin:0;font-size:1.5rem;font-weight:600}._subtitle_uygcw_21{color:var(--color-text-muted);font-size:.9rem;font-family:var(--font-mono);word-break:break-all;margin:.25rem 0 0}._chips_uygcw_29{flex-wrap:wrap;gap:.5rem;display:flex}._chip_uygcw_29{border-radius:var(--radius-md);background:var(--color-bg-elevated);border:1px solid var(--color-border);color:var(--color-text-muted);transition:var(--transition-theme);padding:.35rem .75rem;font-size:.85rem}._chip_uygcw_29 strong{color:var(--color-text);margin-right:.25rem}._toolbar_uygcw_50{flex-wrap:wrap;align-items:center;gap:.75rem 1rem;display:flex}._tabs_uygcw_57{flex-wrap:wrap;gap:.35rem;display:flex}._tab_uygcw_57{border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-bg-elevated);color:var(--color-text-muted);cursor:pointer;transition:var(--transition-theme);padding:.4rem .85rem;font-size:.85rem}._tab_uygcw_57:hover{border-color:var(--color-border-strong);color:var(--color-text)}._tabActive_uygcw_79{background:var(--color-accent-soft);border-color:var(--color-accent);color:var(--color-accent)}._search_uygcw_85{border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-elevated);min-width:12rem;max-width:22rem;color:var(--color-text);flex:1;padding:.5rem .75rem}._search_uygcw_85:focus{border-color:var(--color-accent);box-shadow:0 0 0 3px var(--color-focus);outline:none}._layout_uygcw_102{grid-template-columns:minmax(0,1fr) minmax(14rem,18rem);align-items:start;gap:1.25rem;display:grid}@media (width<=960px){._layout_uygcw_102{grid-template-columns:1fr}}._loading_uygcw_115,._error_uygcw_116{border-radius:var(--radius-md);margin:0;padding:1rem}._loading_uygcw_115{color:var(--color-text-muted);background:var(--color-bg-elevated);border:1px solid var(--color-border)}._error_uygcw_116{color:var(--color-danger);background:var(--color-danger-soft);border:1px solid var(--color-danger)}._graphFilters_uygcw_134{flex-wrap:wrap;align-items:center;gap:.75rem 1.25rem;display:flex}._checkLabel_uygcw_141{color:var(--color-text-muted);cursor:pointer;align-items:center;gap:.4rem;font-size:.85rem;display:inline-flex}._graphPanel_uygcw_150{min-width:0}
`````

## File: src/metagit/data/web/assets/index-DOullneW.js
`````javascript
var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function ee(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function te(e,t){return ee(e.type,t,e.props)}function ne(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function re(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ie=/\/+/g;function ae(e,t){return typeof e==`object`&&e&&e.key!=null?re(``+e.key):t.toString(36)}function oe(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function se(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,se(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+ae(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(ie,`$&/`)+`/`),se(o,r,i,``,function(e){return e})):o!=null&&(ne(o)&&(o=te(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ie,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u<e.length;u++)a=e[u],s=l+ae(a,u),c+=se(a,r,i,s,o);else if(u=m(e),typeof u==`function`)for(e=u.call(e),u=0;!(a=e.next()).done;)a=a.value,s=l+ae(a,u++),c+=se(a,r,i,s,o);else if(s===`object`){if(typeof e.then==`function`)return se(oe(e),r,i,a,o);throw r=String(e),Error(`Objects are not valid as a React child (found: `+(r===`[object Object]`?`object with keys {`+Object.keys(e).join(`, `)+`}`:r)+`). If you meant to render a collection of children, use an array instead.`)}return c}function ce(e,t,n){if(e==null)return e;var r=[],i=0;return se(e,r,``,``,function(e){return t.call(n,e,i++)}),r}function le(e){if(e._status===-1){var t=e._result;t=t(),t.then(function(t){(e._status===0||e._status===-1)&&(e._status=1,e._result=t)},function(t){(e._status===0||e._status===-1)&&(e._status=2,e._result=t)}),e._status===-1&&(e._status=0,e._result=t)}if(e._status===1)return e._result.default;throw e._result}var E=typeof reportError==`function`?reportError:function(e){if(typeof window==`object`&&typeof window.ErrorEvent==`function`){var t=new window.ErrorEvent(`error`,{bubbles:!0,cancelable:!0,message:typeof e==`object`&&e&&typeof e.message==`string`?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process==`object`&&typeof process.emit==`function`){process.emit(`uncaughtException`,e);return}console.error(e)},D={map:ce,forEach:function(e,t,n){ce(e,function(){t.apply(this,arguments)},n)},count:function(e){var t=0;return ce(e,function(){t++}),t},toArray:function(e){return ce(e,function(e){return e})||[]},only:function(e){if(!ne(e))throw Error(`React.Children.only expected to receive a single React element child.`);return e}};e.Activity=f,e.Children=D,e.Component=v,e.Fragment=r,e.Profiler=a,e.PureComponent=b,e.StrictMode=i,e.Suspense=l,e.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=w,e.__COMPILER_RUNTIME={__proto__:null,c:function(e){return w.H.useMemoCache(e)}},e.cache=function(e){return function(){return e.apply(null,arguments)}},e.cacheSignal=function(){return null},e.cloneElement=function(e,t,n){if(e==null)throw Error(`The argument must be a React element, but you passed `+e+`.`);var r=g({},e.props),i=e.key;if(t!=null)for(a in t.key!==void 0&&(i=``+t.key),t)!T.call(t,a)||a===`key`||a===`__self`||a===`__source`||a===`ref`&&t.ref===void 0||(r[a]=t[a]);var a=arguments.length-2;if(a===1)r.children=n;else if(1<a){for(var o=Array(a),s=0;s<a;s++)o[s]=arguments[s+2];r.children=o}return ee(e.type,i,r)},e.createContext=function(e){return e={$$typeof:s,_currentValue:e,_currentValue2:e,_threadCount:0,Provider:null,Consumer:null},e.Provider=e,e.Consumer={$$typeof:o,_context:e},e},e.createElement=function(e,t,n){var r,i={},a=null;if(t!=null)for(r in t.key!==void 0&&(a=``+t.key),t)T.call(t,r)&&r!==`key`&&r!==`__self`&&r!==`__source`&&(i[r]=t[r]);var o=arguments.length-2;if(o===1)i.children=n;else if(1<o){for(var s=Array(o),c=0;c<o;c++)s[c]=arguments[c+2];i.children=s}if(e&&e.defaultProps)for(r in o=e.defaultProps,o)i[r]===void 0&&(i[r]=o[r]);return ee(e,a,i)},e.createRef=function(){return{current:null}},e.forwardRef=function(e){return{$$typeof:c,render:e}},e.isValidElement=ne,e.lazy=function(e){return{$$typeof:d,_payload:{_status:-1,_result:e},_init:le}},e.memo=function(e,t){return{$$typeof:u,type:e,compare:t===void 0?null:t}},e.startTransition=function(e){var t=w.T,n={};w.T=n;try{var r=e(),i=w.S;i!==null&&i(n,r),typeof r==`object`&&r&&typeof r.then==`function`&&r.then(C,E)}catch(e){E(e)}finally{t!==null&&n.types!==null&&(t.types=n.types),w.T=t}},e.unstable_useCacheRefresh=function(){return w.H.useCacheRefresh()},e.use=function(e){return w.H.use(e)},e.useActionState=function(e,t,n){return w.H.useActionState(e,t,n)},e.useCallback=function(e,t){return w.H.useCallback(e,t)},e.useContext=function(e){return w.H.useContext(e)},e.useDebugValue=function(){},e.useDeferredValue=function(e,t){return w.H.useDeferredValue(e,t)},e.useEffect=function(e,t){return w.H.useEffect(e,t)},e.useEffectEvent=function(e){return w.H.useEffectEvent(e)},e.useId=function(){return w.H.useId()},e.useImperativeHandle=function(e,t,n){return w.H.useImperativeHandle(e,t,n)},e.useInsertionEffect=function(e,t){return w.H.useInsertionEffect(e,t)},e.useLayoutEffect=function(e,t){return w.H.useLayoutEffect(e,t)},e.useMemo=function(e,t){return w.H.useMemo(e,t)},e.useOptimistic=function(e,t){return w.H.useOptimistic(e,t)},e.useReducer=function(e,t,n){return w.H.useReducer(e,t,n)},e.useRef=function(e){return w.H.useRef(e)},e.useState=function(e){return w.H.useState(e)},e.useSyncExternalStore=function(e,t,n){return w.H.useSyncExternalStore(e,t,n)},e.useTransition=function(){return w.H.useTransition()},e.version=`19.2.6`})),u=o(((e,t)=>{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0<n;){var r=n-1>>>1,a=e[r];if(0<i(a,t))e[r]=t,e[n]=a,n=r;else break a}}function n(e){return e.length===0?null:e[0]}function r(e){if(e.length===0)return null;var t=e[0],n=e.pop();if(n!==t){e[0]=n;a:for(var r=0,a=e.length,o=a>>>1;r<o;){var s=2*(r+1)-1,c=e[s],l=s+1,u=e[l];if(0>i(c,n))l<a&&0>i(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(l<a&&0>i(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,ne());else{var t=n(l);t!==null&&ae(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function ee(){return g?!0:!(e.unstable_now()-T<w)}function te(){if(g=!1,S){var t=e.unstable_now();T=t;var i=!0;try{a:{m=!1,h&&(h=!1,v(C),C=-1),p=!0;var a=f;try{b:{for(b(t),d=n(c);d!==null&&!(d.expirationTime>t&&ee());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&ae(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?ne():S=!1}}}var ne;if(typeof y==`function`)ne=function(){y(te)};else if(typeof MessageChannel<`u`){var re=new MessageChannel,ie=re.port2;re.port1.onmessage=te,ne=function(){ie.postMessage(null)}}else ne=function(){_(te,0)};function ae(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125<e?console.error(`forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported`):w=0<e?Math.floor(1e3/e):5},e.unstable_getCurrentPriorityLevel=function(){return f},e.unstable_next=function(e){switch(f){case 1:case 2:case 3:var t=3;break;default:t=f}var n=f;f=t;try{return e()}finally{f=n}},e.unstable_requestPaint=function(){g=!0},e.unstable_runWithPriority=function(e,t){switch(e){case 1:case 2:case 3:case 4:case 5:break;default:e=3}var n=f;f=e;try{return t()}finally{f=n}},e.unstable_scheduleCallback=function(r,i,a){var o=e.unstable_now();switch(typeof a==`object`&&a?(a=a.delay,a=typeof a==`number`&&0<a?o+a:o):a=o,r){case 1:var s=-1;break;case 2:s=250;break;case 5:s=1073741823;break;case 4:s=1e4;break;default:s=5e3}return s=a+s,r={id:u++,callback:i,priorityLevel:r,startTime:a,expirationTime:s,sortIndex:-1},a>o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,ae(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,ne()))),r},e.unstable_shouldYield=ee,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u();function n(e){var t=`https://react.dev/errors/`+e;if(1<arguments.length){t+=`?args[]=`+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)t+=`&args[]=`+encodeURIComponent(arguments[n])}return`Minified React error #`+e+`; visit `+t+` for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`}function r(){}var i={d:{f:r,r:function(){throw Error(n(522))},D:r,C:r,L:r,m:r,X:r,S:r,M:r},p:0,findDOMNode:null},a=Symbol.for(`react.portal`);function o(e,t,n){var r=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:a,key:r==null?null:``+r,children:e,containerInfo:t,implementation:n}}var s=t.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function c(e,t){if(e===`font`)return``;if(typeof t==`string`)return t===`use-credentials`?t:``}e.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=i,e.createPortal=function(e,t){var r=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)throw Error(n(299));return o(e,t,null,r)},e.flushSync=function(e){var t=s.T,n=i.p;try{if(s.T=null,i.p=2,e)return e()}finally{s.T=t,i.p=n,i.d.f()}},e.preconnect=function(e,t){typeof e==`string`&&(t?(t=t.crossOrigin,t=typeof t==`string`?t===`use-credentials`?t:``:void 0):t=null,i.d.C(e,t))},e.prefetchDNS=function(e){typeof e==`string`&&i.d.D(e)},e.preinit=function(e,t){if(typeof e==`string`&&t&&typeof t.as==`string`){var n=t.as,r=c(n,t.crossOrigin),a=typeof t.integrity==`string`?t.integrity:void 0,o=typeof t.fetchPriority==`string`?t.fetchPriority:void 0;n===`style`?i.d.S(e,typeof t.precedence==`string`?t.precedence:void 0,{crossOrigin:r,integrity:a,fetchPriority:o}):n===`script`&&i.d.X(e,{crossOrigin:r,integrity:a,fetchPriority:o,nonce:typeof t.nonce==`string`?t.nonce:void 0})}},e.preinitModule=function(e,t){if(typeof e==`string`)if(typeof t==`object`&&t){if(t.as==null||t.as===`script`){var n=c(t.as,t.crossOrigin);i.d.M(e,{crossOrigin:n,integrity:typeof t.integrity==`string`?t.integrity:void 0,nonce:typeof t.nonce==`string`?t.nonce:void 0})}}else t??i.d.M(e)},e.preload=function(e,t){if(typeof e==`string`&&typeof t==`object`&&t&&typeof t.as==`string`){var n=t.as,r=c(n,t.crossOrigin);i.d.L(e,n,{crossOrigin:r,integrity:typeof t.integrity==`string`?t.integrity:void 0,nonce:typeof t.nonce==`string`?t.nonce:void 0,type:typeof t.type==`string`?t.type:void 0,fetchPriority:typeof t.fetchPriority==`string`?t.fetchPriority:void 0,referrerPolicy:typeof t.referrerPolicy==`string`?t.referrerPolicy:void 0,imageSrcSet:typeof t.imageSrcSet==`string`?t.imageSrcSet:void 0,imageSizes:typeof t.imageSizes==`string`?t.imageSizes:void 0,media:typeof t.media==`string`?t.media:void 0})}},e.preloadModule=function(e,t){if(typeof e==`string`)if(t){var n=c(t.as,t.crossOrigin);i.d.m(e,{as:typeof t.as==`string`&&t.as!==`script`?t.as:void 0,crossOrigin:n,integrity:typeof t.integrity==`string`?t.integrity:void 0})}else i.d.m(e)},e.requestFormReset=function(e){i.d.r(e)},e.unstable_batchedUpdates=function(e,t){return e(t)},e.useFormState=function(e,t,n){return s.H.useFormState(e,t,n)},e.useFormStatus=function(){return s.H.useHostTransitionStatus()},e.version=`19.2.6`})),m=o(((e,t)=>{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=f(),n=u(),r=m();function i(e){var t=`https://react.dev/errors/`+e;if(1<arguments.length){t+=`?args[]=`+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)t+=`&args[]=`+encodeURIComponent(arguments[n])}return`Minified React error #`+e+`; visit `+t+` for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`}function a(e){return!(!e||e.nodeType!==1&&e.nodeType!==9&&e.nodeType!==11)}function o(e){var t=e,n=e;if(e.alternate)for(;t.return;)t=t.return;else{e=t;do t=e,t.flags&4098&&(n=t.return),e=t.return;while(e)}return t.tag===3?n:null}function s(e){if(e.tag===13){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function c(e){if(e.tag===31){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function l(e){if(o(e)!==e)throw Error(i(188))}function d(e){var t=e.alternate;if(!t){if(t=o(e),t===null)throw Error(i(188));return t===e?e:null}for(var n=e,r=t;;){var a=n.return;if(a===null)break;var s=a.alternate;if(s===null){if(r=a.return,r!==null){n=r;continue}break}if(a.child===s.child){for(s=a.child;s;){if(s===n)return l(a),e;if(s===r)return l(a),t;s=s.sibling}throw Error(i(188))}if(n.return!==r.return)n=a,r=s;else{for(var c=!1,u=a.child;u;){if(u===n){c=!0,n=a,r=s;break}if(u===r){c=!0,r=a,n=s;break}u=u.sibling}if(!c){for(u=s.child;u;){if(u===n){c=!0,n=s,r=a;break}if(u===r){c=!0,r=s,n=a;break}u=u.sibling}if(!c)throw Error(i(189))}}if(n.alternate!==r)throw Error(i(190))}if(n.tag!==3)throw Error(i(188));return n.stateNode.current===n?e:t}function p(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e;for(e=e.child;e!==null;){if(t=p(e),t!==null)return t;e=e.sibling}return null}var h=Object.assign,g=Symbol.for(`react.element`),_=Symbol.for(`react.transitional.element`),v=Symbol.for(`react.portal`),y=Symbol.for(`react.fragment`),b=Symbol.for(`react.strict_mode`),x=Symbol.for(`react.profiler`),S=Symbol.for(`react.consumer`),C=Symbol.for(`react.context`),w=Symbol.for(`react.forward_ref`),T=Symbol.for(`react.suspense`),ee=Symbol.for(`react.suspense_list`),te=Symbol.for(`react.memo`),ne=Symbol.for(`react.lazy`),re=Symbol.for(`react.activity`),ie=Symbol.for(`react.memo_cache_sentinel`),ae=Symbol.iterator;function oe(e){return typeof e!=`object`||!e?null:(e=ae&&e[ae]||e[`@@iterator`],typeof e==`function`?e:null)}var se=Symbol.for(`react.client.reference`);function ce(e){if(e==null)return null;if(typeof e==`function`)return e.$$typeof===se?null:e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case y:return`Fragment`;case x:return`Profiler`;case b:return`StrictMode`;case T:return`Suspense`;case ee:return`SuspenseList`;case re:return`Activity`}if(typeof e==`object`)switch(e.$$typeof){case v:return`Portal`;case C:return e.displayName||`Context`;case S:return(e._context.displayName||`Context`)+`.Consumer`;case w:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case te:return t=e.displayName||null,t===null?ce(e.type)||`Memo`:t;case ne:t=e._payload,e=e._init;try{return ce(e(t))}catch{}}return null}var le=Array.isArray,E=n.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,D=r.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ue={pending:!1,data:null,method:null,action:null},de=[],fe=-1;function pe(e){return{current:e}}function me(e){0>fe||(e.current=de[fe],de[fe]=null,fe--)}function O(e,t){fe++,de[fe]=e.current,e.current=t}var he=pe(null),ge=pe(null),_e=pe(null),ve=pe(null);function ye(e,t){switch(O(_e,t),O(ge,e),O(he,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}me(he),O(he,e)}function be(){me(he),me(ge),me(_e)}function xe(e){e.memoizedState!==null&&O(ve,e);var t=he.current,n=Hd(t,e.type);t!==n&&(O(ge,e),O(he,n))}function Se(e){ge.current===e&&(me(he),me(ge)),ve.current===e&&(me(ve),Qf._currentValue=ue)}var Ce,we;function k(e){if(Ce===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);Ce=t&&t[1]||``,we=-1<e.stack.indexOf(`
    at`)?` (<anonymous>)`:-1<e.stack.indexOf(`@`)?`@unknown:0:0`:``}return`
`+Ce+e+we}var Te=!1;function Ee(e,t){if(!e||Te)return``;Te=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var r={DetermineComponentFrameRoot:function(){try{if(t){var n=function(){throw Error()};if(Object.defineProperty(n.prototype,`props`,{set:function(){throw Error()}}),typeof Reflect==`object`&&Reflect.construct){try{Reflect.construct(n,[])}catch(e){var r=e}Reflect.construct(e,[],n)}else{try{n.call()}catch(e){r=e}e.call(n.prototype)}}else{try{throw Error()}catch(e){r=e}(n=e())&&typeof n.catch==`function`&&n.catch(function(){})}}catch(e){if(e&&r&&typeof e.stack==`string`)return[e.stack,r.stack]}return[null,null]}};r.DetermineComponentFrameRoot.displayName=`DetermineComponentFrameRoot`;var i=Object.getOwnPropertyDescriptor(r.DetermineComponentFrameRoot,`name`);i&&i.configurable&&Object.defineProperty(r.DetermineComponentFrameRoot,`name`,{value:`DetermineComponentFrameRoot`});var a=r.DetermineComponentFrameRoot(),o=a[0],s=a[1];if(o&&s){var c=o.split(`
`),l=s.split(`
`);for(i=r=0;r<c.length&&!c[r].includes(`DetermineComponentFrameRoot`);)r++;for(;i<l.length&&!l[i].includes(`DetermineComponentFrameRoot`);)i++;if(r===c.length||i===l.length)for(r=c.length-1,i=l.length-1;1<=r&&0<=i&&c[r]!==l[i];)i--;for(;1<=r&&0<=i;r--,i--)if(c[r]!==l[i]){if(r!==1||i!==1)do if(r--,i--,0>i||c[r]!==l[i]){var u=`
`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(`<anonymous>`)&&(u=u.replace(`<anonymous>`,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{Te=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?k(n):``}function De(e,t){switch(e.tag){case 26:case 27:case 5:return k(e.type);case 16:return k(`Lazy`);case 13:return e.child!==t&&t!==null?k(`Suspense Fallback`):k(`Suspense`);case 19:return k(`SuspenseList`);case 0:case 15:return Ee(e.type,!1);case 11:return Ee(e.type.render,!1);case 1:return Ee(e.type,!0);case 31:return k(`Activity`);default:return``}}function Oe(e){try{var t=``,n=null;do t+=De(e,n),n=e,e=e.return;while(e);return t}catch(e){return`
Error generating stack: `+e.message+`
`+e.stack}}var ke=Object.prototype.hasOwnProperty,Ae=t.unstable_scheduleCallback,je=t.unstable_cancelCallback,Me=t.unstable_shouldYield,Ne=t.unstable_requestPaint,Pe=t.unstable_now,Fe=t.unstable_getCurrentPriorityLevel,Ie=t.unstable_ImmediatePriority,Le=t.unstable_UserBlockingPriority,Re=t.unstable_NormalPriority,ze=t.unstable_LowPriority,Be=t.unstable_IdlePriority,Ve=t.log,He=t.unstable_setDisableYieldValue,Ue=null,We=null;function Ge(e){if(typeof Ve==`function`&&He(e),We&&typeof We.setStrictMode==`function`)try{We.setStrictMode(Ue,e)}catch{}}var Ke=Math.clz32?Math.clz32:Ye,qe=Math.log,Je=Math.LN2;function Ye(e){return e>>>=0,e===0?32:31-(qe(e)/Je|0)|0}var Xe=256,Ze=262144,Qe=4194304;function A(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function $e(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=A(n))):i=A(o):i=A(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=A(n))):i=A(o)):i=A(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function et(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function tt(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function nt(){var e=Qe;return Qe<<=1,!(Qe&62914560)&&(Qe=4194304),e}function rt(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function it(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function at(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0<n;){var u=31-Ke(n),d=1<<u;s[u]=0,c[u]=-1;var f=l[u];if(f!==null)for(l[u]=null,u=0;u<f.length;u++){var p=f[u];p!==null&&(p.lane&=-536870913)}n&=~d}r!==0&&ot(e,r,0),a!==0&&i===0&&e.tag!==0&&(e.suspendedLanes|=a&~(o&~t))}function ot(e,t,n){e.pendingLanes|=t,e.suspendedLanes&=~t;var r=31-Ke(t);e.entangledLanes|=t,e.entanglements[r]=e.entanglements[r]|1073741824|n&261930}function st(e,t){var n=e.entangledLanes|=t;for(e=e.entanglements;n;){var r=31-Ke(n),i=1<<r;i&t|e[r]&t&&(e[r]|=t),n&=~i}}function ct(e,t){var n=t&-t;return n=n&42?1:lt(n),(n&(e.suspendedLanes|t))===0?n:0}function lt(e){switch(e){case 2:e=1;break;case 8:e=4;break;case 32:e=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:e=128;break;case 268435456:e=134217728;break;default:e=0}return e}function ut(e){return e&=-e,2<e?8<e?e&134217727?32:268435456:8:2}function dt(){var e=D.p;return e===0?(e=window.event,e===void 0?32:mp(e.type)):e}function ft(e,t){var n=D.p;try{return D.p=e,t()}finally{D.p=n}}var pt=Math.random().toString(36).slice(2),mt=`__reactFiber$`+pt,ht=`__reactProps$`+pt,gt=`__reactContainer$`+pt,_t=`__reactEvents$`+pt,vt=`__reactListeners$`+pt,yt=`__reactHandles$`+pt,bt=`__reactResources$`+pt,xt=`__reactMarker$`+pt;function St(e){delete e[mt],delete e[ht],delete e[_t],delete e[vt],delete e[yt]}function Ct(e){var t=e[mt];if(t)return t;for(var n=e.parentNode;n;){if(t=n[gt]||n[mt]){if(n=t.alternate,t.child!==null||n!==null&&n.child!==null)for(e=df(e);e!==null;){if(n=e[mt])return n;e=df(e)}return t}e=n,n=e.parentNode}return null}function j(e){if(e=e[mt]||e[gt]){var t=e.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return e}return null}function wt(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e.stateNode;throw Error(i(33))}function Tt(e){var t=e[bt];return t||=e[bt]={hoistableStyles:new Map,hoistableScripts:new Map},t}function Et(e){e[xt]=!0}var Dt=new Set,Ot={};function kt(e,t){At(e,t),At(e+`Capture`,t)}function At(e,t){for(Ot[e]=t,e=0;e<t.length;e++)Dt.add(t[e])}var jt=RegExp(`^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD][:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$`),Mt={},Nt={};function Pt(e){return ke.call(Nt,e)?!0:ke.call(Mt,e)?!1:jt.test(e)?Nt[e]=!0:(Mt[e]=!0,!1)}function Ft(e,t,n){if(Pt(t))if(n===null)e.removeAttribute(t);else{switch(typeof n){case`undefined`:case`function`:case`symbol`:e.removeAttribute(t);return;case`boolean`:var r=t.toLowerCase().slice(0,5);if(r!==`data-`&&r!==`aria-`){e.removeAttribute(t);return}}e.setAttribute(t,``+n)}}function It(e,t,n){if(n===null)e.removeAttribute(t);else{switch(typeof n){case`undefined`:case`function`:case`symbol`:case`boolean`:e.removeAttribute(t);return}e.setAttribute(t,``+n)}}function Lt(e,t,n,r){if(r===null)e.removeAttribute(n);else{switch(typeof r){case`undefined`:case`function`:case`symbol`:case`boolean`:e.removeAttribute(n);return}e.setAttributeNS(t,n,``+r)}}function Rt(e){switch(typeof e){case`bigint`:case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function zt(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function Bt(e,t,n){var r=Object.getOwnPropertyDescriptor(e.constructor.prototype,t);if(!e.hasOwnProperty(t)&&r!==void 0&&typeof r.get==`function`&&typeof r.set==`function`){var i=r.get,a=r.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){n=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:r.enumerable}),{getValue:function(){return n},setValue:function(e){n=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Vt(e){if(!e._valueTracker){var t=zt(e)?`checked`:`value`;e._valueTracker=Bt(e,t,``+e[t])}}function Ht(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=zt(e)?e.checked?`true`:`false`:e.value),e=r,e===n?!1:(t.setValue(e),!0)}function Ut(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}var Wt=/[\n"\\]/g;function Gt(e){return e.replace(Wt,function(e){return`\\`+e.charCodeAt(0).toString(16)+` `})}function Kt(e,t,n,r,i,a,o,s){e.name=``,o!=null&&typeof o!=`function`&&typeof o!=`symbol`&&typeof o!=`boolean`?e.type=o:e.removeAttribute(`type`),t==null?o!==`submit`&&o!==`reset`||e.removeAttribute(`value`):o===`number`?(t===0&&e.value===``||e.value!=t)&&(e.value=``+Rt(t)):e.value!==``+Rt(t)&&(e.value=``+Rt(t)),t==null?n==null?r!=null&&e.removeAttribute(`value`):Jt(e,o,Rt(n)):Jt(e,o,Rt(t)),i==null&&a!=null&&(e.defaultChecked=!!a),i!=null&&(e.checked=i&&typeof i!=`function`&&typeof i!=`symbol`),s!=null&&typeof s!=`function`&&typeof s!=`symbol`&&typeof s!=`boolean`?e.name=``+Rt(s):e.removeAttribute(`name`)}function qt(e,t,n,r,i,a,o,s){if(a!=null&&typeof a!=`function`&&typeof a!=`symbol`&&typeof a!=`boolean`&&(e.type=a),t!=null||n!=null){if(!(a!==`submit`&&a!==`reset`||t!=null)){Vt(e);return}n=n==null?``:``+Rt(n),t=t==null?n:``+Rt(t),s||t===e.value||(e.value=t),e.defaultValue=t}r??=i,r=typeof r!=`function`&&typeof r!=`symbol`&&!!r,e.checked=s?e.checked:!!r,e.defaultChecked=!!r,o!=null&&typeof o!=`function`&&typeof o!=`symbol`&&typeof o!=`boolean`&&(e.name=o),Vt(e)}function Jt(e,t,n){t===`number`&&Ut(e.ownerDocument)===e||e.defaultValue===``+n||(e.defaultValue=``+n)}function Yt(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i<n.length;i++)t[`$`+n[i]]=!0;for(n=0;n<e.length;n++)i=t.hasOwnProperty(`$`+e[n].value),e[n].selected!==i&&(e[n].selected=i),i&&r&&(e[n].defaultSelected=!0)}else{for(n=``+Rt(n),t=null,i=0;i<e.length;i++){if(e[i].value===n){e[i].selected=!0,r&&(e[i].defaultSelected=!0);return}t!==null||e[i].disabled||(t=e[i])}t!==null&&(t.selected=!0)}}function Xt(e,t,n){if(t!=null&&(t=``+Rt(t),t!==e.value&&(e.value=t),n==null)){e.defaultValue!==t&&(e.defaultValue=t);return}e.defaultValue=n==null?``:``+Rt(n)}function Zt(e,t,n,r){if(t==null){if(r!=null){if(n!=null)throw Error(i(92));if(le(r)){if(1<r.length)throw Error(i(93));r=r[0]}n=r}n??=``,t=n}n=Rt(t),e.defaultValue=n,r=e.textContent,r===n&&r!==``&&r!==null&&(e.value=r),Vt(e)}function Qt(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var $t=new Set(`animationIterationCount aspectRatio borderImageOutset borderImageSlice borderImageWidth boxFlex boxFlexGroup boxOrdinalGroup columnCount columns flex flexGrow flexPositive flexShrink flexNegative flexOrder gridArea gridRow gridRowEnd gridRowSpan gridRowStart gridColumn gridColumnEnd gridColumnSpan gridColumnStart fontWeight lineClamp lineHeight opacity order orphans scale tabSize widows zIndex zoom fillOpacity floodOpacity stopOpacity strokeDasharray strokeDashoffset strokeMiterlimit strokeOpacity strokeWidth MozAnimationIterationCount MozBoxFlex MozBoxFlexGroup MozLineClamp msAnimationIterationCount msFlex msZoom msFlexGrow msFlexNegative msFlexOrder msFlexPositive msFlexShrink msGridColumn msGridColumnSpan msGridRow msGridRowSpan WebkitAnimationIterationCount WebkitBoxFlex WebKitBoxFlexGroup WebkitBoxOrdinalGroup WebkitColumnCount WebkitColumns WebkitFlex WebkitFlexGrow WebkitFlexPositive WebkitFlexShrink WebkitLineClamp`.split(` `));function en(e,t,n){var r=t.indexOf(`--`)===0;n==null||typeof n==`boolean`||n===``?r?e.setProperty(t,``):t===`float`?e.cssFloat=``:e[t]=``:r?e.setProperty(t,n):typeof n!=`number`||n===0||$t.has(t)?t===`float`?e.cssFloat=n:e[t]=(``+n).trim():e[t]=n+`px`}function tn(e,t,n){if(t!=null&&typeof t!=`object`)throw Error(i(62));if(e=e.style,n!=null){for(var r in n)!n.hasOwnProperty(r)||t!=null&&t.hasOwnProperty(r)||(r.indexOf(`--`)===0?e.setProperty(r,``):r===`float`?e.cssFloat=``:e[r]=``);for(var a in t)r=t[a],t.hasOwnProperty(a)&&n[a]!==r&&en(e,a,r)}else for(var o in t)t.hasOwnProperty(o)&&en(e,o,t[o])}function nn(e){if(e.indexOf(`-`)===-1)return!1;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var rn=new Map([[`acceptCharset`,`accept-charset`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`],[`crossOrigin`,`crossorigin`],[`accentHeight`,`accent-height`],[`alignmentBaseline`,`alignment-baseline`],[`arabicForm`,`arabic-form`],[`baselineShift`,`baseline-shift`],[`capHeight`,`cap-height`],[`clipPath`,`clip-path`],[`clipRule`,`clip-rule`],[`colorInterpolation`,`color-interpolation`],[`colorInterpolationFilters`,`color-interpolation-filters`],[`colorProfile`,`color-profile`],[`colorRendering`,`color-rendering`],[`dominantBaseline`,`dominant-baseline`],[`enableBackground`,`enable-background`],[`fillOpacity`,`fill-opacity`],[`fillRule`,`fill-rule`],[`floodColor`,`flood-color`],[`floodOpacity`,`flood-opacity`],[`fontFamily`,`font-family`],[`fontSize`,`font-size`],[`fontSizeAdjust`,`font-size-adjust`],[`fontStretch`,`font-stretch`],[`fontStyle`,`font-style`],[`fontVariant`,`font-variant`],[`fontWeight`,`font-weight`],[`glyphName`,`glyph-name`],[`glyphOrientationHorizontal`,`glyph-orientation-horizontal`],[`glyphOrientationVertical`,`glyph-orientation-vertical`],[`horizAdvX`,`horiz-adv-x`],[`horizOriginX`,`horiz-origin-x`],[`imageRendering`,`image-rendering`],[`letterSpacing`,`letter-spacing`],[`lightingColor`,`lighting-color`],[`markerEnd`,`marker-end`],[`markerMid`,`marker-mid`],[`markerStart`,`marker-start`],[`overlinePosition`,`overline-position`],[`overlineThickness`,`overline-thickness`],[`paintOrder`,`paint-order`],[`panose-1`,`panose-1`],[`pointerEvents`,`pointer-events`],[`renderingIntent`,`rendering-intent`],[`shapeRendering`,`shape-rendering`],[`stopColor`,`stop-color`],[`stopOpacity`,`stop-opacity`],[`strikethroughPosition`,`strikethrough-position`],[`strikethroughThickness`,`strikethrough-thickness`],[`strokeDasharray`,`stroke-dasharray`],[`strokeDashoffset`,`stroke-dashoffset`],[`strokeLinecap`,`stroke-linecap`],[`strokeLinejoin`,`stroke-linejoin`],[`strokeMiterlimit`,`stroke-miterlimit`],[`strokeOpacity`,`stroke-opacity`],[`strokeWidth`,`stroke-width`],[`textAnchor`,`text-anchor`],[`textDecoration`,`text-decoration`],[`textRendering`,`text-rendering`],[`transformOrigin`,`transform-origin`],[`underlinePosition`,`underline-position`],[`underlineThickness`,`underline-thickness`],[`unicodeBidi`,`unicode-bidi`],[`unicodeRange`,`unicode-range`],[`unitsPerEm`,`units-per-em`],[`vAlphabetic`,`v-alphabetic`],[`vHanging`,`v-hanging`],[`vIdeographic`,`v-ideographic`],[`vMathematical`,`v-mathematical`],[`vectorEffect`,`vector-effect`],[`vertAdvY`,`vert-adv-y`],[`vertOriginX`,`vert-origin-x`],[`vertOriginY`,`vert-origin-y`],[`wordSpacing`,`word-spacing`],[`writingMode`,`writing-mode`],[`xmlnsXlink`,`xmlns:xlink`],[`xHeight`,`x-height`]]),an=/^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i;function on(e){return an.test(``+e)?`javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')`:e}function sn(){}var cn=null;function ln(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var un=null,dn=null;function fn(e){var t=j(e);if(t&&(e=t.stateNode)){var n=e[ht]||null;a:switch(e=t.stateNode,t.type){case`input`:if(Kt(e,n.value,n.defaultValue,n.defaultValue,n.checked,n.defaultChecked,n.type,n.name),t=n.name,n.type===`radio`&&t!=null){for(n=e;n.parentNode;)n=n.parentNode;for(n=n.querySelectorAll(`input[name="`+Gt(``+t)+`"][type="radio"]`),t=0;t<n.length;t++){var r=n[t];if(r!==e&&r.form===e.form){var a=r[ht]||null;if(!a)throw Error(i(90));Kt(r,a.value,a.defaultValue,a.defaultValue,a.checked,a.defaultChecked,a.type,a.name)}}for(t=0;t<n.length;t++)r=n[t],r.form===e.form&&Ht(r)}break a;case`textarea`:Xt(e,n.value,n.defaultValue);break a;case`select`:t=n.value,t!=null&&Yt(e,!!n.multiple,t,!1)}}}var pn=!1;function mn(e,t,n){if(pn)return e(t,n);pn=!0;try{return e(t)}finally{if(pn=!1,(un!==null||dn!==null)&&(bu(),un&&(t=un,e=dn,dn=un=null,fn(t),e)))for(t=0;t<e.length;t++)fn(e[t])}}function hn(e,t){var n=e.stateNode;if(n===null)return null;var r=n[ht]||null;if(r===null)return null;n=r[t];a:switch(t){case`onClick`:case`onClickCapture`:case`onDoubleClick`:case`onDoubleClickCapture`:case`onMouseDown`:case`onMouseDownCapture`:case`onMouseMove`:case`onMouseMoveCapture`:case`onMouseUp`:case`onMouseUpCapture`:case`onMouseEnter`:(r=!r.disabled)||(e=e.type,r=!(e===`button`||e===`input`||e===`select`||e===`textarea`)),e=!r;break a;default:e=!1}if(e)return null;if(n&&typeof n!=`function`)throw Error(i(231,t,typeof n));return n}var gn=!(typeof window>`u`||window.document===void 0||window.document.createElement===void 0),_n=!1;if(gn)try{var vn={};Object.defineProperty(vn,`passive`,{get:function(){_n=!0}}),window.addEventListener(`test`,vn,vn),window.removeEventListener(`test`,vn,vn)}catch{_n=!1}var yn=null,bn=null,xn=null;function Sn(){if(xn)return xn;var e,t=bn,n=t.length,r,i=`value`in yn?yn.value:yn.textContent,a=i.length;for(e=0;e<n&&t[e]===i[e];e++);var o=n-e;for(r=1;r<=o&&t[n-r]===i[a-r];r++);return xn=i.slice(e,1<r?1-r:void 0)}function Cn(e){var t=e.keyCode;return`charCode`in e?(e=e.charCode,e===0&&t===13&&(e=13)):e=t,e===10&&(e=13),32<=e||e===13?e:0}function wn(){return!0}function Tn(){return!1}function En(e){function t(t,n,r,i,a){for(var o in this._reactName=t,this._targetInst=r,this.type=n,this.nativeEvent=i,this.target=a,this.currentTarget=null,e)e.hasOwnProperty(o)&&(t=e[o],this[o]=t?t(i):i[o]);return this.isDefaultPrevented=(i.defaultPrevented==null?!1===i.returnValue:i.defaultPrevented)?wn:Tn,this.isPropagationStopped=Tn,this}return h(t.prototype,{preventDefault:function(){this.defaultPrevented=!0;var e=this.nativeEvent;e&&(e.preventDefault?e.preventDefault():typeof e.returnValue!=`unknown`&&(e.returnValue=!1),this.isDefaultPrevented=wn)},stopPropagation:function(){var e=this.nativeEvent;e&&(e.stopPropagation?e.stopPropagation():typeof e.cancelBubble!=`unknown`&&(e.cancelBubble=!0),this.isPropagationStopped=wn)},persist:function(){},isPersistent:wn}),t}var M={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(e){return e.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},Dn=En(M),On=h({},M,{view:0,detail:0}),kn=En(On),An,jn,Mn,Nn=h({},On,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:Wn,button:0,buttons:0,relatedTarget:function(e){return e.relatedTarget===void 0?e.fromElement===e.srcElement?e.toElement:e.fromElement:e.relatedTarget},movementX:function(e){return`movementX`in e?e.movementX:(e!==Mn&&(Mn&&e.type===`mousemove`?(An=e.screenX-Mn.screenX,jn=e.screenY-Mn.screenY):jn=An=0,Mn=e),An)},movementY:function(e){return`movementY`in e?e.movementY:jn}}),Pn=En(Nn),Fn=En(h({},Nn,{dataTransfer:0})),In=En(h({},On,{relatedTarget:0})),Ln=En(h({},M,{animationName:0,elapsedTime:0,pseudoElement:0})),Rn=En(h({},M,{clipboardData:function(e){return`clipboardData`in e?e.clipboardData:window.clipboardData}})),zn=En(h({},M,{data:0})),Bn={Esc:`Escape`,Spacebar:` `,Left:`ArrowLeft`,Up:`ArrowUp`,Right:`ArrowRight`,Down:`ArrowDown`,Del:`Delete`,Win:`OS`,Menu:`ContextMenu`,Apps:`ContextMenu`,Scroll:`ScrollLock`,MozPrintableKey:`Unidentified`},Vn={8:`Backspace`,9:`Tab`,12:`Clear`,13:`Enter`,16:`Shift`,17:`Control`,18:`Alt`,19:`Pause`,20:`CapsLock`,27:`Escape`,32:` `,33:`PageUp`,34:`PageDown`,35:`End`,36:`Home`,37:`ArrowLeft`,38:`ArrowUp`,39:`ArrowRight`,40:`ArrowDown`,45:`Insert`,46:`Delete`,112:`F1`,113:`F2`,114:`F3`,115:`F4`,116:`F5`,117:`F6`,118:`F7`,119:`F8`,120:`F9`,121:`F10`,122:`F11`,123:`F12`,144:`NumLock`,145:`ScrollLock`,224:`Meta`},Hn={Alt:`altKey`,Control:`ctrlKey`,Meta:`metaKey`,Shift:`shiftKey`};function Un(e){var t=this.nativeEvent;return t.getModifierState?t.getModifierState(e):(e=Hn[e])?!!t[e]:!1}function Wn(){return Un}var Gn=En(h({},On,{key:function(e){if(e.key){var t=Bn[e.key]||e.key;if(t!==`Unidentified`)return t}return e.type===`keypress`?(e=Cn(e),e===13?`Enter`:String.fromCharCode(e)):e.type===`keydown`||e.type===`keyup`?Vn[e.keyCode]||`Unidentified`:``},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,repeat:0,locale:0,getModifierState:Wn,charCode:function(e){return e.type===`keypress`?Cn(e):0},keyCode:function(e){return e.type===`keydown`||e.type===`keyup`?e.keyCode:0},which:function(e){return e.type===`keypress`?Cn(e):e.type===`keydown`||e.type===`keyup`?e.keyCode:0}})),Kn=En(h({},Nn,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0})),qn=En(h({},On,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,ctrlKey:0,shiftKey:0,getModifierState:Wn})),Jn=En(h({},M,{propertyName:0,elapsedTime:0,pseudoElement:0})),Yn=En(h({},Nn,{deltaX:function(e){return`deltaX`in e?e.deltaX:`wheelDeltaX`in e?-e.wheelDeltaX:0},deltaY:function(e){return`deltaY`in e?e.deltaY:`wheelDeltaY`in e?-e.wheelDeltaY:`wheelDelta`in e?-e.wheelDelta:0},deltaZ:0,deltaMode:0})),Xn=En(h({},M,{newState:0,oldState:0})),Zn=[9,13,27,32],Qn=gn&&`CompositionEvent`in window,$n=null;gn&&`documentMode`in document&&($n=document.documentMode);var er=gn&&`TextEvent`in window&&!$n,tr=gn&&(!Qn||$n&&8<$n&&11>=$n),nr=` `,rr=!1;function ir(e,t){switch(e){case`keyup`:return Zn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function ar(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var or=!1;function sr(e,t){switch(e){case`compositionend`:return ar(t);case`keypress`:return t.which===32?(rr=!0,nr):null;case`textInput`:return e=t.data,e===nr&&rr?null:e;default:return null}}function cr(e,t){if(or)return e===`compositionend`||!Qn&&ir(e,t)?(e=Sn(),xn=bn=yn=null,or=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1<t.char.length)return t.char;if(t.which)return String.fromCharCode(t.which)}return null;case`compositionend`:return tr&&t.locale!==`ko`?null:t.data;default:return null}}var lr={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function ur(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t===`input`?!!lr[e.type]:t===`textarea`}function dr(e,t,n,r){un?dn?dn.push(r):dn=[r]:un=r,t=Ed(t,`onChange`),0<t.length&&(n=new Dn(`onChange`,`change`,null,n,r),e.push({event:n,listeners:t}))}var fr=null,pr=null;function mr(e){yd(e,0)}function hr(e){if(Ht(wt(e)))return e}function gr(e,t){if(e===`change`)return t}var _r=!1;if(gn){var vr;if(gn){var yr=`oninput`in document;if(!yr){var br=document.createElement(`div`);br.setAttribute(`oninput`,`return;`),yr=typeof br.oninput==`function`}vr=yr}else vr=!1;_r=vr&&(!document.documentMode||9<document.documentMode)}function xr(){fr&&(fr.detachEvent(`onpropertychange`,Sr),pr=fr=null)}function Sr(e){if(e.propertyName===`value`&&hr(pr)){var t=[];dr(t,pr,e,ln(e)),mn(mr,t)}}function Cr(e,t,n){e===`focusin`?(xr(),fr=t,pr=n,fr.attachEvent(`onpropertychange`,Sr)):e===`focusout`&&xr()}function wr(e){if(e===`selectionchange`||e===`keyup`||e===`keydown`)return hr(pr)}function Tr(e,t){if(e===`click`)return hr(t)}function Er(e,t){if(e===`input`||e===`change`)return hr(t)}function Dr(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var Or=typeof Object.is==`function`?Object.is:Dr;function kr(e,t){if(Or(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;var n=Object.keys(e),r=Object.keys(t);if(n.length!==r.length)return!1;for(r=0;r<n.length;r++){var i=n[r];if(!ke.call(t,i)||!Or(e[i],t[i]))return!1}return!0}function Ar(e){for(;e&&e.firstChild;)e=e.firstChild;return e}function jr(e,t){var n=Ar(e);e=0;for(var r;n;){if(n.nodeType===3){if(r=e+n.textContent.length,e<=t&&r>=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Ar(n)}}function Mr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Mr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Nr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Ut(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Ut(e.document)}return t}function Pr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Fr=gn&&`documentMode`in document&&11>=document.documentMode,Ir=null,Lr=null,Rr=null,zr=!1;function Br(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;zr||Ir==null||Ir!==Ut(r)||(r=Ir,`selectionStart`in r&&Pr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Rr&&kr(Rr,r)||(Rr=r,r=Ed(Lr,`onSelect`),0<r.length&&(t=new Dn(`onSelect`,`select`,null,t,n),e.push({event:t,listeners:r}),t.target=Ir)))}function Vr(e,t){var n={};return n[e.toLowerCase()]=t.toLowerCase(),n[`Webkit`+e]=`webkit`+t,n[`Moz`+e]=`moz`+t,n}var Hr={animationend:Vr(`Animation`,`AnimationEnd`),animationiteration:Vr(`Animation`,`AnimationIteration`),animationstart:Vr(`Animation`,`AnimationStart`),transitionrun:Vr(`Transition`,`TransitionRun`),transitionstart:Vr(`Transition`,`TransitionStart`),transitioncancel:Vr(`Transition`,`TransitionCancel`),transitionend:Vr(`Transition`,`TransitionEnd`)},Ur={},Wr={};gn&&(Wr=document.createElement(`div`).style,`AnimationEvent`in window||(delete Hr.animationend.animation,delete Hr.animationiteration.animation,delete Hr.animationstart.animation),`TransitionEvent`in window||delete Hr.transitionend.transition);function Gr(e){if(Ur[e])return Ur[e];if(!Hr[e])return e;var t=Hr[e],n;for(n in t)if(t.hasOwnProperty(n)&&n in Wr)return Ur[e]=t[n];return e}var Kr=Gr(`animationend`),qr=Gr(`animationiteration`),Jr=Gr(`animationstart`),Yr=Gr(`transitionrun`),Xr=Gr(`transitionstart`),Zr=Gr(`transitioncancel`),Qr=Gr(`transitionend`),$r=new Map,ei=`abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel`.split(` `);ei.push(`scrollEnd`);function ti(e,t){$r.set(e,t),kt(t,[e])}var ni=typeof reportError==`function`?reportError:function(e){if(typeof window==`object`&&typeof window.ErrorEvent==`function`){var t=new window.ErrorEvent(`error`,{bubbles:!0,cancelable:!0,message:typeof e==`object`&&e&&typeof e.message==`string`?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process==`object`&&typeof process.emit==`function`){process.emit(`uncaughtException`,e);return}console.error(e)},ri=[],ii=0,ai=0;function oi(){for(var e=ii,t=ai=ii=0;t<e;){var n=ri[t];ri[t++]=null;var r=ri[t];ri[t++]=null;var i=ri[t];ri[t++]=null;var a=ri[t];if(ri[t++]=null,r!==null&&i!==null){var o=r.pending;o===null?i.next=i:(i.next=o.next,o.next=i),r.pending=i}a!==0&&ui(n,i,a)}}function si(e,t,n,r){ri[ii++]=e,ri[ii++]=t,ri[ii++]=n,ri[ii++]=r,ai|=r,e.lanes|=r,e=e.alternate,e!==null&&(e.lanes|=r)}function ci(e,t,n,r){return si(e,t,n,r),di(e)}function li(e,t){return si(e,null,null,t),di(e)}function ui(e,t,n){e.lanes|=n;var r=e.alternate;r!==null&&(r.lanes|=n);for(var i=!1,a=e.return;a!==null;)a.childLanes|=n,r=a.alternate,r!==null&&(r.childLanes|=n),a.tag===22&&(e=a.stateNode,e===null||e._visibility&1||(i=!0)),e=a,a=a.return;return e.tag===3?(a=e.stateNode,i&&t!==null&&(i=31-Ke(n),e=a.hiddenUpdates,r=e[i],r===null?e[i]=[t]:r.push(t),t.lane=n|536870912),a):null}function di(e){if(50<du)throw du=0,fu=null,Error(i(185));for(var t=e.return;t!==null;)e=t,t=e.return;return e.tag===3?e.stateNode:null}var fi={};function pi(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.refCleanup=this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function mi(e,t,n,r){return new pi(e,t,n,r)}function hi(e){return e=e.prototype,!(!e||!e.isReactComponent)}function gi(e,t){var n=e.alternate;return n===null?(n=mi(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&65011712,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n.refCleanup=e.refCleanup,n}function _i(e,t){e.flags&=65011714;var n=e.alternate;return n===null?(e.childLanes=0,e.lanes=t,e.child=null,e.subtreeFlags=0,e.memoizedProps=null,e.memoizedState=null,e.updateQueue=null,e.dependencies=null,e.stateNode=null):(e.childLanes=n.childLanes,e.lanes=n.lanes,e.child=n.child,e.subtreeFlags=0,e.deletions=null,e.memoizedProps=n.memoizedProps,e.memoizedState=n.memoizedState,e.updateQueue=n.updateQueue,e.type=n.type,t=n.dependencies,e.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext}),e}function vi(e,t,n,r,a,o){var s=0;if(r=e,typeof e==`function`)hi(e)&&(s=1);else if(typeof e==`string`)s=Uf(e,n,he.current)?26:e===`html`||e===`head`||e===`body`?27:5;else a:switch(e){case re:return e=mi(31,n,t,a),e.elementType=re,e.lanes=o,e;case y:return yi(n.children,a,o,t);case b:s=8,a|=24;break;case x:return e=mi(12,n,t,a|2),e.elementType=x,e.lanes=o,e;case T:return e=mi(13,n,t,a),e.elementType=T,e.lanes=o,e;case ee:return e=mi(19,n,t,a),e.elementType=ee,e.lanes=o,e;default:if(typeof e==`object`&&e)switch(e.$$typeof){case C:s=10;break a;case S:s=9;break a;case w:s=11;break a;case te:s=14;break a;case ne:s=16,r=null;break a}s=29,n=Error(i(130,e===null?`null`:typeof e,``)),r=null}return t=mi(s,n,t,a),t.elementType=e,t.type=r,t.lanes=o,t}function yi(e,t,n,r){return e=mi(7,e,r,t),e.lanes=n,e}function bi(e,t,n){return e=mi(6,e,null,t),e.lanes=n,e}function xi(e){var t=mi(18,null,null,0);return t.stateNode=e,t}function Si(e,t,n){return t=mi(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}var Ci=new WeakMap;function wi(e,t){if(typeof e==`object`&&e){var n=Ci.get(e);return n===void 0?(t={value:e,source:t,stack:Oe(t)},Ci.set(e,t),t):n}return{value:e,source:t,stack:Oe(t)}}var Ti=[],Ei=0,Di=null,Oi=0,ki=[],Ai=0,ji=null,Mi=1,Ni=``;function Pi(e,t){Ti[Ei++]=Oi,Ti[Ei++]=Di,Di=e,Oi=t}function Fi(e,t,n){ki[Ai++]=Mi,ki[Ai++]=Ni,ki[Ai++]=ji,ji=e;var r=Mi;e=Ni;var i=32-Ke(r)-1;r&=~(1<<i),n+=1;var a=32-Ke(t)+i;if(30<a){var o=i-i%5;a=(r&(1<<o)-1).toString(32),r>>=o,i-=o,Mi=1<<32-Ke(t)+i|n<<i|r,Ni=a+e}else Mi=1<<a|n<<i|r,Ni=e}function Ii(e){e.return!==null&&(Pi(e,1),Fi(e,1,0))}function Li(e){for(;e===Di;)Di=Ti[--Ei],Ti[Ei]=null,Oi=Ti[--Ei],Ti[Ei]=null;for(;e===ji;)ji=ki[--Ai],ki[Ai]=null,Ni=ki[--Ai],ki[Ai]=null,Mi=ki[--Ai],ki[Ai]=null}function Ri(e,t){ki[Ai++]=Mi,ki[Ai++]=Ni,ki[Ai++]=ji,Mi=t.id,Ni=t.overflow,ji=e}var zi=null,N=null,P=!1,Bi=null,Vi=!1,Hi=Error(i(519));function Ui(e){throw Yi(wi(Error(i(418,1<arguments.length&&arguments[1]!==void 0&&arguments[1]?`text`:`HTML`,``)),e)),Hi}function Wi(e){var t=e.stateNode,n=e.type,r=e.memoizedProps;switch(t[mt]=e,t[ht]=r,n){case`dialog`:Q(`cancel`,t),Q(`close`,t);break;case`iframe`:case`object`:case`embed`:Q(`load`,t);break;case`video`:case`audio`:for(n=0;n<_d.length;n++)Q(_d[n],t);break;case`source`:Q(`error`,t);break;case`img`:case`image`:case`link`:Q(`error`,t),Q(`load`,t);break;case`details`:Q(`toggle`,t);break;case`input`:Q(`invalid`,t),qt(t,r.value,r.defaultValue,r.checked,r.defaultChecked,r.type,r.name,!0);break;case`select`:Q(`invalid`,t);break;case`textarea`:Q(`invalid`,t),Zt(t,r.value,r.defaultValue,r.children)}n=r.children,typeof n!=`string`&&typeof n!=`number`&&typeof n!=`bigint`||t.textContent===``+n||!0===r.suppressHydrationWarning||Md(t.textContent,n)?(r.popover!=null&&(Q(`beforetoggle`,t),Q(`toggle`,t)),r.onScroll!=null&&Q(`scroll`,t),r.onScrollEnd!=null&&Q(`scrollend`,t),r.onClick!=null&&(t.onclick=sn),t=!0):t=!1,t||Ui(e,!0)}function Gi(e){for(zi=e.return;zi;)switch(zi.tag){case 5:case 31:case 13:Vi=!1;return;case 27:case 3:Vi=!0;return;default:zi=zi.return}}function Ki(e){if(e!==zi)return!1;if(!P)return Gi(e),P=!0,!1;var t=e.tag,n;if((n=t!==3&&t!==27)&&((n=t===5)&&(n=e.type,n=!(n!==`form`&&n!==`button`)||Ud(e.type,e.memoizedProps)),n=!n),n&&N&&Ui(e),Gi(e),t===13){if(e=e.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(317));N=uf(e)}else if(t===31){if(e=e.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(317));N=uf(e)}else t===27?(t=N,Zd(e.type)?(e=lf,lf=null,N=e):N=t):N=zi?cf(e.stateNode.nextSibling):null;return!0}function qi(){N=zi=null,P=!1}function Ji(){var e=Bi;return e!==null&&(Zl===null?Zl=e:Zl.push.apply(Zl,e),Bi=null),e}function Yi(e){Bi===null?Bi=[e]:Bi.push(e)}var Xi=pe(null),Zi=null,Qi=null;function $i(e,t,n){O(Xi,t._currentValue),t._currentValue=n}function ea(e){e._currentValue=Xi.current,me(Xi)}function ta(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function na(e,t,n,r){var a=e.child;for(a!==null&&(a.return=e);a!==null;){var o=a.dependencies;if(o!==null){var s=a.child;o=o.firstContext;a:for(;o!==null;){var c=o;o=a;for(var l=0;l<t.length;l++)if(c.context===t[l]){o.lanes|=n,c=o.alternate,c!==null&&(c.lanes|=n),ta(o.return,n,e),r||(s=null);break a}o=c.next}}else if(a.tag===18){if(s=a.return,s===null)throw Error(i(341));s.lanes|=n,o=s.alternate,o!==null&&(o.lanes|=n),ta(s,n,e),s=null}else s=a.child;if(s!==null)s.return=a;else for(s=a;s!==null;){if(s===e){s=null;break}if(a=s.sibling,a!==null){a.return=s.return,s=a;break}s=s.return}a=s}}function ra(e,t,n,r){e=null;for(var a=t,o=!1;a!==null;){if(!o){if(a.flags&524288)o=!0;else if(a.flags&262144)break}if(a.tag===10){var s=a.alternate;if(s===null)throw Error(i(387));if(s=s.memoizedProps,s!==null){var c=a.type;Or(a.pendingProps.value,s.value)||(e===null?e=[c]:e.push(c))}}else if(a===ve.current){if(s=a.alternate,s===null)throw Error(i(387));s.memoizedState.memoizedState!==a.memoizedState.memoizedState&&(e===null?e=[Qf]:e.push(Qf))}a=a.return}e!==null&&na(t,e,n,r),t.flags|=262144}function F(e){for(e=e.firstContext;e!==null;){if(!Or(e.context._currentValue,e.memoizedValue))return!0;e=e.next}return!1}function ia(e){Zi=e,Qi=null,e=e.dependencies,e!==null&&(e.firstContext=null)}function aa(e){return sa(Zi,e)}function oa(e,t){return Zi===null&&ia(e),sa(e,t)}function sa(e,t){var n=t._currentValue;if(t={context:t,memoizedValue:n,next:null},Qi===null){if(e===null)throw Error(i(308));Qi=t,e.dependencies={lanes:0,firstContext:t},e.flags|=524288}else Qi=Qi.next=t;return n}var ca=typeof AbortController<`u`?AbortController:function(){var e=[],t=this.signal={aborted:!1,addEventListener:function(t,n){e.push(n)}};this.abort=function(){t.aborted=!0,e.forEach(function(e){return e()})}},la=t.unstable_scheduleCallback,ua=t.unstable_NormalPriority,I={$$typeof:C,Consumer:null,Provider:null,_currentValue:null,_currentValue2:null,_threadCount:0};function da(){return{controller:new ca,data:new Map,refCount:0}}function fa(e){e.refCount--,e.refCount===0&&la(ua,function(){e.controller.abort()})}var pa=null,ma=0,ha=0,ga=null;function _a(e,t){if(pa===null){var n=pa=[];ma=0,ha=dd(),ga={status:`pending`,value:void 0,then:function(e){n.push(e)}}}return ma++,t.then(va,va),t}function va(){if(--ma===0&&pa!==null){ga!==null&&(ga.status=`fulfilled`);var e=pa;pa=null,ha=0,ga=null;for(var t=0;t<e.length;t++)(0,e[t])()}}function ya(e,t){var n=[],r={status:`pending`,value:null,reason:null,then:function(e){n.push(e)}};return e.then(function(){r.status=`fulfilled`,r.value=t;for(var e=0;e<n.length;e++)(0,n[e])(t)},function(e){for(r.status=`rejected`,r.reason=e,e=0;e<n.length;e++)(0,n[e])(void 0)}),r}var ba=E.S;E.S=function(e,t){eu=Pe(),typeof t==`object`&&t&&typeof t.then==`function`&&_a(e,t),ba!==null&&ba(e,t)};var xa=pe(null);function Sa(){var e=xa.current;return e===null?K.pooledCache:e}function Ca(e,t){t===null?O(xa,xa.current):O(xa,t.pool)}function wa(){var e=Sa();return e===null?null:{parent:I._currentValue,pool:e}}var Ta=Error(i(460)),Ea=Error(i(474)),Da=Error(i(542)),Oa={then:function(){}};function ka(e){return e=e.status,e===`fulfilled`||e===`rejected`}function Aa(e,t,n){switch(n=e[n],n===void 0?e.push(t):n!==t&&(t.then(sn,sn),t=n),t.status){case`fulfilled`:return t.value;case`rejected`:throw e=t.reason,Na(e),e;default:if(typeof t.status==`string`)t.then(sn,sn);else{if(e=K,e!==null&&100<e.shellSuspendCounter)throw Error(i(482));e=t,e.status=`pending`,e.then(function(e){if(t.status===`pending`){var n=t;n.status=`fulfilled`,n.value=e}},function(e){if(t.status===`pending`){var n=t;n.status=`rejected`,n.reason=e}})}switch(t.status){case`fulfilled`:return t.value;case`rejected`:throw e=t.reason,Na(e),e}throw Ma=t,Ta}}function ja(e){try{var t=e._init;return t(e._payload)}catch(e){throw typeof e==`object`&&e&&typeof e.then==`function`?(Ma=e,Ta):e}}var Ma=null;function L(){if(Ma===null)throw Error(i(459));var e=Ma;return Ma=null,e}function Na(e){if(e===Ta||e===Da)throw Error(i(483))}var Pa=null,Fa=0;function Ia(e){var t=Fa;return Fa+=1,Pa===null&&(Pa=[]),Aa(Pa,e,t)}function La(e,t){t=t.props.ref,e.ref=t===void 0?null:t}function Ra(e,t){throw t.$$typeof===g?Error(i(525)):(e=Object.prototype.toString.call(t),Error(i(31,e===`[object Object]`?`object with keys {`+Object.keys(t).join(`, `)+`}`:e)))}function za(e){function t(t,n){if(e){var r=t.deletions;r===null?(t.deletions=[n],t.flags|=16):r.push(n)}}function n(n,r){if(!e)return null;for(;r!==null;)t(n,r),r=r.sibling;return null}function r(e){for(var t=new Map;e!==null;)e.key===null?t.set(e.index,e):t.set(e.key,e),e=e.sibling;return t}function a(e,t){return e=gi(e,t),e.index=0,e.sibling=null,e}function o(t,n,r){return t.index=r,e?(r=t.alternate,r===null?(t.flags|=67108866,n):(r=r.index,r<n?(t.flags|=67108866,n):r)):(t.flags|=1048576,n)}function s(t){return e&&t.alternate===null&&(t.flags|=67108866),t}function c(e,t,n,r){return t===null||t.tag!==6?(t=bi(n,e.mode,r),t.return=e,t):(t=a(t,n),t.return=e,t)}function l(e,t,n,r){var i=n.type;return i===y?d(e,t,n.props.children,r,n.key):t!==null&&(t.elementType===i||typeof i==`object`&&i&&i.$$typeof===ne&&ja(i)===t.type)?(t=a(t,n.props),La(t,n),t.return=e,t):(t=vi(n.type,n.key,n.props,null,e.mode,r),La(t,n),t.return=e,t)}function u(e,t,n,r){return t===null||t.tag!==4||t.stateNode.containerInfo!==n.containerInfo||t.stateNode.implementation!==n.implementation?(t=Si(n,e.mode,r),t.return=e,t):(t=a(t,n.children||[]),t.return=e,t)}function d(e,t,n,r,i){return t===null||t.tag!==7?(t=yi(n,e.mode,r,i),t.return=e,t):(t=a(t,n),t.return=e,t)}function f(e,t,n){if(typeof t==`string`&&t!==``||typeof t==`number`||typeof t==`bigint`)return t=bi(``+t,e.mode,n),t.return=e,t;if(typeof t==`object`&&t){switch(t.$$typeof){case _:return n=vi(t.type,t.key,t.props,null,e.mode,n),La(n,t),n.return=e,n;case v:return t=Si(t,e.mode,n),t.return=e,t;case ne:return t=ja(t),f(e,t,n)}if(le(t)||oe(t))return t=yi(t,e.mode,n,null),t.return=e,t;if(typeof t.then==`function`)return f(e,Ia(t),n);if(t.$$typeof===C)return f(e,oa(e,t),n);Ra(e,t)}return null}function p(e,t,n,r){var i=t===null?null:t.key;if(typeof n==`string`&&n!==``||typeof n==`number`||typeof n==`bigint`)return i===null?c(e,t,``+n,r):null;if(typeof n==`object`&&n){switch(n.$$typeof){case _:return n.key===i?l(e,t,n,r):null;case v:return n.key===i?u(e,t,n,r):null;case ne:return n=ja(n),p(e,t,n,r)}if(le(n)||oe(n))return i===null?d(e,t,n,r,null):null;if(typeof n.then==`function`)return p(e,t,Ia(n),r);if(n.$$typeof===C)return p(e,t,oa(e,n),r);Ra(e,n)}return null}function m(e,t,n,r,i){if(typeof r==`string`&&r!==``||typeof r==`number`||typeof r==`bigint`)return e=e.get(n)||null,c(t,e,``+r,i);if(typeof r==`object`&&r){switch(r.$$typeof){case _:return e=e.get(r.key===null?n:r.key)||null,l(t,e,r,i);case v:return e=e.get(r.key===null?n:r.key)||null,u(t,e,r,i);case ne:return r=ja(r),m(e,t,n,r,i)}if(le(r)||oe(r))return e=e.get(n)||null,d(t,e,r,i,null);if(typeof r.then==`function`)return m(e,t,n,Ia(r),i);if(r.$$typeof===C)return m(e,t,n,oa(t,r),i);Ra(t,r)}return null}function h(i,a,s,c){for(var l=null,u=null,d=a,h=a=0,g=null;d!==null&&h<s.length;h++){d.index>h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),P&&Pi(i,h),l;if(d===null){for(;h<s.length;h++)d=f(i,s[h],c),d!==null&&(a=o(d,a,h),u===null?l=d:u.sibling=d,u=d);return P&&Pi(i,h),l}for(d=r(d);h<s.length;h++)g=m(d,i,h,s[h],c),g!==null&&(e&&g.alternate!==null&&d.delete(g.key===null?h:g.key),a=o(g,a,h),u===null?l=g:u.sibling=g,u=g);return e&&d.forEach(function(e){return t(i,e)}),P&&Pi(i,h),l}function g(a,s,c,l){if(c==null)throw Error(i(151));for(var u=null,d=null,h=s,g=s=0,_=null,v=c.next();h!==null&&!v.done;g++,v=c.next()){h.index>g?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),P&&Pi(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return P&&Pi(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),P&&Pi(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===ne&&ja(l)===r.type){n(e,r.sibling),c=a(r,o.props),La(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=yi(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=vi(o.type,o.key,o.props,null,e.mode,c),La(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=Si(o,e.mode,c),c.return=e,e=c}return s(e);case ne:return o=ja(o),b(e,r,o,c)}if(le(o))return h(e,r,o,c);if(oe(o)){if(l=oe(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Ia(o),c);if(o.$$typeof===C)return b(e,r,oa(e,o),c);Ra(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=bi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{Fa=0;var i=b(e,t,n,r);return Pa=null,i}catch(t){if(t===Ta||t===Da)throw t;var a=mi(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Ba=za(!0),Va=za(!1),R=!1;function Ha(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ua(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Wa(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function z(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,G&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=di(e),ui(e,null,n),t}return si(e,r,t,n),di(e)}function Ga(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,st(e,n)}}function Ka(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var qa=!1;function Ja(){if(qa){var e=ga;if(e!==null)throw e}}function Ya(e,t,n,r){qa=!1;var i=e.updateQueue;R=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(J&f)===f:(r&f)===f){f!==0&&f===ha&&(qa=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(m=g.payload,typeof m==`function`){d=m.call(_,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=g.payload,f=typeof m==`function`?m.call(_,d,f):m,f==null)break a;d=h({},d,f);break a;case 2:R=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Gl|=o,e.lanes=o,e.memoizedState=d}}function Xa(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Za(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;e<n.length;e++)Xa(n[e],t)}var Qa=pe(null),$a=pe(0);function eo(e,t){e=Wl,O($a,e),O(Qa,t),Wl=e|t.baseLanes}function to(){O($a,Wl),O(Qa,Qa.current)}function no(){Wl=$a.current,me(Qa),me($a)}var ro=pe(null),io=null;function ao(e){var t=e.alternate;O(uo,uo.current&1),O(ro,e),io===null&&(t===null||Qa.current!==null||t.memoizedState!==null)&&(io=e)}function oo(e){O(uo,uo.current),O(ro,e),io===null&&(io=e)}function so(e){e.tag===22?(O(uo,uo.current),O(ro,e),io===null&&(io=e)):co(e)}function co(){O(uo,uo.current),O(ro,ro.current)}function lo(e){me(ro),io===e&&(io=null),me(uo)}var uo=pe(0);function fo(e){for(var t=e;t!==null;){if(t.tag===13){var n=t.memoizedState;if(n!==null&&(n=n.dehydrated,n===null||af(n)||of(n)))return t}else if(t.tag===19&&(t.memoizedProps.revealOrder===`forwards`||t.memoizedProps.revealOrder===`backwards`||t.memoizedProps.revealOrder===`unstable_legacy-backwards`||t.memoizedProps.revealOrder===`together`)){if(t.flags&128)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}var po=0,B=null,V=null,mo=null,ho=!1,go=!1,_o=!1,vo=0,yo=0,bo=null,xo=0;function H(){throw Error(i(321))}function So(e,t){if(t===null)return!1;for(var n=0;n<t.length&&n<e.length;n++)if(!Or(e[n],t[n]))return!1;return!0}function Co(e,t,n,r,i,a){return po=a,B=t,t.memoizedState=null,t.updateQueue=null,t.lanes=0,E.H=e===null||e.memoizedState===null?Bs:Vs,_o=!1,a=n(r,i),_o=!1,go&&(a=To(t,n,r,i)),wo(e),a}function wo(e){E.H=zs;var t=V!==null&&V.next!==null;if(po=0,mo=V=B=null,ho=!1,yo=0,bo=null,t)throw Error(i(300));e===null||ic||(e=e.dependencies,e!==null&&F(e)&&(ic=!0))}function To(e,t,n,r){B=e;var a=0;do{if(go&&(bo=null),yo=0,go=!1,25<=a)throw Error(i(301));if(a+=1,mo=V=null,e.updateQueue!=null){var o=e.updateQueue;o.lastEffect=null,o.events=null,o.stores=null,o.memoCache!=null&&(o.memoCache.index=0)}E.H=Hs,o=t(n,r)}while(go);return o}function Eo(){var e=E.H,t=e.useState()[0];return t=typeof t.then==`function`?No(t):t,e=e.useState()[0],(V===null?null:V.memoizedState)!==e&&(B.flags|=1024),t}function Do(){var e=vo!==0;return vo=0,e}function Oo(e,t,n){t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~n}function ko(e){if(ho){for(e=e.memoizedState;e!==null;){var t=e.queue;t!==null&&(t.pending=null),e=e.next}ho=!1}po=0,mo=V=B=null,go=!1,yo=vo=0,bo=null}function Ao(){var e={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};return mo===null?B.memoizedState=mo=e:mo=mo.next=e,mo}function jo(){if(V===null){var e=B.alternate;e=e===null?null:e.memoizedState}else e=V.next;var t=mo===null?B.memoizedState:mo.next;if(t!==null)mo=t,V=e;else{if(e===null)throw B.alternate===null?Error(i(467)):Error(i(310));V=e,e={memoizedState:V.memoizedState,baseState:V.baseState,baseQueue:V.baseQueue,queue:V.queue,next:null},mo===null?B.memoizedState=mo=e:mo=mo.next=e}return mo}function Mo(){return{lastEffect:null,events:null,stores:null,memoCache:null}}function No(e){var t=yo;return yo+=1,bo===null&&(bo=[]),e=Aa(bo,e,t),t=B,(mo===null?t.memoizedState:mo.next)===null&&(t=t.alternate,E.H=t===null||t.memoizedState===null?Bs:Vs),e}function Po(e){if(typeof e==`object`&&e){if(typeof e.then==`function`)return No(e);if(e.$$typeof===C)return aa(e)}throw Error(i(438,String(e)))}function Fo(e){var t=null,n=B.updateQueue;if(n!==null&&(t=n.memoCache),t==null){var r=B.alternate;r!==null&&(r=r.updateQueue,r!==null&&(r=r.memoCache,r!=null&&(t={data:r.data.map(function(e){return e.slice()}),index:0})))}if(t??={data:[],index:0},n===null&&(n=Mo(),B.updateQueue=n),n.memoCache=t,n=t.data[t.index],n===void 0)for(n=t.data[t.index]=Array(e),r=0;r<e;r++)n[r]=ie;return t.index++,n}function Io(e,t){return typeof t==`function`?t(e):t}function Lo(e){return Ro(jo(),V,e)}function Ro(e,t,n){var r=e.queue;if(r===null)throw Error(i(311));r.lastRenderedReducer=n;var a=e.baseQueue,o=r.pending;if(o!==null){if(a!==null){var s=a.next;a.next=o.next,o.next=s}t.baseQueue=a=o,r.pending=null}if(o=e.baseState,a===null)e.memoizedState=o;else{t=a.next;var c=s=null,l=null,u=t,d=!1;do{var f=u.lane&-536870913;if(f===u.lane?(po&f)===f:(J&f)===f){var p=u.revertLane;if(p===0)l!==null&&(l=l.next={lane:0,revertLane:0,gesture:null,action:u.action,hasEagerState:u.hasEagerState,eagerState:u.eagerState,next:null}),f===ha&&(d=!0);else if((po&p)===p){u=u.next,p===ha&&(d=!0);continue}else f={lane:0,revertLane:u.revertLane,gesture:null,action:u.action,hasEagerState:u.hasEagerState,eagerState:u.eagerState,next:null},l===null?(c=l=f,s=o):l=l.next=f,B.lanes|=p,Gl|=p;f=u.action,_o&&n(o,f),o=u.hasEagerState?u.eagerState:n(o,f)}else p={lane:f,revertLane:u.revertLane,gesture:u.gesture,action:u.action,hasEagerState:u.hasEagerState,eagerState:u.eagerState,next:null},l===null?(c=l=p,s=o):l=l.next=p,B.lanes|=f,Gl|=f;u=u.next}while(u!==null&&u!==t);if(l===null?s=o:l.next=c,!Or(o,e.memoizedState)&&(ic=!0,d&&(n=ga,n!==null)))throw n;e.memoizedState=o,e.baseState=s,e.baseQueue=l,r.lastRenderedState=o}return a===null&&(r.lanes=0),[e.memoizedState,r.dispatch]}function zo(e){var t=jo(),n=t.queue;if(n===null)throw Error(i(311));n.lastRenderedReducer=e;var r=n.dispatch,a=n.pending,o=t.memoizedState;if(a!==null){n.pending=null;var s=a=a.next;do o=e(o,s.action),s=s.next;while(s!==a);Or(o,t.memoizedState)||(ic=!0),t.memoizedState=o,t.baseQueue===null&&(t.baseState=o),n.lastRenderedState=o}return[o,r]}function Bo(e,t,n){var r=B,a=jo(),o=P;if(o){if(n===void 0)throw Error(i(407));n=n()}else n=t();var s=!Or((V||a).memoizedState,n);if(s&&(a.memoizedState=n,ic=!0),a=a.queue,ds(Uo.bind(null,r,a,e),[e]),a.getSnapshot!==t||s||mo!==null&&mo.memoizedState.tag&1){if(r.flags|=2048,os(9,{destroy:void 0},Ho.bind(null,r,a,n,t),null),K===null)throw Error(i(349));o||po&127||Vo(r,t,n)}return n}function Vo(e,t,n){e.flags|=16384,e={getSnapshot:t,value:n},t=B.updateQueue,t===null?(t=Mo(),B.updateQueue=t,t.stores=[e]):(n=t.stores,n===null?t.stores=[e]:n.push(e))}function Ho(e,t,n,r){t.value=n,t.getSnapshot=r,Wo(t)&&Go(e)}function Uo(e,t,n){return n(function(){Wo(t)&&Go(e)})}function Wo(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!Or(e,n)}catch{return!0}}function Go(e){var t=li(e,2);t!==null&&hu(t,e,2)}function Ko(e){var t=Ao();if(typeof e==`function`){var n=e;if(e=n(),_o){Ge(!0);try{n()}finally{Ge(!1)}}}return t.memoizedState=t.baseState=e,t.queue={pending:null,lanes:0,dispatch:null,lastRenderedReducer:Io,lastRenderedState:e},t}function qo(e,t,n,r){return e.baseState=n,Ro(e,V,typeof r==`function`?r:Io)}function Jo(e,t,n,r,a){if(Is(e))throw Error(i(485));if(e=t.action,e!==null){var o={payload:a,action:e,next:null,isTransition:!0,status:`pending`,value:null,reason:null,listeners:[],then:function(e){o.listeners.push(e)}};E.T===null?o.isTransition=!1:n(!0),r(o),n=t.pending,n===null?(o.next=t.pending=o,Yo(t,o)):(o.next=n.next,t.pending=n.next=o)}}function Yo(e,t){var n=t.action,r=t.payload,i=e.state;if(t.isTransition){var a=E.T,o={};E.T=o;try{var s=n(i,r),c=E.S;c!==null&&c(o,s),Xo(e,t,s)}catch(n){Qo(e,t,n)}finally{a!==null&&o.types!==null&&(a.types=o.types),E.T=a}}else try{a=n(i,r),Xo(e,t,a)}catch(n){Qo(e,t,n)}}function Xo(e,t,n){typeof n==`object`&&n&&typeof n.then==`function`?n.then(function(n){Zo(e,t,n)},function(n){return Qo(e,t,n)}):Zo(e,t,n)}function Zo(e,t,n){t.status=`fulfilled`,t.value=n,$o(t),e.state=n,t=e.pending,t!==null&&(n=t.next,n===t?e.pending=null:(n=n.next,t.next=n,Yo(e,n)))}function Qo(e,t,n){var r=e.pending;if(e.pending=null,r!==null){r=r.next;do t.status=`rejected`,t.reason=n,$o(t),t=t.next;while(t!==r)}e.action=null}function $o(e){e=e.listeners;for(var t=0;t<e.length;t++)(0,e[t])()}function es(e,t){return t}function ts(e,t){if(P){var n=K.formState;if(n!==null){a:{var r=B;if(P){if(N){b:{for(var i=N,a=Vi;i.nodeType!==8;){if(!a){i=null;break b}if(i=cf(i.nextSibling),i===null){i=null;break b}}a=i.data,i=a===`F!`||a===`F`?i:null}if(i){N=cf(i.nextSibling),r=i.data===`F!`;break a}}Ui(r)}r=!1}r&&(t=n[0])}}return n=Ao(),n.memoizedState=n.baseState=t,r={pending:null,lanes:0,dispatch:null,lastRenderedReducer:es,lastRenderedState:t},n.queue=r,n=Ns.bind(null,B,r),r.dispatch=n,r=Ko(!1),a=Fs.bind(null,B,!1,r.queue),r=Ao(),i={state:t,dispatch:null,action:e,pending:null},r.queue=i,n=Jo.bind(null,B,i,a,n),i.dispatch=n,r.memoizedState=e,[t,n,!1]}function ns(e){return rs(jo(),V,e)}function rs(e,t,n){if(t=Ro(e,t,es)[0],e=Lo(Io)[0],typeof t==`object`&&t&&typeof t.then==`function`)try{var r=No(t)}catch(e){throw e===Ta?Da:e}else r=t;t=jo();var i=t.queue,a=i.dispatch;return n!==t.memoizedState&&(B.flags|=2048,os(9,{destroy:void 0},is.bind(null,i,n),null)),[r,a,e]}function is(e,t){e.action=t}function as(e){var t=jo(),n=V;if(n!==null)return rs(t,n,e);jo(),t=t.memoizedState,n=jo();var r=n.queue.dispatch;return n.memoizedState=e,[t,r,!1]}function os(e,t,n,r){return e={tag:e,create:n,deps:r,inst:t,next:null},t=B.updateQueue,t===null&&(t=Mo(),B.updateQueue=t),n=t.lastEffect,n===null?t.lastEffect=e.next=e:(r=n.next,n.next=e,e.next=r,t.lastEffect=e),e}function ss(){return jo().memoizedState}function cs(e,t,n,r){var i=Ao();B.flags|=e,i.memoizedState=os(1|t,{destroy:void 0},n,r===void 0?null:r)}function ls(e,t,n,r){var i=jo();r=r===void 0?null:r;var a=i.memoizedState.inst;V!==null&&r!==null&&So(r,V.memoizedState.deps)?i.memoizedState=os(t,a,n,r):(B.flags|=e,i.memoizedState=os(1|t,a,n,r))}function us(e,t){cs(8390656,8,e,t)}function ds(e,t){ls(2048,8,e,t)}function fs(e){B.flags|=4;var t=B.updateQueue;if(t===null)t=Mo(),B.updateQueue=t,t.events=[e];else{var n=t.events;n===null?t.events=[e]:n.push(e)}}function ps(e){var t=jo().memoizedState;return fs({ref:t,nextImpl:e}),function(){if(G&2)throw Error(i(440));return t.impl.apply(void 0,arguments)}}function ms(e,t){return ls(4,2,e,t)}function hs(e,t){return ls(4,4,e,t)}function gs(e,t){if(typeof t==`function`){e=e();var n=t(e);return function(){typeof n==`function`?n():t(null)}}if(t!=null)return e=e(),t.current=e,function(){t.current=null}}function _s(e,t,n){n=n==null?null:n.concat([e]),ls(4,4,gs.bind(null,t,e),n)}function vs(){}function ys(e,t){var n=jo();t=t===void 0?null:t;var r=n.memoizedState;return t!==null&&So(t,r[1])?r[0]:(n.memoizedState=[e,t],e)}function bs(e,t){var n=jo();t=t===void 0?null:t;var r=n.memoizedState;if(t!==null&&So(t,r[1]))return r[0];if(r=e(),_o){Ge(!0);try{e()}finally{Ge(!1)}}return n.memoizedState=[r,t],r}function xs(e,t,n){return n===void 0||po&1073741824&&!(J&261930)?e.memoizedState=t:(e.memoizedState=n,e=mu(),B.lanes|=e,Gl|=e,n)}function Ss(e,t,n,r){return Or(n,t)?n:Qa.current===null?!(po&42)||po&1073741824&&!(J&261930)?(ic=!0,e.memoizedState=n):(e=mu(),B.lanes|=e,Gl|=e,t):(e=xs(e,n,r),Or(e,t)||(ic=!0),e)}function Cs(e,t,n,r,i){var a=D.p;D.p=a!==0&&8>a?a:8;var o=E.T,s={};E.T=s,Fs(e,!1,t,n);try{var c=i(),l=E.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Ps(e,t,ya(c,r),pu(e)):Ps(e,t,r,pu(e))}catch(n){Ps(e,t,{then:function(){},status:`rejected`,reason:n},pu())}finally{D.p=a,o!==null&&s.types!==null&&(o.types=s.types),E.T=o}}function ws(){}function Ts(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=Es(e).queue;Cs(e,a,t,ue,n===null?ws:function(){return Ds(e),n(r)})}function Es(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ue,baseState:ue,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Io,lastRenderedState:ue},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Io,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Ds(e){var t=Es(e);t.next===null&&(t=e.alternate.memoizedState),Ps(e,t.next.queue,{},pu())}function Os(){return aa(Qf)}function ks(){return jo().memoizedState}function As(){return jo().memoizedState}function js(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=pu();e=Wa(n);var r=z(t,e,n);r!==null&&(hu(r,t,n),Ga(r,t,n)),t={cache:da()},e.payload=t;return}t=t.return}}function Ms(e,t,n){var r=pu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Is(e)?Ls(t,n):(n=ci(e,t,n,r),n!==null&&(hu(n,e,r),Rs(n,t,r)))}function Ns(e,t,n){Ps(e,t,n,pu())}function Ps(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Is(e))Ls(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Or(s,o))return si(e,t,i,0),K===null&&oi(),!1}catch{}if(n=ci(e,t,i,r),n!==null)return hu(n,e,r),Rs(n,t,r),!0}return!1}function Fs(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Is(e)){if(t)throw Error(i(479))}else t=ci(e,n,r,2),t!==null&&hu(t,e,2)}function Is(e){var t=e.alternate;return e===B||t!==null&&t===B}function Ls(e,t){go=ho=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Rs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,st(e,n)}}var zs={readContext:aa,use:Po,useCallback:H,useContext:H,useEffect:H,useImperativeHandle:H,useLayoutEffect:H,useInsertionEffect:H,useMemo:H,useReducer:H,useRef:H,useState:H,useDebugValue:H,useDeferredValue:H,useTransition:H,useSyncExternalStore:H,useId:H,useHostTransitionStatus:H,useFormState:H,useActionState:H,useOptimistic:H,useMemoCache:H,useCacheRefresh:H};zs.useEffectEvent=H;var Bs={readContext:aa,use:Po,useCallback:function(e,t){return Ao().memoizedState=[e,t===void 0?null:t],e},useContext:aa,useEffect:us,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),cs(4194308,4,gs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return cs(4194308,4,e,t)},useInsertionEffect:function(e,t){cs(4,2,e,t)},useMemo:function(e,t){var n=Ao();t=t===void 0?null:t;var r=e();if(_o){Ge(!0);try{e()}finally{Ge(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Ao();if(n!==void 0){var i=n(t);if(_o){Ge(!0);try{n(t)}finally{Ge(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Ms.bind(null,B,e),[r.memoizedState,e]},useRef:function(e){var t=Ao();return e={current:e},t.memoizedState=e},useState:function(e){e=Ko(e);var t=e.queue,n=Ns.bind(null,B,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:vs,useDeferredValue:function(e,t){return xs(Ao(),e,t)},useTransition:function(){var e=Ko(!1);return e=Cs.bind(null,B,e.queue,!0,!1),Ao().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=B,a=Ao();if(P){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),K===null)throw Error(i(349));J&127||Vo(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,us(Uo.bind(null,r,o,e),[e]),r.flags|=2048,os(9,{destroy:void 0},Ho.bind(null,r,o,n,t),null),n},useId:function(){var e=Ao(),t=K.identifierPrefix;if(P){var n=Ni,r=Mi;n=(r&~(1<<32-Ke(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=vo++,0<n&&(t+=`H`+n.toString(32)),t+=`_`}else n=xo++,t=`_`+t+`r_`+n.toString(32)+`_`;return e.memoizedState=t},useHostTransitionStatus:Os,useFormState:ts,useActionState:ts,useOptimistic:function(e){var t=Ao();t.memoizedState=t.baseState=e;var n={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return t.queue=n,t=Fs.bind(null,B,!0,n),n.dispatch=t,[e,t]},useMemoCache:Fo,useCacheRefresh:function(){return Ao().memoizedState=js.bind(null,B)},useEffectEvent:function(e){var t=Ao(),n={impl:e};return t.memoizedState=n,function(){if(G&2)throw Error(i(440));return n.impl.apply(void 0,arguments)}}},Vs={readContext:aa,use:Po,useCallback:ys,useContext:aa,useEffect:ds,useImperativeHandle:_s,useInsertionEffect:ms,useLayoutEffect:hs,useMemo:bs,useReducer:Lo,useRef:ss,useState:function(){return Lo(Io)},useDebugValue:vs,useDeferredValue:function(e,t){return Ss(jo(),V.memoizedState,e,t)},useTransition:function(){var e=Lo(Io)[0],t=jo().memoizedState;return[typeof e==`boolean`?e:No(e),t]},useSyncExternalStore:Bo,useId:ks,useHostTransitionStatus:Os,useFormState:ns,useActionState:ns,useOptimistic:function(e,t){return qo(jo(),V,e,t)},useMemoCache:Fo,useCacheRefresh:As};Vs.useEffectEvent=ps;var Hs={readContext:aa,use:Po,useCallback:ys,useContext:aa,useEffect:ds,useImperativeHandle:_s,useInsertionEffect:ms,useLayoutEffect:hs,useMemo:bs,useReducer:zo,useRef:ss,useState:function(){return zo(Io)},useDebugValue:vs,useDeferredValue:function(e,t){var n=jo();return V===null?xs(n,e,t):Ss(n,V.memoizedState,e,t)},useTransition:function(){var e=zo(Io)[0],t=jo().memoizedState;return[typeof e==`boolean`?e:No(e),t]},useSyncExternalStore:Bo,useId:ks,useHostTransitionStatus:Os,useFormState:as,useActionState:as,useOptimistic:function(e,t){var n=jo();return V===null?(n.baseState=e,[e,n.queue.dispatch]):qo(n,V,e,t)},useMemoCache:Fo,useCacheRefresh:As};Hs.useEffectEvent=ps;function Us(e,t,n,r){t=e.memoizedState,n=n(r,t),n=n==null?t:h({},t,n),e.memoizedState=n,e.lanes===0&&(e.updateQueue.baseState=n)}var Ws={enqueueSetState:function(e,t,n){e=e._reactInternals;var r=pu(),i=Wa(r);i.payload=t,n!=null&&(i.callback=n),t=z(e,i,r),t!==null&&(hu(t,e,r),Ga(t,e,r))},enqueueReplaceState:function(e,t,n){e=e._reactInternals;var r=pu(),i=Wa(r);i.tag=1,i.payload=t,n!=null&&(i.callback=n),t=z(e,i,r),t!==null&&(hu(t,e,r),Ga(t,e,r))},enqueueForceUpdate:function(e,t){e=e._reactInternals;var n=pu(),r=Wa(n);r.tag=2,t!=null&&(r.callback=t),t=z(e,r,n),t!==null&&(hu(t,e,n),Ga(t,e,n))}};function Gs(e,t,n,r,i,a,o){return e=e.stateNode,typeof e.shouldComponentUpdate==`function`?e.shouldComponentUpdate(r,a,o):t.prototype&&t.prototype.isPureReactComponent?!kr(n,r)||!kr(i,a):!0}function Ks(e,t,n,r){e=t.state,typeof t.componentWillReceiveProps==`function`&&t.componentWillReceiveProps(n,r),typeof t.UNSAFE_componentWillReceiveProps==`function`&&t.UNSAFE_componentWillReceiveProps(n,r),t.state!==e&&Ws.enqueueReplaceState(t,t.state,null)}function qs(e,t){var n=t;if(`ref`in t)for(var r in n={},t)r!==`ref`&&(n[r]=t[r]);if(e=e.defaultProps)for(var i in n===t&&(n=h({},n)),e)n[i]===void 0&&(n[i]=e[i]);return n}function Js(e){ni(e)}function Ys(e){console.error(e)}function Xs(e){ni(e)}function Zs(e,t){try{var n=e.onUncaughtError;n(t.value,{componentStack:t.stack})}catch(e){setTimeout(function(){throw e})}}function Qs(e,t,n){try{var r=e.onCaughtError;r(n.value,{componentStack:n.stack,errorBoundary:t.tag===1?t.stateNode:null})}catch(e){setTimeout(function(){throw e})}}function $s(e,t,n){return n=Wa(n),n.tag=3,n.payload={element:null},n.callback=function(){Zs(e,t)},n}function ec(e){return e=Wa(e),e.tag=3,e}function tc(e,t,n,r){var i=n.type.getDerivedStateFromError;if(typeof i==`function`){var a=r.value;e.payload=function(){return i(a)},e.callback=function(){Qs(t,n,r)}}var o=n.stateNode;o!==null&&typeof o.componentDidCatch==`function`&&(e.callback=function(){Qs(t,n,r),typeof i!=`function`&&(ru===null?ru=new Set([this]):ru.add(this));var e=r.stack;this.componentDidCatch(r.value,{componentStack:e===null?``:e})})}function nc(e,t,n,r,a){if(n.flags|=32768,typeof r==`object`&&r&&typeof r.then==`function`){if(t=n.alternate,t!==null&&ra(t,n,a,!0),n=ro.current,n!==null){switch(n.tag){case 31:case 13:return io===null?Du():n.alternate===null&&X===0&&(X=3),n.flags&=-257,n.flags|=65536,n.lanes=a,r===Oa?n.flags|=16384:(t=n.updateQueue,t===null?n.updateQueue=new Set([r]):t.add(r),Gu(e,r,a)),!1;case 22:return n.flags|=65536,r===Oa?n.flags|=16384:(t=n.updateQueue,t===null?(t={transitions:null,markerInstances:null,retryQueue:new Set([r])},n.updateQueue=t):(n=t.retryQueue,n===null?t.retryQueue=new Set([r]):n.add(r)),Gu(e,r,a)),!1}throw Error(i(435,n.tag))}return Gu(e,r,a),Du(),!1}if(P)return t=ro.current,t===null?(r!==Hi&&(t=Error(i(423),{cause:r}),Yi(wi(t,n))),e=e.current.alternate,e.flags|=65536,a&=-a,e.lanes|=a,r=wi(r,n),a=$s(e.stateNode,r,a),Ka(e,a),X!==4&&(X=2)):(!(t.flags&65536)&&(t.flags|=256),t.flags|=65536,t.lanes=a,r!==Hi&&(e=Error(i(422),{cause:r}),Yi(wi(e,n)))),!1;var o=Error(i(520),{cause:r});if(o=wi(o,n),Xl===null?Xl=[o]:Xl.push(o),X!==4&&(X=2),t===null)return!0;r=wi(r,n),n=t;do{switch(n.tag){case 3:return n.flags|=65536,e=a&-a,n.lanes|=e,e=$s(n.stateNode,r,e),Ka(n,e),!1;case 1:if(t=n.type,o=n.stateNode,!(n.flags&128)&&(typeof t.getDerivedStateFromError==`function`||o!==null&&typeof o.componentDidCatch==`function`&&(ru===null||!ru.has(o))))return n.flags|=65536,a&=-a,n.lanes|=a,a=ec(a),tc(a,e,n,r),Ka(n,a),!1}n=n.return}while(n!==null);return!1}var rc=Error(i(461)),ic=!1;function ac(e,t,n,r){t.child=e===null?Va(t,null,n,r):Ba(t,e.child,n,r)}function oc(e,t,n,r,i){n=n.render;var a=t.ref;if(`ref`in r){var o={};for(var s in r)s!==`ref`&&(o[s]=r[s])}else o=r;return ia(t),r=Co(e,t,n,o,a,i),s=Do(),e!==null&&!ic?(Oo(e,t,i),Ac(e,t,i)):(P&&s&&Ii(t),t.flags|=1,ac(e,t,r,i),t.child)}function sc(e,t,n,r,i){if(e===null){var a=n.type;return typeof a==`function`&&!hi(a)&&a.defaultProps===void 0&&n.compare===null?(t.tag=15,t.type=a,cc(e,t,a,r,i)):(e=vi(n.type,null,r,t,t.mode,i),e.ref=t.ref,e.return=t,t.child=e)}if(a=e.child,!jc(e,i)){var o=a.memoizedProps;if(n=n.compare,n=n===null?kr:n,n(o,r)&&e.ref===t.ref)return Ac(e,t,i)}return t.flags|=1,e=gi(a,r),e.ref=t.ref,e.return=t,t.child=e}function cc(e,t,n,r,i){if(e!==null){var a=e.memoizedProps;if(kr(a,r)&&e.ref===t.ref)if(ic=!1,t.pendingProps=r=a,jc(e,i))e.flags&131072&&(ic=!0);else return t.lanes=e.lanes,Ac(e,t,i)}return gc(e,t,n,r,i)}function lc(e,t,n,r){var i=r.children,a=e===null?null:e.memoizedState;if(e===null&&t.stateNode===null&&(t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),r.mode===`hidden`){if(t.flags&128){if(a=a===null?n:a.baseLanes|n,e!==null){for(r=t.child=e.child,i=0;r!==null;)i=i|r.lanes|r.childLanes,r=r.sibling;r=i&~a}else r=0,t.child=null;return dc(e,t,a,n,r)}if(n&536870912)t.memoizedState={baseLanes:0,cachePool:null},e!==null&&Ca(t,a===null?null:a.cachePool),a===null?to():eo(t,a),so(t);else return r=t.lanes=536870912,dc(e,t,a===null?n:a.baseLanes|n,n,r)}else a===null?(e!==null&&Ca(t,null),to(),co(t)):(Ca(t,a.cachePool),eo(t,a),co(t),t.memoizedState=null);return ac(e,t,i,n),t.child}function uc(e,t){return e!==null&&e.tag===22||t.stateNode!==null||(t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),t.sibling}function dc(e,t,n,r,i){var a=Sa();return a=a===null?null:{parent:I._currentValue,pool:a},t.memoizedState={baseLanes:n,cachePool:a},e!==null&&Ca(t,null),to(),so(t),e!==null&&ra(e,t,r,!0),t.childLanes=i,null}function fc(e,t){return t=Tc({mode:t.mode,children:t.children},e.mode),t.ref=e.ref,e.child=t,t.return=e,t}function pc(e,t,n){return Ba(t,e.child,null,n),e=fc(t,t.pendingProps),e.flags|=2,lo(t),t.memoizedState=null,e}function mc(e,t,n){var r=t.pendingProps,a=(t.flags&128)!=0;if(t.flags&=-129,e===null){if(P){if(r.mode===`hidden`)return e=fc(t,r),t.lanes=536870912,uc(null,e);if(oo(t),(e=N)?(e=rf(e,Vi),e=e!==null&&e.data===`&`?e:null,e!==null&&(t.memoizedState={dehydrated:e,treeContext:ji===null?null:{id:Mi,overflow:Ni},retryLane:536870912,hydrationErrors:null},n=xi(e),n.return=t,t.child=n,zi=t,N=null)):e=null,e===null)throw Ui(t);return t.lanes=536870912,null}return fc(t,r)}var o=e.memoizedState;if(o!==null){var s=o.dehydrated;if(oo(t),a)if(t.flags&256)t.flags&=-257,t=pc(e,t,n);else if(t.memoizedState!==null)t.child=e.child,t.flags|=128,t=null;else throw Error(i(558));else if(ic||ra(e,t,n,!1),a=(n&e.childLanes)!==0,ic||a){if(r=K,r!==null&&(s=ct(r,n),s!==0&&s!==o.retryLane))throw o.retryLane=s,li(e,s),hu(r,e,s),rc;Du(),t=pc(e,t,n)}else e=o.treeContext,N=cf(s.nextSibling),zi=t,P=!0,Bi=null,Vi=!1,e!==null&&Ri(t,e),t=fc(t,r),t.flags|=4096;return t}return e=gi(e.child,{mode:r.mode,children:r.children}),e.ref=t.ref,t.child=e,e.return=t,e}function hc(e,t){var n=t.ref;if(n===null)e!==null&&e.ref!==null&&(t.flags|=4194816);else{if(typeof n!=`function`&&typeof n!=`object`)throw Error(i(284));(e===null||e.ref!==n)&&(t.flags|=4194816)}}function gc(e,t,n,r,i){return ia(t),n=Co(e,t,n,r,void 0,i),r=Do(),e!==null&&!ic?(Oo(e,t,i),Ac(e,t,i)):(P&&r&&Ii(t),t.flags|=1,ac(e,t,n,i),t.child)}function _c(e,t,n,r,i,a){return ia(t),t.updateQueue=null,n=To(t,r,n,i),wo(e),r=Do(),e!==null&&!ic?(Oo(e,t,a),Ac(e,t,a)):(P&&r&&Ii(t),t.flags|=1,ac(e,t,n,a),t.child)}function vc(e,t,n,r,i){if(ia(t),t.stateNode===null){var a=fi,o=n.contextType;typeof o==`object`&&o&&(a=aa(o)),a=new n(r,a),t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,a.updater=Ws,t.stateNode=a,a._reactInternals=t,a=t.stateNode,a.props=r,a.state=t.memoizedState,a.refs={},Ha(t),o=n.contextType,a.context=typeof o==`object`&&o?aa(o):fi,a.state=t.memoizedState,o=n.getDerivedStateFromProps,typeof o==`function`&&(Us(t,n,o,r),a.state=t.memoizedState),typeof n.getDerivedStateFromProps==`function`||typeof a.getSnapshotBeforeUpdate==`function`||typeof a.UNSAFE_componentWillMount!=`function`&&typeof a.componentWillMount!=`function`||(o=a.state,typeof a.componentWillMount==`function`&&a.componentWillMount(),typeof a.UNSAFE_componentWillMount==`function`&&a.UNSAFE_componentWillMount(),o!==a.state&&Ws.enqueueReplaceState(a,a.state,null),Ya(t,r,a,i),Ja(),a.state=t.memoizedState),typeof a.componentDidMount==`function`&&(t.flags|=4194308),r=!0}else if(e===null){a=t.stateNode;var s=t.memoizedProps,c=qs(n,s);a.props=c;var l=a.context,u=n.contextType;o=fi,typeof u==`object`&&u&&(o=aa(u));var d=n.getDerivedStateFromProps;u=typeof d==`function`||typeof a.getSnapshotBeforeUpdate==`function`,s=t.pendingProps!==s,u||typeof a.UNSAFE_componentWillReceiveProps!=`function`&&typeof a.componentWillReceiveProps!=`function`||(s||l!==o)&&Ks(t,a,r,o),R=!1;var f=t.memoizedState;a.state=f,Ya(t,r,a,i),Ja(),l=t.memoizedState,s||f!==l||R?(typeof d==`function`&&(Us(t,n,d,r),l=t.memoizedState),(c=R||Gs(t,n,c,r,f,l,o))?(u||typeof a.UNSAFE_componentWillMount!=`function`&&typeof a.componentWillMount!=`function`||(typeof a.componentWillMount==`function`&&a.componentWillMount(),typeof a.UNSAFE_componentWillMount==`function`&&a.UNSAFE_componentWillMount()),typeof a.componentDidMount==`function`&&(t.flags|=4194308)):(typeof a.componentDidMount==`function`&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=l),a.props=r,a.state=l,a.context=o,r=c):(typeof a.componentDidMount==`function`&&(t.flags|=4194308),r=!1)}else{a=t.stateNode,Ua(e,t),o=t.memoizedProps,u=qs(n,o),a.props=u,d=t.pendingProps,f=a.context,l=n.contextType,c=fi,typeof l==`object`&&l&&(c=aa(l)),s=n.getDerivedStateFromProps,(l=typeof s==`function`||typeof a.getSnapshotBeforeUpdate==`function`)||typeof a.UNSAFE_componentWillReceiveProps!=`function`&&typeof a.componentWillReceiveProps!=`function`||(o!==d||f!==c)&&Ks(t,a,r,c),R=!1,f=t.memoizedState,a.state=f,Ya(t,r,a,i),Ja();var p=t.memoizedState;o!==d||f!==p||R||e!==null&&e.dependencies!==null&&F(e.dependencies)?(typeof s==`function`&&(Us(t,n,s,r),p=t.memoizedState),(u=R||Gs(t,n,u,r,f,p,c)||e!==null&&e.dependencies!==null&&F(e.dependencies))?(l||typeof a.UNSAFE_componentWillUpdate!=`function`&&typeof a.componentWillUpdate!=`function`||(typeof a.componentWillUpdate==`function`&&a.componentWillUpdate(r,p,c),typeof a.UNSAFE_componentWillUpdate==`function`&&a.UNSAFE_componentWillUpdate(r,p,c)),typeof a.componentDidUpdate==`function`&&(t.flags|=4),typeof a.getSnapshotBeforeUpdate==`function`&&(t.flags|=1024)):(typeof a.componentDidUpdate!=`function`||o===e.memoizedProps&&f===e.memoizedState||(t.flags|=4),typeof a.getSnapshotBeforeUpdate!=`function`||o===e.memoizedProps&&f===e.memoizedState||(t.flags|=1024),t.memoizedProps=r,t.memoizedState=p),a.props=r,a.state=p,a.context=c,r=u):(typeof a.componentDidUpdate!=`function`||o===e.memoizedProps&&f===e.memoizedState||(t.flags|=4),typeof a.getSnapshotBeforeUpdate!=`function`||o===e.memoizedProps&&f===e.memoizedState||(t.flags|=1024),r=!1)}return a=r,hc(e,t),r=(t.flags&128)!=0,a||r?(a=t.stateNode,n=r&&typeof n.getDerivedStateFromError!=`function`?null:a.render(),t.flags|=1,e!==null&&r?(t.child=Ba(t,e.child,null,i),t.child=Ba(t,null,n,i)):ac(e,t,n,i),t.memoizedState=a.state,e=t.child):e=Ac(e,t,i),e}function yc(e,t,n,r){return qi(),t.flags|=256,ac(e,t,n,r),t.child}var bc={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function xc(e){return{baseLanes:e,cachePool:wa()}}function Sc(e,t,n){return e=e===null?0:e.childLanes&~n,t&&(e|=Jl),e}function Cc(e,t,n){var r=t.pendingProps,a=!1,o=(t.flags&128)!=0,s;if((s=o)||(s=e!==null&&e.memoizedState===null?!1:(uo.current&2)!=0),s&&(a=!0,t.flags&=-129),s=(t.flags&32)!=0,t.flags&=-33,e===null){if(P){if(a?ao(t):co(t),(e=N)?(e=rf(e,Vi),e=e!==null&&e.data!==`&`?e:null,e!==null&&(t.memoizedState={dehydrated:e,treeContext:ji===null?null:{id:Mi,overflow:Ni},retryLane:536870912,hydrationErrors:null},n=xi(e),n.return=t,t.child=n,zi=t,N=null)):e=null,e===null)throw Ui(t);return of(e)?t.lanes=32:t.lanes=536870912,null}var c=r.children;return r=r.fallback,a?(co(t),a=t.mode,c=Tc({mode:`hidden`,children:c},a),r=yi(r,a,n,null),c.return=t,r.return=t,c.sibling=r,t.child=c,r=t.child,r.memoizedState=xc(n),r.childLanes=Sc(e,s,n),t.memoizedState=bc,uc(null,r)):(ao(t),wc(t,c))}var l=e.memoizedState;if(l!==null&&(c=l.dehydrated,c!==null)){if(o)t.flags&256?(ao(t),t.flags&=-257,t=Ec(e,t,n)):t.memoizedState===null?(co(t),c=r.fallback,a=t.mode,r=Tc({mode:`visible`,children:r.children},a),c=yi(c,a,n,null),c.flags|=2,r.return=t,c.return=t,r.sibling=c,t.child=r,Ba(t,e.child,null,n),r=t.child,r.memoizedState=xc(n),r.childLanes=Sc(e,s,n),t.memoizedState=bc,t=uc(null,r)):(co(t),t.child=e.child,t.flags|=128,t=null);else if(ao(t),of(c)){if(s=c.nextSibling&&c.nextSibling.dataset,s)var u=s.dgst;s=u,r=Error(i(419)),r.stack=``,r.digest=s,Yi({value:r,source:null,stack:null}),t=Ec(e,t,n)}else if(ic||ra(e,t,n,!1),s=(n&e.childLanes)!==0,ic||s){if(s=K,s!==null&&(r=ct(s,n),r!==0&&r!==l.retryLane))throw l.retryLane=r,li(e,r),hu(s,e,r),rc;af(c)||Du(),t=Ec(e,t,n)}else af(c)?(t.flags|=192,t.child=e.child,t=null):(e=l.treeContext,N=cf(c.nextSibling),zi=t,P=!0,Bi=null,Vi=!1,e!==null&&Ri(t,e),t=wc(t,r.children),t.flags|=4096);return t}return a?(co(t),c=r.fallback,a=t.mode,l=e.child,u=l.sibling,r=gi(l,{mode:`hidden`,children:r.children}),r.subtreeFlags=l.subtreeFlags&65011712,u===null?(c=yi(c,a,n,null),c.flags|=2):c=gi(u,c),c.return=t,r.return=t,r.sibling=c,t.child=r,uc(null,r),r=t.child,c=e.child.memoizedState,c===null?c=xc(n):(a=c.cachePool,a===null?a=wa():(l=I._currentValue,a=a.parent===l?a:{parent:l,pool:l}),c={baseLanes:c.baseLanes|n,cachePool:a}),r.memoizedState=c,r.childLanes=Sc(e,s,n),t.memoizedState=bc,uc(e.child,r)):(ao(t),n=e.child,e=n.sibling,n=gi(n,{mode:`visible`,children:r.children}),n.return=t,n.sibling=null,e!==null&&(s=t.deletions,s===null?(t.deletions=[e],t.flags|=16):s.push(e)),t.child=n,t.memoizedState=null,n)}function wc(e,t){return t=Tc({mode:`visible`,children:t},e.mode),t.return=e,e.child=t}function Tc(e,t){return e=mi(22,e,null,t),e.lanes=0,e}function Ec(e,t,n){return Ba(t,e.child,null,n),e=wc(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function Dc(e,t,n){e.lanes|=t;var r=e.alternate;r!==null&&(r.lanes|=t),ta(e.return,t,n)}function Oc(e,t,n,r,i,a){var o=e.memoizedState;o===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:r,tail:n,tailMode:i,treeForkCount:a}:(o.isBackwards=t,o.rendering=null,o.renderingStartTime=0,o.last=r,o.tail=n,o.tailMode=i,o.treeForkCount=a)}function kc(e,t,n){var r=t.pendingProps,i=r.revealOrder,a=r.tail;r=r.children;var o=uo.current,s=(o&2)!=0;if(s?(o=o&1|2,t.flags|=128):o&=1,O(uo,o),ac(e,t,r,n),r=P?Oi:0,!s&&e!==null&&e.flags&128)a:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&Dc(e,n,t);else if(e.tag===19)Dc(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break a;for(;e.sibling===null;){if(e.return===null||e.return===t)break a;e=e.return}e.sibling.return=e.return,e=e.sibling}switch(i){case`forwards`:for(n=t.child,i=null;n!==null;)e=n.alternate,e!==null&&fo(e)===null&&(i=n),n=n.sibling;n=i,n===null?(i=t.child,t.child=null):(i=n.sibling,n.sibling=null),Oc(t,!1,i,n,a,r);break;case`backwards`:case`unstable_legacy-backwards`:for(n=null,i=t.child,t.child=null;i!==null;){if(e=i.alternate,e!==null&&fo(e)===null){t.child=i;break}e=i.sibling,i.sibling=n,n=i,i=e}Oc(t,!0,n,null,a,r);break;case`together`:Oc(t,!1,null,null,void 0,r);break;default:t.memoizedState=null}return t.child}function Ac(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),Gl|=t.lanes,(n&t.childLanes)===0)if(e!==null){if(ra(e,t,n,!1),(n&t.childLanes)===0)return null}else return null;if(e!==null&&t.child!==e.child)throw Error(i(153));if(t.child!==null){for(e=t.child,n=gi(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=gi(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function jc(e,t){return(e.lanes&t)===0?(e=e.dependencies,!!(e!==null&&F(e))):!0}function Mc(e,t,n){switch(t.tag){case 3:ye(t,t.stateNode.containerInfo),$i(t,I,e.memoizedState.cache),qi();break;case 27:case 5:xe(t);break;case 4:ye(t,t.stateNode.containerInfo);break;case 10:$i(t,t.type,t.memoizedProps.value);break;case 31:if(t.memoizedState!==null)return t.flags|=128,oo(t),null;break;case 13:var r=t.memoizedState;if(r!==null)return r.dehydrated===null?(n&t.child.childLanes)===0?(ao(t),e=Ac(e,t,n),e===null?null:e.sibling):Cc(e,t,n):(ao(t),t.flags|=128,null);ao(t);break;case 19:var i=(e.flags&128)!=0;if(r=(n&t.childLanes)!==0,r||=(ra(e,t,n,!1),(n&t.childLanes)!==0),i){if(r)return kc(e,t,n);t.flags|=128}if(i=t.memoizedState,i!==null&&(i.rendering=null,i.tail=null,i.lastEffect=null),O(uo,uo.current),r)break;return null;case 22:return t.lanes=0,lc(e,t,n,t.pendingProps);case 24:$i(t,I,e.memoizedState.cache)}return Ac(e,t,n)}function Nc(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps)ic=!0;else{if(!jc(e,n)&&!(t.flags&128))return ic=!1,Mc(e,t,n);ic=!!(e.flags&131072)}else ic=!1,P&&t.flags&1048576&&Fi(t,Oi,t.index);switch(t.lanes=0,t.tag){case 16:a:{var r=t.pendingProps;if(e=ja(t.elementType),t.type=e,typeof e==`function`)hi(e)?(r=qs(e,r),t.tag=1,t=vc(null,t,e,r,n)):(t.tag=0,t=gc(null,t,e,r,n));else{if(e!=null){var a=e.$$typeof;if(a===w){t.tag=11,t=oc(null,t,e,r,n);break a}else if(a===te){t.tag=14,t=sc(null,t,e,r,n);break a}}throw t=ce(e)||e,Error(i(306,t,``))}}return t;case 0:return gc(e,t,t.type,t.pendingProps,n);case 1:return r=t.type,a=qs(r,t.pendingProps),vc(e,t,r,a,n);case 3:a:{if(ye(t,t.stateNode.containerInfo),e===null)throw Error(i(387));r=t.pendingProps;var o=t.memoizedState;a=o.element,Ua(e,t),Ya(t,r,null,n);var s=t.memoizedState;if(r=s.cache,$i(t,I,r),r!==o.cache&&na(t,[I],n,!0),Ja(),r=s.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:s.cache},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){t=yc(e,t,r,n);break a}else if(r!==a){a=wi(Error(i(424)),t),Yi(a),t=yc(e,t,r,n);break a}else{switch(e=t.stateNode.containerInfo,e.nodeType){case 9:e=e.body;break;default:e=e.nodeName===`HTML`?e.ownerDocument.body:e}for(N=cf(e.firstChild),zi=t,P=!0,Bi=null,Vi=!0,n=Va(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling}else{if(qi(),r===a){t=Ac(e,t,n);break a}ac(e,t,r,n)}t=t.child}return t;case 26:return hc(e,t),e===null?(n=kf(t.type,null,t.pendingProps,null))?t.memoizedState=n:P||(n=t.type,e=t.pendingProps,r=Bd(_e.current).createElement(n),r[mt]=t,r[ht]=e,Pd(r,n,e),Et(r),t.stateNode=r):t.memoizedState=kf(t.type,e.memoizedProps,t.pendingProps,e.memoizedState),null;case 27:return xe(t),e===null&&P&&(r=t.stateNode=ff(t.type,t.pendingProps,_e.current),zi=t,Vi=!0,a=N,Zd(t.type)?(lf=a,N=cf(r.firstChild)):N=a),ac(e,t,t.pendingProps.children,n),hc(e,t),e===null&&(t.flags|=4194304),t.child;case 5:return e===null&&P&&((a=r=N)&&(r=tf(r,t.type,t.pendingProps,Vi),r===null?a=!1:(t.stateNode=r,zi=t,N=cf(r.firstChild),Vi=!1,a=!0)),a||Ui(t)),xe(t),a=t.type,o=t.pendingProps,s=e===null?null:e.memoizedProps,r=o.children,Ud(a,o)?r=null:s!==null&&Ud(a,s)&&(t.flags|=32),t.memoizedState!==null&&(a=Co(e,t,Eo,null,null,n),Qf._currentValue=a),hc(e,t),ac(e,t,r,n),t.child;case 6:return e===null&&P&&((e=n=N)&&(n=nf(n,t.pendingProps,Vi),n===null?e=!1:(t.stateNode=n,zi=t,N=null,e=!0)),e||Ui(t)),null;case 13:return Cc(e,t,n);case 4:return ye(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Ba(t,null,r,n):ac(e,t,r,n),t.child;case 11:return oc(e,t,t.type,t.pendingProps,n);case 7:return ac(e,t,t.pendingProps,n),t.child;case 8:return ac(e,t,t.pendingProps.children,n),t.child;case 12:return ac(e,t,t.pendingProps.children,n),t.child;case 10:return r=t.pendingProps,$i(t,t.type,r.value),ac(e,t,r.children,n),t.child;case 9:return a=t.type._context,r=t.pendingProps.children,ia(t),a=aa(a),r=r(a),t.flags|=1,ac(e,t,r,n),t.child;case 14:return sc(e,t,t.type,t.pendingProps,n);case 15:return cc(e,t,t.type,t.pendingProps,n);case 19:return kc(e,t,n);case 31:return mc(e,t,n);case 22:return lc(e,t,n,t.pendingProps);case 24:return ia(t),r=aa(I),e===null?(a=Sa(),a===null&&(a=K,o=da(),a.pooledCache=o,o.refCount++,o!==null&&(a.pooledCacheLanes|=n),a=o),t.memoizedState={parent:r,cache:a},Ha(t),$i(t,I,a)):((e.lanes&n)!==0&&(Ua(e,t),Ya(t,null,null,n),Ja()),a=e.memoizedState,o=t.memoizedState,a.parent===r?(r=o.cache,$i(t,I,r),r!==a.cache&&na(t,[I],n,!0)):(a={parent:r,cache:r},t.memoizedState=a,t.lanes===0&&(t.memoizedState=t.updateQueue.baseState=a),$i(t,I,r))),ac(e,t,t.pendingProps.children,n),t.child;case 29:throw t.pendingProps}throw Error(i(156,t.tag))}function Pc(e){e.flags|=4}function Fc(e,t,n,r,i){if((t=(e.mode&32)!=0)&&(t=!1),t){if(e.flags|=16777216,(i&335544128)===i)if(e.stateNode.complete)e.flags|=8192;else if(wu())e.flags|=8192;else throw Ma=Oa,Ea}else e.flags&=-16777217}function Ic(e,t){if(t.type!==`stylesheet`||t.state.loading&4)e.flags&=-16777217;else if(e.flags|=16777216,!Wf(t))if(wu())e.flags|=8192;else throw Ma=Oa,Ea}function Lc(e,t){t!==null&&(e.flags|=4),e.flags&16384&&(t=e.tag===22?536870912:nt(),e.lanes|=t,Yl|=t)}function Rc(e,t){if(!P)switch(e.tailMode){case`hidden`:t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case`collapsed`:n=e.tail;for(var r=null;n!==null;)n.alternate!==null&&(r=n),n=n.sibling;r===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:r.sibling=null}}function U(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,r=0;if(t)for(var i=e.child;i!==null;)n|=i.lanes|i.childLanes,r|=i.subtreeFlags&65011712,r|=i.flags&65011712,i.return=e,i=i.sibling;else for(i=e.child;i!==null;)n|=i.lanes|i.childLanes,r|=i.subtreeFlags,r|=i.flags,i.return=e,i=i.sibling;return e.subtreeFlags|=r,e.childLanes=n,t}function zc(e,t,n){var r=t.pendingProps;switch(Li(t),t.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return U(t),null;case 1:return U(t),null;case 3:return n=t.stateNode,r=null,e!==null&&(r=e.memoizedState.cache),t.memoizedState.cache!==r&&(t.flags|=2048),ea(I),be(),n.pendingContext&&(n.context=n.pendingContext,n.pendingContext=null),(e===null||e.child===null)&&(Ki(t)?Pc(t):e===null||e.memoizedState.isDehydrated&&!(t.flags&256)||(t.flags|=1024,Ji())),U(t),null;case 26:var a=t.type,o=t.memoizedState;return e===null?(Pc(t),o===null?(U(t),Fc(t,a,null,r,n)):(U(t),Ic(t,o))):o?o===e.memoizedState?(U(t),t.flags&=-16777217):(Pc(t),U(t),Ic(t,o)):(e=e.memoizedProps,e!==r&&Pc(t),U(t),Fc(t,a,e,r,n)),null;case 27:if(Se(t),n=_e.current,a=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==r&&Pc(t);else{if(!r){if(t.stateNode===null)throw Error(i(166));return U(t),null}e=he.current,Ki(t)?Wi(t,e):(e=ff(a,r,n),t.stateNode=e,Pc(t))}return U(t),null;case 5:if(Se(t),a=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==r&&Pc(t);else{if(!r){if(t.stateNode===null)throw Error(i(166));return U(t),null}if(o=he.current,Ki(t))Wi(t,o);else{var s=Bd(_e.current);switch(o){case 1:o=s.createElementNS(`http://www.w3.org/2000/svg`,a);break;case 2:o=s.createElementNS(`http://www.w3.org/1998/Math/MathML`,a);break;default:switch(a){case`svg`:o=s.createElementNS(`http://www.w3.org/2000/svg`,a);break;case`math`:o=s.createElementNS(`http://www.w3.org/1998/Math/MathML`,a);break;case`script`:o=s.createElement(`div`),o.innerHTML=`<script><\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[mt]=t,o[ht]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Pc(t)}}return U(t),Fc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Pc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=_e.current,Ki(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=zi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[mt]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||Ui(t,!0)}else e=Bd(e).createTextNode(r),e[mt]=t,t.stateNode=e}return U(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ki(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[mt]=t}else qi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;U(t),e=!1}else n=Ji(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(lo(t),t):(lo(t),null);if(t.flags&128)throw Error(i(558))}return U(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Ki(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[mt]=t}else qi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;U(t),a=!1}else a=Ji(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(lo(t),t):(lo(t),null)}return lo(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Lc(t,t.updateQueue),U(t),null);case 4:return be(),e===null&&Sd(t.stateNode.containerInfo),U(t),null;case 10:return ea(t.type),U(t),null;case 19:if(me(uo),r=t.memoizedState,r===null)return U(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Rc(r,!1);else{if(X!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=fo(e),o!==null){for(t.flags|=128,Rc(r,!1),e=o.updateQueue,t.updateQueue=e,Lc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)_i(n,e),n=n.sibling;return O(uo,uo.current&1|2),P&&Pi(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Pe()>tu&&(t.flags|=128,a=!0,Rc(r,!1),t.lanes=4194304)}else{if(!a)if(e=fo(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Lc(t,e),Rc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!P)return U(t),null}else 2*Pe()-r.renderingStartTime>tu&&n!==536870912&&(t.flags|=128,a=!0,Rc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(U(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Pe(),e.sibling=null,n=uo.current,O(uo,a?n&1|2:n&1),P&&Pi(t,r.treeForkCount),e);case 22:case 23:return lo(t),no(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(U(t),t.subtreeFlags&6&&(t.flags|=8192)):U(t),n=t.updateQueue,n!==null&&Lc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&me(xa),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),ea(I),U(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Bc(e,t){switch(Li(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ea(I),be(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return Se(t),null;case 31:if(t.memoizedState!==null){if(lo(t),t.alternate===null)throw Error(i(340));qi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(lo(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));qi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return me(uo),null;case 4:return be(),null;case 10:return ea(t.type),null;case 22:case 23:return lo(t),no(),e!==null&&me(xa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return ea(I),null;case 25:return null;default:return null}}function Vc(e,t){switch(Li(t),t.tag){case 3:ea(I),be();break;case 26:case 27:case 5:Se(t);break;case 4:be();break;case 31:t.memoizedState!==null&&lo(t);break;case 13:lo(t);break;case 19:me(uo);break;case 10:ea(t.type);break;case 22:case 23:lo(t),no(),e!==null&&me(xa);break;case 24:ea(I)}}function Hc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Z(t,t.return,e)}}function Uc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Z(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Z(t,t.return,e)}}function Wc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Za(t,n)}catch(t){Z(e,e.return,t)}}}function Gc(e,t,n){n.props=qs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Kc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Z(e,t,n)}}function qc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Z(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Z(e,t,n)}else n.current=null}function Jc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Z(e,e.return,t)}}function Yc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[ht]=t}catch(t){Z(e,e.return,t)}}function Xc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function Zc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Xc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Qc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=sn));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Qc(e,t,n),e=e.sibling;e!==null;)Qc(e,t,n),e=e.sibling}function $c(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for($c(e,t,n),e=e.sibling;e!==null;)$c(e,t,n),e=e.sibling}function el(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[mt]=e,t[ht]=n}catch(t){Z(e,e.return,t)}}var tl=!1,nl=!1,rl=!1,il=typeof WeakSet==`function`?WeakSet:Set,al=null;function ol(e,t){if(e=e.containerInfo,Rd=sp,e=Nr(e),Pr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,al=t;al!==null;)if(t=al,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,al=e;else for(;al!==null;){switch(t=al,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n<e.length;n++)a=e[n],a.ref.impl=a.nextImpl;break;case 11:case 15:break;case 1:if(e&1024&&o!==null){e=void 0,n=t,a=o.memoizedProps,o=o.memoizedState,r=n.stateNode;try{var h=qs(n.type,a);e=r.getSnapshotBeforeUpdate(h,o),r.__reactInternalSnapshotBeforeUpdate=e}catch(e){Z(n,n.return,e)}}break;case 3:if(e&1024){if(e=t.stateNode.containerInfo,n=e.nodeType,n===9)ef(e);else if(n===1)switch(e.nodeName){case`HEAD`:case`HTML`:case`BODY`:ef(e);break;default:e.textContent=``}}break;case 5:case 26:case 27:case 6:case 4:case 17:break;default:if(e&1024)throw Error(i(163))}if(e=t.sibling,e!==null){e.return=t.return,al=e;break}al=t.return}}function sl(e,t,n){var r=n.flags;switch(n.tag){case 0:case 11:case 15:xl(e,n),r&4&&Hc(5,n);break;case 1:if(xl(e,n),r&4)if(e=n.stateNode,t===null)try{e.componentDidMount()}catch(e){Z(n,n.return,e)}else{var i=qs(n.type,t.memoizedProps);t=t.memoizedState;try{e.componentDidUpdate(i,t,e.__reactInternalSnapshotBeforeUpdate)}catch(e){Z(n,n.return,e)}}r&64&&Wc(n),r&512&&Kc(n,n.return);break;case 3:if(xl(e,n),r&64&&(e=n.updateQueue,e!==null)){if(t=null,n.child!==null)switch(n.child.tag){case 27:case 5:t=n.child.stateNode;break;case 1:t=n.child.stateNode}try{Za(e,t)}catch(e){Z(n,n.return,e)}}break;case 27:t===null&&r&4&&el(n);case 26:case 5:xl(e,n),t===null&&r&4&&Jc(n),r&512&&Kc(n,n.return);break;case 12:xl(e,n);break;case 31:xl(e,n),r&4&&fl(e,n);break;case 13:xl(e,n),r&4&&pl(e,n),r&64&&(e=n.memoizedState,e!==null&&(e=e.dehydrated,e!==null&&(n=Ju.bind(null,n),sf(e,n))));break;case 22:if(r=n.memoizedState!==null||tl,!r){t=t!==null&&t.memoizedState!==null||nl,i=tl;var a=nl;tl=r,(nl=t)&&!a?Cl(e,n,(n.subtreeFlags&8772)!=0):xl(e,n),tl=i,nl=a}break;case 30:break;default:xl(e,n)}}function cl(e){var t=e.alternate;t!==null&&(e.alternate=null,cl(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&St(t)),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}var W=null,ll=!1;function ul(e,t,n){for(n=n.child;n!==null;)dl(e,t,n),n=n.sibling}function dl(e,t,n){if(We&&typeof We.onCommitFiberUnmount==`function`)try{We.onCommitFiberUnmount(Ue,n)}catch{}switch(n.tag){case 26:nl||qc(n,t),ul(e,t,n),n.memoizedState?n.memoizedState.count--:n.stateNode&&(n=n.stateNode,n.parentNode.removeChild(n));break;case 27:nl||qc(n,t);var r=W,i=ll;Zd(n.type)&&(W=n.stateNode,ll=!1),ul(e,t,n),pf(n.stateNode),W=r,ll=i;break;case 5:nl||qc(n,t);case 6:if(r=W,i=ll,W=null,ul(e,t,n),W=r,ll=i,W!==null)if(ll)try{(W.nodeType===9?W.body:W.nodeName===`HTML`?W.ownerDocument.body:W).removeChild(n.stateNode)}catch(e){Z(n,t,e)}else try{W.removeChild(n.stateNode)}catch(e){Z(n,t,e)}break;case 18:W!==null&&(ll?(e=W,Qd(e.nodeType===9?e.body:e.nodeName===`HTML`?e.ownerDocument.body:e,n.stateNode),Np(e)):Qd(W,n.stateNode));break;case 4:r=W,i=ll,W=n.stateNode.containerInfo,ll=!0,ul(e,t,n),W=r,ll=i;break;case 0:case 11:case 14:case 15:Uc(2,n,t),nl||Uc(4,n,t),ul(e,t,n);break;case 1:nl||(qc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`&&Gc(n,t,r)),ul(e,t,n);break;case 21:ul(e,t,n);break;case 22:nl=(r=nl)||n.memoizedState!==null,ul(e,t,n),nl=r;break;default:ul(e,t,n)}}function fl(e,t){if(t.memoizedState===null&&(e=t.alternate,e!==null&&(e=e.memoizedState,e!==null))){e=e.dehydrated;try{Np(e)}catch(e){Z(t,t.return,e)}}}function pl(e,t){if(t.memoizedState===null&&(e=t.alternate,e!==null&&(e=e.memoizedState,e!==null&&(e=e.dehydrated,e!==null))))try{Np(e)}catch(e){Z(t,t.return,e)}}function ml(e){switch(e.tag){case 31:case 13:case 19:var t=e.stateNode;return t===null&&(t=e.stateNode=new il),t;case 22:return e=e.stateNode,t=e._retryCache,t===null&&(t=e._retryCache=new il),t;default:throw Error(i(435,e.tag))}}function hl(e,t){var n=ml(e);t.forEach(function(t){if(!n.has(t)){n.add(t);var r=Yu.bind(null,e,t);t.then(r,r)}})}function gl(e,t){var n=t.deletions;if(n!==null)for(var r=0;r<n.length;r++){var a=n[r],o=e,s=t,c=s;a:for(;c!==null;){switch(c.tag){case 27:if(Zd(c.type)){W=c.stateNode,ll=!1;break a}break;case 5:W=c.stateNode,ll=!1;break a;case 3:case 4:W=c.stateNode.containerInfo,ll=!0;break a}c=c.return}if(W===null)throw Error(i(160));dl(o,s,a),W=null,ll=!1,o=a.alternate,o!==null&&(o.return=null),a.return=null}if(t.subtreeFlags&13886)for(t=t.child;t!==null;)vl(t,e),t=t.sibling}var _l=null;function vl(e,t){var n=e.alternate,r=e.flags;switch(e.tag){case 0:case 11:case 14:case 15:gl(t,e),yl(e),r&4&&(Uc(3,e,e.return),Hc(3,e),Uc(5,e,e.return));break;case 1:gl(t,e),yl(e),r&512&&(nl||n===null||qc(n,n.return)),r&64&&tl&&(e=e.updateQueue,e!==null&&(r=e.callbacks,r!==null&&(n=e.shared.hiddenCallbacks,e.shared.hiddenCallbacks=n===null?r:n.concat(r))));break;case 26:var a=_l;if(gl(t,e),yl(e),r&512&&(nl||n===null||qc(n,n.return)),r&4){var o=n===null?null:n.memoizedState;if(r=e.memoizedState,n===null)if(r===null)if(e.stateNode===null){a:{r=e.type,n=e.memoizedProps,a=a.ownerDocument||a;b:switch(r){case`title`:o=a.getElementsByTagName(`title`)[0],(!o||o[xt]||o[mt]||o.namespaceURI===`http://www.w3.org/2000/svg`||o.hasAttribute(`itemprop`))&&(o=a.createElement(r),a.head.insertBefore(o,a.querySelector(`head > title`))),Pd(o,r,n),o[mt]=e,Et(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;c<s.length;c++)if(o=s[c],o.getAttribute(`href`)===(n.href==null||n.href===``?null:n.href)&&o.getAttribute(`rel`)===(n.rel==null?null:n.rel)&&o.getAttribute(`title`)===(n.title==null?null:n.title)&&o.getAttribute(`crossorigin`)===(n.crossOrigin==null?null:n.crossOrigin)){s.splice(c,1);break b}}o=a.createElement(r),Pd(o,r,n),a.head.appendChild(o);break;case`meta`:if(s=Vf(`meta`,`content`,a).get(r+(n.content||``))){for(c=0;c<s.length;c++)if(o=s[c],o.getAttribute(`content`)===(n.content==null?null:``+n.content)&&o.getAttribute(`name`)===(n.name==null?null:n.name)&&o.getAttribute(`property`)===(n.property==null?null:n.property)&&o.getAttribute(`http-equiv`)===(n.httpEquiv==null?null:n.httpEquiv)&&o.getAttribute(`charset`)===(n.charSet==null?null:n.charSet)){s.splice(c,1);break b}}o=a.createElement(r),Pd(o,r,n),a.head.appendChild(o);break;default:throw Error(i(468,r))}o[mt]=e,Et(o),r=o}e.stateNode=r}else Hf(a,e.type,e.stateNode);else e.stateNode=If(a,r,e.memoizedProps);else o===r?r===null&&e.stateNode!==null&&Yc(e,e.memoizedProps,n.memoizedProps):(o===null?n.stateNode!==null&&(n=n.stateNode,n.parentNode.removeChild(n)):o.count--,r===null?Hf(a,e.type,e.stateNode):If(a,r,e.memoizedProps))}break;case 27:gl(t,e),yl(e),r&512&&(nl||n===null||qc(n,n.return)),n!==null&&r&4&&Yc(e,e.memoizedProps,n.memoizedProps);break;case 5:if(gl(t,e),yl(e),r&512&&(nl||n===null||qc(n,n.return)),e.flags&32){a=e.stateNode;try{Qt(a,``)}catch(t){Z(e,e.return,t)}}r&4&&e.stateNode!=null&&(a=e.memoizedProps,Yc(e,a,n===null?a:n.memoizedProps)),r&1024&&(rl=!0);break;case 6:if(gl(t,e),yl(e),r&4){if(e.stateNode===null)throw Error(i(162));r=e.memoizedProps,n=e.stateNode;try{n.nodeValue=r}catch(t){Z(e,e.return,t)}}break;case 3:if(Bf=null,a=_l,_l=gf(t.containerInfo),gl(t,e),_l=a,yl(e),r&4&&n!==null&&n.memoizedState.isDehydrated)try{Np(t.containerInfo)}catch(t){Z(e,e.return,t)}rl&&(rl=!1,bl(e));break;case 4:r=_l,_l=gf(e.stateNode.containerInfo),gl(t,e),yl(e),_l=r;break;case 12:gl(t,e),yl(e);break;case 31:gl(t,e),yl(e),r&4&&(r=e.updateQueue,r!==null&&(e.updateQueue=null,hl(e,r)));break;case 13:gl(t,e),yl(e),e.child.flags&8192&&e.memoizedState!==null!=(n!==null&&n.memoizedState!==null)&&($l=Pe()),r&4&&(r=e.updateQueue,r!==null&&(e.updateQueue=null,hl(e,r)));break;case 22:a=e.memoizedState!==null;var l=n!==null&&n.memoizedState!==null,u=tl,d=nl;if(tl=u||a,nl=d||l,gl(t,e),nl=d,tl=u,yl(e),r&8192)a:for(t=e.stateNode,t._visibility=a?t._visibility&-2:t._visibility|1,a&&(n===null||l||tl||nl||Sl(e)),n=null,t=e;;){if(t.tag===5||t.tag===26){if(n===null){l=n=t;try{if(o=l.stateNode,a)s=o.style,typeof s.setProperty==`function`?s.setProperty(`display`,`none`,`important`):s.display=`none`;else{c=l.stateNode;var f=l.memoizedProps.style,p=f!=null&&f.hasOwnProperty(`display`)?f.display:null;c.style.display=p==null||typeof p==`boolean`?``:(``+p).trim()}}catch(e){Z(l,l.return,e)}}}else if(t.tag===6){if(n===null){l=t;try{l.stateNode.nodeValue=a?``:l.memoizedProps}catch(e){Z(l,l.return,e)}}}else if(t.tag===18){if(n===null){l=t;try{var m=l.stateNode;a?$d(m,!0):$d(l.stateNode,!1)}catch(e){Z(l,l.return,e)}}}else if((t.tag!==22&&t.tag!==23||t.memoizedState===null||t===e)&&t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break a;for(;t.sibling===null;){if(t.return===null||t.return===e)break a;n===t&&(n=null),t=t.return}n===t&&(n=null),t.sibling.return=t.return,t=t.sibling}r&4&&(r=e.updateQueue,r!==null&&(n=r.retryQueue,n!==null&&(r.retryQueue=null,hl(e,n))));break;case 19:gl(t,e),yl(e),r&4&&(r=e.updateQueue,r!==null&&(e.updateQueue=null,hl(e,r)));break;case 30:break;case 21:break;default:gl(t,e),yl(e)}}function yl(e){var t=e.flags;if(t&2){try{for(var n,r=e.return;r!==null;){if(Xc(r)){n=r;break}r=r.return}if(n==null)throw Error(i(160));switch(n.tag){case 27:var a=n.stateNode;$c(e,Zc(e),a);break;case 5:var o=n.stateNode;n.flags&32&&(Qt(o,``),n.flags&=-33),$c(e,Zc(e),o);break;case 3:case 4:var s=n.stateNode.containerInfo;Qc(e,Zc(e),s);break;default:throw Error(i(161))}}catch(t){Z(e,e.return,t)}e.flags&=-3}t&4096&&(e.flags&=-4097)}function bl(e){if(e.subtreeFlags&1024)for(e=e.child;e!==null;){var t=e;bl(t),t.tag===5&&t.flags&1024&&t.stateNode.reset(),e=e.sibling}}function xl(e,t){if(t.subtreeFlags&8772)for(t=t.child;t!==null;)sl(e,t.alternate,t),t=t.sibling}function Sl(e){for(e=e.child;e!==null;){var t=e;switch(t.tag){case 0:case 11:case 14:case 15:Uc(4,t,t.return),Sl(t);break;case 1:qc(t,t.return);var n=t.stateNode;typeof n.componentWillUnmount==`function`&&Gc(t,t.return,n),Sl(t);break;case 27:pf(t.stateNode);case 26:case 5:qc(t,t.return),Sl(t);break;case 22:t.memoizedState===null&&Sl(t);break;case 30:Sl(t);break;default:Sl(t)}e=e.sibling}}function Cl(e,t,n){for(n&&=(t.subtreeFlags&8772)!=0,t=t.child;t!==null;){var r=t.alternate,i=e,a=t,o=a.flags;switch(a.tag){case 0:case 11:case 15:Cl(i,a,n),Hc(4,a);break;case 1:if(Cl(i,a,n),r=a,i=r.stateNode,typeof i.componentDidMount==`function`)try{i.componentDidMount()}catch(e){Z(r,r.return,e)}if(r=a,i=r.updateQueue,i!==null){var s=r.stateNode;try{var c=i.shared.hiddenCallbacks;if(c!==null)for(i.shared.hiddenCallbacks=null,i=0;i<c.length;i++)Xa(c[i],s)}catch(e){Z(r,r.return,e)}}n&&o&64&&Wc(a),Kc(a,a.return);break;case 27:el(a);case 26:case 5:Cl(i,a,n),n&&r===null&&o&4&&Jc(a),Kc(a,a.return);break;case 12:Cl(i,a,n);break;case 31:Cl(i,a,n),n&&o&4&&fl(i,a);break;case 13:Cl(i,a,n),n&&o&4&&pl(i,a);break;case 22:a.memoizedState===null&&Cl(i,a,n),Kc(a,a.return);break;case 30:break;default:Cl(i,a,n)}t=t.sibling}}function wl(e,t){var n=null;e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),e=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),e!==n&&(e!=null&&e.refCount++,n!=null&&fa(n))}function Tl(e,t){e=null,t.alternate!==null&&(e=t.alternate.memoizedState.cache),t=t.memoizedState.cache,t!==e&&(t.refCount++,e!=null&&fa(e))}function El(e,t,n,r){if(t.subtreeFlags&10256)for(t=t.child;t!==null;)Dl(e,t,n,r),t=t.sibling}function Dl(e,t,n,r){var i=t.flags;switch(t.tag){case 0:case 11:case 15:El(e,t,n,r),i&2048&&Hc(9,t);break;case 1:El(e,t,n,r);break;case 3:El(e,t,n,r),i&2048&&(e=null,t.alternate!==null&&(e=t.alternate.memoizedState.cache),t=t.memoizedState.cache,t!==e&&(t.refCount++,e!=null&&fa(e)));break;case 12:if(i&2048){El(e,t,n,r),e=t.stateNode;try{var a=t.memoizedProps,o=a.id,s=a.onPostCommit;typeof s==`function`&&s(o,t.alternate===null?`mount`:`update`,e.passiveEffectDuration,-0)}catch(e){Z(t,t.return,e)}}else El(e,t,n,r);break;case 31:El(e,t,n,r);break;case 13:El(e,t,n,r);break;case 23:break;case 22:a=t.stateNode,o=t.alternate,t.memoizedState===null?a._visibility&2?El(e,t,n,r):(a._visibility|=2,Ol(e,t,n,r,(t.subtreeFlags&10256)!=0||!1)):a._visibility&2?El(e,t,n,r):kl(e,t),i&2048&&wl(o,t);break;case 24:El(e,t,n,r),i&2048&&Tl(t.alternate,t);break;default:El(e,t,n,r)}}function Ol(e,t,n,r,i){for(i&&=(t.subtreeFlags&10256)!=0||!1,t=t.child;t!==null;){var a=e,o=t,s=n,c=r,l=o.flags;switch(o.tag){case 0:case 11:case 15:Ol(a,o,s,c,i),Hc(8,o);break;case 23:break;case 22:var u=o.stateNode;o.memoizedState===null?(u._visibility|=2,Ol(a,o,s,c,i)):u._visibility&2?Ol(a,o,s,c,i):kl(a,o),i&&l&2048&&wl(o.alternate,o);break;case 24:Ol(a,o,s,c,i),i&&l&2048&&Tl(o.alternate,o);break;default:Ol(a,o,s,c,i)}t=t.sibling}}function kl(e,t){if(t.subtreeFlags&10256)for(t=t.child;t!==null;){var n=e,r=t,i=r.flags;switch(r.tag){case 22:kl(n,r),i&2048&&wl(r.alternate,r);break;case 24:kl(n,r),i&2048&&Tl(r.alternate,r);break;default:kl(n,r)}t=t.sibling}}var Al=8192;function jl(e,t,n){if(e.subtreeFlags&Al)for(e=e.child;e!==null;)Ml(e,t,n),e=e.sibling}function Ml(e,t,n){switch(e.tag){case 26:jl(e,t,n),e.flags&Al&&e.memoizedState!==null&&Gf(n,_l,e.memoizedState,e.memoizedProps);break;case 5:jl(e,t,n);break;case 3:case 4:var r=_l;_l=gf(e.stateNode.containerInfo),jl(e,t,n),_l=r;break;case 22:e.memoizedState===null&&(r=e.alternate,r!==null&&r.memoizedState!==null?(r=Al,Al=16777216,jl(e,t,n),Al=r):jl(e,t,n));break;default:jl(e,t,n)}}function Nl(e){var t=e.alternate;if(t!==null&&(e=t.child,e!==null)){t.child=null;do t=e.sibling,e.sibling=null,e=t;while(e!==null)}}function Pl(e){var t=e.deletions;if(e.flags&16){if(t!==null)for(var n=0;n<t.length;n++){var r=t[n];al=r,Ll(r,e)}Nl(e)}if(e.subtreeFlags&10256)for(e=e.child;e!==null;)Fl(e),e=e.sibling}function Fl(e){switch(e.tag){case 0:case 11:case 15:Pl(e),e.flags&2048&&Uc(9,e,e.return);break;case 3:Pl(e);break;case 12:Pl(e);break;case 22:var t=e.stateNode;e.memoizedState!==null&&t._visibility&2&&(e.return===null||e.return.tag!==13)?(t._visibility&=-3,Il(e)):Pl(e);break;default:Pl(e)}}function Il(e){var t=e.deletions;if(e.flags&16){if(t!==null)for(var n=0;n<t.length;n++){var r=t[n];al=r,Ll(r,e)}Nl(e)}for(e=e.child;e!==null;){switch(t=e,t.tag){case 0:case 11:case 15:Uc(8,t,t.return),Il(t);break;case 22:n=t.stateNode,n._visibility&2&&(n._visibility&=-3,Il(t));break;default:Il(t)}e=e.sibling}}function Ll(e,t){for(;al!==null;){var n=al;switch(n.tag){case 0:case 11:case 15:Uc(8,n,t);break;case 23:case 22:if(n.memoizedState!==null&&n.memoizedState.cachePool!==null){var r=n.memoizedState.cachePool.pool;r!=null&&r.refCount++}break;case 24:fa(n.memoizedState.cache)}if(r=n.child,r!==null)r.return=n,al=r;else a:for(n=e;al!==null;){r=al;var i=r.sibling,a=r.return;if(cl(r),r===n){al=null;break a}if(i!==null){i.return=a,al=i;break a}al=a}}}var Rl={getCacheForType:function(e){var t=aa(I),n=t.data.get(e);return n===void 0&&(n=e(),t.data.set(e,n)),n},cacheSignal:function(){return aa(I).controller.signal}},zl=typeof WeakMap==`function`?WeakMap:Map,G=0,K=null,q=null,J=0,Y=0,Bl=null,Vl=!1,Hl=!1,Ul=!1,Wl=0,X=0,Gl=0,Kl=0,ql=0,Jl=0,Yl=0,Xl=null,Zl=null,Ql=!1,$l=0,eu=0,tu=1/0,nu=null,ru=null,iu=0,au=null,ou=null,su=0,cu=0,lu=null,uu=null,du=0,fu=null;function pu(){return G&2&&J!==0?J&-J:E.T===null?dt():dd()}function mu(){if(Jl===0)if(!(J&536870912)||P){var e=Ze;Ze<<=1,!(Ze&3932160)&&(Ze=262144),Jl=e}else Jl=536870912;return e=ro.current,e!==null&&(e.flags|=32),Jl}function hu(e,t,n){(e===K&&(Y===2||Y===9)||e.cancelPendingCommit!==null)&&(Su(e,0),yu(e,J,Jl,!1)),it(e,n),(!(G&2)||e!==K)&&(e===K&&(!(G&2)&&(Kl|=n),X===4&&yu(e,J,Jl,!1)),rd(e))}function gu(e,t,n){if(G&6)throw Error(i(327));var r=!n&&(t&127)==0&&(t&e.expiredLanes)===0||et(e,t),a=r?Au(e,t):Ou(e,t,!0),o=r;do{if(a===0){Hl&&!r&&yu(e,t,0,!1);break}else{if(n=e.current.alternate,o&&!vu(n)){a=Ou(e,t,!1),o=!1;continue}if(a===2){if(o=t,e.errorRecoveryDisabledLanes&o)var s=0;else s=e.pendingLanes&-536870913,s=s===0?s&536870912?536870912:0:s;if(s!==0){t=s;a:{var c=e;a=Xl;var l=c.current.memoizedState.isDehydrated;if(l&&(Su(c,s).flags|=256),s=Ou(c,s,!1),s!==2){if(Ul&&!l){c.errorRecoveryDisabledLanes|=o,Kl|=o,a=4;break a}o=Zl,Zl=a,o!==null&&(Zl===null?Zl=o:Zl.push.apply(Zl,o))}a=s}if(o=!1,a!==2)continue}}if(a===1){Su(e,0),yu(e,t,0,!0);break}a:{switch(r=e,o=a,o){case 0:case 1:throw Error(i(345));case 4:if((t&4194048)!==t)break;case 6:yu(r,t,Jl,!Vl);break a;case 2:Zl=null;break;case 3:case 5:break;default:throw Error(i(329))}if((t&62914560)===t&&(a=$l+300-Pe(),10<a)){if(yu(r,t,Jl,!Vl),$e(r,0,!0)!==0)break a;su=t,r.timeoutHandle=Kd(_u.bind(null,r,n,Zl,nu,Ql,t,Jl,Kl,Yl,Vl,o,`Throttled`,-0,0),a);break a}_u(r,n,Zl,nu,Ql,t,Jl,Kl,Yl,Vl,o,null,-0,0)}}break}while(1);rd(e)}function _u(e,t,n,r,i,a,o,s,c,l,u,d,f,p){if(e.timeoutHandle=-1,d=t.subtreeFlags,d&8192||(d&16785408)==16785408){d={stylesheets:null,count:0,imgCount:0,imgBytes:0,suspenseyImages:[],waitingForImages:!0,waitingForViewTransition:!1,unsuspend:sn},Ml(t,a,d);var m=(a&62914560)===a?$l-Pe():(a&4194048)===a?eu-Pe():0;if(m=qf(d,m),m!==null){su=a,e.cancelPendingCommit=m(Lu.bind(null,e,t,a,n,r,i,o,s,c,u,d,null,f,p)),yu(e,a,o,!l);return}}Lu(e,t,a,n,r,i,o,s,c)}function vu(e){for(var t=e;;){var n=t.tag;if((n===0||n===11||n===15)&&t.flags&16384&&(n=t.updateQueue,n!==null&&(n=n.stores,n!==null)))for(var r=0;r<n.length;r++){var i=n[r],a=i.getSnapshot;i=i.value;try{if(!Or(a(),i))return!1}catch{return!1}}if(n=t.child,t.subtreeFlags&16384&&n!==null)n.return=t,t=n;else{if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return!0;t=t.return}t.sibling.return=t.return,t=t.sibling}}return!0}function yu(e,t,n,r){t&=~ql,t&=~Kl,e.suspendedLanes|=t,e.pingedLanes&=~t,r&&(e.warmLanes|=t),r=e.expirationTimes;for(var i=t;0<i;){var a=31-Ke(i),o=1<<a;r[a]=-1,i&=~o}n!==0&&ot(e,n,t)}function bu(){return G&6?!0:(id(0,!1),!1)}function xu(){if(q!==null){if(Y===0)var e=q.return;else e=q,Qi=Zi=null,ko(e),Pa=null,Fa=0,e=q;for(;e!==null;)Vc(e.alternate,e),e=e.return;q=null}}function Su(e,t){var n=e.timeoutHandle;n!==-1&&(e.timeoutHandle=-1,qd(n)),n=e.cancelPendingCommit,n!==null&&(e.cancelPendingCommit=null,n()),su=0,xu(),K=e,q=n=gi(e.current,null),J=t,Y=0,Bl=null,Vl=!1,Hl=et(e,t),Ul=!1,Yl=Jl=ql=Kl=Gl=X=0,Zl=Xl=null,Ql=!1,t&8&&(t|=t&32);var r=e.entangledLanes;if(r!==0)for(e=e.entanglements,r&=t;0<r;){var i=31-Ke(r),a=1<<i;t|=e[i],r&=~a}return Wl=t,oi(),n}function Cu(e,t){B=null,E.H=zs,t===Ta||t===Da?(t=L(),Y=3):t===Ea?(t=L(),Y=4):Y=t===rc?8:typeof t==`object`&&t&&typeof t.then==`function`?6:1,Bl=t,q===null&&(X=1,Zs(e,wi(t,e.current)))}function wu(){var e=ro.current;return e===null?!0:(J&4194048)===J?io===null:(J&62914560)===J||J&536870912?e===io:!1}function Tu(){var e=E.H;return E.H=zs,e===null?zs:e}function Eu(){var e=E.A;return E.A=Rl,e}function Du(){X=4,Vl||(J&4194048)!==J&&ro.current!==null||(Hl=!0),!(Gl&134217727)&&!(Kl&134217727)||K===null||yu(K,J,Jl,!1)}function Ou(e,t,n){var r=G;G|=2;var i=Tu(),a=Eu();(K!==e||J!==t)&&(nu=null,Su(e,t)),t=!1;var o=X;a:do try{if(Y!==0&&q!==null){var s=q,c=Bl;switch(Y){case 8:xu(),o=6;break a;case 3:case 2:case 9:case 6:ro.current===null&&(t=!0);var l=Y;if(Y=0,Bl=null,Pu(e,s,c,l),n&&Hl){o=0;break a}break;default:l=Y,Y=0,Bl=null,Pu(e,s,c,l)}}ku(),o=X;break}catch(t){Cu(e,t)}while(1);return t&&e.shellSuspendCounter++,Qi=Zi=null,G=r,E.H=i,E.A=a,q===null&&(K=null,J=0,oi()),o}function ku(){for(;q!==null;)Mu(q)}function Au(e,t){var n=G;G|=2;var r=Tu(),a=Eu();K!==e||J!==t?(nu=null,tu=Pe()+500,Su(e,t)):Hl=et(e,t);a:do try{if(Y!==0&&q!==null){t=q;var o=Bl;b:switch(Y){case 1:Y=0,Bl=null,Pu(e,t,o,1);break;case 2:case 9:if(ka(o)){Y=0,Bl=null,Nu(t);break}t=function(){Y!==2&&Y!==9||K!==e||(Y=7),rd(e)},o.then(t,t);break a;case 3:Y=7;break a;case 4:Y=5;break a;case 7:ka(o)?(Y=0,Bl=null,Nu(t)):(Y=0,Bl=null,Pu(e,t,o,7));break;case 5:var s=null;switch(q.tag){case 26:s=q.memoizedState;case 5:case 27:var c=q;if(s?Wf(s):c.stateNode.complete){Y=0,Bl=null;var l=c.sibling;if(l!==null)q=l;else{var u=c.return;u===null?q=null:(q=u,Fu(u))}break b}}Y=0,Bl=null,Pu(e,t,o,5);break;case 6:Y=0,Bl=null,Pu(e,t,o,6);break;case 8:xu(),X=6;break a;default:throw Error(i(462))}}ju();break}catch(t){Cu(e,t)}while(1);return Qi=Zi=null,E.H=r,E.A=a,G=n,q===null?(K=null,J=0,oi(),X):0}function ju(){for(;q!==null&&!Me();)Mu(q)}function Mu(e){var t=Nc(e.alternate,e,Wl);e.memoizedProps=e.pendingProps,t===null?Fu(e):q=t}function Nu(e){var t=e,n=t.alternate;switch(t.tag){case 15:case 0:t=_c(n,t,t.pendingProps,t.type,void 0,J);break;case 11:t=_c(n,t,t.pendingProps,t.type.render,t.ref,J);break;case 5:ko(t);default:Vc(n,t),t=q=_i(t,Wl),t=Nc(n,t,Wl)}e.memoizedProps=e.pendingProps,t===null?Fu(e):q=t}function Pu(e,t,n,r){Qi=Zi=null,ko(t),Pa=null,Fa=0;var i=t.return;try{if(nc(e,i,t,n,J)){X=1,Zs(e,wi(n,e.current)),q=null;return}}catch(t){if(i!==null)throw q=i,t;X=1,Zs(e,wi(n,e.current)),q=null;return}t.flags&32768?(P||r===1?e=!0:Hl||J&536870912?e=!1:(Vl=e=!0,(r===2||r===9||r===3||r===6)&&(r=ro.current,r!==null&&r.tag===13&&(r.flags|=16384))),Iu(t,e)):Fu(t)}function Fu(e){var t=e;do{if(t.flags&32768){Iu(t,Vl);return}e=t.return;var n=zc(t.alternate,t,Wl);if(n!==null){q=n;return}if(t=t.sibling,t!==null){q=t;return}q=t=e}while(t!==null);X===0&&(X=5)}function Iu(e,t){do{var n=Bc(e.alternate,e);if(n!==null){n.flags&=32767,q=n;return}if(n=e.return,n!==null&&(n.flags|=32768,n.subtreeFlags=0,n.deletions=null),!t&&(e=e.sibling,e!==null)){q=e;return}q=e=n}while(e!==null);X=6,q=null}function Lu(e,t,n,r,a,o,s,c,l){e.cancelPendingCommit=null;do Hu();while(iu!==0);if(G&6)throw Error(i(327));if(t!==null){if(t===e.current)throw Error(i(177));if(o=t.lanes|t.childLanes,o|=ai,at(e,n,o,s,c,l),e===K&&(q=K=null,J=0),ou=t,au=e,su=n,cu=o,lu=a,uu=r,t.subtreeFlags&10256||t.flags&10256?(e.callbackNode=null,e.callbackPriority=0,Xu(Re,function(){return Uu(),null})):(e.callbackNode=null,e.callbackPriority=0),r=(t.flags&13878)!=0,t.subtreeFlags&13878||r){r=E.T,E.T=null,a=D.p,D.p=2,s=G,G|=4;try{ol(e,t,n)}finally{G=s,D.p=a,E.T=r}}iu=1,Ru(),zu(),Bu()}}function Ru(){if(iu===1){iu=0;var e=au,t=ou,n=(t.flags&13878)!=0;if(t.subtreeFlags&13878||n){n=E.T,E.T=null;var r=D.p;D.p=2;var i=G;G|=4;try{vl(t,e);var a=zd,o=Nr(e.containerInfo),s=a.focusedElem,c=a.selectionRange;if(o!==s&&s&&s.ownerDocument&&Mr(s.ownerDocument.documentElement,s)){if(c!==null&&Pr(s)){var l=c.start,u=c.end;if(u===void 0&&(u=l),`selectionStart`in s)s.selectionStart=l,s.selectionEnd=Math.min(u,s.value.length);else{var d=s.ownerDocument||document,f=d&&d.defaultView||window;if(f.getSelection){var p=f.getSelection(),m=s.textContent.length,h=Math.min(c.start,m),g=c.end===void 0?h:Math.min(c.end,m);!p.extend&&h>g&&(o=g,g=h,h=o);var _=jr(s,h),v=jr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;s<d.length;s++){var b=d[s];b.element.scrollLeft=b.left,b.element.scrollTop=b.top}}sp=!!Rd,zd=Rd=null}finally{G=i,D.p=r,E.T=n}}e.current=t,iu=2}}function zu(){if(iu===2){iu=0;var e=au,t=ou,n=(t.flags&8772)!=0;if(t.subtreeFlags&8772||n){n=E.T,E.T=null;var r=D.p;D.p=2;var i=G;G|=4;try{sl(e,t.alternate,t)}finally{G=i,D.p=r,E.T=n}}iu=3}}function Bu(){if(iu===4||iu===3){iu=0,Ne();var e=au,t=ou,n=su,r=uu;t.subtreeFlags&10256||t.flags&10256?iu=5:(iu=0,ou=au=null,Vu(e,e.pendingLanes));var i=e.pendingLanes;if(i===0&&(ru=null),ut(n),t=t.stateNode,We&&typeof We.onCommitFiberRoot==`function`)try{We.onCommitFiberRoot(Ue,t,void 0,(t.current.flags&128)==128)}catch{}if(r!==null){t=E.T,i=D.p,D.p=2,E.T=null;try{for(var a=e.onRecoverableError,o=0;o<r.length;o++){var s=r[o];a(s.value,{componentStack:s.stack})}}finally{E.T=t,D.p=i}}su&3&&Hu(),rd(e),i=e.pendingLanes,n&261930&&i&42?e===fu?du++:(du=0,fu=e):du=0,id(0,!1)}}function Vu(e,t){(e.pooledCacheLanes&=t)===0&&(t=e.pooledCache,t!=null&&(e.pooledCache=null,fa(t)))}function Hu(){return Ru(),zu(),Bu(),Uu()}function Uu(){if(iu!==5)return!1;var e=au,t=cu;cu=0;var n=ut(su),r=E.T,a=D.p;try{D.p=32>n?32:n,E.T=null,n=lu,lu=null;var o=au,s=su;if(iu=0,ou=au=null,su=0,G&6)throw Error(i(331));var c=G;if(G|=4,Fl(o.current),Dl(o,o.current,s,n),G=c,id(0,!1),We&&typeof We.onPostCommitFiberRoot==`function`)try{We.onPostCommitFiberRoot(Ue,o)}catch{}return!0}finally{D.p=a,E.T=r,Vu(e,t)}}function Wu(e,t,n){t=wi(n,t),t=$s(e.stateNode,t,2),e=z(e,t,2),e!==null&&(it(e,2),rd(e))}function Z(e,t,n){if(e.tag===3)Wu(e,e,n);else for(;t!==null;){if(t.tag===3){Wu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(ru===null||!ru.has(r))){e=wi(n,e),n=ec(2),r=z(t,n,2),r!==null&&(tc(n,r,t,e),it(r,2),rd(r));break}}t=t.return}}function Gu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new zl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Ul=!0,i.add(n),e=Ku.bind(null,e,t,n),t.then(e,e))}function Ku(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,K===e&&(J&n)===n&&(X===4||X===3&&(J&62914560)===J&&300>Pe()-$l?!(G&2)&&Su(e,0):ql|=n,Yl===J&&(Yl=0)),rd(e)}function qu(e,t){t===0&&(t=nt()),e=li(e,t),e!==null&&(it(e,t),rd(e))}function Ju(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qu(e,n)}function Yu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),qu(e,n)}function Xu(e,t){return Ae(e,t)}var Zu=null,Qu=null,$u=!1,ed=!1,td=!1,nd=0;function rd(e){e!==Qu&&e.next===null&&(Qu===null?Zu=Qu=e:Qu=Qu.next=e),ed=!0,$u||($u=!0,ud())}function id(e,t){if(!td&&ed){td=!0;do for(var n=!1,r=Zu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ke(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ld(r,a))}else a=J,a=$e(r,r===K?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||et(r,a)||(n=!0,ld(r,a));r=r.next}while(n);td=!1}}function ad(){od()}function od(){ed=$u=!1;var e=0;nd!==0&&Gd()&&(e=nd);for(var t=Pe(),n=null,r=Zu;r!==null;){var i=r.next,a=sd(r,t);a===0?(r.next=null,n===null?Zu=i:n.next=i,i===null&&(Qu=n)):(n=r,(e!==0||a&3)&&(ed=!0)),r=i}iu!==0&&iu!==5||id(e,!1),nd!==0&&(nd=0)}function sd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0<a;){var o=31-Ke(a),s=1<<o,c=i[o];c===-1?((s&n)===0||(s&r)!==0)&&(i[o]=tt(s,t)):c<=t&&(e.expiredLanes|=s),a&=~s}if(t=K,n=J,n=$e(e,e===t?n:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),r=e.callbackNode,n===0||e===t&&(Y===2||Y===9)||e.cancelPendingCommit!==null)return r!==null&&r!==null&&je(r),e.callbackNode=null,e.callbackPriority=0;if(!(n&3)||et(e,n)){if(t=n&-n,t===e.callbackPriority)return t;switch(r!==null&&je(r),ut(n)){case 2:case 8:n=Le;break;case 32:n=Re;break;case 268435456:n=Be;break;default:n=Re}return r=cd.bind(null,e),n=Ae(n,r),e.callbackPriority=t,e.callbackNode=n,t}return r!==null&&r!==null&&je(r),e.callbackPriority=2,e.callbackNode=null,2}function cd(e,t){if(iu!==0&&iu!==5)return e.callbackNode=null,e.callbackPriority=0,null;var n=e.callbackNode;if(Hu()&&e.callbackNode!==n)return null;var r=J;return r=$e(e,e===K?r:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),r===0?null:(gu(e,r,t),sd(e,Pe()),e.callbackNode!=null&&e.callbackNode===n?cd.bind(null,e):null)}function ld(e,t){if(Hu())return null;gu(e,t,!0)}function ud(){Yd(function(){G&6?Ae(Ie,ad):od()})}function dd(){if(nd===0){var e=ha;e===0&&(e=Xe,Xe<<=1,!(Xe&261888)&&(Xe=256)),nd=e}return nd}function fd(e){return e==null||typeof e==`symbol`||typeof e==`boolean`?null:typeof e==`function`?e:on(``+e)}function pd(e,t){var n=t.ownerDocument.createElement(`input`);return n.name=t.name,n.value=t.value,e.id&&n.setAttribute(`form`,e.id),t.parentNode.insertBefore(n,t),e=new FormData(e),n.parentNode.removeChild(n),e}function md(e,t,n,r,i){if(t===`submit`&&n&&n.stateNode===i){var a=fd((i[ht]||null).action),o=r.submitter;o&&(t=(t=o[ht]||null)?fd(t.formAction):o.getAttribute(`formAction`),t!==null&&(a=t,o=null));var s=new Dn(`action`,`action`,null,r,i);e.push({event:s,listeners:[{instance:null,listener:function(){if(r.defaultPrevented){if(nd!==0){var e=o?pd(i,o):new FormData(i);Ts(n,{pending:!0,data:e,method:i.method,action:a},null,e)}}else typeof a==`function`&&(s.preventDefault(),e=o?pd(i,o):new FormData(i),Ts(n,{pending:!0,data:e,method:i.method,action:a},a,e))},currentTarget:i}]})}}for(var hd=0;hd<ei.length;hd++){var gd=ei[hd];ti(gd.toLowerCase(),`on`+(gd[0].toUpperCase()+gd.slice(1)))}ti(Kr,`onAnimationEnd`),ti(qr,`onAnimationIteration`),ti(Jr,`onAnimationStart`),ti(`dblclick`,`onDoubleClick`),ti(`focusin`,`onFocus`),ti(`focusout`,`onBlur`),ti(Yr,`onTransitionRun`),ti(Xr,`onTransitionStart`),ti(Zr,`onTransitionCancel`),ti(Qr,`onTransitionEnd`),At(`onMouseEnter`,[`mouseout`,`mouseover`]),At(`onMouseLeave`,[`mouseout`,`mouseover`]),At(`onPointerEnter`,[`pointerout`,`pointerover`]),At(`onPointerLeave`,[`pointerout`,`pointerover`]),kt(`onChange`,`change click focusin focusout input keydown keyup selectionchange`.split(` `)),kt(`onSelect`,`focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange`.split(` `)),kt(`onBeforeInput`,[`compositionend`,`keypress`,`textInput`,`paste`]),kt(`onCompositionEnd`,`compositionend focusout keydown keypress keyup mousedown`.split(` `)),kt(`onCompositionStart`,`compositionstart focusout keydown keypress keyup mousedown`.split(` `)),kt(`onCompositionUpdate`,`compositionupdate focusout keydown keypress keyup mousedown`.split(` `));var _d=`abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting`.split(` `),vd=new Set(`beforetoggle cancel close invalid load scroll scrollend toggle`.split(` `).concat(_d));function yd(e,t){t=(t&4)!=0;for(var n=0;n<e.length;n++){var r=e[n],i=r.event;r=r.listeners;a:{var a=void 0;if(t)for(var o=r.length-1;0<=o;o--){var s=r[o],c=s.instance,l=s.currentTarget;if(s=s.listener,c!==a&&i.isPropagationStopped())break a;a=s,i.currentTarget=l;try{a(i)}catch(e){ni(e)}i.currentTarget=null,a=c}else for(o=0;o<r.length;o++){if(s=r[o],c=s.instance,l=s.currentTarget,s=s.listener,c!==a&&i.isPropagationStopped())break a;a=s,i.currentTarget=l;try{a(i)}catch(e){ni(e)}i.currentTarget=null,a=c}}}}function Q(e,t){var n=t[_t];n===void 0&&(n=t[_t]=new Set);var r=e+`__bubble`;n.has(r)||(Cd(t,e,2,!1),n.add(r))}function bd(e,t,n){var r=0;t&&(r|=4),Cd(n,e,r,t)}var xd=`_reactListening`+Math.random().toString(36).slice(2);function Sd(e){if(!e[xd]){e[xd]=!0,Dt.forEach(function(t){t!==`selectionchange`&&(vd.has(t)||bd(t,!1,e),bd(t,!0,e))});var t=e.nodeType===9?e:e.ownerDocument;t===null||t[xd]||(t[xd]=!0,bd(`selectionchange`,!1,t))}}function Cd(e,t,n,r){switch(mp(t)){case 2:var i=cp;break;case 8:i=lp;break;default:i=up}n=i.bind(null,t,n,e),i=void 0,!_n||t!==`touchstart`&&t!==`touchmove`&&t!==`wheel`||(i=!0),r?i===void 0?e.addEventListener(t,n,!0):e.addEventListener(t,n,{capture:!0,passive:i}):i===void 0?e.addEventListener(t,n,!1):e.addEventListener(t,n,{passive:i})}function wd(e,t,n,r,i){var a=r;if(!(t&1)&&!(t&2)&&r!==null)a:for(;;){if(r===null)return;var s=r.tag;if(s===3||s===4){var c=r.stateNode.containerInfo;if(c===i)break;if(s===4)for(s=r.return;s!==null;){var l=s.tag;if((l===3||l===4)&&s.stateNode.containerInfo===i)return;s=s.return}for(;c!==null;){if(s=Ct(c),s===null)return;if(l=s.tag,l===5||l===6||l===26||l===27){r=a=s;continue a}c=c.parentNode}}r=r.return}mn(function(){var r=a,i=ln(n),s=[];a:{var c=$r.get(e);if(c!==void 0){var l=Dn,u=e;switch(e){case`keypress`:if(Cn(n)===0)break a;case`keydown`:case`keyup`:l=Gn;break;case`focusin`:u=`focus`,l=In;break;case`focusout`:u=`blur`,l=In;break;case`beforeblur`:case`afterblur`:l=In;break;case`click`:if(n.button===2)break a;case`auxclick`:case`dblclick`:case`mousedown`:case`mousemove`:case`mouseup`:case`mouseout`:case`mouseover`:case`contextmenu`:l=Pn;break;case`drag`:case`dragend`:case`dragenter`:case`dragexit`:case`dragleave`:case`dragover`:case`dragstart`:case`drop`:l=Fn;break;case`touchcancel`:case`touchend`:case`touchmove`:case`touchstart`:l=qn;break;case Kr:case qr:case Jr:l=Ln;break;case Qr:l=Jn;break;case`scroll`:case`scrollend`:l=kn;break;case`wheel`:l=Yn;break;case`copy`:case`cut`:case`paste`:l=Rn;break;case`gotpointercapture`:case`lostpointercapture`:case`pointercancel`:case`pointerdown`:case`pointermove`:case`pointerout`:case`pointerover`:case`pointerup`:l=Kn;break;case`toggle`:case`beforetoggle`:l=Xn}var d=(t&4)!=0,f=!d&&(e===`scroll`||e===`scrollend`),p=d?c===null?null:c+`Capture`:c;d=[];for(var m=r,h;m!==null;){var g=m;if(h=g.stateNode,g=g.tag,g!==5&&g!==26&&g!==27||h===null||p===null||(g=hn(m,p),g!=null&&d.push(Td(m,g,h))),f)break;m=m.return}0<d.length&&(c=new l(c,u,null,n,i),s.push({event:c,listeners:d}))}}if(!(t&7)){a:{if(c=e===`mouseover`||e===`pointerover`,l=e===`mouseout`||e===`pointerout`,c&&n!==cn&&(u=n.relatedTarget||n.fromElement)&&(Ct(u)||u[gt]))break a;if((l||c)&&(c=i.window===i?i:(c=i.ownerDocument)?c.defaultView||c.parentWindow:window,l?(u=n.relatedTarget||n.toElement,l=r,u=u?Ct(u):null,u!==null&&(f=o(u),d=u.tag,u!==f||d!==5&&d!==27&&d!==6)&&(u=null)):(l=null,u=r),l!==u)){if(d=Pn,g=`onMouseLeave`,p=`onMouseEnter`,m=`mouse`,(e===`pointerout`||e===`pointerover`)&&(d=Kn,g=`onPointerLeave`,p=`onPointerEnter`,m=`pointer`),f=l==null?c:wt(l),h=u==null?c:wt(u),c=new d(g,m+`leave`,l,n,i),c.target=f,c.relatedTarget=h,g=null,Ct(i)===r&&(d=new d(p,m+`enter`,u,n,i),d.target=h,d.relatedTarget=f,g=d),f=g,l&&u)b:{for(d=Dd,p=l,m=u,h=0,g=p;g;g=d(g))h++;g=0;for(var _=m;_;_=d(_))g++;for(;0<h-g;)p=d(p),h--;for(;0<g-h;)m=d(m),g--;for(;h--;){if(p===m||m!==null&&p===m.alternate){d=p;break b}p=d(p),m=d(m)}d=null}else d=null;l!==null&&Od(s,c,l,d,!1),u!==null&&f!==null&&Od(s,f,u,d,!0)}}a:{if(c=r?wt(r):window,l=c.nodeName&&c.nodeName.toLowerCase(),l===`select`||l===`input`&&c.type===`file`)var v=gr;else if(ur(c))if(_r)v=Er;else{v=wr;var y=Cr}else l=c.nodeName,!l||l.toLowerCase()!==`input`||c.type!==`checkbox`&&c.type!==`radio`?r&&nn(r.elementType)&&(v=gr):v=Tr;if(v&&=v(e,r)){dr(s,v,n,i);break a}y&&y(e,c,r),e===`focusout`&&r&&c.type===`number`&&r.memoizedProps.value!=null&&Jt(c,`number`,c.value)}switch(y=r?wt(r):window,e){case`focusin`:(ur(y)||y.contentEditable===`true`)&&(Ir=y,Lr=r,Rr=null);break;case`focusout`:Rr=Lr=Ir=null;break;case`mousedown`:zr=!0;break;case`contextmenu`:case`mouseup`:case`dragend`:zr=!1,Br(s,n,i);break;case`selectionchange`:if(Fr)break;case`keydown`:case`keyup`:Br(s,n,i)}var b;if(Qn)b:{switch(e){case`compositionstart`:var x=`onCompositionStart`;break b;case`compositionend`:x=`onCompositionEnd`;break b;case`compositionupdate`:x=`onCompositionUpdate`;break b}x=void 0}else or?ir(e,n)&&(x=`onCompositionEnd`):e===`keydown`&&n.keyCode===229&&(x=`onCompositionStart`);x&&(tr&&n.locale!==`ko`&&(or||x!==`onCompositionStart`?x===`onCompositionEnd`&&or&&(b=Sn()):(yn=i,bn=`value`in yn?yn.value:yn.textContent,or=!0)),y=Ed(r,x),0<y.length&&(x=new zn(x,e,null,n,i),s.push({event:x,listeners:y}),b?x.data=b:(b=ar(n),b!==null&&(x.data=b)))),(b=er?sr(e,n):cr(e,n))&&(x=Ed(r,`onBeforeInput`),0<x.length&&(y=new zn(`onBeforeInput`,`beforeinput`,null,n,i),s.push({event:y,listeners:x}),y.data=b)),md(s,e,r,n,i)}yd(s,t)})}function Td(e,t,n){return{instance:e,listener:t,currentTarget:n}}function Ed(e,t){for(var n=t+`Capture`,r=[];e!==null;){var i=e,a=i.stateNode;if(i=i.tag,i!==5&&i!==26&&i!==27||a===null||(i=hn(e,n),i!=null&&r.unshift(Td(e,i,a)),i=hn(e,t),i!=null&&r.push(Td(e,i,a))),e.tag===3)return r;e=e.return}return[]}function Dd(e){if(e===null)return null;do e=e.return;while(e&&e.tag!==5&&e.tag!==27);return e||null}function Od(e,t,n,r,i){for(var a=t._reactName,o=[];n!==null&&n!==r;){var s=n,c=s.alternate,l=s.stateNode;if(s=s.tag,c!==null&&c===r)break;s!==5&&s!==26&&s!==27||l===null||(c=l,i?(l=hn(n,a),l!=null&&o.unshift(Td(n,l,c))):i||(l=hn(n,a),l!=null&&o.push(Td(n,l,c)))),n=n.return}o.length!==0&&e.push({event:t,listeners:o})}var kd=/\r\n?/g,Ad=/\u0000|\uFFFD/g;function jd(e){return(typeof e==`string`?e:``+e).replace(kd,`
`).replace(Ad,``)}function Md(e,t){return t=jd(t),jd(e)===t}function $(e,t,n,r,a,o){switch(n){case`children`:typeof r==`string`?t===`body`||t===`textarea`&&r===``||Qt(e,r):(typeof r==`number`||typeof r==`bigint`)&&t!==`body`&&Qt(e,``+r);break;case`className`:It(e,`class`,r);break;case`tabIndex`:It(e,`tabindex`,r);break;case`dir`:case`role`:case`viewBox`:case`width`:case`height`:It(e,n,r);break;case`style`:tn(e,r,o);break;case`data`:if(t!==`object`){It(e,`data`,r);break}case`src`:case`href`:if(r===``&&(t!==`a`||n!==`href`)){e.removeAttribute(n);break}if(r==null||typeof r==`function`||typeof r==`symbol`||typeof r==`boolean`){e.removeAttribute(n);break}r=on(``+r),e.setAttribute(n,r);break;case`action`:case`formAction`:if(typeof r==`function`){e.setAttribute(n,`javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')`);break}else typeof o==`function`&&(n===`formAction`?(t!==`input`&&$(e,t,`name`,a.name,a,null),$(e,t,`formEncType`,a.formEncType,a,null),$(e,t,`formMethod`,a.formMethod,a,null),$(e,t,`formTarget`,a.formTarget,a,null)):($(e,t,`encType`,a.encType,a,null),$(e,t,`method`,a.method,a,null),$(e,t,`target`,a.target,a,null)));if(r==null||typeof r==`symbol`||typeof r==`boolean`){e.removeAttribute(n);break}r=on(``+r),e.setAttribute(n,r);break;case`onClick`:r!=null&&(e.onclick=sn);break;case`onScroll`:r!=null&&Q(`scroll`,e);break;case`onScrollEnd`:r!=null&&Q(`scrollend`,e);break;case`dangerouslySetInnerHTML`:if(r!=null){if(typeof r!=`object`||!(`__html`in r))throw Error(i(61));if(n=r.__html,n!=null){if(a.children!=null)throw Error(i(60));e.innerHTML=n}}break;case`multiple`:e.multiple=r&&typeof r!=`function`&&typeof r!=`symbol`;break;case`muted`:e.muted=r&&typeof r!=`function`&&typeof r!=`symbol`;break;case`suppressContentEditableWarning`:case`suppressHydrationWarning`:case`defaultValue`:case`defaultChecked`:case`innerHTML`:case`ref`:break;case`autoFocus`:break;case`xlinkHref`:if(r==null||typeof r==`function`||typeof r==`boolean`||typeof r==`symbol`){e.removeAttribute(`xlink:href`);break}n=on(``+r),e.setAttributeNS(`http://www.w3.org/1999/xlink`,`xlink:href`,n);break;case`contentEditable`:case`spellCheck`:case`draggable`:case`value`:case`autoReverse`:case`externalResourcesRequired`:case`focusable`:case`preserveAlpha`:r!=null&&typeof r!=`function`&&typeof r!=`symbol`?e.setAttribute(n,``+r):e.removeAttribute(n);break;case`inert`:case`allowFullScreen`:case`async`:case`autoPlay`:case`controls`:case`default`:case`defer`:case`disabled`:case`disablePictureInPicture`:case`disableRemotePlayback`:case`formNoValidate`:case`hidden`:case`loop`:case`noModule`:case`noValidate`:case`open`:case`playsInline`:case`readOnly`:case`required`:case`reversed`:case`scoped`:case`seamless`:case`itemScope`:r&&typeof r!=`function`&&typeof r!=`symbol`?e.setAttribute(n,``):e.removeAttribute(n);break;case`capture`:case`download`:!0===r?e.setAttribute(n,``):!1!==r&&r!=null&&typeof r!=`function`&&typeof r!=`symbol`?e.setAttribute(n,r):e.removeAttribute(n);break;case`cols`:case`rows`:case`size`:case`span`:r!=null&&typeof r!=`function`&&typeof r!=`symbol`&&!isNaN(r)&&1<=r?e.setAttribute(n,r):e.removeAttribute(n);break;case`rowSpan`:case`start`:r==null||typeof r==`function`||typeof r==`symbol`||isNaN(r)?e.removeAttribute(n):e.setAttribute(n,r);break;case`popover`:Q(`beforetoggle`,e),Q(`toggle`,e),Ft(e,`popover`,r);break;case`xlinkActuate`:Lt(e,`http://www.w3.org/1999/xlink`,`xlink:actuate`,r);break;case`xlinkArcrole`:Lt(e,`http://www.w3.org/1999/xlink`,`xlink:arcrole`,r);break;case`xlinkRole`:Lt(e,`http://www.w3.org/1999/xlink`,`xlink:role`,r);break;case`xlinkShow`:Lt(e,`http://www.w3.org/1999/xlink`,`xlink:show`,r);break;case`xlinkTitle`:Lt(e,`http://www.w3.org/1999/xlink`,`xlink:title`,r);break;case`xlinkType`:Lt(e,`http://www.w3.org/1999/xlink`,`xlink:type`,r);break;case`xmlBase`:Lt(e,`http://www.w3.org/XML/1998/namespace`,`xml:base`,r);break;case`xmlLang`:Lt(e,`http://www.w3.org/XML/1998/namespace`,`xml:lang`,r);break;case`xmlSpace`:Lt(e,`http://www.w3.org/XML/1998/namespace`,`xml:space`,r);break;case`is`:Ft(e,`is`,r);break;case`innerText`:case`textContent`:break;default:(!(2<n.length)||n[0]!==`o`&&n[0]!==`O`||n[1]!==`n`&&n[1]!==`N`)&&(n=rn.get(n)||n,Ft(e,n,r))}}function Nd(e,t,n,r,a,o){switch(n){case`style`:tn(e,r,o);break;case`dangerouslySetInnerHTML`:if(r!=null){if(typeof r!=`object`||!(`__html`in r))throw Error(i(61));if(n=r.__html,n!=null){if(a.children!=null)throw Error(i(60));e.innerHTML=n}}break;case`children`:typeof r==`string`?Qt(e,r):(typeof r==`number`||typeof r==`bigint`)&&Qt(e,``+r);break;case`onScroll`:r!=null&&Q(`scroll`,e);break;case`onScrollEnd`:r!=null&&Q(`scrollend`,e);break;case`onClick`:r!=null&&(e.onclick=sn);break;case`suppressContentEditableWarning`:case`suppressHydrationWarning`:case`innerHTML`:case`ref`:break;case`innerText`:case`textContent`:break;default:if(!Ot.hasOwnProperty(n))a:{if(n[0]===`o`&&n[1]===`n`&&(a=n.endsWith(`Capture`),t=n.slice(2,a?n.length-7:void 0),o=e[ht]||null,o=o==null?null:o[n],typeof o==`function`&&e.removeEventListener(t,o,a),typeof r==`function`)){typeof o!=`function`&&o!==null&&(n in e?e[n]=null:e.hasAttribute(n)&&e.removeAttribute(n)),e.addEventListener(t,r,a);break a}n in e?e[n]=r:!0===r?e.setAttribute(n,``):Ft(e,n,r)}}}function Pd(e,t,n){switch(t){case`div`:case`span`:case`svg`:case`path`:case`a`:case`g`:case`p`:case`li`:break;case`img`:Q(`error`,e),Q(`load`,e);var r=!1,a=!1,o;for(o in n)if(n.hasOwnProperty(o)){var s=n[o];if(s!=null)switch(o){case`src`:r=!0;break;case`srcSet`:a=!0;break;case`children`:case`dangerouslySetInnerHTML`:throw Error(i(137,t));default:$(e,t,o,s,n,null)}}a&&$(e,t,`srcSet`,n.srcSet,n,null),r&&$(e,t,`src`,n.src,n,null);return;case`input`:Q(`invalid`,e);var c=o=s=a=null,l=null,u=null;for(r in n)if(n.hasOwnProperty(r)){var d=n[r];if(d!=null)switch(r){case`name`:a=d;break;case`type`:s=d;break;case`checked`:l=d;break;case`defaultChecked`:u=d;break;case`value`:o=d;break;case`defaultValue`:c=d;break;case`children`:case`dangerouslySetInnerHTML`:if(d!=null)throw Error(i(137,t));break;default:$(e,t,r,d,n,null)}}qt(e,o,c,l,u,s,a,!1);return;case`select`:for(a in Q(`invalid`,e),r=s=o=null,n)if(n.hasOwnProperty(a)&&(c=n[a],c!=null))switch(a){case`value`:o=c;break;case`defaultValue`:s=c;break;case`multiple`:r=c;default:$(e,t,a,c,n,null)}t=o,n=s,e.multiple=!!r,t==null?n!=null&&Yt(e,!!r,n,!0):Yt(e,!!r,t,!1);return;case`textarea`:for(s in Q(`invalid`,e),o=a=r=null,n)if(n.hasOwnProperty(s)&&(c=n[s],c!=null))switch(s){case`value`:r=c;break;case`defaultValue`:a=c;break;case`children`:o=c;break;case`dangerouslySetInnerHTML`:if(c!=null)throw Error(i(91));break;default:$(e,t,s,c,n,null)}Zt(e,r,a,o);return;case`option`:for(l in n)if(n.hasOwnProperty(l)&&(r=n[l],r!=null))switch(l){case`selected`:e.selected=r&&typeof r!=`function`&&typeof r!=`symbol`;break;default:$(e,t,l,r,n,null)}return;case`dialog`:Q(`beforetoggle`,e),Q(`toggle`,e),Q(`cancel`,e),Q(`close`,e);break;case`iframe`:case`object`:Q(`load`,e);break;case`video`:case`audio`:for(r=0;r<_d.length;r++)Q(_d[r],e);break;case`image`:Q(`error`,e),Q(`load`,e);break;case`details`:Q(`toggle`,e);break;case`embed`:case`source`:case`link`:Q(`error`,e),Q(`load`,e);case`area`:case`base`:case`br`:case`col`:case`hr`:case`keygen`:case`meta`:case`param`:case`track`:case`wbr`:case`menuitem`:for(u in n)if(n.hasOwnProperty(u)&&(r=n[u],r!=null))switch(u){case`children`:case`dangerouslySetInnerHTML`:throw Error(i(137,t));default:$(e,t,u,r,n,null)}return;default:if(nn(t)){for(d in n)n.hasOwnProperty(d)&&(r=n[d],r!==void 0&&Nd(e,t,d,r,n,void 0));return}}for(c in n)n.hasOwnProperty(c)&&(r=n[c],r!=null&&$(e,t,c,r,n,null))}function Fd(e,t,n,r){switch(t){case`div`:case`span`:case`svg`:case`path`:case`a`:case`g`:case`p`:case`li`:break;case`input`:var a=null,o=null,s=null,c=null,l=null,u=null,d=null;for(m in n){var f=n[m];if(n.hasOwnProperty(m)&&f!=null)switch(m){case`checked`:break;case`value`:break;case`defaultValue`:l=f;default:r.hasOwnProperty(m)||$(e,t,m,null,r,f)}}for(var p in r){var m=r[p];if(f=n[p],r.hasOwnProperty(p)&&(m!=null||f!=null))switch(p){case`type`:o=m;break;case`name`:a=m;break;case`checked`:u=m;break;case`defaultChecked`:d=m;break;case`value`:s=m;break;case`defaultValue`:c=m;break;case`children`:case`dangerouslySetInnerHTML`:if(m!=null)throw Error(i(137,t));break;default:m!==f&&$(e,t,p,m,r,f)}}Kt(e,s,c,l,u,d,o,a);return;case`select`:for(o in m=s=c=p=null,n)if(l=n[o],n.hasOwnProperty(o)&&l!=null)switch(o){case`value`:break;case`multiple`:m=l;default:r.hasOwnProperty(o)||$(e,t,o,null,r,l)}for(a in r)if(o=r[a],l=n[a],r.hasOwnProperty(a)&&(o!=null||l!=null))switch(a){case`value`:p=o;break;case`defaultValue`:c=o;break;case`multiple`:s=o;default:o!==l&&$(e,t,a,o,r,l)}t=c,n=s,r=m,p==null?!!r!=!!n&&(t==null?Yt(e,!!n,n?[]:``,!1):Yt(e,!!n,t,!0)):Yt(e,!!n,p,!1);return;case`textarea`:for(c in m=p=null,n)if(a=n[c],n.hasOwnProperty(c)&&a!=null&&!r.hasOwnProperty(c))switch(c){case`value`:break;case`children`:break;default:$(e,t,c,null,r,a)}for(s in r)if(a=r[s],o=n[s],r.hasOwnProperty(s)&&(a!=null||o!=null))switch(s){case`value`:p=a;break;case`defaultValue`:m=a;break;case`children`:break;case`dangerouslySetInnerHTML`:if(a!=null)throw Error(i(91));break;default:a!==o&&$(e,t,s,a,r,o)}Xt(e,p,m);return;case`option`:for(var h in n)if(p=n[h],n.hasOwnProperty(h)&&p!=null&&!r.hasOwnProperty(h))switch(h){case`selected`:e.selected=!1;break;default:$(e,t,h,null,r,p)}for(l in r)if(p=r[l],m=n[l],r.hasOwnProperty(l)&&p!==m&&(p!=null||m!=null))switch(l){case`selected`:e.selected=p&&typeof p!=`function`&&typeof p!=`symbol`;break;default:$(e,t,l,p,r,m)}return;case`img`:case`link`:case`area`:case`base`:case`br`:case`col`:case`embed`:case`hr`:case`keygen`:case`meta`:case`param`:case`source`:case`track`:case`wbr`:case`menuitem`:for(var g in n)p=n[g],n.hasOwnProperty(g)&&p!=null&&!r.hasOwnProperty(g)&&$(e,t,g,null,r,p);for(u in r)if(p=r[u],m=n[u],r.hasOwnProperty(u)&&p!==m&&(p!=null||m!=null))switch(u){case`children`:case`dangerouslySetInnerHTML`:if(p!=null)throw Error(i(137,t));break;default:$(e,t,u,p,r,m)}return;default:if(nn(t)){for(var _ in n)p=n[_],n.hasOwnProperty(_)&&p!==void 0&&!r.hasOwnProperty(_)&&Nd(e,t,_,void 0,r,p);for(d in r)p=r[d],m=n[d],!r.hasOwnProperty(d)||p===m||p===void 0&&m===void 0||Nd(e,t,d,p,r,m);return}}for(var v in n)p=n[v],n.hasOwnProperty(v)&&p!=null&&!r.hasOwnProperty(v)&&$(e,t,v,null,r,p);for(f in r)p=r[f],m=n[f],!r.hasOwnProperty(f)||p===m||p==null&&m==null||$(e,t,f,p,r,m)}function Id(e){switch(e){case`css`:case`script`:case`font`:case`img`:case`image`:case`input`:case`link`:return!0;default:return!1}}function Ld(){if(typeof performance.getEntriesByType==`function`){for(var e=0,t=0,n=performance.getEntriesByType(`resource`),r=0;r<n.length;r++){var i=n[r],a=i.transferSize,o=i.initiatorType,s=i.duration;if(a&&s&&Id(o)){for(o=0,s=i.responseEnd,r+=1;r<n.length;r++){var c=n[r],l=c.startTime;if(l>s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c<s?1:(s-l)/(c-l)))}if(--r,t+=8*(a+o)/(i.duration/1e3),e++,10<e)break}}if(0<e)return t/e/1e6}return navigator.connection&&(e=navigator.connection.downlink,typeof e==`number`)?e:5}var Rd=null,zd=null;function Bd(e){return e.nodeType===9?e:e.ownerDocument}function Vd(e){switch(e){case`http://www.w3.org/2000/svg`:return 1;case`http://www.w3.org/1998/Math/MathML`:return 2;default:return 0}}function Hd(e,t){if(e===0)switch(t){case`svg`:return 1;case`math`:return 2;default:return 0}return e===1&&t===`foreignObject`?0:e}function Ud(e,t){return e===`textarea`||e===`noscript`||typeof t.children==`string`||typeof t.children==`number`||typeof t.children==`bigint`||typeof t.dangerouslySetInnerHTML==`object`&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var Wd=null;function Gd(){var e=window.event;return e&&e.type===`popstate`?e===Wd?!1:(Wd=e,!0):(Wd=null,!1)}var Kd=typeof setTimeout==`function`?setTimeout:void 0,qd=typeof clearTimeout==`function`?clearTimeout:void 0,Jd=typeof Promise==`function`?Promise:void 0,Yd=typeof queueMicrotask==`function`?queueMicrotask:Jd===void 0?Kd:function(e){return Jd.resolve(null).then(e).catch(Xd)};function Xd(e){setTimeout(function(){throw e})}function Zd(e){return e===`head`}function Qd(e,t){var n=t,r=0;do{var i=n.nextSibling;if(e.removeChild(n),i&&i.nodeType===8)if(n=i.data,n===`/$`||n===`/&`){if(r===0){e.removeChild(i),Np(t);return}r--}else if(n===`$`||n===`$?`||n===`$~`||n===`$!`||n===`&`)r++;else if(n===`html`)pf(e.ownerDocument.documentElement);else if(n===`head`){n=e.ownerDocument.head,pf(n);for(var a=n.firstChild;a;){var o=a.nextSibling,s=a.nodeName;a[xt]||s===`SCRIPT`||s===`STYLE`||s===`LINK`&&a.rel.toLowerCase()===`stylesheet`||n.removeChild(a),a=o}}else n===`body`&&pf(e.ownerDocument.body);n=i}while(n);Np(t)}function $d(e,t){var n=e;e=0;do{var r=n.nextSibling;if(n.nodeType===1?t?(n._stashedDisplay=n.style.display,n.style.display=`none`):(n.style.display=n._stashedDisplay||``,n.getAttribute(`style`)===``&&n.removeAttribute(`style`)):n.nodeType===3&&(t?(n._stashedText=n.nodeValue,n.nodeValue=``):n.nodeValue=n._stashedText||``),r&&r.nodeType===8)if(n=r.data,n===`/$`){if(e===0)break;e--}else n!==`$`&&n!==`$?`&&n!==`$~`&&n!==`$!`||e++;n=r}while(n)}function ef(e){var t=e.firstChild;for(t&&t.nodeType===10&&(t=t.nextSibling);t;){var n=t;switch(t=t.nextSibling,n.nodeName){case`HTML`:case`HEAD`:case`BODY`:ef(n),St(n);continue;case`SCRIPT`:case`STYLE`:continue;case`LINK`:if(n.rel.toLowerCase()===`stylesheet`)continue}e.removeChild(n)}}function tf(e,t,n,r){for(;e.nodeType===1;){var i=n;if(e.nodeName.toLowerCase()!==t.toLowerCase()){if(!r&&(e.nodeName!==`INPUT`||e.type!==`hidden`))break}else if(!r)if(t===`input`&&e.type===`hidden`){var a=i.name==null?null:``+i.name;if(i.type===`hidden`&&e.getAttribute(`name`)===a)return e}else return e;else if(!e[xt])switch(t){case`meta`:if(!e.hasAttribute(`itemprop`))break;return e;case`link`:if(a=e.getAttribute(`rel`),a===`stylesheet`&&e.hasAttribute(`data-precedence`)||a!==i.rel||e.getAttribute(`href`)!==(i.href==null||i.href===``?null:i.href)||e.getAttribute(`crossorigin`)!==(i.crossOrigin==null?null:i.crossOrigin)||e.getAttribute(`title`)!==(i.title==null?null:i.title))break;return e;case`style`:if(e.hasAttribute(`data-precedence`))break;return e;case`script`:if(a=e.getAttribute(`src`),(a!==(i.src==null?null:i.src)||e.getAttribute(`type`)!==(i.type==null?null:i.type)||e.getAttribute(`crossorigin`)!==(i.crossOrigin==null?null:i.crossOrigin))&&a&&e.hasAttribute(`async`)&&!e.hasAttribute(`itemprop`))break;return e;default:return e}if(e=cf(e.nextSibling),e===null)break}return null}function nf(e,t,n){if(t===``)return null;for(;e.nodeType!==3;)if((e.nodeType!==1||e.nodeName!==`INPUT`||e.type!==`hidden`)&&!n||(e=cf(e.nextSibling),e===null))return null;return e}function rf(e,t){for(;e.nodeType!==8;)if((e.nodeType!==1||e.nodeName!==`INPUT`||e.type!==`hidden`)&&!t||(e=cf(e.nextSibling),e===null))return null;return e}function af(e){return e.data===`$?`||e.data===`$~`}function of(e){return e.data===`$!`||e.data===`$?`&&e.ownerDocument.readyState!==`loading`}function sf(e,t){var n=e.ownerDocument;if(e.data===`$~`)e._reactRetry=t;else if(e.data!==`$?`||n.readyState!==`loading`)t();else{var r=function(){t(),n.removeEventListener(`DOMContentLoaded`,r)};n.addEventListener(`DOMContentLoaded`,r),e._reactRetry=r}}function cf(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t===`$`||t===`$!`||t===`$?`||t===`$~`||t===`&`||t===`F!`||t===`F`)break;if(t===`/$`||t===`/&`)return null}}return e}var lf=null;function uf(e){e=e.nextSibling;for(var t=0;e;){if(e.nodeType===8){var n=e.data;if(n===`/$`||n===`/&`){if(t===0)return cf(e.nextSibling);t--}else n!==`$`&&n!==`$!`&&n!==`$?`&&n!==`$~`&&n!==`&`||t++}e=e.nextSibling}return null}function df(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var n=e.data;if(n===`$`||n===`$!`||n===`$?`||n===`$~`||n===`&`){if(t===0)return e;t--}else n!==`/$`&&n!==`/&`||t++}e=e.previousSibling}return null}function ff(e,t,n){switch(t=Bd(n),e){case`html`:if(e=t.documentElement,!e)throw Error(i(452));return e;case`head`:if(e=t.head,!e)throw Error(i(453));return e;case`body`:if(e=t.body,!e)throw Error(i(454));return e;default:throw Error(i(451))}}function pf(e){for(var t=e.attributes;t.length;)e.removeAttributeNode(t[0]);St(e)}var mf=new Map,hf=new Set;function gf(e){return typeof e.getRootNode==`function`?e.getRootNode():e.nodeType===9?e:e.ownerDocument}var _f=D.d;D.d={f:vf,r:yf,D:Sf,C:Cf,L:wf,m:Tf,X:Df,S:Ef,M:Of};function vf(){var e=_f.f(),t=bu();return e||t}function yf(e){var t=j(e);t!==null&&t.tag===5&&t.type===`form`?Ds(t):_f.r(e)}var bf=typeof document>`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=Gt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),Et(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Gt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Gt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Gt(n.imageSizes)+`"]`)):i+=`[href="`+Gt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=h({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),Et(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Gt(r)+`"][href="`+Gt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=h({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),Et(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=Tt(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=h({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);Et(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=Tt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Et(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=Tt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Et(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=_e.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=Tt(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=Tt(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=Tt(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+Gt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return h({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),Et(t),e.head.appendChild(t))}function Pf(e){return`[src="`+Gt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Gt(n.href)+`"]`);if(r)return t.instance=r,Et(r),r;var a=h({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),Et(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,Et(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),Et(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,Et(a),a):(r=n,(a=mf.get(o))&&(r=h({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),Et(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o<r.length;o++){var s=r[o];if(s.dataset.precedence===t)a=s;else if(a!==i)break}a?a.parentNode.insertBefore(e,a.nextSibling):(t=n.nodeType===9?n.head:n,t.insertBefore(e,t.firstChild))}function Rf(e,t){e.crossOrigin??=t.crossOrigin,e.referrerPolicy??=t.referrerPolicy,e.title??=t.title}function zf(e,t){e.crossOrigin??=t.crossOrigin,e.referrerPolicy??=t.referrerPolicy,e.integrity??=t.integrity}var Bf=null;function Vf(e,t,n){if(Bf===null){var r=new Map,i=Bf=new Map;i.set(n,r)}else i=Bf,r=i.get(n),r||(r=new Map,i.set(n,r));if(r.has(e))return r;for(r.set(e,null),n=n.getElementsByTagName(e),i=0;i<n.length;i++){var a=n[i];if(!(a[xt]||a[mt]||e===`link`&&a.getAttribute(`rel`)===`stylesheet`)&&a.namespaceURI!==`http://www.w3.org/2000/svg`){var o=a.getAttribute(t)||``;o=e+o;var s=r.get(o);s?s.push(a):r.set(o,[a])}}return r}function Hf(e,t,n){e=e.ownerDocument||e,e.head.insertBefore(n,t===`title`?e.querySelector(`head > title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,Et(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),Et(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0<e.count||0<e.imgCount?function(n){var r=setTimeout(function(){if(e.stylesheets&&Xf(e,e.stylesheets),e.unsuspend){var t=e.unsuspend;e.unsuspend=null,t()}},6e4+t);0<e.imgBytes&&Kf===0&&(Kf=62500*Ld());var i=setTimeout(function(){if(e.waitingForImages=!1,e.count===0&&(e.stylesheets&&Xf(e,e.stylesheets),e.unsuspend)){var t=e.unsuspend;e.unsuspend=null,t()}},(e.imgBytes>Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a<i.length;a++){var o=i[a];(o.nodeName===`LINK`||o.getAttribute(`media`)!==`not all`)&&(n.set(o.dataset.precedence,o),r=o)}r&&n.set(null,r)}i=t.instance,o=i.getAttribute(`data-precedence`),a=n.get(o)||r,a===r&&n.set(null,i),n.set(o,i),this.count++,r=Jf.bind(this),i.addEventListener(`load`,r),i.addEventListener(`error`,r),a?a.parentNode.insertBefore(i,a.nextSibling):(e=e.nodeType===9?e.head:e,e.insertBefore(i,e.firstChild)),t.state.loading|=4}}var Qf={$$typeof:C,Provider:null,Consumer:null,_currentValue:ue,_currentValue2:ue,_threadCount:0};function $f(e,t,n,r,i,a,o,s,c){this.tag=1,this.containerInfo=e,this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.next=this.pendingContext=this.context=this.cancelPendingCommit=null,this.callbackPriority=0,this.expirationTimes=rt(-1),this.entangledLanes=this.shellSuspendCounter=this.errorRecoveryDisabledLanes=this.expiredLanes=this.warmLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=rt(0),this.hiddenUpdates=rt(null),this.identifierPrefix=r,this.onUncaughtError=i,this.onCaughtError=a,this.onRecoverableError=o,this.pooledCache=null,this.pooledCacheLanes=0,this.formState=c,this.incompleteTransitions=new Map}function ep(e,t,n,r,i,a,o,s,c,l,u,d){return e=new $f(e,t,n,o,c,l,u,d,s),t=1,!0===a&&(t|=24),a=mi(3,null,null,t),e.current=a,a.stateNode=e,t=da(),t.refCount++,e.pooledCache=t,t.refCount++,a.memoizedState={element:r,isDehydrated:n,cache:t},Ha(a),e}function tp(e){return e?(e=fi,e):fi}function np(e,t,n,r,i,a){i=tp(i),r.context===null?r.context=i:r.pendingContext=i,r=Wa(t),r.payload={element:n},a=a===void 0?null:a,a!==null&&(r.callback=a),n=z(e,r,t),n!==null&&(hu(n,e,t),Ga(n,e,t))}function rp(e,t){if(e=e.memoizedState,e!==null&&e.dehydrated!==null){var n=e.retryLane;e.retryLane=n!==0&&n<t?n:t}}function ip(e,t){rp(e,t),(e=e.alternate)&&rp(e,t)}function ap(e){if(e.tag===13||e.tag===31){var t=li(e,67108864);t!==null&&hu(t,e,67108864),ip(e,67108864)}}function op(e){if(e.tag===13||e.tag===31){var t=pu();t=lt(t);var n=li(e,t);n!==null&&hu(n,e,t),ip(e,t)}}var sp=!0;function cp(e,t,n,r){var i=E.T;E.T=null;var a=D.p;try{D.p=2,up(e,t,n,r)}finally{D.p=a,E.T=i}}function lp(e,t,n,r){var i=E.T;E.T=null;var a=D.p;try{D.p=8,up(e,t,n,r)}finally{D.p=a,E.T=i}}function up(e,t,n,r){if(sp){var i=dp(r);if(i===null)wd(e,t,r,fp,n),Cp(e,r);else if(Tp(i,e,t,n,r))r.stopPropagation();else if(Cp(e,r),t&4&&-1<Sp.indexOf(e)){for(;i!==null;){var a=j(i);if(a!==null)switch(a.tag){case 3:if(a=a.stateNode,a.current.memoizedState.isDehydrated){var o=A(a.pendingLanes);if(o!==0){var s=a;for(s.pendingLanes|=2,s.entangledLanes|=2;o;){var c=1<<31-Ke(o);s.entanglements[1]|=c,o&=~c}rd(a),!(G&6)&&(tu=Pe()+500,id(0,!1))}}break;case 31:case 13:s=li(a,2),s!==null&&hu(s,a,2),bu(),ip(a,2)}if(a=dp(r),a===null&&wd(e,t,r,fp,n),a===i)break;i=a}i!==null&&r.stopPropagation()}else wd(e,t,r,null,n)}}function dp(e){return e=ln(e),pp(e)}var fp=null;function pp(e){if(fp=null,e=Ct(e),e!==null){var t=o(e);if(t===null)e=null;else{var n=t.tag;if(n===13){if(e=s(t),e!==null)return e;e=null}else if(n===31){if(e=c(t),e!==null)return e;e=null}else if(n===3){if(t.stateNode.current.memoizedState.isDehydrated)return t.tag===3?t.stateNode.containerInfo:null;e=null}else t!==e&&(e=null)}}return fp=e,null}function mp(e){switch(e){case`beforetoggle`:case`cancel`:case`click`:case`close`:case`contextmenu`:case`copy`:case`cut`:case`auxclick`:case`dblclick`:case`dragend`:case`dragstart`:case`drop`:case`focusin`:case`focusout`:case`input`:case`invalid`:case`keydown`:case`keypress`:case`keyup`:case`mousedown`:case`mouseup`:case`paste`:case`pause`:case`play`:case`pointercancel`:case`pointerdown`:case`pointerup`:case`ratechange`:case`reset`:case`resize`:case`seeked`:case`submit`:case`toggle`:case`touchcancel`:case`touchend`:case`touchstart`:case`volumechange`:case`change`:case`selectionchange`:case`textInput`:case`compositionstart`:case`compositionend`:case`compositionupdate`:case`beforeblur`:case`afterblur`:case`beforeinput`:case`blur`:case`fullscreenchange`:case`focus`:case`hashchange`:case`popstate`:case`select`:case`selectstart`:return 2;case`drag`:case`dragenter`:case`dragexit`:case`dragleave`:case`dragover`:case`mousemove`:case`mouseout`:case`mouseover`:case`pointermove`:case`pointerout`:case`pointerover`:case`scroll`:case`touchmove`:case`wheel`:case`mouseenter`:case`mouseleave`:case`pointerenter`:case`pointerleave`:return 8;case`message`:switch(Fe()){case Ie:return 2;case Le:return 8;case Re:case ze:return 32;case Be:return 268435456;default:return 32}default:return 32}}var hp=!1,gp=null,_p=null,vp=null,yp=new Map,bp=new Map,xp=[],Sp=`mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset`.split(` `);function Cp(e,t){switch(e){case`focusin`:case`focusout`:gp=null;break;case`dragenter`:case`dragleave`:_p=null;break;case`mouseover`:case`mouseout`:vp=null;break;case`pointerover`:case`pointerout`:yp.delete(t.pointerId);break;case`gotpointercapture`:case`lostpointercapture`:bp.delete(t.pointerId)}}function wp(e,t,n,r,i,a){return e===null||e.nativeEvent!==a?(e={blockedOn:t,domEventName:n,eventSystemFlags:r,nativeEvent:a,targetContainers:[i]},t!==null&&(t=j(t),t!==null&&ap(t)),e):(e.eventSystemFlags|=r,t=e.targetContainers,i!==null&&t.indexOf(i)===-1&&t.push(i),e)}function Tp(e,t,n,r,i){switch(t){case`focusin`:return gp=wp(gp,e,t,n,r,i),!0;case`dragenter`:return _p=wp(_p,e,t,n,r,i),!0;case`mouseover`:return vp=wp(vp,e,t,n,r,i),!0;case`pointerover`:var a=i.pointerId;return yp.set(a,wp(yp.get(a)||null,e,t,n,r,i)),!0;case`gotpointercapture`:return a=i.pointerId,bp.set(a,wp(bp.get(a)||null,e,t,n,r,i)),!0}return!1}function Ep(e){var t=Ct(e.target);if(t!==null){var n=o(t);if(n!==null){if(t=n.tag,t===13){if(t=s(n),t!==null){e.blockedOn=t,ft(e.priority,function(){op(n)});return}}else if(t===31){if(t=c(n),t!==null){e.blockedOn=t,ft(e.priority,function(){op(n)});return}}else if(t===3&&n.stateNode.current.memoizedState.isDehydrated){e.blockedOn=n.tag===3?n.stateNode.containerInfo:null;return}}}e.blockedOn=null}function Dp(e){if(e.blockedOn!==null)return!1;for(var t=e.targetContainers;0<t.length;){var n=dp(e.nativeEvent);if(n===null){n=e.nativeEvent;var r=new n.constructor(n.type,n);cn=r,n.target.dispatchEvent(r),cn=null}else return t=j(n),t!==null&&ap(t),e.blockedOn=n,!1;t.shift()}return!0}function Op(e,t,n){Dp(e)&&n.delete(t)}function kp(){hp=!1,gp!==null&&Dp(gp)&&(gp=null),_p!==null&&Dp(_p)&&(_p=null),vp!==null&&Dp(vp)&&(vp=null),yp.forEach(Op),bp.forEach(Op)}function Ap(e,n){e.blockedOn===n&&(e.blockedOn=null,hp||(hp=!0,t.unstable_scheduleCallback(t.unstable_NormalPriority,kp)))}var jp=null;function Mp(e){jp!==e&&(jp=e,t.unstable_scheduleCallback(t.unstable_NormalPriority,function(){jp===e&&(jp=null);for(var t=0;t<e.length;t+=3){var n=e[t],r=e[t+1],i=e[t+2];if(typeof r!=`function`){if(pp(r||n)===null)continue;break}var a=j(n);a!==null&&(e.splice(t,3),t-=3,Ts(a,{pending:!0,data:i,method:n.method,action:r},r,i))}}))}function Np(e){function t(t){return Ap(t,e)}gp!==null&&Ap(gp,e),_p!==null&&Ap(_p,e),vp!==null&&Ap(vp,e),yp.forEach(t),bp.forEach(t);for(var n=0;n<xp.length;n++){var r=xp[n];r.blockedOn===e&&(r.blockedOn=null)}for(;0<xp.length&&(n=xp[0],n.blockedOn===null);)Ep(n),n.blockedOn===null&&xp.shift();if(n=(e.ownerDocument||e).$$reactFormReplay,n!=null)for(r=0;r<n.length;r+=3){var i=n[r],a=n[r+1],o=i[ht]||null;if(typeof a==`function`)o||Mp(n);else if(o){var s=null;if(a&&a.hasAttribute(`formAction`)){if(i=a,o=a[ht]||null)s=o.formAction;else if(pp(i)!==null)continue}else s=o.action;typeof s==`function`?n[r+1]=s:(n.splice(r,3),r-=3),Mp(n)}}}function Pp(){function e(e){e.canIntercept&&e.info===`react-transition`&&e.intercept({handler:function(){return new Promise(function(e){return i=e})},focusReset:`manual`,scroll:`manual`})}function t(){i!==null&&(i(),i=null),r||setTimeout(n,20)}function n(){if(!r&&!navigation.transition){var e=navigation.currentEntry;e&&e.url!=null&&navigation.navigate(e.url,{state:e.getState(),info:`react-transition`,history:`replace`})}}if(typeof navigation==`object`){var r=!1,i=null;return navigation.addEventListener(`navigate`,e),navigation.addEventListener(`navigatesuccess`,t),navigation.addEventListener(`navigateerror`,t),setTimeout(n,100),function(){r=!0,navigation.removeEventListener(`navigate`,e),navigation.removeEventListener(`navigatesuccess`,t),navigation.removeEventListener(`navigateerror`,t),i!==null&&(i(),i=null)}}}function Fp(e){this._internalRoot=e}Ip.prototype.render=Fp.prototype.render=function(e){var t=this._internalRoot;if(t===null)throw Error(i(409));var n=t.current;np(n,pu(),e,t,null,null)},Ip.prototype.unmount=Fp.prototype.unmount=function(){var e=this._internalRoot;if(e!==null){this._internalRoot=null;var t=e.containerInfo;np(e.current,2,null,e,null,null),bu(),t[gt]=null}};function Ip(e){this._internalRoot=e}Ip.prototype.unstable_scheduleHydration=function(e){if(e){var t=dt();e={blockedOn:null,target:e,priority:t};for(var n=0;n<xp.length&&t!==0&&t<xp[n].priority;n++);xp.splice(n,0,e),n===0&&Ep(e)}};var Lp=n.version;if(Lp!==`19.2.6`)throw Error(i(527,Lp,`19.2.6`));D.findDOMNode=function(e){var t=e._reactInternals;if(t===void 0)throw typeof e.render==`function`?Error(i(188)):(e=Object.keys(e).join(`,`),Error(i(268,e)));return e=d(t),e=e===null?null:p(e),e=e===null?null:e.stateNode,e};var Rp={bundleType:0,version:`19.2.6`,rendererPackageName:`react-dom`,currentDispatcherRef:E,reconcilerVersion:`19.2.6`};if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<`u`){var zp=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!zp.isDisabled&&zp.supportsFiber)try{Ue=zp.inject(Rp),We=zp}catch{}}e.createRoot=function(e,t){if(!a(e))throw Error(i(299));var n=!1,r=``,o=Js,s=Ys,c=Xs;return t!=null&&(!0===t.unstable_strictMode&&(n=!0),t.identifierPrefix!==void 0&&(r=t.identifierPrefix),t.onUncaughtError!==void 0&&(o=t.onUncaughtError),t.onCaughtError!==void 0&&(s=t.onCaughtError),t.onRecoverableError!==void 0&&(c=t.onRecoverableError)),t=ep(e,1,!1,null,null,n,r,null,o,s,c,Pp),e[gt]=t.current,Sd(e),new Fp(t)}})),g=o(((e,t)=>{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=h()})),_=c(u(),1),v=g(),y=class{constructor(){this.listeners=new Set,this.subscribe=this.subscribe.bind(this)}subscribe(e){return this.listeners.add(e),this.onSubscribe(),()=>{this.listeners.delete(e),this.onUnsubscribe()}}hasListeners(){return this.listeners.size>0}onSubscribe(){}onUnsubscribe(){}},b=new class extends y{#e;#t;#n;constructor(){super(),this.#n=e=>{if(typeof window<`u`&&window.addEventListener){let t=()=>e();return window.addEventListener(`visibilitychange`,t,!1),()=>{window.removeEventListener(`visibilitychange`,t)}}}}onSubscribe(){this.#t||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#t?.(),this.#t=void 0)}setEventListener(e){this.#n=e,this.#t?.(),this.#t=e(e=>{typeof e==`boolean`?this.setFocused(e):this.onFocus()})}setFocused(e){this.#e!==e&&(this.#e=e,this.onFocus())}onFocus(){let e=this.isFocused();this.listeners.forEach(t=>{t(e)})}isFocused(){return typeof this.#e==`boolean`?this.#e:globalThis.document?.visibilityState!==`hidden`}},x={setTimeout:(e,t)=>setTimeout(e,t),clearTimeout:e=>clearTimeout(e),setInterval:(e,t)=>setInterval(e,t),clearInterval:e=>clearInterval(e)},S=new class{#e=x;setTimeoutProvider(e){this.#e=e}setTimeout(e,t){return this.#e.setTimeout(e,t)}clearTimeout(e){this.#e.clearTimeout(e)}setInterval(e,t){return this.#e.setInterval(e,t)}clearInterval(e){this.#e.clearInterval(e)}};function C(e){setTimeout(e,0)}var w=typeof window>`u`||`Deno`in globalThis;function T(){}function ee(e,t){return typeof e==`function`?e(t):e}function te(e){return typeof e==`number`&&e>=0&&e!==1/0}function ne(e,t){return Math.max(e+(t||0)-Date.now(),0)}function re(e,t){return typeof e==`function`?e(t):e}function ie(e,t){return typeof e==`function`?e(t):e}function ae(e,t){let{type:n=`all`,exact:r,fetchStatus:i,predicate:a,queryKey:o,stale:s}=e;if(o){if(r){if(t.queryHash!==se(o,t.options))return!1}else if(!le(t.queryKey,o))return!1}if(n!==`all`){let e=t.isActive();if(n===`active`&&!e||n===`inactive`&&e)return!1}return!(typeof s==`boolean`&&t.isStale()!==s||i&&i!==t.state.fetchStatus||a&&!a(t))}function oe(e,t){let{exact:n,status:r,predicate:i,mutationKey:a}=e;if(a){if(!t.options.mutationKey)return!1;if(n){if(ce(t.options.mutationKey)!==ce(a))return!1}else if(!le(t.options.mutationKey,a))return!1}return!(r&&t.state.status!==r||i&&!i(t))}function se(e,t){return(t?.queryKeyHashFn||ce)(e)}function ce(e){return JSON.stringify(e,(e,t)=>fe(t)?Object.keys(t).sort().reduce((e,n)=>(e[n]=t[n],e),{}):t)}function le(e,t){return e===t?!0:typeof e==typeof t&&e&&t&&typeof e==`object`&&typeof t==`object`?Object.keys(t).every(n=>le(e[n],t[n])):!1}var E=Object.prototype.hasOwnProperty;function D(e,t,n=0){if(e===t)return e;if(n>500)return t;let r=de(e)&&de(t);if(!r&&!(fe(e)&&fe(t)))return t;let i=(r?e:Object.keys(e)).length,a=r?t:Object.keys(t),o=a.length,s=r?Array(o):{},c=0;for(let l=0;l<o;l++){let o=r?l:a[l],u=e[o],d=t[o];if(u===d){s[o]=u,(r?l<i:E.call(e,o))&&c++;continue}if(u===null||d===null||typeof u!=`object`||typeof d!=`object`){s[o]=d;continue}let f=D(u,d,n+1);s[o]=f,f===u&&c++}return i===o&&c===i?e:s}function ue(e,t){if(!t||Object.keys(e).length!==Object.keys(t).length)return!1;for(let n in e)if(e[n]!==t[n])return!1;return!0}function de(e){return Array.isArray(e)&&e.length===Object.keys(e).length}function fe(e){if(!pe(e))return!1;let t=e.constructor;if(t===void 0)return!0;let n=t.prototype;return!(!pe(n)||!n.hasOwnProperty(`isPrototypeOf`)||Object.getPrototypeOf(e)!==Object.prototype)}function pe(e){return Object.prototype.toString.call(e)===`[object Object]`}function me(e){return new Promise(t=>{S.setTimeout(t,e)})}function O(e,t,n){return typeof n.structuralSharing==`function`?n.structuralSharing(e,t):n.structuralSharing===!1?t:D(e,t)}function he(e,t,n=0){let r=[...e,t];return n&&r.length>n?r.slice(1):r}function ge(e,t,n=0){let r=[t,...e];return n&&r.length>n?r.slice(0,-1):r}var _e=Symbol();function ve(e,t){return!e.queryFn&&t?.initialPromise?()=>t.initialPromise:!e.queryFn||e.queryFn===_e?()=>Promise.reject(Error(`Missing queryFn: '${e.queryHash}'`)):e.queryFn}function ye(e,t){return typeof e==`function`?e(...t):!!e}function be(e,t,n){let r=!1,i;return Object.defineProperty(e,`signal`,{enumerable:!0,get:()=>(i??=t(),r?i:(r=!0,i.aborted?n():i.addEventListener(`abort`,n,{once:!0}),i))}),e}var xe=(()=>{let e=()=>w;return{isServer(){return e()},setIsServer(t){e=t}}})();function Se(){let e,t,n=new Promise((n,r)=>{e=n,t=r});n.status=`pending`,n.catch(()=>{});function r(e){Object.assign(n,e),delete n.resolve,delete n.reject}return n.resolve=t=>{r({status:`fulfilled`,value:t}),e(t)},n.reject=e=>{r({status:`rejected`,reason:e}),t(e)},n}var Ce=C;function we(){let e=[],t=0,n=e=>{e()},r=e=>{e()},i=Ce,a=r=>{t?e.push(r):i(()=>{n(r)})},o=()=>{let t=e;e=[],t.length&&i(()=>{r(()=>{t.forEach(e=>{n(e)})})})};return{batch:e=>{let n;t++;try{n=e()}finally{t--,t||o()}return n},batchCalls:e=>(...t)=>{a(()=>{e(...t)})},schedule:a,setNotifyFunction:e=>{n=e},setBatchNotifyFunction:e=>{r=e},setScheduler:e=>{i=e}}}var k=we(),Te=new class extends y{#e=!0;#t;#n;constructor(){super(),this.#n=e=>{if(typeof window<`u`&&window.addEventListener){let t=()=>e(!0),n=()=>e(!1);return window.addEventListener(`online`,t,!1),window.addEventListener(`offline`,n,!1),()=>{window.removeEventListener(`online`,t),window.removeEventListener(`offline`,n)}}}}onSubscribe(){this.#t||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#t?.(),this.#t=void 0)}setEventListener(e){this.#n=e,this.#t?.(),this.#t=e(this.setOnline.bind(this))}setOnline(e){this.#e!==e&&(this.#e=e,this.listeners.forEach(t=>{t(e)}))}isOnline(){return this.#e}};function Ee(e){return Math.min(1e3*2**e,3e4)}function De(e){return(e??`online`)===`online`?Te.isOnline():!0}var Oe=class extends Error{constructor(e){super(`CancelledError`),this.revert=e?.revert,this.silent=e?.silent}};function ke(e){let t=!1,n=0,r,i=Se(),a=()=>i.status!==`pending`,o=t=>{if(!a()){let n=new Oe(t);f(n),e.onCancel?.(n)}},s=()=>{t=!0},c=()=>{t=!1},l=()=>b.isFocused()&&(e.networkMode===`always`||Te.isOnline())&&e.canRun(),u=()=>De(e.networkMode)&&e.canRun(),d=e=>{a()||(r?.(),i.resolve(e))},f=e=>{a()||(r?.(),i.reject(e))},p=()=>new Promise(t=>{r=e=>{(a()||l())&&t(e)},e.onPause?.()}).then(()=>{r=void 0,a()||e.onContinue?.()}),m=()=>{if(a())return;let r,i=n===0?e.initialPromise:void 0;try{r=i??e.fn()}catch(e){r=Promise.reject(e)}Promise.resolve(r).then(d).catch(r=>{if(a())return;let i=e.retry??(xe.isServer()?0:3),o=e.retryDelay??Ee,s=typeof o==`function`?o(n,r):o,c=i===!0||typeof i==`number`&&n<i||typeof i==`function`&&i(n,r);if(t||!c){f(r);return}n++,e.onFail?.(n,r),me(s).then(()=>l()?void 0:p()).then(()=>{t?f(r):m()})})};return{promise:i,status:()=>i.status,cancel:o,continue:()=>(r?.(),i),cancelRetry:s,continueRetry:c,canStart:u,start:()=>(u()?m():p().then(m),i)}}var Ae=class{#e;destroy(){this.clearGcTimeout()}scheduleGc(){this.clearGcTimeout(),te(this.gcTime)&&(this.#e=S.setTimeout(()=>{this.optionalRemove()},this.gcTime))}updateGcTime(e){this.gcTime=Math.max(this.gcTime||0,e??(xe.isServer()?1/0:300*1e3))}clearGcTimeout(){this.#e!==void 0&&(S.clearTimeout(this.#e),this.#e=void 0)}};function je(e){return{onFetch:(t,n)=>{let r=t.options,i=t.fetchOptions?.meta?.fetchMore?.direction,a=t.state.data?.pages||[],o=t.state.data?.pageParams||[],s={pages:[],pageParams:[]},c=0,l=async()=>{let n=!1,l=e=>{be(e,()=>t.signal,()=>n=!0)},u=ve(t.options,t.fetchOptions),d=async(e,r,i)=>{if(n)return Promise.reject(t.signal.reason);if(r==null&&e.pages.length)return Promise.resolve(e);let a=await u((()=>{let e={client:t.client,queryKey:t.queryKey,pageParam:r,direction:i?`backward`:`forward`,meta:t.options.meta};return l(e),e})()),{maxPages:o}=t.options,s=i?ge:he;return{pages:s(e.pages,a,o),pageParams:s(e.pageParams,r,o)}};if(i&&a.length){let e=i===`backward`,t=e?Ne:Me,n={pages:a,pageParams:o};s=await d(n,t(r,n),e)}else{let t=e??a.length;do{let e=c===0?o[0]??r.initialPageParam:Me(r,s);if(c>0&&e==null)break;s=await d(s,e),c++}while(c<t)}return s};t.options.persister?t.fetchFn=()=>t.options.persister?.(l,{client:t.client,queryKey:t.queryKey,meta:t.options.meta,signal:t.signal},n):t.fetchFn=l}}}function Me(e,{pages:t,pageParams:n}){let r=t.length-1;return t.length>0?e.getNextPageParam(t[r],t,n[r],n):void 0}function Ne(e,{pages:t,pageParams:n}){return t.length>0?e.getPreviousPageParam?.(t[0],t,n[0],n):void 0}var Pe=class extends Ae{#e;#t;#n;#r;#i;#a;#o;#s;constructor(e){super(),this.#s=!1,this.#o=e.defaultOptions,this.setOptions(e.options),this.observers=[],this.#i=e.client,this.#r=this.#i.getQueryCache(),this.queryKey=e.queryKey,this.queryHash=e.queryHash,this.#t=Le(this.options),this.state=e.state??this.#t,this.scheduleGc()}get meta(){return this.options.meta}get queryType(){return this.#e}get promise(){return this.#a?.promise}setOptions(e){if(this.options={...this.#o,...e},e?._type&&(this.#e=e._type),this.updateGcTime(this.options.gcTime),this.state&&this.state.data===void 0){let e=Le(this.options);e.data!==void 0&&(this.setState(Ie(e.data,e.dataUpdatedAt)),this.#t=e)}}optionalRemove(){!this.observers.length&&this.state.fetchStatus===`idle`&&this.#r.remove(this)}setData(e,t){let n=O(this.state.data,e,this.options);return this.#l({data:n,type:`success`,dataUpdatedAt:t?.updatedAt,manual:t?.manual}),n}setState(e){this.#l({type:`setState`,state:e})}cancel(e){let t=this.#a?.promise;return this.#a?.cancel(e),t?t.then(T).catch(T):Promise.resolve()}destroy(){super.destroy(),this.cancel({silent:!0})}get resetState(){return this.#t}reset(){this.destroy(),this.setState(this.resetState)}isActive(){return this.observers.some(e=>ie(e.options.enabled,this)!==!1)}isDisabled(){return this.getObserversCount()>0?!this.isActive():this.options.queryFn===_e||!this.isFetched()}isFetched(){return this.state.dataUpdateCount+this.state.errorUpdateCount>0}isStatic(){return this.getObserversCount()>0?this.observers.some(e=>re(e.options.staleTime,this)===`static`):!1}isStale(){return this.getObserversCount()>0?this.observers.some(e=>e.getCurrentResult().isStale):this.state.data===void 0||this.state.isInvalidated}isStaleByTime(e=0){return this.state.data===void 0?!0:e===`static`?!1:this.state.isInvalidated?!0:!ne(this.state.dataUpdatedAt,e)}onFocus(){this.observers.find(e=>e.shouldFetchOnWindowFocus())?.refetch({cancelRefetch:!1}),this.#a?.continue()}onOnline(){this.observers.find(e=>e.shouldFetchOnReconnect())?.refetch({cancelRefetch:!1}),this.#a?.continue()}addObserver(e){this.observers.includes(e)||(this.observers.push(e),this.clearGcTimeout(),this.#r.notify({type:`observerAdded`,query:this,observer:e}))}removeObserver(e){this.observers.includes(e)&&(this.observers=this.observers.filter(t=>t!==e),this.observers.length||(this.#a&&(this.#s||this.#c()?this.#a.cancel({revert:!0}):this.#a.cancelRetry()),this.scheduleGc()),this.#r.notify({type:`observerRemoved`,query:this,observer:e}))}getObserversCount(){return this.observers.length}#c(){return this.state.fetchStatus===`paused`&&this.state.status===`pending`}invalidate(){this.state.isInvalidated||this.#l({type:`invalidate`})}async fetch(e,t){if(this.state.fetchStatus!==`idle`&&this.#a?.status()!==`rejected`){if(this.state.data!==void 0&&t?.cancelRefetch)this.cancel({silent:!0});else if(this.#a)return this.#a.continueRetry(),this.#a.promise}if(e&&this.setOptions(e),!this.options.queryFn){let e=this.observers.find(e=>e.options.queryFn);e&&this.setOptions(e.options)}let n=new AbortController,r=e=>{Object.defineProperty(e,`signal`,{enumerable:!0,get:()=>(this.#s=!0,n.signal)})},i=()=>{let e=ve(this.options,t),n=(()=>{let e={client:this.#i,queryKey:this.queryKey,meta:this.meta};return r(e),e})();return this.#s=!1,this.options.persister?this.options.persister(e,n,this):e(n)},a=(()=>{let e={fetchOptions:t,options:this.options,queryKey:this.queryKey,client:this.#i,state:this.state,fetchFn:i};return r(e),e})();(this.#e===`infinite`?je(this.options.pages):this.options.behavior)?.onFetch(a,this),this.#n=this.state,(this.state.fetchStatus===`idle`||this.state.fetchMeta!==a.fetchOptions?.meta)&&this.#l({type:`fetch`,meta:a.fetchOptions?.meta}),this.#a=ke({initialPromise:t?.initialPromise,fn:a.fetchFn,onCancel:e=>{e instanceof Oe&&e.revert&&this.setState({...this.#n,fetchStatus:`idle`}),n.abort()},onFail:(e,t)=>{this.#l({type:`failed`,failureCount:e,error:t})},onPause:()=>{this.#l({type:`pause`})},onContinue:()=>{this.#l({type:`continue`})},retry:a.options.retry,retryDelay:a.options.retryDelay,networkMode:a.options.networkMode,canRun:()=>!0});try{let e=await this.#a.start();if(e===void 0)throw Error(`${this.queryHash} data is undefined`);return this.setData(e),this.#r.config.onSuccess?.(e,this),this.#r.config.onSettled?.(e,this.state.error,this),e}catch(e){if(e instanceof Oe){if(e.silent)return this.#a.promise;if(e.revert){if(this.state.data===void 0)throw e;return this.state.data}}throw this.#l({type:`error`,error:e}),this.#r.config.onError?.(e,this),this.#r.config.onSettled?.(this.state.data,e,this),e}finally{this.scheduleGc()}}#l(e){let t=t=>{switch(e.type){case`failed`:return{...t,fetchFailureCount:e.failureCount,fetchFailureReason:e.error};case`pause`:return{...t,fetchStatus:`paused`};case`continue`:return{...t,fetchStatus:`fetching`};case`fetch`:return{...t,...Fe(t.data,this.options),fetchMeta:e.meta??null};case`success`:let n={...t,...Ie(e.data,e.dataUpdatedAt),dataUpdateCount:t.dataUpdateCount+1,...!e.manual&&{fetchStatus:`idle`,fetchFailureCount:0,fetchFailureReason:null}};return this.#n=e.manual?n:void 0,n;case`error`:let r=e.error;return{...t,error:r,errorUpdateCount:t.errorUpdateCount+1,errorUpdatedAt:Date.now(),fetchFailureCount:t.fetchFailureCount+1,fetchFailureReason:r,fetchStatus:`idle`,status:`error`,isInvalidated:!0};case`invalidate`:return{...t,isInvalidated:!0};case`setState`:return{...t,...e.state}}};this.state=t(this.state),k.batch(()=>{this.observers.forEach(e=>{e.onQueryUpdate()}),this.#r.notify({query:this,type:`updated`,action:e})})}};function Fe(e,t){return{fetchFailureCount:0,fetchFailureReason:null,fetchStatus:De(t.networkMode)?`fetching`:`paused`,...e===void 0&&{error:null,status:`pending`}}}function Ie(e,t){return{data:e,dataUpdatedAt:t??Date.now(),error:null,isInvalidated:!1,status:`success`}}function Le(e){let t=typeof e.initialData==`function`?e.initialData():e.initialData,n=t!==void 0,r=n?typeof e.initialDataUpdatedAt==`function`?e.initialDataUpdatedAt():e.initialDataUpdatedAt:0;return{data:t,dataUpdateCount:0,dataUpdatedAt:n?r??Date.now():0,error:null,errorUpdateCount:0,errorUpdatedAt:0,fetchFailureCount:0,fetchFailureReason:null,fetchMeta:null,isInvalidated:!1,status:n?`success`:`pending`,fetchStatus:`idle`}}var Re=class extends y{constructor(e,t){super(),this.options=t,this.#e=e,this.#s=null,this.#o=Se(),this.bindMethods(),this.setOptions(t)}#e;#t=void 0;#n=void 0;#r=void 0;#i;#a;#o;#s;#c;#l;#u;#d;#f;#p;#m=new Set;bindMethods(){this.refetch=this.refetch.bind(this)}onSubscribe(){this.listeners.size===1&&(this.#t.addObserver(this),Be(this.#t,this.options)?this.#h():this.updateResult(),this.#y())}onUnsubscribe(){this.hasListeners()||this.destroy()}shouldFetchOnReconnect(){return Ve(this.#t,this.options,this.options.refetchOnReconnect)}shouldFetchOnWindowFocus(){return Ve(this.#t,this.options,this.options.refetchOnWindowFocus)}destroy(){this.listeners=new Set,this.#b(),this.#x(),this.#t.removeObserver(this)}setOptions(e){let t=this.options,n=this.#t;if(this.options=this.#e.defaultQueryOptions(e),this.options.enabled!==void 0&&typeof this.options.enabled!=`boolean`&&typeof this.options.enabled!=`function`&&typeof ie(this.options.enabled,this.#t)!=`boolean`)throw Error(`Expected enabled to be a boolean or a callback that returns a boolean`);this.#S(),this.#t.setOptions(this.options),t._defaulted&&!ue(this.options,t)&&this.#e.getQueryCache().notify({type:`observerOptionsUpdated`,query:this.#t,observer:this});let r=this.hasListeners();r&&He(this.#t,n,this.options,t)&&this.#h(),this.updateResult(),r&&(this.#t!==n||ie(this.options.enabled,this.#t)!==ie(t.enabled,this.#t)||re(this.options.staleTime,this.#t)!==re(t.staleTime,this.#t))&&this.#g();let i=this.#_();r&&(this.#t!==n||ie(this.options.enabled,this.#t)!==ie(t.enabled,this.#t)||i!==this.#p)&&this.#v(i)}getOptimisticResult(e){let t=this.#e.getQueryCache().build(this.#e,e),n=this.createResult(t,e);return We(this,n)&&(this.#r=n,this.#a=this.options,this.#i=this.#t.state),n}getCurrentResult(){return this.#r}trackResult(e,t){return new Proxy(e,{get:(e,n)=>(this.trackProp(n),t?.(n),n===`promise`&&(this.trackProp(`data`),!this.options.experimental_prefetchInRender&&this.#o.status===`pending`&&this.#o.reject(Error(`experimental_prefetchInRender feature flag is not enabled`))),Reflect.get(e,n))})}trackProp(e){this.#m.add(e)}getCurrentQuery(){return this.#t}refetch({...e}={}){return this.fetch({...e})}fetchOptimistic(e){let t=this.#e.defaultQueryOptions(e),n=this.#e.getQueryCache().build(this.#e,t);return n.fetch().then(()=>this.createResult(n,t))}fetch(e){return this.#h({...e,cancelRefetch:e.cancelRefetch??!0}).then(()=>(this.updateResult(),this.#r))}#h(e){this.#S();let t=this.#t.fetch(this.options,e);return e?.throwOnError||(t=t.catch(T)),t}#g(){this.#b();let e=re(this.options.staleTime,this.#t);if(xe.isServer()||this.#r.isStale||!te(e))return;let t=ne(this.#r.dataUpdatedAt,e)+1;this.#d=S.setTimeout(()=>{this.#r.isStale||this.updateResult()},t)}#_(){return(typeof this.options.refetchInterval==`function`?this.options.refetchInterval(this.#t):this.options.refetchInterval)??!1}#v(e){this.#x(),this.#p=e,!(xe.isServer()||ie(this.options.enabled,this.#t)===!1||!te(this.#p)||this.#p===0)&&(this.#f=S.setInterval(()=>{(this.options.refetchIntervalInBackground||b.isFocused())&&this.#h()},this.#p))}#y(){this.#g(),this.#v(this.#_())}#b(){this.#d!==void 0&&(S.clearTimeout(this.#d),this.#d=void 0)}#x(){this.#f!==void 0&&(S.clearInterval(this.#f),this.#f=void 0)}createResult(e,t){let n=this.#t,r=this.options,i=this.#r,a=this.#i,o=this.#a,s=e===n?this.#n:e.state,{state:c}=e,l={...c},u=!1,d;if(t._optimisticResults){let i=this.hasListeners(),a=!i&&Be(e,t),o=i&&He(e,n,t,r);(a||o)&&(l={...l,...Fe(c.data,e.options)}),t._optimisticResults===`isRestoring`&&(l.fetchStatus=`idle`)}let{error:f,errorUpdatedAt:p,status:m}=l;d=l.data;let h=!1;if(t.placeholderData!==void 0&&d===void 0&&m===`pending`){let e;i?.isPlaceholderData&&t.placeholderData===o?.placeholderData?(e=i.data,h=!0):e=typeof t.placeholderData==`function`?t.placeholderData(this.#u?.state.data,this.#u):t.placeholderData,e!==void 0&&(m=`success`,d=O(i?.data,e,t),u=!0)}if(t.select&&d!==void 0&&!h)if(i&&d===a?.data&&t.select===this.#c)d=this.#l;else try{this.#c=t.select,d=t.select(d),d=O(i?.data,d,t),this.#l=d,this.#s=null}catch(e){this.#s=e}this.#s&&(f=this.#s,d=this.#l,p=Date.now(),m=`error`);let g=l.fetchStatus===`fetching`,_=m===`pending`,v=m===`error`,y=_&&g,b=d!==void 0,x={status:m,fetchStatus:l.fetchStatus,isPending:_,isSuccess:m===`success`,isError:v,isInitialLoading:y,isLoading:y,data:d,dataUpdatedAt:l.dataUpdatedAt,error:f,errorUpdatedAt:p,failureCount:l.fetchFailureCount,failureReason:l.fetchFailureReason,errorUpdateCount:l.errorUpdateCount,isFetched:e.isFetched(),isFetchedAfterMount:l.dataUpdateCount>s.dataUpdateCount||l.errorUpdateCount>s.errorUpdateCount,isFetching:g,isRefetching:g&&!_,isLoadingError:v&&!b,isPaused:l.fetchStatus===`paused`,isPlaceholderData:u,isRefetchError:v&&b,isStale:Ue(e,t),refetch:this.refetch,promise:this.#o,isEnabled:ie(t.enabled,e)!==!1};if(this.options.experimental_prefetchInRender){let t=x.data!==void 0,r=x.status===`error`&&!t,i=e=>{r?e.reject(x.error):t&&e.resolve(x.data)},a=()=>{i(this.#o=x.promise=Se())},o=this.#o;switch(o.status){case`pending`:e.queryHash===n.queryHash&&i(o);break;case`fulfilled`:(r||x.data!==o.value)&&a();break;case`rejected`:(!r||x.error!==o.reason)&&a();break}}return x}updateResult(){let e=this.#r,t=this.createResult(this.#t,this.options);this.#i=this.#t.state,this.#a=this.options,this.#i.data!==void 0&&(this.#u=this.#t),!ue(t,e)&&(this.#r=t,this.#C({listeners:(()=>{if(!e)return!0;let{notifyOnChangeProps:t}=this.options,n=typeof t==`function`?t():t;if(n===`all`||!n&&!this.#m.size)return!0;let r=new Set(n??this.#m);return this.options.throwOnError&&r.add(`error`),Object.keys(this.#r).some(t=>{let n=t;return this.#r[n]!==e[n]&&r.has(n)})})()}))}#S(){let e=this.#e.getQueryCache().build(this.#e,this.options);if(e===this.#t)return;let t=this.#t;this.#t=e,this.#n=e.state,this.hasListeners()&&(t?.removeObserver(this),e.addObserver(this))}onQueryUpdate(){this.updateResult(),this.hasListeners()&&this.#y()}#C(e){k.batch(()=>{e.listeners&&this.listeners.forEach(e=>{e(this.#r)}),this.#e.getQueryCache().notify({query:this.#t,type:`observerResultsUpdated`})})}};function ze(e,t){return ie(t.enabled,e)!==!1&&e.state.data===void 0&&!(e.state.status===`error`&&ie(t.retryOnMount,e)===!1)}function Be(e,t){return ze(e,t)||e.state.data!==void 0&&Ve(e,t,t.refetchOnMount)}function Ve(e,t,n){if(ie(t.enabled,e)!==!1&&re(t.staleTime,e)!==`static`){let r=typeof n==`function`?n(e):n;return r===`always`||r!==!1&&Ue(e,t)}return!1}function He(e,t,n,r){return(e!==t||ie(r.enabled,e)===!1)&&(!n.suspense||e.state.status!==`error`)&&Ue(e,n)}function Ue(e,t){return ie(t.enabled,e)!==!1&&e.isStaleByTime(re(t.staleTime,e))}function We(e,t){return!ue(e.getCurrentResult(),t)}var Ge=class extends Ae{#e;#t;#n;#r;constructor(e){super(),this.#e=e.client,this.mutationId=e.mutationId,this.#n=e.mutationCache,this.#t=[],this.state=e.state||Ke(),this.setOptions(e.options),this.scheduleGc()}setOptions(e){this.options=e,this.updateGcTime(this.options.gcTime)}get meta(){return this.options.meta}addObserver(e){this.#t.includes(e)||(this.#t.push(e),this.clearGcTimeout(),this.#n.notify({type:`observerAdded`,mutation:this,observer:e}))}removeObserver(e){this.#t=this.#t.filter(t=>t!==e),this.scheduleGc(),this.#n.notify({type:`observerRemoved`,mutation:this,observer:e})}optionalRemove(){this.#t.length||(this.state.status===`pending`?this.scheduleGc():this.#n.remove(this))}continue(){return this.#r?.continue()??this.execute(this.state.variables)}async execute(e){let t=()=>{this.#i({type:`continue`})},n={client:this.#e,meta:this.options.meta,mutationKey:this.options.mutationKey};this.#r=ke({fn:()=>this.options.mutationFn?this.options.mutationFn(e,n):Promise.reject(Error(`No mutationFn found`)),onFail:(e,t)=>{this.#i({type:`failed`,failureCount:e,error:t})},onPause:()=>{this.#i({type:`pause`})},onContinue:t,retry:this.options.retry??0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode,canRun:()=>this.#n.canRun(this)});let r=this.state.status===`pending`,i=!this.#r.canStart();try{if(r)t();else{this.#i({type:`pending`,variables:e,isPaused:i}),this.#n.config.onMutate&&await this.#n.config.onMutate(e,this,n);let t=await this.options.onMutate?.(e,n);t!==this.state.context&&this.#i({type:`pending`,context:t,variables:e,isPaused:i})}let a=await this.#r.start();return await this.#n.config.onSuccess?.(a,e,this.state.context,this,n),await this.options.onSuccess?.(a,e,this.state.context,n),await this.#n.config.onSettled?.(a,null,this.state.variables,this.state.context,this,n),await this.options.onSettled?.(a,null,e,this.state.context,n),this.#i({type:`success`,data:a}),a}catch(t){try{await this.#n.config.onError?.(t,e,this.state.context,this,n)}catch(e){Promise.reject(e)}try{await this.options.onError?.(t,e,this.state.context,n)}catch(e){Promise.reject(e)}try{await this.#n.config.onSettled?.(void 0,t,this.state.variables,this.state.context,this,n)}catch(e){Promise.reject(e)}try{await this.options.onSettled?.(void 0,t,e,this.state.context,n)}catch(e){Promise.reject(e)}throw this.#i({type:`error`,error:t}),t}finally{this.#n.runNext(this)}}#i(e){let t=t=>{switch(e.type){case`failed`:return{...t,failureCount:e.failureCount,failureReason:e.error};case`pause`:return{...t,isPaused:!0};case`continue`:return{...t,isPaused:!1};case`pending`:return{...t,context:e.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:e.isPaused,status:`pending`,variables:e.variables,submittedAt:Date.now()};case`success`:return{...t,data:e.data,failureCount:0,failureReason:null,error:null,status:`success`,isPaused:!1};case`error`:return{...t,data:void 0,error:e.error,failureCount:t.failureCount+1,failureReason:e.error,isPaused:!1,status:`error`}}};this.state=t(this.state),k.batch(()=>{this.#t.forEach(t=>{t.onMutationUpdate(e)}),this.#n.notify({mutation:this,type:`updated`,action:e})})}};function Ke(){return{context:void 0,data:void 0,error:null,failureCount:0,failureReason:null,isPaused:!1,status:`idle`,variables:void 0,submittedAt:0}}var qe=class extends y{constructor(e={}){super(),this.config=e,this.#e=new Set,this.#t=new Map,this.#n=0}#e;#t;#n;build(e,t,n){let r=new Ge({client:e,mutationCache:this,mutationId:++this.#n,options:e.defaultMutationOptions(t),state:n});return this.add(r),r}add(e){this.#e.add(e);let t=Je(e);if(typeof t==`string`){let n=this.#t.get(t);n?n.push(e):this.#t.set(t,[e])}this.notify({type:`added`,mutation:e})}remove(e){if(this.#e.delete(e)){let t=Je(e);if(typeof t==`string`){let n=this.#t.get(t);if(n)if(n.length>1){let t=n.indexOf(e);t!==-1&&n.splice(t,1)}else n[0]===e&&this.#t.delete(t)}}this.notify({type:`removed`,mutation:e})}canRun(e){let t=Je(e);if(typeof t==`string`){let n=this.#t.get(t)?.find(e=>e.state.status===`pending`);return!n||n===e}else return!0}runNext(e){let t=Je(e);return typeof t==`string`?(this.#t.get(t)?.find(t=>t!==e&&t.state.isPaused))?.continue()??Promise.resolve():Promise.resolve()}clear(){k.batch(()=>{this.#e.forEach(e=>{this.notify({type:`removed`,mutation:e})}),this.#e.clear(),this.#t.clear()})}getAll(){return Array.from(this.#e)}find(e){let t={exact:!0,...e};return this.getAll().find(e=>oe(t,e))}findAll(e={}){return this.getAll().filter(t=>oe(e,t))}notify(e){k.batch(()=>{this.listeners.forEach(t=>{t(e)})})}resumePausedMutations(){let e=this.getAll().filter(e=>e.state.isPaused);return k.batch(()=>Promise.all(e.map(e=>e.continue().catch(T))))}};function Je(e){return e.options.scope?.id}var Ye=class extends y{#e;#t=void 0;#n;#r;constructor(e,t){super(),this.#e=e,this.setOptions(t),this.bindMethods(),this.#i()}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){let t=this.options;this.options=this.#e.defaultMutationOptions(e),ue(this.options,t)||this.#e.getMutationCache().notify({type:`observerOptionsUpdated`,mutation:this.#n,observer:this}),t?.mutationKey&&this.options.mutationKey&&ce(t.mutationKey)!==ce(this.options.mutationKey)?this.reset():this.#n?.state.status===`pending`&&this.#n.setOptions(this.options)}onUnsubscribe(){this.hasListeners()||this.#n?.removeObserver(this)}onMutationUpdate(e){this.#i(),this.#a(e)}getCurrentResult(){return this.#t}reset(){this.#n?.removeObserver(this),this.#n=void 0,this.#i(),this.#a()}mutate(e,t){return this.#r=t,this.#n?.removeObserver(this),this.#n=this.#e.getMutationCache().build(this.#e,this.options),this.#n.addObserver(this),this.#n.execute(e)}#i(){let e=this.#n?.state??Ke();this.#t={...e,isPending:e.status===`pending`,isSuccess:e.status===`success`,isError:e.status===`error`,isIdle:e.status===`idle`,mutate:this.mutate,reset:this.reset}}#a(e){k.batch(()=>{if(this.#r&&this.hasListeners()){let t=this.#t.variables,n=this.#t.context,r={client:this.#e,meta:this.options.meta,mutationKey:this.options.mutationKey};if(e?.type===`success`){try{this.#r.onSuccess?.(e.data,t,n,r)}catch(e){Promise.reject(e)}try{this.#r.onSettled?.(e.data,null,t,n,r)}catch(e){Promise.reject(e)}}else if(e?.type===`error`){try{this.#r.onError?.(e.error,t,n,r)}catch(e){Promise.reject(e)}try{this.#r.onSettled?.(void 0,e.error,t,n,r)}catch(e){Promise.reject(e)}}}this.listeners.forEach(e=>{e(this.#t)})})}},Xe=class extends y{constructor(e={}){super(),this.config=e,this.#e=new Map}#e;build(e,t,n){let r=t.queryKey,i=t.queryHash??se(r,t),a=this.get(i);return a||(a=new Pe({client:e,queryKey:r,queryHash:i,options:e.defaultQueryOptions(t),state:n,defaultOptions:e.getQueryDefaults(r)}),this.add(a)),a}add(e){this.#e.has(e.queryHash)||(this.#e.set(e.queryHash,e),this.notify({type:`added`,query:e}))}remove(e){let t=this.#e.get(e.queryHash);t&&(e.destroy(),t===e&&this.#e.delete(e.queryHash),this.notify({type:`removed`,query:e}))}clear(){k.batch(()=>{this.getAll().forEach(e=>{this.remove(e)})})}get(e){return this.#e.get(e)}getAll(){return[...this.#e.values()]}find(e){let t={exact:!0,...e};return this.getAll().find(e=>ae(t,e))}findAll(e={}){let t=this.getAll();return Object.keys(e).length>0?t.filter(t=>ae(e,t)):t}notify(e){k.batch(()=>{this.listeners.forEach(t=>{t(e)})})}onFocus(){k.batch(()=>{this.getAll().forEach(e=>{e.onFocus()})})}onOnline(){k.batch(()=>{this.getAll().forEach(e=>{e.onOnline()})})}},Ze=class{#e;#t;#n;#r;#i;#a;#o;#s;constructor(e={}){this.#e=e.queryCache||new Xe,this.#t=e.mutationCache||new qe,this.#n=e.defaultOptions||{},this.#r=new Map,this.#i=new Map,this.#a=0}mount(){this.#a++,this.#a===1&&(this.#o=b.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onFocus())}),this.#s=Te.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onOnline())}))}unmount(){this.#a--,this.#a===0&&(this.#o?.(),this.#o=void 0,this.#s?.(),this.#s=void 0)}isFetching(e){return this.#e.findAll({...e,fetchStatus:`fetching`}).length}isMutating(e){return this.#t.findAll({...e,status:`pending`}).length}getQueryData(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state.data}ensureQueryData(e){let t=this.defaultQueryOptions(e),n=this.#e.build(this,t),r=n.state.data;return r===void 0?this.fetchQuery(e):(e.revalidateIfStale&&n.isStaleByTime(re(t.staleTime,n))&&this.prefetchQuery(t),Promise.resolve(r))}getQueriesData(e){return this.#e.findAll(e).map(({queryKey:e,state:t})=>[e,t.data])}setQueryData(e,t,n){let r=this.defaultQueryOptions({queryKey:e}),i=this.#e.get(r.queryHash)?.state.data,a=ee(t,i);if(a!==void 0)return this.#e.build(this,r).setData(a,{...n,manual:!0})}setQueriesData(e,t,n){return k.batch(()=>this.#e.findAll(e).map(({queryKey:e})=>[e,this.setQueryData(e,t,n)]))}getQueryState(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state}removeQueries(e){let t=this.#e;k.batch(()=>{t.findAll(e).forEach(e=>{t.remove(e)})})}resetQueries(e,t){let n=this.#e;return k.batch(()=>(n.findAll(e).forEach(e=>{e.reset()}),this.refetchQueries({type:`active`,...e},t)))}cancelQueries(e,t={}){let n={revert:!0,...t},r=k.batch(()=>this.#e.findAll(e).map(e=>e.cancel(n)));return Promise.all(r).then(T).catch(T)}invalidateQueries(e,t={}){return k.batch(()=>(this.#e.findAll(e).forEach(e=>{e.invalidate()}),e?.refetchType===`none`?Promise.resolve():this.refetchQueries({...e,type:e?.refetchType??e?.type??`active`},t)))}refetchQueries(e,t={}){let n={...t,cancelRefetch:t.cancelRefetch??!0},r=k.batch(()=>this.#e.findAll(e).filter(e=>!e.isDisabled()&&!e.isStatic()).map(e=>{let t=e.fetch(void 0,n);return n.throwOnError||(t=t.catch(T)),e.state.fetchStatus===`paused`?Promise.resolve():t}));return Promise.all(r).then(T)}fetchQuery(e){let t=this.defaultQueryOptions(e);t.retry===void 0&&(t.retry=!1);let n=this.#e.build(this,t);return n.isStaleByTime(re(t.staleTime,n))?n.fetch(t):Promise.resolve(n.state.data)}prefetchQuery(e){return this.fetchQuery(e).then(T).catch(T)}fetchInfiniteQuery(e){return e._type=`infinite`,this.fetchQuery(e)}prefetchInfiniteQuery(e){return this.fetchInfiniteQuery(e).then(T).catch(T)}ensureInfiniteQueryData(e){return e._type=`infinite`,this.ensureQueryData(e)}resumePausedMutations(){return Te.isOnline()?this.#t.resumePausedMutations():Promise.resolve()}getQueryCache(){return this.#e}getMutationCache(){return this.#t}getDefaultOptions(){return this.#n}setDefaultOptions(e){this.#n=e}setQueryDefaults(e,t){this.#r.set(ce(e),{queryKey:e,defaultOptions:t})}getQueryDefaults(e){let t=[...this.#r.values()],n={};return t.forEach(t=>{le(e,t.queryKey)&&Object.assign(n,t.defaultOptions)}),n}setMutationDefaults(e,t){this.#i.set(ce(e),{mutationKey:e,defaultOptions:t})}getMutationDefaults(e){let t=[...this.#i.values()],n={};return t.forEach(t=>{le(e,t.mutationKey)&&Object.assign(n,t.defaultOptions)}),n}defaultQueryOptions(e){if(e._defaulted)return e;let t={...this.#n.queries,...this.getQueryDefaults(e.queryKey),...e,_defaulted:!0};return t.queryHash||=se(t.queryKey,t),t.refetchOnReconnect===void 0&&(t.refetchOnReconnect=t.networkMode!==`always`),t.throwOnError===void 0&&(t.throwOnError=!!t.suspense),!t.networkMode&&t.persister&&(t.networkMode=`offlineFirst`),t.queryFn===_e&&(t.enabled=!1),t}defaultMutationOptions(e){return e?._defaulted?e:{...this.#n.mutations,...e?.mutationKey&&this.getMutationDefaults(e.mutationKey),...e,_defaulted:!0}}clear(){this.#e.clear(),this.#t.clear()}},Qe=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),A=o(((e,t)=>{t.exports=Qe()}))(),$e=_.createContext(void 0),et=e=>{let t=_.useContext($e);if(e)return e;if(!t)throw Error(`No QueryClient set, use QueryClientProvider to set one`);return t},tt=({client:e,children:t})=>(_.useEffect(()=>(e.mount(),()=>{e.unmount()}),[e]),(0,A.jsx)($e.Provider,{value:e,children:t})),nt=_.createContext(!1),rt=()=>_.useContext(nt);nt.Provider;function it(){let e=!1;return{clearReset:()=>{e=!1},reset:()=>{e=!0},isReset:()=>e}}var at=_.createContext(it()),ot=()=>_.useContext(at),st=(e,t,n)=>{let r=n?.state.error&&typeof e.throwOnError==`function`?ye(e.throwOnError,[n.state.error,n]):e.throwOnError;(e.suspense||e.experimental_prefetchInRender||r)&&(t.isReset()||(e.retryOnMount=!1))},ct=e=>{_.useEffect(()=>{e.clearReset()},[e])},lt=({result:e,errorResetBoundary:t,throwOnError:n,query:r,suspense:i})=>e.isError&&!t.isReset()&&!e.isFetching&&r&&(i&&e.data===void 0||ye(n,[e.error,r])),ut=e=>{if(e.suspense){let t=1e3,n=e=>e===`static`?e:Math.max(e??t,t),r=e.staleTime;e.staleTime=typeof r==`function`?(...e)=>n(r(...e)):n(r),typeof e.gcTime==`number`&&(e.gcTime=Math.max(e.gcTime,t))}},dt=(e,t)=>e.isLoading&&e.isFetching&&!t,ft=(e,t)=>e?.suspense&&t.isPending,pt=(e,t,n)=>t.fetchOptimistic(e).catch(()=>{n.clearReset()});function mt(e,t,n){let r=rt(),i=ot(),a=et(n),o=a.defaultQueryOptions(e);a.getDefaultOptions().queries?._experimental_beforeQuery?.(o);let s=a.getQueryCache().get(o.queryHash);o._optimisticResults=r?`isRestoring`:`optimistic`,ut(o),st(o,i,s),ct(i);let c=!a.getQueryCache().get(o.queryHash),[l]=_.useState(()=>new t(a,o)),u=l.getOptimisticResult(o),d=!r&&e.subscribed!==!1;if(_.useSyncExternalStore(_.useCallback(e=>{let t=d?l.subscribe(k.batchCalls(e)):T;return l.updateResult(),t},[l,d]),()=>l.getCurrentResult(),()=>l.getCurrentResult()),_.useEffect(()=>{l.setOptions(o)},[o,l]),ft(o,u))throw pt(o,l,i);if(lt({result:u,errorResetBoundary:i,throwOnError:o.throwOnError,query:s,suspense:o.suspense}))throw u.error;return a.getDefaultOptions().queries?._experimental_afterQuery?.(o,u),o.experimental_prefetchInRender&&!xe.isServer()&&dt(u,r)&&(c?pt(o,l,i):s?.promise)?.catch(T).finally(()=>{l.updateResult()}),o.notifyOnChangeProps?u:l.trackResult(u)}function ht(e,t){return mt(e,Re,t)}function gt(e,t){let n=et(t),[r]=_.useState(()=>new Ye(n,e));_.useEffect(()=>{r.setOptions(e)},[r,e]);let i=_.useSyncExternalStore(_.useCallback(e=>r.subscribe(k.batchCalls(e)),[r]),()=>r.getCurrentResult(),()=>r.getCurrentResult()),a=_.useCallback((e,t)=>{r.mutate(e,t).catch(T)},[r]);if(i.error&&ye(r.options.throwOnError,[i.error]))throw i.error;return{...i,mutate:a,mutateAsync:i.mutate}}var _t=`modulepreload`,vt=function(e){return`/`+e},yt={},bt=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=vt(t,n),t in yt)return;yt[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:_t,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},xt=`popstate`;function St(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function Ct(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return Dt(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:Ot(t)}return At(t,n,null,e)}function j(e,t){if(e===!1||e==null)throw Error(t)}function wt(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function Tt(){return Math.random().toString(36).substring(2,10)}function Et(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function Dt(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?kt(t):t,state:n,key:t&&t.key||r||Tt(),mask:i}}function Ot({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function kt(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function At(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=St(e)?e:Dt(h.location,e,t);n&&n(r,e),l=u()+1;let d=Et(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=St(e)?e:Dt(h.location,e,t);n&&n(r,e),l=u();let i=Et(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return jt(e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(xt,d),c=e,()=>{i.removeEventListener(xt,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function jt(e,t=!1){let n=`http://localhost`;typeof window<`u`&&(n=window.location.origin===`null`?window.location.href:window.location.origin),j(n,`No window.location.(origin|href) available to create URL`);let r=typeof e==`string`?e:Ot(e);return r=r.replace(/ $/,`%20`),!t&&r.startsWith(`//`)&&(r=n+r),new URL(r,n)}function Mt(e,t,n=`/`){return Nt(e,t,n,!1)}function Nt(e,t,n,r,i){let a=Qt((typeof t==`string`?kt(t):t).pathname||`/`,n);if(a==null)return null;let o=i??Ft(e),s=null,c=Zt(a);for(let e=0;s==null&&e<o.length;++e)s=Jt(o[e],c,r);return s}function Pt(e,t){let{route:n,pathname:r,params:i}=e;return{id:n.id,pathname:r,params:i,data:t[n.id],loaderData:t[n.id],handle:n.handle}}function Ft(e){let t=It(e);return Rt(t),t}function It(e,t=[],n=[],r=``,i=!1){let a=(e,a,o=i,s)=>{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;j(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=cn([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(j(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),It(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:Kt(l,e.index),routesMeta:u})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of Lt(e.path))a(e,t,!0,n)}),t}function Lt(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=Lt(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function Rt(e){e.sort((e,t)=>e.score===t.score?qt(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var zt=/^:[\w-]+$/,Bt=3,Vt=2,Ht=1,Ut=10,Wt=-2,Gt=e=>e===`*`;function Kt(e,t){let n=e.split(`/`),r=n.length;return n.some(Gt)&&(r+=Wt),t&&(r+=Vt),n.filter(e=>!Gt(e)).reduce((e,t)=>e+(zt.test(t)?Bt:t===``?Ht:Ut),r)}function qt(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function Jt(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e<r.length;++e){let s=r[e],c=e===r.length-1,l=a===`/`?t:t.slice(a.length)||`/`,u=Yt({path:s.relativePath,caseSensitive:s.caseSensitive,end:c},l),d=s.route;if(!u&&c&&n&&!r[r.length-1].route.index&&(u=Yt({path:s.relativePath,caseSensitive:s.caseSensitive,end:!1},l)),!u)return null;Object.assign(i,u.params),o.push({params:i,pathname:cn([a,u.pathname]),pathnameBase:un(cn([a,u.pathnameBase])),route:d}),u.pathnameBase!==`/`&&(a=cn([a,u.pathnameBase]))}return o}function Yt(e,t){typeof e==`string`&&(e={path:e,caseSensitive:!1,end:!0});let[n,r]=Xt(e.path,e.caseSensitive,e.end),i=t.match(n);if(!i)return null;let a=i[0],o=a.replace(/(.)\/+$/,`$1`),s=i.slice(1);return{params:r.reduce((e,{paramName:t,isOptional:n},r)=>{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function Xt(e,t=!1,n=!0){wt(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function Zt(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return wt(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function Qt(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}var $t=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function en(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?kt(e):e,a;return n?(n=sn(n),a=n.startsWith(`/`)?tn(n.substring(1),`/`):tn(n,t)):a=t,{pathname:a,search:dn(r),hash:fn(i)}}function tn(e,t){let n=ln(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function nn(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}].  Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in <Link to="..."> and the router will parse it for you.`}function rn(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function an(e){let t=rn(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function on(e,t,n,r=!1){let i;typeof e==`string`?i=kt(e):(i={...e},j(!i.pathname||!i.pathname.includes(`?`),nn(`?`,`pathname`,`search`,i)),j(!i.pathname||!i.pathname.includes(`#`),nn(`#`,`pathname`,`hash`,i)),j(!i.search||!i.search.includes(`#`),nn(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=en(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var sn=e=>e.replace(/\/\/+/g,`/`),cn=e=>sn(e.join(`/`)),ln=e=>e.replace(/\/+$/,``),un=e=>ln(e).replace(/^\/*/,`/`),dn=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,fn=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,pn=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function mn(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function hn(e){return cn(e.map(e=>e.route.path).filter(Boolean))||`/`}var gn=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function _n(e,t){let n=e;if(typeof n!=`string`||!$t.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(gn)try{let e=new URL(window.location.href),r=n.startsWith(`//`)?new URL(e.protocol+n):new URL(n),a=Qt(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{wt(!1,`<Link to="${n}"> contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var vn=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(vn);var yn=[`GET`,...vn];new Set(yn);var bn=_.createContext(null);bn.displayName=`DataRouter`;var xn=_.createContext(null);xn.displayName=`DataRouterState`;var Sn=_.createContext(!1);function Cn(){return _.useContext(Sn)}var wn=_.createContext({isTransitioning:!1});wn.displayName=`ViewTransition`;var Tn=_.createContext(new Map);Tn.displayName=`Fetchers`;var En=_.createContext(null);En.displayName=`Await`;var M=_.createContext(null);M.displayName=`Navigation`;var Dn=_.createContext(null);Dn.displayName=`Location`;var On=_.createContext({outlet:null,matches:[],isDataRoute:!1});On.displayName=`Route`;var kn=_.createContext(null);kn.displayName=`RouteError`;var An=`REACT_ROUTER_ERROR`,jn=`REDIRECT`,Mn=`ROUTE_ERROR_RESPONSE`;function Nn(e){if(e.startsWith(`${An}:${jn}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function Pn(e){if(e.startsWith(`${An}:${Mn}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new pn(t.status,t.statusText,t.data)}catch{}}function Fn(e,{relative:t}={}){j(In(),`useHref() may be used only in the context of a <Router> component.`);let{basename:n,navigator:r}=_.useContext(M),{hash:i,pathname:a,search:o}=Wn(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:cn([n,a])),r.createHref({pathname:s,search:o,hash:i})}function In(){return _.useContext(Dn)!=null}function Ln(){return j(In(),`useLocation() may be used only in the context of a <Router> component.`),_.useContext(Dn).location}var Rn=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function zn(e){_.useContext(M).static||_.useLayoutEffect(e)}function Bn(){let{isDataRoute:e}=_.useContext(On);return e?lr():Vn()}function Vn(){j(In(),`useNavigate() may be used only in the context of a <Router> component.`);let e=_.useContext(bn),{basename:t,navigator:n}=_.useContext(M),{matches:r}=_.useContext(On),{pathname:i}=Ln(),a=JSON.stringify(an(r)),o=_.useRef(!1);return zn(()=>{o.current=!0}),_.useCallback((r,s={})=>{if(wt(o.current,Rn),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=on(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:cn([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}var Hn=_.createContext(null);function Un(e){let t=_.useContext(On).outlet;return _.useMemo(()=>t&&_.createElement(Hn.Provider,{value:e},t),[t,e])}function Wn(e,{relative:t}={}){let{matches:n}=_.useContext(On),{pathname:r}=Ln(),i=JSON.stringify(an(n));return _.useMemo(()=>on(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function Gn(e,t){return Kn(e,t)}function Kn(e,t,n){j(In(),`useRoutes() may be used only in the context of a <Router> component.`);let{navigator:r}=_.useContext(M),{matches:i}=_.useContext(On),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;dr(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant <Routes> (or called \`useRoutes()\`) at "${s}" (under <Route path="${e}">) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render.

Please change the parent <Route path="${e}"> to <Route path="${e===`/`?`*`:`${e}/*`}">.`)}let u=Ln(),d;if(t){let e=typeof t==`string`?kt(t):t;j(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`<Routes location>\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):Mt(e,{pathname:p});wt(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),wt(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an <Outlet /> with a null value by default resulting in an "empty" page.`);let h=$n(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:cn([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:cn([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?_.createElement(Dn.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function qn(){let e=cr(),t=mn(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=_.createElement(_.Fragment,null,_.createElement(`p`,null,`💿 Hey developer 👋`),_.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,_.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,_.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),_.createElement(_.Fragment,null,_.createElement(`h2`,null,`Unexpected Application Error!`),_.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?_.createElement(`pre`,{style:i},n):null,o)}var Jn=_.createElement(qn,null),Yn=class extends _.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=Pn(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:_.createElement(On.Provider,{value:this.props.routeContext},_.createElement(kn.Provider,{value:e,children:this.props.component}));return this.context?_.createElement(Zn,{error:e},t):t}};Yn.contextType=Sn;var Xn=new WeakMap;function Zn({children:e,error:t}){let{basename:n}=_.useContext(M);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=Nn(t.digest);if(e){let r=Xn.get(t);if(r)throw r;let i=_n(e.location,n);if(gn&&!Xn.get(t))if(i.isExternal||e.reloadDocument)window.location.href=i.absoluteURL||i.to;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw Xn.set(t,n),n}return _.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${i.absoluteURL||i.to}`})}}return e}function Qn({routeContext:e,match:t,children:n}){let r=_.useContext(bn);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),_.createElement(On.Provider,{value:e},n)}function $n(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);j(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e<i.length;e++){let t=i[e];if((t.route.HydrateFallback||t.route.hydrateFallbackElement)&&(s=e),t.route.id){let{loaderData:e,errors:a}=r,c=t.route.loader&&!e.hasOwnProperty(t.route.id)&&(!a||a[t.route.id]===void 0);if(t.route.lazy||c){n.isStatic&&(o=!0),i=s>=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:hn(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||Jn,o&&(s<0&&c===0?(dr(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?_.createElement(n.route.Component,null):n.route.element?n.route.element:e,_.createElement(Qn,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?_.createElement(Yn,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function er(e){return`${e} must be used within a data router.  See https://reactrouter.com/en/main/routers/picking-a-router.`}function tr(e){let t=_.useContext(bn);return j(t,er(e)),t}function nr(e){let t=_.useContext(xn);return j(t,er(e)),t}function rr(e){let t=_.useContext(On);return j(t,er(e)),t}function ir(e){let t=rr(e),n=t.matches[t.matches.length-1];return j(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function ar(){return ir(`useRouteId`)}function or(){let e=nr(`useNavigation`);return _.useMemo(()=>{let{matches:t,historyAction:n,...r}=e.navigation;return r},[e.navigation])}function sr(){let{matches:e,loaderData:t}=nr(`useMatches`);return _.useMemo(()=>e.map(e=>Pt(e,t)),[e,t])}function cr(){let e=_.useContext(kn),t=nr(`useRouteError`),n=ir(`useRouteError`);return e===void 0?t.errors?.[n]:e}function lr(){let{router:e}=tr(`useNavigate`),t=ir(`useNavigate`),n=_.useRef(!1);return zn(()=>{n.current=!0}),_.useCallback(async(r,i={})=>{wt(n.current,Rn),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var ur={};function dr(e,t,n){!t&&!ur[e]&&(ur[e]=!0,wt(!1,n))}_.memo(fr);function fr({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return Kn(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function pr({to:e,replace:t,state:n,relative:r}){j(In(),`<Navigate> may be used only in the context of a <Router> component.`);let{static:i}=_.useContext(M);wt(!i,`<Navigate> must not be used on the initial render in a <StaticRouter>. This is a no-op, but you should modify your code so the <Navigate> is only ever rendered in response to some user interaction or state change.`);let{matches:a}=_.useContext(On),{pathname:o}=Ln(),s=Bn(),c=on(e,an(a),o,r===`path`),l=JSON.stringify(c);return _.useEffect(()=>{s(JSON.parse(l),{replace:t,state:n,relative:r})},[s,l,r,t,n]),null}function mr(e){return Un(e.context)}function hr(e){j(!1,`A <Route> is only ever to be used as the child of <Routes> element, never rendered directly. Please wrap your <Route> in a <Routes>.`)}function gr({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){j(!In(),`You cannot render a <Router> inside another <Router>. You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=_.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=kt(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=_.useMemo(()=>{let e=Qt(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return wt(h!=null,`<Router basename="${s}"> is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the <Router> won't render anything.`),h==null?null:_.createElement(M.Provider,{value:c},_.createElement(Dn.Provider,{children:t,value:h}))}function _r({children:e,location:t}){return Gn(vr(e),t)}_.Component;function vr(e,t=[]){let n=[];return _.Children.forEach(e,(e,r)=>{if(!_.isValidElement(e))return;let i=[...t,r];if(e.type===_.Fragment){n.push.apply(n,vr(e.props.children,i));return}j(e.type===hr,`[${typeof e.type==`string`?e.type:e.type.name}] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`),j(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=vr(e.props.children,i)),n.push(a)}),n}var yr=`get`,br=`application/x-www-form-urlencoded`;function xr(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Sr(e){return xr(e)&&e.tagName.toLowerCase()===`button`}function Cr(e){return xr(e)&&e.tagName.toLowerCase()===`form`}function wr(e){return xr(e)&&e.tagName.toLowerCase()===`input`}function Tr(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Er(e,t){return e.button===0&&(!t||t===`_self`)&&!Tr(e)}var Dr=null;function Or(){if(Dr===null)try{new FormData(document.createElement(`form`),0),Dr=!1}catch{Dr=!0}return Dr}var kr=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function Ar(e){return e!=null&&!kr.has(e)?(wt(!1,`"${e}" is not a valid \`encType\` for \`<Form>\`/\`<fetcher.Form>\` and will default to "${br}"`),null):e}function jr(e,t){let n,r,i,a,o;if(Cr(e)){let o=e.getAttribute(`action`);r=o?Qt(o,t):null,n=e.getAttribute(`method`)||yr,i=Ar(e.getAttribute(`enctype`))||br,a=new FormData(e)}else if(Sr(e)||wr(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a <button> or <input type="submit"> without a <form>`);let s=e.getAttribute(`formaction`)||o.getAttribute(`action`);if(r=s?Qt(s,t):null,n=e.getAttribute(`formmethod`)||o.getAttribute(`method`)||yr,i=Ar(e.getAttribute(`formenctype`))||Ar(o.getAttribute(`enctype`))||br,a=new FormData(o,e),!Or()){let{name:t,type:n,value:r}=e;if(n===`image`){let e=t?`${t}.`:``;a.append(`${e}x`,`0`),a.append(`${e}y`,`0`)}else t&&a.append(t,r)}}else if(xr(e))throw Error(`Cannot submit element that is not <form>, <button>, or <input type="submit|image">`);else n=yr,r=null,i=br,o=e;return a&&i===`text/plain`&&(o=a,a=void 0),{action:r,method:n.toLowerCase(),encType:i,formData:a,body:o}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var Mr={"&":`\\u0026`,">":`\\u003e`,"<":`\\u003c`,"\u2028":`\\u2028`,"\u2029":`\\u2029`},Nr=/[&><\u2028\u2029]/g;function Pr(e){return e.replace(Nr,e=>Mr[e])}function Fr(e,t){if(e===!1||e==null)throw Error(t)}function Ir(e,t,n,r){let i=typeof e==`string`?new URL(e,typeof window>`u`?`server://singlefetch/`:window.location.origin):e;return n?i.pathname.endsWith(`/`)?i.pathname=`${i.pathname}_.${r}`:i.pathname=`${i.pathname}.${r}`:i.pathname===`/`?i.pathname=`_root.${r}`:t&&Qt(i.pathname,t)===`/`?i.pathname=`${ln(t)}/_root.${r}`:i.pathname=`${ln(i.pathname)}.${r}`,i}async function Lr(e,t){if(e.id in t)return t[e.id];try{let n=await bt(()=>import(e.module),[]);return t[e.id]=n,n}catch(t){return console.error(`Error loading route module \`${e.module}\`, reloading page...`),console.error(t),window.__reactRouterContext&&window.__reactRouterContext.isSpaMode,window.location.reload(),new Promise(()=>{})}}function Rr(e){return e!=null&&typeof e.page==`string`}function zr(e){return e==null?!1:e.href==null?e.rel===`preload`&&typeof e.imageSrcSet==`string`&&typeof e.imageSizes==`string`:typeof e.rel==`string`&&typeof e.href==`string`}async function Br(e,t,n){return Gr((await Promise.all(e.map(async e=>{let r=t.routes[e.route.id];if(r){let e=await Lr(r,n);return e.links?e.links():[]}return[]}))).flat(1).filter(zr).filter(e=>e.rel===`stylesheet`||e.rel===`preload`).map(e=>e.rel===`stylesheet`?{...e,rel:`prefetch`,as:`style`}:{...e,rel:`prefetch`}))}function Vr(e,t,n,r,i,a){let o=(e,t)=>n[t]?e.route.id!==n[t].route.id:!0,s=(e,t)=>n[t].pathname!==e.pathname||n[t].route.path?.endsWith(`*`)&&n[t].params[`*`]!==e.params[`*`];return a===`assets`?t.filter((e,t)=>o(e,t)||s(e,t)):a===`data`?t.filter((t,a)=>{let c=r.routes[t.route.id];if(!c||!c.hasLoader)return!1;if(o(t,a)||s(t,a))return!0;if(t.route.shouldRevalidate){let r=t.route.shouldRevalidate({currentUrl:new URL(i.pathname+i.search+i.hash,window.origin),currentParams:n[0]?.params||{},nextUrl:new URL(e,window.origin),nextParams:t.params,defaultShouldRevalidate:!0});if(typeof r==`boolean`)return r}return!0}):[]}function Hr(e,t,{includeHydrateFallback:n}={}){return Ur(e.map(e=>{let r=t.routes[e.route.id];if(!r)return[];let i=[r.module];return r.clientActionModule&&(i=i.concat(r.clientActionModule)),r.clientLoaderModule&&(i=i.concat(r.clientLoaderModule)),n&&r.hydrateFallbackModule&&(i=i.concat(r.hydrateFallbackModule)),r.imports&&(i=i.concat(r.imports)),i}).flat(1))}function Ur(e){return[...new Set(e)]}function Wr(e){let t={},n=Object.keys(e).sort();for(let r of n)t[r]=e[r];return t}function Gr(e,t){let n=new Set,r=new Set(t);return e.reduce((e,i)=>{if(t&&!Rr(i)&&i.as===`script`&&i.href&&r.has(i.href))return e;let a=JSON.stringify(Wr(i));return n.has(a)||(n.add(a),e.push({key:a,link:i})),e},[])}function Kr(){let e=_.useContext(bn);return Fr(e,`You must render this element inside a <DataRouterContext.Provider> element`),e}function qr(){let e=_.useContext(xn);return Fr(e,`You must render this element inside a <DataRouterStateContext.Provider> element`),e}var Jr=_.createContext(void 0);Jr.displayName=`FrameworkContext`;function Yr(){let e=_.useContext(Jr);return Fr(e,`You must render this element inside a <HydratedRouter> element`),e}function Xr(e,t){let n=_.useContext(Jr),[r,i]=_.useState(!1),[a,o]=_.useState(!1),{onFocus:s,onBlur:c,onMouseEnter:l,onMouseLeave:u,onTouchStart:d}=t,f=_.useRef(null);_.useEffect(()=>{if(e===`render`&&o(!0),e===`viewport`){let e=new IntersectionObserver(e=>{e.forEach(e=>{o(e.isIntersecting)})},{threshold:.5});return f.current&&e.observe(f.current),()=>{e.disconnect()}}},[e]),_.useEffect(()=>{if(r){let e=setTimeout(()=>{o(!0)},100);return()=>{clearTimeout(e)}}},[r]);let p=()=>{i(!0)},m=()=>{i(!1),o(!1)};return n?e===`intent`?[a,f,{onFocus:Zr(s,p),onBlur:Zr(c,m),onMouseEnter:Zr(l,p),onMouseLeave:Zr(u,m),onTouchStart:Zr(d,p)}]:[a,f,{}]:[!1,f,{}]}function Zr(e,t){return n=>{e&&e(n),n.defaultPrevented||t(n)}}function Qr({page:e,...t}){let n=Cn(),{router:r}=Kr(),i=_.useMemo(()=>Mt(r.routes,e,r.basename),[r.routes,e,r.basename]);return i?n?_.createElement(ei,{page:e,matches:i,...t}):_.createElement(ti,{page:e,matches:i,...t}):null}function $r(e){let{manifest:t,routeModules:n}=Yr(),[r,i]=_.useState([]);return _.useEffect(()=>{let r=!1;return Br(e,t,n).then(e=>{r||i(e)}),()=>{r=!0}},[e,t,n]),r}function ei({page:e,matches:t,...n}){let r=Ln(),{future:i}=Yr(),{basename:a}=Kr(),o=_.useMemo(()=>{if(e===r.pathname+r.search+r.hash)return[];let n=Ir(e,a,i.unstable_trailingSlashAwareDataRequests,`rsc`),o=!1,s=[];for(let e of t)typeof e.route.shouldRevalidate==`function`?o=!0:s.push(e.route.id);return o&&s.length>0&&n.searchParams.set(`_routes`,s.join(`,`)),[n.pathname+n.search]},[a,i.unstable_trailingSlashAwareDataRequests,e,r,t]);return _.createElement(_.Fragment,null,o.map(e=>_.createElement(`link`,{key:e,rel:`prefetch`,as:`fetch`,href:e,...n})))}function ti({page:e,matches:t,...n}){let r=Ln(),{future:i,manifest:a,routeModules:o}=Yr(),{basename:s}=Kr(),{loaderData:c,matches:l}=qr(),u=_.useMemo(()=>Vr(e,t,l,a,r,`data`),[e,t,l,a,r]),d=_.useMemo(()=>Vr(e,t,l,a,r,`assets`),[e,t,l,a,r]),f=_.useMemo(()=>{if(e===r.pathname+r.search+r.hash)return[];let n=new Set,l=!1;if(t.forEach(e=>{let t=a.routes[e.route.id];!t||!t.hasLoader||(!u.some(t=>t.route.id===e.route.id)&&e.route.id in c&&o[e.route.id]?.shouldRevalidate||t.hasClientLoader?l=!0:n.add(e.route.id))}),n.size===0)return[];let d=Ir(e,s,i.unstable_trailingSlashAwareDataRequests,`data`);return l&&n.size>0&&d.searchParams.set(`_routes`,t.filter(e=>n.has(e.route.id)).map(e=>e.route.id).join(`,`)),[d.pathname+d.search]},[s,i.unstable_trailingSlashAwareDataRequests,c,r,a,u,t,e,o]),p=_.useMemo(()=>Hr(d,a),[d,a]),m=$r(d);return _.createElement(_.Fragment,null,f.map(e=>_.createElement(`link`,{key:e,rel:`prefetch`,as:`fetch`,href:e,...n})),p.map(e=>_.createElement(`link`,{key:e,rel:`modulepreload`,href:e,...n})),m.map(({key:e,link:t})=>_.createElement(`link`,{key:e,nonce:n.nonce,...t,crossOrigin:t.crossOrigin??n.crossOrigin})))}function ni(...e){return t=>{e.forEach(e=>{typeof e==`function`?e(t):e!=null&&(e.current=t)})}}_.Component;var ri=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;try{ri&&(window.__reactRouterVersion=`7.15.1`)}catch{}function ii({basename:e,children:t,useTransitions:n,window:r}){let i=_.useRef();i.current??=Ct({window:r,v5Compat:!0});let a=i.current,[o,s]=_.useState({action:a.action,location:a.location}),c=_.useCallback(e=>{n===!1?s(e):_.startTransition(()=>s(e))},[n]);return _.useLayoutEffect(()=>a.listen(c),[a,c]),_.createElement(gr,{basename:e,children:t,location:o.location,navigationType:o.action,navigator:a,useTransitions:n})}function ai({basename:e,children:t,history:n,useTransitions:r}){let[i,a]=_.useState({action:n.action,location:n.location}),o=_.useCallback(e=>{r===!1?a(e):_.startTransition(()=>a(e))},[r]);return _.useLayoutEffect(()=>n.listen(o),[n,o]),_.createElement(gr,{basename:e,children:t,location:i.location,navigationType:i.action,navigator:n,useTransitions:r})}ai.displayName=`unstable_HistoryRouter`;var oi=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,si=_.forwardRef(function({onClick:e,discover:t=`render`,prefetch:n=`none`,relative:r,reloadDocument:i,replace:a,mask:o,state:s,target:c,to:l,preventScrollReset:u,viewTransition:d,defaultShouldRevalidate:f,...p},m){let{basename:h,navigator:g,useTransitions:v}=_.useContext(M),y=typeof l==`string`&&oi.test(l),b=_n(l,h);l=b.to;let x=Fn(l,{relative:r}),S=Ln(),C=null;if(o){let e=on(o,[],S.mask?S.mask.pathname:`/`,!0);h!==`/`&&(e.pathname=e.pathname===`/`?h:cn([h,e.pathname])),C=g.createHref(e)}let[w,T,ee]=Xr(n,p),te=mi(l,{replace:a,mask:o,state:s,target:c,preventScrollReset:u,relative:r,viewTransition:d,defaultShouldRevalidate:f,useTransitions:v});function ne(t){e&&e(t),t.defaultPrevented||te(t)}let re=!(b.isExternal||i),ie=_.createElement(`a`,{...p,...ee,href:(re?C:void 0)||b.absoluteURL||x,onClick:re?ne:e,ref:ni(m,T),target:c,"data-discover":!y&&t===`render`?`true`:void 0});return w&&!y?_.createElement(_.Fragment,null,ie,_.createElement(Qr,{page:x})):ie});si.displayName=`Link`;var ci=_.forwardRef(function({"aria-current":e=`page`,caseSensitive:t=!1,className:n=``,end:r=!1,style:i,to:a,viewTransition:o,children:s,...c},l){let u=Wn(a,{relative:c.relative}),d=Ln(),f=_.useContext(xn),{navigator:p,basename:m}=_.useContext(M),h=f!=null&&wi(u)&&o===!0,g=p.encodeLocation?p.encodeLocation(u).pathname:u.pathname,v=d.pathname,y=f&&f.navigation&&f.navigation.location?f.navigation.location.pathname:null;t||(v=v.toLowerCase(),y=y?y.toLowerCase():null,g=g.toLowerCase()),y&&m&&(y=Qt(y,m)||y);let b=g!==`/`&&g.endsWith(`/`)?g.length-1:g.length,x=v===g||!r&&v.startsWith(g)&&v.charAt(b)===`/`,S=y!=null&&(y===g||!r&&y.startsWith(g)&&y.charAt(g.length)===`/`),C={isActive:x,isPending:S,isTransitioning:h},w=x?e:void 0,T;T=typeof n==`function`?n(C):[n,x?`active`:null,S?`pending`:null,h?`transitioning`:null].filter(Boolean).join(` `);let ee=typeof i==`function`?i(C):i;return _.createElement(si,{...c,"aria-current":w,className:T,ref:l,style:ee,to:a,viewTransition:o},typeof s==`function`?s(C):s)});ci.displayName=`NavLink`;var li=_.forwardRef(({discover:e=`render`,fetcherKey:t,navigate:n,reloadDocument:r,replace:i,state:a,method:o=yr,action:s,onSubmit:c,relative:l,preventScrollReset:u,viewTransition:d,defaultShouldRevalidate:f,...p},m)=>{let{useTransitions:h}=_.useContext(M),g=_i(),v=vi(s,{relative:l}),y=o.toLowerCase()===`get`?`get`:`post`,b=typeof s==`string`&&oi.test(s);return _.createElement(`form`,{ref:m,method:y,action:v,onSubmit:r?c:e=>{if(c&&c(e),e.defaultPrevented)return;e.preventDefault();let r=e.nativeEvent.submitter,s=r?.getAttribute(`formmethod`)||o,p=()=>g(r||e.currentTarget,{fetcherKey:t,method:s,navigate:n,replace:i,state:a,relative:l,preventScrollReset:u,viewTransition:d,defaultShouldRevalidate:f});h&&n!==!1?_.startTransition(()=>p()):p()},...p,"data-discover":!b&&e===`render`?`true`:void 0})});li.displayName=`Form`;function ui({getKey:e,storageKey:t,...n}){let r=_.useContext(Jr),{basename:i}=_.useContext(M),a=Ln(),o=sr();Si({getKey:e,storageKey:t});let s=_.useMemo(()=>{if(!r||!e)return null;let t=xi(a,o,i,e);return t===a.key?null:t},[]);if(!r||r.isSpaMode)return null;let c=((e,t)=>{if(!window.history.state||!window.history.state.key){let e=Math.random().toString(32).slice(2);window.history.replaceState({key:e},``)}try{let n=JSON.parse(sessionStorage.getItem(e)||`{}`)[t||window.history.state.key];typeof n==`number`&&window.scrollTo(0,n)}catch(t){console.error(t),sessionStorage.removeItem(e)}}).toString();return _.createElement(`script`,{...n,suppressHydrationWarning:!0,dangerouslySetInnerHTML:{__html:`(${c})(${Pr(JSON.stringify(t||yi))}, ${Pr(JSON.stringify(s))})`}})}ui.displayName=`ScrollRestoration`;function di(e){return`${e} must be used within a data router.  See https://reactrouter.com/en/main/routers/picking-a-router.`}function fi(e){let t=_.useContext(bn);return j(t,di(e)),t}function pi(e){let t=_.useContext(xn);return j(t,di(e)),t}function mi(e,{target:t,replace:n,mask:r,state:i,preventScrollReset:a,relative:o,viewTransition:s,defaultShouldRevalidate:c,useTransitions:l}={}){let u=Bn(),d=Ln(),f=Wn(e,{relative:o});return _.useCallback(p=>{if(Er(p,t)){p.preventDefault();let t=n===void 0?Ot(d)===Ot(f):n,m=()=>u(e,{replace:t,mask:r,state:i,preventScrollReset:a,relative:o,viewTransition:s,defaultShouldRevalidate:c});l?_.startTransition(()=>m()):m()}},[d,u,f,n,r,i,t,e,a,o,s,c,l])}var hi=0,gi=()=>`__${String(++hi)}__`;function _i(){let{router:e}=fi(`useSubmit`),{basename:t}=_.useContext(M),n=ar(),r=e.fetch,i=e.navigate;return _.useCallback(async(e,a={})=>{let{action:o,method:s,encType:c,formData:l,body:u}=jr(e,t);a.navigate===!1?await r(a.fetcherKey||gi(),n,a.action||o,{defaultShouldRevalidate:a.defaultShouldRevalidate,preventScrollReset:a.preventScrollReset,formData:l,body:u,formMethod:a.method||s,formEncType:a.encType||c,flushSync:a.flushSync}):await i(a.action||o,{defaultShouldRevalidate:a.defaultShouldRevalidate,preventScrollReset:a.preventScrollReset,formData:l,body:u,formMethod:a.method||s,formEncType:a.encType||c,replace:a.replace,state:a.state,fromRouteId:n,flushSync:a.flushSync,viewTransition:a.viewTransition})},[r,i,t,n])}function vi(e,{relative:t}={}){let{basename:n}=_.useContext(M),r=_.useContext(On);j(r,`useFormAction must be used inside a RouteContext`);let[i]=r.matches.slice(-1),a={...Wn(e||`.`,{relative:t})},o=Ln();if(e==null){a.search=o.search;let e=new URLSearchParams(a.search),t=e.getAll(`index`);if(t.some(e=>e===``)){e.delete(`index`),t.filter(e=>e).forEach(t=>e.append(`index`,t));let n=e.toString();a.search=n?`?${n}`:``}}return(!e||e===`.`)&&i.route.index&&(a.search=a.search?a.search.replace(/^\?/,`?index&`):`?index`),n!==`/`&&(a.pathname=a.pathname===`/`?n:cn([n,a.pathname])),Ot(a)}var yi=`react-router-scroll-positions`,bi={};function xi(e,t,n,r){let i=null;return r&&(i=r(n===`/`?e:{...e,pathname:Qt(e.pathname,n)||e.pathname},t)),i??=e.key,i}function Si({getKey:e,storageKey:t}={}){let{router:n}=fi(`useScrollRestoration`),{restoreScrollPosition:r,preventScrollReset:i}=pi(`useScrollRestoration`),{basename:a}=_.useContext(M),o=Ln(),s=sr(),c=or();_.useEffect(()=>(window.history.scrollRestoration=`manual`,()=>{window.history.scrollRestoration=`auto`}),[]),Ci(_.useCallback(()=>{if(c.state===`idle`){let t=xi(o,s,a,e);bi[t]=window.scrollY}try{sessionStorage.setItem(t||yi,JSON.stringify(bi))}catch(e){wt(!1,`Failed to save scroll positions in sessionStorage, <ScrollRestoration /> will not work properly (${e}).`)}window.history.scrollRestoration=`auto`},[c.state,e,a,o,s,t])),typeof document<`u`&&(_.useLayoutEffect(()=>{try{let e=sessionStorage.getItem(t||yi);e&&(bi=JSON.parse(e))}catch{}},[t]),_.useLayoutEffect(()=>{let t=n?.enableScrollRestoration(bi,()=>window.scrollY,e?(t,n)=>xi(t,n,a,e):void 0);return()=>t&&t()},[n,a,e]),_.useLayoutEffect(()=>{if(r!==!1){if(typeof r==`number`){window.scrollTo(0,r);return}try{if(o.hash){let e=document.getElementById(decodeURIComponent(o.hash.slice(1)));if(e){e.scrollIntoView();return}}}catch{wt(!1,`"${o.hash.slice(1)}" is not a decodable element ID. The view will not scroll to it.`)}i!==!0&&window.scrollTo(0,0)}},[o,r,i]))}function Ci(e,t){let{capture:n}=t||{};_.useEffect(()=>{let t=n==null?void 0:{capture:n};return window.addEventListener(`pagehide`,e,t),()=>{window.removeEventListener(`pagehide`,e,t)}},[e,n])}function wi(e,{relative:t}={}){let n=_.useContext(wn);j(n!=null,"`useViewTransitionState` must be used within `react-router-dom`'s `RouterProvider`.  Did you accidentally import `RouterProvider` from `react-router`?");let{basename:r}=fi(`useViewTransitionState`),i=Wn(e,{relative:t});if(!n.isTransitioning)return!1;let a=Qt(n.currentLocation.pathname,r)||n.currentLocation.pathname,o=Qt(n.nextLocation.pathname,r)||n.nextLocation.pathname;return Yt(i.pathname,o)!=null||Yt(i.pathname,a)!=null}var Ti=e=>{let t,n=new Set,r=(e,r)=>{let i=typeof e==`function`?e(t):e;if(!Object.is(i,t)){let e=t;t=r??(typeof i!=`object`||!i)?i:Object.assign({},t,i),n.forEach(n=>n(t,e))}},i=()=>t,a={setState:r,getState:i,getInitialState:()=>o,subscribe:e=>(n.add(e),()=>n.delete(e))},o=t=e(r,i,a);return a},Ei=(e=>e?Ti(e):Ti),Di=e=>e;function Oi(e,t=Di){let n=_.useSyncExternalStore(e.subscribe,_.useCallback(()=>t(e.getState()),[e,t]),_.useCallback(()=>t(e.getInitialState()),[e,t]));return _.useDebugValue(n),n}var ki=e=>{let t=Ei(e),n=e=>Oi(t,e);return Object.assign(n,t),n},Ai=(e=>e?ki(e):ki),ji=`metagit-web-theme`;function Mi(){if(typeof window>`u`)return`system`;let e=window.localStorage.getItem(ji);return e===`light`||e===`dark`||e===`system`?e:`system`}function Ni(e){return e===`light`||e===`dark`?e:typeof window>`u`?`light`:window.matchMedia(`(prefers-color-scheme: dark)`).matches?`dark`:`light`}function Pi(e){if(typeof document>`u`)return;let t=Ni(e);document.documentElement.dataset.theme=t}var Fi=Ai((e,t)=>({mode:Mi(),resolved:Ni(Mi()),setMode:t=>{window.localStorage.setItem(ji,t),Pi(t),e({mode:t,resolved:Ni(t)})},toggleResolved:()=>{let e=t().resolved===`dark`?`light`:`dark`;t().setMode(e)},init:()=>{let t=Mi();Pi(t),e({mode:t,resolved:Ni(t)})},syncSystemTheme:()=>{t().mode===`system`&&(Pi(`system`),e({resolved:Ni(`system`)}))}}));Pi(Mi());var Ii={shell:`_shell_1gsdz_1`,header:`_header_1gsdz_8`,title:`_title_1gsdz_19`,nav:`_nav_1gsdz_26`,navLink:`_navLink_1gsdz_33`,navLinkActive:`_navLinkActive_1gsdz_46`,themeToggle:`_themeToggle_1gsdz_51`,themeIcon:`_themeIcon_1gsdz_71`,main:`_main_1gsdz_76`},Li=({isActive:e})=>e?`${Ii.navLink} ${Ii.navLinkActive}`:Ii.navLink;function Ri(){let e=Fi(e=>e.resolved),t=Fi(e=>e.toggleResolved);return(0,A.jsxs)(`div`,{className:Ii.shell,children:[(0,A.jsxs)(`header`,{className:Ii.header,children:[(0,A.jsx)(`h1`,{className:Ii.title,children:`Metagit Web`}),(0,A.jsxs)(`nav`,{className:Ii.nav,"aria-label":`Main`,children:[(0,A.jsx)(ci,{to:`/workspace`,className:Li,children:`Workspace`}),(0,A.jsx)(ci,{to:`/config/metagit`,className:Li,children:`Metagit config`}),(0,A.jsx)(ci,{to:`/config/appconfig`,className:Li,children:`App config`})]}),(0,A.jsx)(`button`,{type:`button`,className:Ii.themeToggle,onClick:t,"aria-label":`Switch to ${e===`dark`?`light`:`dark`} theme`,title:`Switch to ${e===`dark`?`light`:`dark`} theme`,children:e===`dark`?(0,A.jsx)(`span`,{className:Ii.themeIcon,"aria-hidden":!0,children:`☀`}):(0,A.jsx)(`span`,{className:Ii.themeIcon,"aria-hidden":!0,children:`☾`})})]}),(0,A.jsx)(`main`,{className:Ii.main,children:(0,A.jsx)(mr,{})})]})}function zi({children:e}){let t=Fi(e=>e.init),n=Fi(e=>e.syncSystemTheme);return(0,_.useEffect)(()=>{t();let e=window.matchMedia(`(prefers-color-scheme: dark)`),r=()=>n();return e.addEventListener(`change`,r),()=>e.removeEventListener(`change`,r)},[t,n]),(0,A.jsx)(A.Fragment,{children:e})}var N=class extends Error{status;body;constructor(e,t,n){super(t),this.name=`ApiError`,this.status=e,this.body=n}};async function P(e,t){let n=await fetch(e,{...t,headers:{"Content-Type":`application/json`,...t?.headers}}),r=await n.text(),i=null;if(r)try{i=JSON.parse(r)}catch{i=r}if(!n.ok){let e=typeof i==`object`&&i&&`message`in i&&typeof i.message==`string`?i.message:n.statusText;throw new N(n.status,e,i)}return i}function Bi(){return P(`/v3/config/metagit/tree`)}function Vi(){return P(`/v3/config/appconfig/tree`)}function Hi(e,t){return P(`/v3/config/metagit`,{method:`PATCH`,body:JSON.stringify({operations:e,save:t})})}function Ui(e,t){return P(`/v3/config/appconfig`,{method:`PATCH`,body:JSON.stringify({operations:e,save:t})})}function Wi(e,t,n){return P(`/v3/config/${e}/preview`,{method:`POST`,body:JSON.stringify({style:t,operations:n})})}function Gi(){return P(`/v2/workspace`)}function Ki(e){let t=new URLSearchParams;e?.includeInferred===!1&&t.set(`include_inferred`,`false`),e?.includeStructure===!1&&t.set(`include_structure`,`false`);let n=t.toString();return P(n?`/v3/ops/graph?${n}`:`/v3/ops/graph`)}function qi(e={}){return P(`/v3/ops/health`,{method:`POST`,body:JSON.stringify(e)})}function Ji(e){return P(`/v3/ops/sync`,{method:`POST`,body:JSON.stringify(e)})}function Yi(e){return P(`/v3/ops/sync/${e}`)}function Xi(e){return P(`/v3/ops/prune/preview`,{method:`POST`,body:JSON.stringify(e)})}function Zi(e){return P(`/v3/ops/prune`,{method:`POST`,body:JSON.stringify(e)})}var Qi=e=>[`config-tree`,e];function $i(e){return e===`metagit`?Bi():Vi()}function ea(e,t,n){return e===`metagit`?Hi(t,n):Ui(t,n)}function ta(e,t,n){return Wi(e,t,n)}var na={previewPanel:`_previewPanel_tee41_1`,header:`_header_tee41_15`,title:`_title_tee41_23`,controls:`_controls_tee41_32`,select:`_select_tee41_39`,badge:`_badge_tee41_48`,badgeInvalid:`_badgeInvalid_tee41_59`,codeWrap:`_codeWrap_tee41_64`,code:`_code_tee41_64`,state:`_state_tee41_83`,errors:`_errors_tee41_89`};function ra({target:e,pendingOps:t}){let[n,r]=(0,_.useState)(`normalized`),i=(0,_.useMemo)(()=>n===`disk`?[]:t,[t,n]),{data:a,isLoading:o,isError:s,error:c}=ht({queryKey:[`config-preview`,e,n,i],queryFn:()=>ta(e,n,i)});return(0,A.jsxs)(`section`,{className:na.previewPanel,"aria-label":`YAML preview`,children:[(0,A.jsxs)(`header`,{className:na.header,children:[(0,A.jsx)(`h3`,{className:na.title,children:`YAML preview`}),(0,A.jsxs)(`div`,{className:na.controls,children:[(0,A.jsxs)(`select`,{className:na.select,value:n,"aria-label":`Preview style`,onChange:e=>r(e.target.value),children:[(0,A.jsx)(`option`,{value:`normalized`,children:`Normalized`}),(0,A.jsx)(`option`,{value:`minimal`,children:`Minimal (non-default)`}),(0,A.jsx)(`option`,{value:`disk`,children:`On disk`})]}),a?.draft?(0,A.jsx)(`span`,{className:na.badge,children:`Draft`}):null,a&&!a.ok?(0,A.jsx)(`span`,{className:`${na.badge} ${na.badgeInvalid}`,children:`Invalid`}):null]})]}),o?(0,A.jsx)(`p`,{className:na.state,children:`Rendering preview…`}):null,s?(0,A.jsx)(`p`,{className:na.state,children:c instanceof Error?c.message:`Preview failed`}):null,a?.validation_errors&&a.validation_errors.length>0?(0,A.jsx)(`ul`,{className:na.errors,children:a.validation_errors.map(e=>(0,A.jsxs)(`li`,{children:[e.path?`${e.path}: `:``,e.message]},`${e.path}:${e.message}`))}):null,a?.yaml?(0,A.jsx)(`div`,{className:na.codeWrap,children:(0,A.jsx)(`pre`,{className:na.code,children:a.yaml})}):null]})}var F={panel:`_panel_4l6gj_1`,empty:`_empty_4l6gj_13`,header:`_header_4l6gj_18`,title:`_title_4l6gj_24`,path:`_path_4l6gj_30`,meta:`_meta_4l6gj_38`,badge:`_badge_4l6gj_46`,description:`_description_4l6gj_54`,hint:`_hint_4l6gj_61`,field:`_field_4l6gj_70`,label:`_label_4l6gj_76`,input:`_input_4l6gj_82`,select:`_select_4l6gj_83`,textarea:`_textarea_4l6gj_84`,actions:`_actions_4l6gj_109`,button:`_button_4l6gj_116`,buttonPrimary:`_buttonPrimary_4l6gj_137`,errors:`_errors_4l6gj_148`,status:`_status_4l6gj_171`};function ia(){return new Set([`string`,`integer`,`number`,`boolean`,`enum`])}function aa(e){return e.map(e=>({path:e.path??``,message:e.message??`Validation error`}))}function oa(e){return e.sensitive===!0&&typeof e.value==`string`&&e.value.startsWith(`***`)}function sa(e,t){return e.sensitive?t.trim()===``:!1}function ca(e){return oa(e)?``:e.value!==void 0&&e.value!==null?e.value:e.default_value!==void 0&&e.default_value!==null?e.default_value:e.type===`boolean`?!1:e.type===`integer`||e.type===`number`?0:``}function la(e,t){return e.type===`boolean`?t===`true`:e.type===`integer`?Number.parseInt(t,10):e.type===`number`?Number.parseFloat(t):t}function ua({target:e,node:t,pendingOps:n,onPendingChange:r}){let i=et(),a=Qi(e),{data:o}=ht({queryKey:a,queryFn:()=>$i(e)}),[s,c]=(0,_.useState)(``),[l,u]=(0,_.useState)(!1);(0,_.useEffect)(()=>{if(!t){c(``),u(!1);return}let e=ca(t);c(t.type===`boolean`?String(e):String(e??``)),u(!1)},[t]);let d=(0,_.useMemo)(()=>aa(o?.validation_errors??[]),[o?.validation_errors]),f=gt({mutationFn:t=>ea(e,t.ops,t.save),onSuccess:(e,t)=>{if(i.setQueryData(a,e),t.save)r([]);else if(t.ops.length>0){let e=[...n];for(let n of t.ops){let t=e.findIndex(e=>e.path===n.path);t>=0?e[t]=n:e.push(n)}r(e)}u(!1)}}),p=(0,_.useMemo)(()=>{if(!t)return[];let e=new Set(t.enum_options??[]);return t.value!=null&&e.add(String(t.value)),t.default_value!=null&&e.add(String(t.default_value)),[...e]},[t]);if(!t)return(0,A.jsx)(`div`,{className:F.panel,children:(0,A.jsx)(`p`,{className:F.empty,children:`Select a field in the tree to edit.`})});let m=ia().has(t.type),h=t.type===`object`||t.type===`array`,g=t.type===`array`,v=t.type_label??t.type,y=t.editable!==!1&&t.enabled!==!1&&!h&&!g,b=e=>{if(!t.path||!m)return;if(sa(t,s)){e&&n.length>0&&f.mutate({ops:n,save:!0});return}let i=la(t,s),a={op:`set`,path:t.path,value:i},o=[...n.filter(e=>e.path!==t.path),a];if(e){f.mutate({ops:o,save:!0});return}r(o),f.mutate({ops:[a],save:!1})};return(0,A.jsxs)(`div`,{className:F.panel,children:[(0,A.jsxs)(`header`,{className:F.header,children:[(0,A.jsx)(`h3`,{className:F.title,children:t.key}),(0,A.jsx)(`p`,{className:F.path,children:t.path||`(root)`}),(0,A.jsxs)(`div`,{className:F.meta,children:[(0,A.jsx)(`span`,{className:F.badge,children:v}),t.required?(0,A.jsx)(`span`,{className:F.badge,children:`required`}):null,t.sensitive?(0,A.jsx)(`span`,{className:F.badge,children:`sensitive`}):null]})]}),t.description?(0,A.jsx)(`p`,{className:F.description,children:t.description}):null,g&&t.enabled?(0,A.jsxs)(`p`,{className:F.hint,children:[`List of `,v,`. Use `,(0,A.jsx)(`strong`,{children:`+`}),` in the schema tree to add items and `,(0,A.jsx)(`strong`,{children:`×`}),` on each row to remove. Currently`,` `,t.item_count??0,` item`,(t.item_count??0)===1?``:`s`,`.`]}):null,t.type===`object`?(0,A.jsx)(`p`,{className:F.hint,children:`Edit via tree — expand nested fields in the schema tree.`}):null,y&&t.type===`boolean`?(0,A.jsxs)(`div`,{className:F.field,children:[(0,A.jsx)(`label`,{className:F.label,htmlFor:`field-boolean`,children:`Value`}),(0,A.jsxs)(`select`,{id:`field-boolean`,className:F.select,value:s,disabled:f.isPending,onChange:e=>{c(e.target.value),u(!0)},children:[(0,A.jsx)(`option`,{value:`true`,children:`true`}),(0,A.jsx)(`option`,{value:`false`,children:`false`})]})]}):null,y&&t.type===`enum`?(0,A.jsxs)(`div`,{className:F.field,children:[(0,A.jsx)(`label`,{className:F.label,htmlFor:`field-enum`,children:`Value`}),(0,A.jsx)(`select`,{id:`field-enum`,className:F.select,value:s,disabled:f.isPending,onChange:e=>{c(e.target.value),u(!0)},children:p.map(e=>(0,A.jsx)(`option`,{value:e,children:e},e))})]}):null,y&&(t.type===`string`||t.type===`integer`||t.type===`number`)?(0,A.jsxs)(`div`,{className:F.field,children:[(0,A.jsx)(`label`,{className:F.label,htmlFor:`field-scalar`,children:`Value`}),(0,A.jsx)(`input`,{id:`field-scalar`,className:F.input,type:t.type===`string`||t.sensitive?`text`:`number`,value:s,placeholder:t.sensitive?`••••••••`:void 0,disabled:f.isPending,onChange:e=>{c(e.target.value),u(!0)}})]}):null,!y&&!h&&!g?(0,A.jsx)(`p`,{className:F.hint,children:`This field is not editable (disabled or read-only).`}):null,d.length>0?(0,A.jsx)(`ul`,{className:F.errors,"aria-live":`polite`,children:d.map(e=>(0,A.jsxs)(`li`,{children:[(0,A.jsxs)(`strong`,{children:[e.path||`config`,`:`]}),` `,e.message]},`${e.path}:${e.message}`))}):null,m&&y?(0,A.jsxs)(`div`,{className:F.actions,children:[(0,A.jsx)(`button`,{type:`button`,className:`${F.button} ${F.buttonPrimary}`,disabled:f.isPending||!l&&n.length===0,onClick:()=>b(!1),children:`Apply`}),(0,A.jsx)(`button`,{type:`button`,className:F.button,disabled:f.isPending||!l&&n.length===0,onClick:()=>b(!0),children:`Save field`}),(0,A.jsx)(`button`,{type:`button`,className:`${F.button} ${F.buttonPrimary}`,disabled:f.isPending,onClick:()=>{if(n.length===0&&!l)return;let e=[...n];if(l&&t?.path&&m&&!sa(t,s)){let n=la(t,s),r={op:`set`,path:t.path,value:n},i=e.findIndex(e=>e.path===t.path);i>=0?e[i]=r:e.push(r)}f.mutate({ops:e,save:!0})},children:`Save to disk`}),(0,A.jsx)(`button`,{type:`button`,className:F.button,disabled:f.isPending,onClick:()=>{r([]),u(!1),i.invalidateQueries({queryKey:a})},children:`Revert`})]}):null,n.length>0?(0,A.jsxs)(`p`,{className:F.status,children:[n.length,` pending change`,n.length===1?``:`s`,` not saved to disk`]}):null,o?.saved?(0,A.jsxs)(`p`,{className:F.status,children:[`Last write saved to `,o.config_path]}):null]})}var I={tree:`_tree_1q1u5_1`,nested:`_nested_1q1u5_7`,row:`_row_1q1u5_13`,rowSelected:`_rowSelected_1q1u5_27`,rowDisabled:`_rowDisabled_1q1u5_31`,checkbox:`_checkbox_1q1u5_35`,checkboxPlaceholder:`_checkboxPlaceholder_1q1u5_42`,label:`_label_1q1u5_47`,key:`_key_1q1u5_55`,type:`_type_1q1u5_60`,required:`_required_1q1u5_66`,state:`_state_1q1u5_73`,error:`_error_1q1u5_78`,expandBtn:`_expandBtn_1q1u5_82`,listBtn:`_listBtn_1q1u5_101`,listBtnDanger:`_listBtnDanger_1q1u5_119`,count:`_count_1q1u5_127`};function da(e){return!e.required&&e.path!==``}function fa(e){return e.type_label??e.type}function pa(e){return/^\[\d+\]$/.test(e.key)}function ma({target:e,selectedPath:t,onSelect:n,onOperationApplied:r}){let i=et(),a=Qi(e),{data:o,isLoading:s,isError:c,error:l}=ht({queryKey:a,queryFn:()=>$i(e)}),u=gt({mutationFn:t=>ea(e,[t],!1),onSuccess:(e,t)=>{i.setQueryData(a,e),r?.(t)}}),d=(0,_.useCallback)((e,t)=>{u.mutate({op:t?`enable`:`disable`,path:e.path})},[u]),f=(0,_.useCallback)(e=>{u.mutate({op:`append`,path:e.path})},[u]),p=(0,_.useCallback)(e=>{u.mutate({op:`remove`,path:e.path})},[u]);return s?(0,A.jsx)(`p`,{className:I.state,children:`Loading schema…`}):c?(0,A.jsx)(`p`,{className:`${I.state} ${I.error}`,children:l instanceof Error?l.message:`Failed to load schema`}):o?.tree?(0,A.jsx)(`ul`,{className:I.tree,role:`tree`,"aria-label":`Configuration fields`,children:(0,A.jsx)(ha,{nodes:o.tree.children??[],selectedPath:t,onSelect:n,onToggle:d,onAppend:f,onRemove:p,mutationPending:u.isPending})}):(0,A.jsx)(`p`,{className:I.state,children:`No schema data`})}function ha({nodes:e,selectedPath:t,onSelect:n,onToggle:r,onAppend:i,onRemove:a,mutationPending:o}){return(0,A.jsx)(A.Fragment,{children:e.map(e=>(0,A.jsx)(ga,{node:e,selectedPath:t,onSelect:n,onToggle:r,onAppend:i,onRemove:a,mutationPending:o},e.path||e.key))})}function ga({node:e,selectedPath:t,onSelect:n,onToggle:r,onAppend:i,onRemove:a,mutationPending:o}){let[s,c]=(0,_.useState)(!0),l=(e.children?.length??0)>0,u=da(e),d=t===e.path,f=e.type===`array`,p=pa(e),m=[I.row,d?I.rowSelected:``,e.enabled===!1?I.rowDisabled:``].filter(Boolean).join(` `);return(0,A.jsxs)(`li`,{role:`treeitem`,"aria-expanded":l?s:void 0,children:[(0,A.jsxs)(`div`,{className:m,onClick:()=>n(e),onKeyDown:t=>{(t.key===`Enter`||t.key===` `)&&(t.preventDefault(),n(e))},role:`button`,tabIndex:0,children:[u?(0,A.jsx)(`input`,{type:`checkbox`,className:I.checkbox,checked:e.enabled??!1,disabled:o,"aria-label":`${e.enabled?`Disable`:`Enable`} ${e.key}`,onClick:e=>e.stopPropagation(),onChange:t=>{t.stopPropagation(),r(e,t.target.checked)}}):(0,A.jsx)(`span`,{className:I.checkboxPlaceholder,"aria-hidden":!0}),(0,A.jsxs)(`span`,{className:I.label,children:[(0,A.jsx)(`span`,{className:I.key,children:e.key}),(0,A.jsx)(`span`,{className:I.type,children:fa(e)}),e.required?(0,A.jsx)(`span`,{className:I.required,children:`required`}):null,f&&e.enabled?(0,A.jsxs)(`span`,{className:I.count,children:[e.item_count??0,` item`,(e.item_count??0)===1?``:`s`]}):null]}),e.can_append?(0,A.jsx)(`button`,{type:`button`,className:I.listBtn,title:`Add item`,"aria-label":`Add ${fa(e)} item`,disabled:o,onClick:t=>{t.stopPropagation(),i(e)},children:`+`}):null,p?(0,A.jsx)(`button`,{type:`button`,className:`${I.listBtn} ${I.listBtnDanger}`,title:`Remove item`,"aria-label":`Remove ${e.path}`,disabled:o,onClick:t=>{t.stopPropagation(),a(e)},children:`×`}):null,l?(0,A.jsx)(`button`,{type:`button`,className:I.expandBtn,"aria-label":s?`Collapse`:`Expand`,onClick:e=>{e.stopPropagation(),c(e=>!e)},children:s?`−`:`+`}):null]}),l&&s?(0,A.jsx)(`ul`,{className:`${I.tree} ${I.nested}`,children:(0,A.jsx)(ha,{nodes:e.children??[],selectedPath:t,onSelect:n,onToggle:r,onAppend:i,onRemove:a,mutationPending:o})}):null]})}var _a={page:`_page_2nlim_1`,header:`_header_2nlim_7`,title:`_title_2nlim_15`,subtitle:`_subtitle_2nlim_21`,layout:`_layout_2nlim_29`,treePanel:`_treePanel_2nlim_56`,treeHeading:`_treeHeading_2nlim_67`};function va(e,t){if(!e||t===null)return null;if(t===``||t===e.path)return e;for(let n of e.children??[]){let e=va(n,t);if(e)return e}return null}function ya(e,t){let n=[...e],r=n.findIndex(e=>e.path===t.path);return r>=0?n[r]=t:n.push(t),n}function ba({target:e,title:t}){let[n,r]=(0,_.useState)(null),[i,a]=(0,_.useState)([]),{data:o}=ht({queryKey:Qi(e),queryFn:()=>$i(e)}),s=(0,_.useMemo)(()=>va(o?.tree,n),[o?.tree,n]),c=(0,_.useCallback)(e=>{r(e.path)},[]),l=(0,_.useCallback)(e=>{a(t=>ya(t,e))},[]);return(0,A.jsxs)(`section`,{className:_a.page,children:[(0,A.jsx)(`header`,{className:_a.header,children:(0,A.jsxs)(`div`,{children:[(0,A.jsx)(`h2`,{className:_a.title,children:t}),o?.config_path?(0,A.jsx)(`p`,{className:_a.subtitle,children:o.config_path}):null]})}),(0,A.jsxs)(`div`,{className:_a.layout,children:[(0,A.jsxs)(`aside`,{className:_a.treePanel,children:[(0,A.jsx)(`h3`,{className:_a.treeHeading,children:`Schema`}),(0,A.jsx)(ma,{target:e,selectedPath:n,onSelect:c,onOperationApplied:l})]}),(0,A.jsx)(ua,{target:e,node:s,pendingOps:i,onPendingChange:a}),(0,A.jsx)(ra,{target:e,pendingOps:i})]})]})}function xa(){return(0,A.jsx)(ba,{target:`appconfig`,title:`App config`})}function Sa(){return(0,A.jsx)(ba,{target:`metagit`,title:`Metagit config`})}var Ca={wrap:`_wrap_6qkdh_1`,legend:`_legend_6qkdh_7`,legendItem:`_legendItem_6qkdh_15`,legendSwatch:`_legendSwatch_6qkdh_21`,canvasScroll:`_canvasScroll_6qkdh_27`,canvas:`_canvas_6qkdh_27`,nodeProject:`_nodeProject_6qkdh_42`,nodeRepo:`_nodeRepo_6qkdh_48`,nodeLabel:`_nodeLabel_6qkdh_54`,edgeLabel:`_edgeLabel_6qkdh_61`,empty:`_empty_6qkdh_67`},wa=160,Ta=44,Ea=36,Da=10,Oa=32;function ka(e,t){return e===`manual`?`var(--graph-edge-manual)`:e===`structure`||t===`contains`?`var(--graph-edge-structure)`:`var(--graph-edge-inferred)`}function Aa(e){let t=new Map;for(let n of e){let e=n.project_name??`_`,r=t.get(e)??{repos:[]};n.kind===`project`?r.project=n:r.repos.push(n),t.set(e,r)}let n=new Map,r=0;for(let[,e]of[...t.entries()].sort(([e],[t])=>e.localeCompare(t))){let t=Oa+r*wa,i=Oa;e.project&&(n.set(e.project.id,{node:e.project,x:t,y:i,width:wa-16,height:Ta}),i+=Ta+Da);let a=[...e.repos].sort((e,t)=>e.label.localeCompare(t.label));for(let e of a)n.set(e.id,{node:e,x:t,y:i,width:wa-16,height:Ea}),i+=Ea+Da;r+=1}return n}function ja(e){return{cx:e.x+e.width/2,cy:e.y+e.height/2}}function Ma({nodes:e,edges:t,manualEdgeCount:n,inferredEdgeCount:r,structureEdgeCount:i}){let a=(0,_.useMemo)(()=>Aa(e),[e]),{width:o,height:s}=(0,_.useMemo)(()=>{let e=400,t=200;for(let n of a.values())e=Math.max(e,n.x+n.width+Oa),t=Math.max(t,n.y+n.height+Oa);return{width:e,height:t}},[a]);return e.length===0?(0,A.jsx)(`p`,{className:Ca.empty,children:"No graph nodes yet. Add workspace projects/repos or manual relationships in `.metagit.yml` under `graph`."}):(0,A.jsxs)(`div`,{className:Ca.wrap,children:[(0,A.jsxs)(`div`,{className:Ca.legend,"aria-label":`Edge legend`,children:[(0,A.jsxs)(`span`,{className:Ca.legendItem,children:[(0,A.jsx)(`span`,{className:Ca.legendSwatch,style:{background:`var(--graph-edge-manual)`}}),`Manual (`,n,`)`]}),(0,A.jsxs)(`span`,{className:Ca.legendItem,children:[(0,A.jsx)(`span`,{className:Ca.legendSwatch,style:{background:`var(--graph-edge-inferred)`}}),`Inferred (`,r,`)`]}),(0,A.jsxs)(`span`,{className:Ca.legendItem,children:[(0,A.jsx)(`span`,{className:Ca.legendSwatch,style:{background:`var(--graph-edge-structure)`}}),`Structure (`,i,`)`]})]}),(0,A.jsx)(`div`,{className:Ca.canvasScroll,children:(0,A.jsxs)(`svg`,{className:Ca.canvas,viewBox:`0 0 ${o} ${s}`,role:`img`,"aria-label":`Workspace relationship diagram`,children:[(0,A.jsx)(`defs`,{children:(0,A.jsx)(`marker`,{id:`graph-arrow`,markerWidth:`8`,markerHeight:`8`,refX:`7`,refY:`4`,orient:`auto`,children:(0,A.jsx)(`path`,{d:`M0,0 L8,4 L0,8 z`,fill:`var(--color-text-muted)`})})}),t.map(e=>{let t=a.get(e.from_id),n=a.get(e.to_id);if(!t||!n)return null;let r=ja(t),i=ja(n),o=ka(e.source,e.type),s=e.source===`structure`||e.type===`contains`?`6 4`:void 0;return(0,A.jsxs)(`g`,{children:[(0,A.jsx)(`line`,{x1:r.cx,y1:r.cy,x2:i.cx,y2:i.cy,stroke:o,strokeWidth:e.source===`manual`?2.5:1.5,strokeDasharray:s,markerEnd:`url(#graph-arrow)`,opacity:.85}),e.label&&e.source===`manual`?(0,A.jsx)(`text`,{x:(r.cx+i.cx)/2,y:(r.cy+i.cy)/2-6,className:Ca.edgeLabel,textAnchor:`middle`,children:e.label}):null]},e.id)}),[...a.values()].map(e=>(0,A.jsxs)(`g`,{children:[(0,A.jsx)(`rect`,{x:e.x,y:e.y,width:e.width,height:e.height,rx:8,className:e.node.kind===`project`?Ca.nodeProject:Ca.nodeRepo}),(0,A.jsx)(`text`,{x:e.x+e.width/2,y:e.y+e.height/2+4,className:Ca.nodeLabel,textAnchor:`middle`,children:e.node.label})]},e.node.id))]})})]})}var L={panel:`_panel_uwnxw_1`,heading:`_heading_uwnxw_13`,section:`_section_uwnxw_22`,sectionTitle:`_sectionTitle_uwnxw_28`,field:`_field_uwnxw_34`,label:`_label_uwnxw_40`,select:`_select_uwnxw_46`,button:`_button_uwnxw_55`,buttonPrimary:`_buttonPrimary_uwnxw_74`,buttonDanger:`_buttonDanger_uwnxw_85`,hint:`_hint_uwnxw_95`,candidateList:`_candidateList_uwnxw_101`,checkboxRow:`_checkboxRow_uwnxw_124`,status:`_status_uwnxw_132`,statusError:`_statusError_uwnxw_138`,divider:`_divider_uwnxw_142`,overlay:`_overlay_uwnxw_148`,modal:`_modal_uwnxw_159`,modalTitle:`_modalTitle_uwnxw_170`,summaryGrid:`_summaryGrid_uwnxw_176`,summaryChip:`_summaryChip_uwnxw_183`,recommendations:`_recommendations_uwnxw_195`,severityCritical:`_severityCritical_uwnxw_209`,severityWarning:`_severityWarning_uwnxw_213`,severityInfo:`_severityInfo_uwnxw_217`,repoTable:`_repoTable_uwnxw_221`,modalActions:`_modalActions_uwnxw_240`};function Na({projects:e,onWorkspaceRefresh:t}){let[n,r]=(0,_.useState)(!1),[i,a]=(0,_.useState)(!1),[o,s]=(0,_.useState)(null),[c,l]=(0,_.useState)(``),[u,d]=(0,_.useState)(``),[f,p]=(0,_.useState)([]),[m,h]=(0,_.useState)(!1),[g,v]=(0,_.useState)(``),[y,b]=(0,_.useState)(``),[x,S]=(0,_.useState)(!1),C=(0,_.useMemo)(()=>e.filter(e=>e.name!==`local`),[e]),w=u||C[0]?.name||``,T=async()=>{a(!0),l(``),s(null);try{s(await qi({})),r(!0)}catch(e){l(e instanceof N?e.message:`Health check failed.`),r(!0)}finally{a(!1)}},ee=async()=>{if(!w){b(`Select a project first.`);return}h(!0),b(``),v(``),p([]),S(!1);try{let e=await Xi({project:w});p(e.candidates??[]),v(e.candidates?.length?`${e.candidates.length} candidate(s) found.`:`No unmanaged directories to prune.`)}catch(e){b(e instanceof N?e.message:`Prune preview failed.`)}finally{h(!1)}},te=async()=>{if(!(!w||f.length===0||!x)){h(!0),b(``),v(``);try{let e=await Zi({project:w,paths:f.map(e=>e.path),force:!0});v(e.removed?.length?`Removed ${e.removed.length} path(s).`:`Prune completed with no removals.`),p([]),S(!1),t?.()}catch(e){b(e instanceof N?e.message:`Prune failed.`)}finally{h(!1)}}};return(0,A.jsxs)(`aside`,{className:L.panel,"aria-label":`Workspace operations`,children:[(0,A.jsx)(`h3`,{className:L.heading,children:`Operations`}),(0,A.jsxs)(`div`,{className:L.section,children:[(0,A.jsx)(`h4`,{className:L.sectionTitle,children:`Health`}),(0,A.jsx)(`p`,{className:L.hint,children:`Run a workspace integrity check and review recommendations.`}),(0,A.jsx)(`button`,{type:`button`,className:`${L.button} ${L.buttonPrimary}`,onClick:()=>void T(),disabled:i,children:i?`Checking…`:`Health check`})]}),(0,A.jsx)(`div`,{className:L.divider}),(0,A.jsxs)(`div`,{className:L.section,children:[(0,A.jsx)(`h4`,{className:L.sectionTitle,children:`Prune sync folders`}),(0,A.jsxs)(`div`,{className:L.field,children:[(0,A.jsx)(`label`,{className:L.label,htmlFor:`prune-project`,children:`Project`}),(0,A.jsx)(`select`,{id:`prune-project`,className:L.select,value:w,onChange:e=>{d(e.target.value),p([]),S(!1),v(``),b(``)},disabled:m||C.length===0,children:C.length===0?(0,A.jsx)(`option`,{value:``,children:`No projects`}):C.map(e=>(0,A.jsx)(`option`,{value:e.name,children:e.name},e.name))})]}),(0,A.jsx)(`button`,{type:`button`,className:L.button,onClick:()=>void ee(),disabled:m||!w,children:`Preview`}),g?(0,A.jsx)(`p`,{className:L.status,children:g}):null,y?(0,A.jsx)(`p`,{className:`${L.status} ${L.statusError}`,children:y}):null,f.length>0?(0,A.jsxs)(A.Fragment,{children:[(0,A.jsx)(`ul`,{className:L.candidateList,children:f.map(e=>(0,A.jsx)(`li`,{children:e.path},e.path))}),(0,A.jsxs)(`label`,{className:L.checkboxRow,children:[(0,A.jsx)(`input`,{type:`checkbox`,checked:x,onChange:e=>S(e.target.checked),disabled:m}),`I confirm deletion of the listed paths`]}),(0,A.jsx)(`button`,{type:`button`,className:`${L.button} ${L.buttonDanger}`,onClick:()=>void te(),disabled:m||!x,children:`Execute prune`})]}):null]}),n?(0,A.jsx)(`div`,{className:L.overlay,role:`presentation`,onClick:e=>{e.target===e.currentTarget&&r(!1)},children:(0,A.jsxs)(`div`,{className:L.modal,role:`dialog`,"aria-modal":`true`,children:[(0,A.jsx)(`h3`,{className:L.modalTitle,children:`Health check results`}),c?(0,A.jsx)(`p`,{className:`${L.status} ${L.statusError}`,children:c}):null,o?(0,A.jsxs)(A.Fragment,{children:[Object.keys(o.summary).length>0?(0,A.jsx)(`div`,{className:L.summaryGrid,children:Object.entries(o.summary).map(([e,t])=>(0,A.jsxs)(`div`,{className:L.summaryChip,children:[(0,A.jsx)(`strong`,{children:t}),e.replaceAll(`_`,` `)]},e))}):null,o.recommendations.length>0?(0,A.jsx)(`ul`,{className:L.recommendations,children:o.recommendations.map((e,t)=>(0,A.jsxs)(`li`,{className:e.severity===`critical`?L.severityCritical:e.severity===`warning`?L.severityWarning:L.severityInfo,children:[(0,A.jsx)(`strong`,{children:e.severity}),` · `,e.action,`: `,e.message]},`${e.action}-${t}`))}):(0,A.jsx)(`p`,{className:L.status,children:`No recommendations.`}),o.repos.length>0?(0,A.jsxs)(`table`,{className:L.repoTable,children:[(0,A.jsx)(`thead`,{children:(0,A.jsxs)(`tr`,{children:[(0,A.jsx)(`th`,{children:`Project`}),(0,A.jsx)(`th`,{children:`Repo`}),(0,A.jsx)(`th`,{children:`Status`}),(0,A.jsx)(`th`,{children:`Branch`})]})}),(0,A.jsx)(`tbody`,{children:o.repos.map(e=>(0,A.jsxs)(`tr`,{children:[(0,A.jsx)(`td`,{children:e.project_name}),(0,A.jsx)(`td`,{children:e.repo_name}),(0,A.jsx)(`td`,{children:e.status}),(0,A.jsx)(`td`,{children:e.branch??`—`})]},`${e.project_name}/${e.repo_name}`))})]}):null]}):null,(0,A.jsx)(`div`,{className:L.modalActions,children:(0,A.jsx)(`button`,{type:`button`,className:L.button,onClick:()=>r(!1),children:`Close`})})]})}):null]})}var Pa=[`workspace`];async function Fa(){let e=await Gi();if(!e.ok||!e.data){let t=e.error?.message??`Failed to load workspace`;throw Error(t)}return e.data}function Ia(e,t){return`${e}/${t}`}var La={tableWrap:`_tableWrap_1nm7q_1`,table:`_table_1nm7q_1`,projectRow:`_projectRow_1nm7q_34`,projectHeader:`_projectHeader_1nm7q_39`,expandButton:`_expandButton_1nm7q_46`,projectMeta:`_projectMeta_1nm7q_63`,repoName:`_repoName_1nm7q_69`,path:`_path_1nm7q_73`,badge:`_badge_1nm7q_80`,badgeSynced:`_badgeSynced_1nm7q_90`,badgeMissing:`_badgeMissing_1nm7q_95`,actions:`_actions_1nm7q_104`,button:`_button_1nm7q_110`,buttonPrimary:`_buttonPrimary_1nm7q_126`,empty:`_empty_1nm7q_137`};function Ra(e,t){return t===`all`?!0:t===`synced`?e.status===`synced`:e.status===`configured_missing`}function za(e,t){let n=t.trim().toLowerCase();return n?e.repo_name.toLowerCase().includes(n)||e.project_name.toLowerCase().includes(n)||(e.repo_path??``).toLowerCase().includes(n):!0}function Ba({projects:e,reposIndex:t,statusFilter:n,search:r,onSync:i}){let[a,o]=(0,_.useState)({}),s=(0,_.useMemo)(()=>{let i=new Map;for(let e of t){if(!Ra(e,n)||!za(e,r))continue;let t=i.get(e.project_name)??[];t.push(e),i.set(e.project_name,t)}let a=[];for(let t of e){let e=i.get(t.name);e?.length&&(e.sort((e,t)=>e.repo_name.localeCompare(t.repo_name)),a.push({project:t,repos:e}))}return a},[e,t,n,r]),c=e=>{o(t=>({...t,[e]:!t[e]}))};return s.length===0?(0,A.jsx)(`p`,{className:La.empty,children:`No repositories match the current filters.`}):(0,A.jsx)(`div`,{className:La.tableWrap,children:(0,A.jsxs)(`table`,{className:La.table,children:[(0,A.jsx)(`thead`,{children:(0,A.jsxs)(`tr`,{children:[(0,A.jsx)(`th`,{scope:`col`,children:`Repository`}),(0,A.jsx)(`th`,{scope:`col`,children:`Status`}),(0,A.jsx)(`th`,{scope:`col`,children:`Path`}),(0,A.jsx)(`th`,{scope:`col`,children:`Actions`})]})}),(0,A.jsx)(`tbody`,{children:s.map(({project:e,repos:t})=>{let n=a[e.name]??!1,r=t.map(e=>Ia(e.project_name,e.repo_name));return(0,A.jsx)(Va,{project:e,repos:t,collapsed:n,onToggle:()=>c(e.name),onSync:i,onSyncAll:()=>i(r,`Sync all in ${e.name} (${t.length})`)},e.name)})})]})})}function Va({project:e,repos:t,collapsed:n,onToggle:r,onSync:i,onSyncAll:a}){return(0,A.jsxs)(A.Fragment,{children:[(0,A.jsx)(`tr`,{className:La.projectRow,children:(0,A.jsx)(`td`,{colSpan:4,children:(0,A.jsxs)(`div`,{className:La.projectHeader,children:[(0,A.jsxs)(`button`,{type:`button`,className:La.expandButton,onClick:r,"aria-expanded":!n,children:[(0,A.jsx)(`span`,{"aria-hidden":!0,children:n?`▸`:`▾`}),e.name]}),(0,A.jsxs)(`span`,{className:La.projectMeta,children:[t.length,` repo`,t.length===1?``:`s`,e.description?` · ${e.description}`:``]}),(0,A.jsx)(`button`,{type:`button`,className:La.buttonPrimary,onClick:a,children:`Sync all`})]})})}),n?null:t.map(e=>(0,A.jsxs)(`tr`,{children:[(0,A.jsx)(`td`,{className:La.repoName,children:e.repo_name}),(0,A.jsx)(`td`,{children:(0,A.jsx)(`span`,{className:e.status===`synced`?`${La.badge} ${La.badgeSynced}`:`${La.badge} ${La.badgeMissing}`,children:e.status===`synced`?`synced`:`missing`})}),(0,A.jsx)(`td`,{className:La.path,children:e.repo_path}),(0,A.jsx)(`td`,{children:(0,A.jsx)(`div`,{className:La.actions,children:(0,A.jsx)(`button`,{type:`button`,className:La.buttonPrimary,onClick:()=>i([Ia(e.project_name,e.repo_name)],`${e.project_name}/${e.repo_name}`),children:`Sync`})})})]},`${e.project_name}/${e.repo_name}`))]})}var R={overlay:`_overlay_1lfb6_1`,dialog:`_dialog_1lfb6_12`,title:`_title_1lfb6_24`,subtitle:`_subtitle_1lfb6_30`,field:`_field_1lfb6_36`,label:`_label_1lfb6_43`,select:`_select_1lfb6_49`,checkboxRow:`_checkboxRow_1lfb6_58`,actions:`_actions_1lfb6_67`,button:`_button_1lfb6_74`,buttonPrimary:`_buttonPrimary_1lfb6_93`,status:`_status_1lfb6_104`,statusError:`_statusError_1lfb6_113`,summary:`_summary_1lfb6_118`};function Ha({open:e,title:t,repos:n,onClose:r}){let i=et(),[a,o]=(0,_.useState)(`fetch`),[s,c]=(0,_.useState)(!1),[l,u]=(0,_.useState)(`idle`),[d,f]=(0,_.useState)(null),[p,m]=(0,_.useState)(``),[h,g]=(0,_.useState)(null),v=(0,_.useCallback)(()=>{u(`idle`),f(null),m(``),g(null)},[]);(0,_.useEffect)(()=>{e||(v(),o(`fetch`),c(!1))},[e,v]),(0,_.useEffect)(()=>{if(!d||l!==`running`)return;let e=!1,t=async()=>{try{let t=await Yi(d);if(e)return;if(t.state===`completed`){u(`done`),g(t.summary),m(`Sync completed.`),i.invalidateQueries({queryKey:Pa});return}if(t.state===`failed`){u(`error`),m(t.error??`Sync failed.`);return}m(`Sync ${t.state}…`)}catch(t){if(e)return;let n=t instanceof N?t.message:`Failed to fetch sync status.`;u(`error`),m(n)}};t();let n=window.setInterval(()=>{t()},1e3);return()=>{e=!0,window.clearInterval(n)}},[d,l,i]);let y=async()=>{v(),u(`running`),m(`Starting sync job…`);try{let e=await Ji({repos:n.length>0?n:void 0,mode:a,dry_run:s}),t=typeof e.job_id==`string`?e.job_id:typeof e.status==`object`&&e.status!==null&&`job_id`in e.status&&typeof e.status.job_id==`string`?e.status.job_id:null;if(!t){u(`error`),m(`Server did not return a job id.`);return}f(t),m(`Sync running…`)}catch(e){u(`error`),m(e instanceof N?e.message:`Failed to start sync.`)}};if(!e)return null;let b=l===`running`;return(0,A.jsx)(`div`,{className:R.overlay,role:`presentation`,onClick:e=>{e.target===e.currentTarget&&!b&&r()},children:(0,A.jsxs)(`div`,{className:R.dialog,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`sync-dialog-title`,children:[(0,A.jsx)(`h3`,{id:`sync-dialog-title`,className:R.title,children:`Sync repositories`}),(0,A.jsx)(`p`,{className:R.subtitle,children:t}),p?(0,A.jsx)(`p`,{className:l===`error`?`${R.status} ${R.statusError}`:R.status,children:p}):null,h?(0,A.jsx)(`ul`,{className:R.summary,children:Object.entries(h).map(([e,t])=>(0,A.jsxs)(`li`,{children:[e,`: `,String(t)]},e))}):null,l===`idle`||l===`error`?(0,A.jsxs)(A.Fragment,{children:[(0,A.jsxs)(`div`,{className:R.field,children:[(0,A.jsx)(`label`,{className:R.label,htmlFor:`sync-mode`,children:`Mode`}),(0,A.jsxs)(`select`,{id:`sync-mode`,className:R.select,value:a,onChange:e=>o(e.target.value),disabled:b,children:[(0,A.jsx)(`option`,{value:`fetch`,children:`fetch`}),(0,A.jsx)(`option`,{value:`pull`,children:`pull`}),(0,A.jsx)(`option`,{value:`clone`,children:`clone`})]})]}),(0,A.jsxs)(`label`,{className:R.checkboxRow,children:[(0,A.jsx)(`input`,{type:`checkbox`,checked:s,onChange:e=>c(e.target.checked),disabled:b}),`Dry run`]})]}):null,(0,A.jsxs)(`div`,{className:R.actions,children:[(0,A.jsx)(`button`,{type:`button`,className:R.button,onClick:r,disabled:b,children:l===`done`?`Close`:`Cancel`}),l===`idle`||l===`error`?(0,A.jsx)(`button`,{type:`button`,className:`${R.button} ${R.buttonPrimary}`,onClick:()=>void y(),disabled:b||n.length===0,children:`Start sync`}):null]})]})})}var Ua=(e,t)=>[`workspace-graph`,e,t];async function Wa(e,t){let n=await Ki({includeInferred:e,includeStructure:t});if(!n.ok)throw Error(`Failed to load workspace graph`);return n}var z={page:`_page_uygcw_1`,header:`_header_uygcw_7`,title:`_title_uygcw_15`,subtitle:`_subtitle_uygcw_21`,chips:`_chips_uygcw_29`,chip:`_chip_uygcw_29`,toolbar:`_toolbar_uygcw_50`,tabs:`_tabs_uygcw_57`,tab:`_tab_uygcw_57`,tabActive:`_tabActive_uygcw_79`,search:`_search_uygcw_85`,layout:`_layout_uygcw_102`,loading:`_loading_uygcw_115`,error:`_error_uygcw_116`,graphFilters:`_graphFilters_uygcw_134`,checkLabel:`_checkLabel_uygcw_141`,graphPanel:`_graphPanel_uygcw_150`};function Ga(){let[e,t]=(0,_.useState)(`repos`),[n,r]=(0,_.useState)(`all`),[i,a]=(0,_.useState)(``),[o,s]=(0,_.useState)(!0),[c,l]=(0,_.useState)(!0),[u,d]=(0,_.useState)(null),{data:f,isLoading:p,isError:m,error:h,refetch:g}=ht({queryKey:Pa,queryFn:Fa}),{data:v,isLoading:y,isError:b,error:x,refetch:S}=ht({queryKey:Ua(o,c),queryFn:()=>Wa(o,c),enabled:e===`graph`}),C=f?.repos_index??[],w=f?.projects??[],T=(0,_.useMemo)(()=>{let e=C.filter(e=>e.status===`synced`).length,t=C.filter(e=>e.status===`configured_missing`).length;return{projects:w.length,repos:C.length,synced:e,missing:t}},[w.length,C]),ee=typeof f?.summary?.definition_path==`string`?f.summary.definition_path:null;return(0,A.jsxs)(`section`,{className:z.page,children:[(0,A.jsx)(`header`,{className:z.header,children:(0,A.jsxs)(`div`,{children:[(0,A.jsx)(`h2`,{className:z.title,children:`Workspace`}),ee?(0,A.jsx)(`p`,{className:z.subtitle,children:ee}):null]})}),(0,A.jsxs)(`div`,{className:z.chips,"aria-label":`Workspace summary`,children:[(0,A.jsxs)(`span`,{className:z.chip,children:[(0,A.jsx)(`strong`,{children:T.projects}),` projects`]}),(0,A.jsxs)(`span`,{className:z.chip,children:[(0,A.jsx)(`strong`,{children:T.repos}),` repos`]}),(0,A.jsxs)(`span`,{className:z.chip,children:[(0,A.jsx)(`strong`,{children:T.synced}),` synced`]}),(0,A.jsxs)(`span`,{className:z.chip,children:[(0,A.jsx)(`strong`,{children:T.missing}),` missing`]})]}),(0,A.jsxs)(`div`,{className:z.toolbar,children:[(0,A.jsx)(`div`,{className:z.tabs,role:`tablist`,"aria-label":`Workspace view`,children:[[`repos`,`Repositories`],[`graph`,`Graph`]].map(([n,r])=>(0,A.jsx)(`button`,{type:`button`,role:`tab`,"aria-selected":e===n,className:e===n?`${z.tab} ${z.tabActive}`:z.tab,onClick:()=>t(n),children:r},n))}),e===`repos`?(0,A.jsxs)(A.Fragment,{children:[(0,A.jsx)(`div`,{className:z.tabs,role:`tablist`,"aria-label":`Repository status filter`,children:[[`all`,`All`],[`synced`,`Synced`],[`missing`,`Missing`]].map(([e,t])=>(0,A.jsx)(`button`,{type:`button`,role:`tab`,"aria-selected":n===e,className:n===e?`${z.tab} ${z.tabActive}`:z.tab,onClick:()=>r(e),children:t},e))}),(0,A.jsx)(`input`,{type:`search`,className:z.search,placeholder:`Search repositories…`,value:i,onChange:e=>a(e.target.value),"aria-label":`Search repositories`})]}):(0,A.jsxs)(`div`,{className:z.graphFilters,children:[(0,A.jsxs)(`label`,{className:z.checkLabel,children:[(0,A.jsx)(`input`,{type:`checkbox`,checked:o,onChange:e=>s(e.target.checked)}),`Inferred dependencies`]}),(0,A.jsxs)(`label`,{className:z.checkLabel,children:[(0,A.jsx)(`input`,{type:`checkbox`,checked:c,onChange:e=>l(e.target.checked)}),`Project → repo structure`]})]})]}),p?(0,A.jsx)(`p`,{className:z.loading,children:`Loading workspace…`}):null,m?(0,A.jsx)(`p`,{className:z.error,children:h instanceof Error?h.message:`Failed to load workspace.`}):null,!p&&!m&&f?(0,A.jsxs)(`div`,{className:z.layout,children:[e===`repos`?(0,A.jsx)(Ba,{projects:w,reposIndex:C,statusFilter:n,search:i,onSync:(e,t)=>d({repos:e,title:t})}):(0,A.jsxs)(`section`,{className:z.graphPanel,children:[y?(0,A.jsx)(`p`,{className:z.loading,children:`Loading relationship graph…`}):null,b?(0,A.jsx)(`p`,{className:z.error,children:x instanceof Error?x.message:`Failed to load graph.`}):null,v?(0,A.jsx)(Ma,{nodes:v.nodes,edges:v.edges,manualEdgeCount:v.manual_edge_count,inferredEdgeCount:v.inferred_edge_count,structureEdgeCount:v.structure_edge_count}):null]}),(0,A.jsx)(Na,{projects:w,onWorkspaceRefresh:()=>{g(),e===`graph`&&S()}})]}):null,(0,A.jsx)(Ha,{open:u!==null,title:u?.title??``,repos:u?.repos??[],onClose:()=>d(null)})]})}var Ka=new Ze({defaultOptions:{queries:{staleTime:3e4,retry:1}}});function qa(){return(0,A.jsx)(tt,{client:Ka,children:(0,A.jsx)(zi,{children:(0,A.jsx)(ii,{children:(0,A.jsx)(_r,{children:(0,A.jsxs)(hr,{element:(0,A.jsx)(Ri,{}),children:[(0,A.jsx)(hr,{index:!0,element:(0,A.jsx)(pr,{to:`/workspace`,replace:!0})}),(0,A.jsx)(hr,{path:`/workspace`,element:(0,A.jsx)(Ga,{})}),(0,A.jsx)(hr,{path:`/config/metagit`,element:(0,A.jsx)(Sa,{})}),(0,A.jsx)(hr,{path:`/config/appconfig`,element:(0,A.jsx)(xa,{})})]})})})})})}(0,v.createRoot)(document.getElementById(`root`)).render((0,A.jsx)(_.StrictMode,{children:(0,A.jsx)(qa,{})}));
`````

## File: src/metagit/data/web/favicon.svg
`````xml
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
`````

## File: src/metagit/data/web/icons.svg
`````xml
<svg xmlns="http://www.w3.org/2000/svg">
  <symbol id="bluesky-icon" viewBox="0 0 16 17">
    <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
    <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
  </symbol>
  <symbol id="discord-icon" viewBox="0 0 20 19">
    <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
  </symbol>
  <symbol id="documentation-icon" viewBox="0 0 21 20">
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
  </symbol>
  <symbol id="github-icon" viewBox="0 0 19 19">
    <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
  </symbol>
  <symbol id="social-icon" viewBox="0 0 20 20">
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
  </symbol>
  <symbol id="x-icon" viewBox="0 0 19 19">
    <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
  </symbol>
</svg>
`````

## File: src/metagit/__init__.py
`````python
#!/usr/bin/env python
"""
Metagit detection tool

.. currentmodule:: metagit
.. moduleauthor:: Metagit <zloeber@gmail.com>
"""

import os
from os import path
from importlib.metadata import PackageNotFoundError, version

here = path.abspath(path.dirname(__file__))

try:
    from ._version import version as __version__
except ImportError:
    try:
        __version__ = version("metagit-cli")
    except PackageNotFoundError:
        __version__ = "0.0.0"


SCRIPT_PATH = os.path.abspath(os.path.split(__file__)[0])
CONFIG_PATH = os.getenv(
    "METAGIT_CONFIG", os.path.join(SCRIPT_PATH, (".metagit.config.yml"))
)
DATA_PATH = os.getenv("METAGIT_DATA", os.path.join(SCRIPT_PATH, "data"))
DEFAULT_CONFIG = os.path.join(DATA_PATH, "metagit.config.yaml")
`````

## File: src/metagit/__main__.py
`````python
#!/usr/bin/env python
"""Compatibility module for legacy metagit entrypoints."""

from metagit.cli.main import main


if __name__ == "__main__":
    main()
`````

## File: tasks/Taskfile.gemini.yml
`````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: "3"
silent: true
vars:
  GEMINI_API_KEY:
    sh: echo ${GEMINI_API_KEY:-""}

tasks:
  show:
    desc: Show python variables for this task
    cmds:
      - |
        echo "GEMINI_API_KEY: {{.GEMINI_API_KEY}}"
  docs:
    desc: Build the docs via gemini
    cmds:
      - |
        gemini -y -p 'You are an expert devops and software engineer. Explore this project for its use of secrets and variables paying special attention to only those affecting the runtime behavior of the code and the cicd workflows used to release the projects artifacts. Create a report in ./docs/secrets.analysis.md on each of the secrets found and were they are sourced from. Using this data create a yaml file at ./docs/secrets.definitions.yml that represents the secrets, their provenance, and where they should be placed.'
        gemini -y -p 'Perform a comprehensive analysis of the files in this project. Update the yaml file named .metagit.example.yml in the root of this project in a manner that adheres to the jsonschema file located at ./schemas/metagit_config.schema.json. Ensure you skip files matching the patterns in .gitignore. Do your best to update even optional elements of this schema. Pay special attention to dependencies found in Dockerfiles, CICD files, and mcp definitions. When complete also create ./docs/app.logic.md with additional documentation on the project components that includes a mermaid diagram of how they interact.'
`````

## File: tasks/Taskfile.github.yml
`````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json

version: "3"
silent: true
vars:
  GITHUB_PATH:
    sh: |
      git remote get-url origin 2>/dev/null | sed -Ee 's/.*:(.+)\.git/\1/'
  GITHUB_URL:
    sh: 'echo ${GITHUB_URL:-"https://www.github.com"}'
  GITHUB_UI_PATH: "{{.GITHUB_URL}}/{{.GITHUB_PATH}}"
  GITHUB_TOKEN:
    sh: echo ${GITHUB_TOKEN:-""}

tasks:
  show:
    desc: Show github variables for this task
    cmds:
      - |
        echo "GITHUB_PATH: {{.GITHUB_PATH}}"
        echo "GITHUB_UI_PATH: {{.GITHUB_UI_PATH}}"

  ui:
    desc: Open website for github project
    cmds:
      - |
        GITHUB_UI_PATH=$(git remote get-url origin 2>/dev/null | sed -Ee 's/.*:(.+)\.git/\1/' | awk '{print "https://www.github.com/" $1}')
        echo "path: ${GITHUB_UI_PATH}"
        if [[ "{{OS}}" == "darwin"* ]]; then
          open ${GITHUB_UI_PATH}
        else
          xdg-open ${GITHUB_UI_PATH}
        fi

  actions:ui:
    desc: Open website for github project actions
    cmds:
      - |
        if [[ "$OSTYPE" == "darwin"* ]]; then
          open {{.GITHUB_UI_PATH}}/actions
        else
          xdg-open {{.GITHUB_UI_PATH}}/actions
        fi

  models:init:
    desc: Initialize models for github project
    cmds:
      - gh extension install https://github.com/github/gh-models || true

  models:list:
    desc: List models for github project
    cmds:
      - gh models list
`````

## File: tasks/Taskfile.mcp.yml
`````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json

version: "3"
silent: true

tasks:
  show:
    desc: Show MCP variables for this task
    cmds:
      - |
        echo "OLLAMA_HOST: {{.OLLAMA_HOST}}"

  inspector:
    desc: Start MCP Inspector
    cmds:
      - npx -y @modelcontextprotocol/inspector
    env:
      DANGEROUSLY_OMIT_AUTH: true

  scan:
    desc: Scan MCP package for issues
    env:
      MCP_SCANNER_LLM_API_KEY: test
      MCP_SCANNER_API_KEY: test
      MCP_SCANNER_LLM_ENDPOINT: $OLLAMA_HOST
      MCP_SCANNER_LLM_MODEL: gemma4:31b
    cmds:
      - |
        uv tool install cisco-ai-mcp-scanner
        uv tool upgrade cisco-ai-mcp-scanner
        mcp-scanner --format summary \
          --stdio-command uvx \
          --stdio-arg=pl-cli --stdio-arg=mcp --stdio-arg=-c --stdio-arg=config.yaml \
          --stdio-arg=--no-ingest-on-startup \
          --format table \
          --analyzers yara \
          -o security_scan_results.md
`````

## File: tests/api/test_catalog_api.py
`````python
#!/usr/bin/env python
"""HTTP API tests for workspace catalog v2 endpoints."""

import json
import threading
import urllib.request
from pathlib import Path

from metagit.core.api.server import build_server


def test_catalog_project_and_repo_crud(tmp_path: Path) -> None:
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects: []",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    server = build_server(root=str(tmp_path), host="127.0.0.1", port=0)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        port = server.server_address[1]
        base = f"http://127.0.0.1:{port}"

        projects = json.loads(
            urllib.request.urlopen(f"{base}/v2/projects", timeout=5).read().decode(
                "utf-8"
            )
        )
        assert projects["data"]["project_count"] == 0

        add_body = json.dumps({"name": "platform"}).encode("utf-8")
        add_req = urllib.request.Request(
            f"{base}/v2/projects",
            data=add_body,
            method="POST",
            headers={"Content-Type": "application/json"},
        )
        added = json.loads(urllib.request.urlopen(add_req, timeout=5).read().decode("utf-8"))
        assert added["ok"] is True

        repo_body = json.dumps(
            {
                "project": "platform",
                "name": "svc-a",
                "path": "platform/svc-a",
                "sync": True,
            }
        ).encode("utf-8")
        repo_req = urllib.request.Request(
            f"{base}/v2/repos",
            data=repo_body,
            method="POST",
            headers={"Content-Type": "application/json"},
        )
        repo_added = json.loads(
            urllib.request.urlopen(repo_req, timeout=5).read().decode("utf-8")
        )
        assert repo_added["ok"] is True

        repos = json.loads(
            urllib.request.urlopen(
                f"{base}/v2/repos?project=platform", timeout=5
            ).read().decode("utf-8")
        )
        assert repos["data"]["repo_count"] == 1

        delete_repo = urllib.request.Request(
            f"{base}/v2/repos/platform/svc-a",
            method="DELETE",
        )
        urllib.request.urlopen(delete_repo, timeout=5).read()

        delete_project = urllib.request.Request(
            f"{base}/v2/projects/platform",
            method="DELETE",
        )
        urllib.request.urlopen(delete_project, timeout=5).read()
    finally:
        server.shutdown()
        thread.join(timeout=10.0)
`````

## File: tests/api/test_layout_api.py
`````python
#!/usr/bin/env python
"""HTTP API tests for workspace layout v2 endpoints."""

import json
import threading
import urllib.request
from pathlib import Path

from metagit.core.api.server import build_server


def test_layout_project_rename_api(tmp_path: Path) -> None:
    sync_root = tmp_path / "sync"
    sync_root.mkdir()
    (sync_root / "alpha").mkdir()
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: alpha",
                "      repos: []",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    server = build_server(root=str(tmp_path), host="127.0.0.1", port=0)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        port = server.server_address[1]
        body = json.dumps({"to_name": "apps"}).encode("utf-8")
        req = urllib.request.Request(
            f"http://127.0.0.1:{port}/v2/projects/alpha/rename",
            data=body,
            method="POST",
            headers={"Content-Type": "application/json"},
        )
        payload = json.loads(urllib.request.urlopen(req, timeout=5).read().decode("utf-8"))
        assert payload["ok"] is True
    finally:
        server.shutdown()
        thread.join(timeout=10.0)
`````

## File: tests/api/test_repo_search_api.py
`````python
#!/usr/bin/env python
"""
HTTP API tests for managed repository search.
"""

import json
import threading
import urllib.error
import urllib.request
from pathlib import Path

from metagit.core.api.server import build_server


def test_repo_search_endpoint_returns_matches(tmp_path: Path) -> None:
    repo_dir = tmp_path / "platform" / "abacus-app"
    repo_dir.mkdir(parents=True)
    (repo_dir / ".git").mkdir()
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: platform",
                "      repos:",
                "        - name: abacus-app",
                "          path: platform/abacus-app",
                "          sync: true",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    server = build_server(root=str(tmp_path), host="127.0.0.1", port=0)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        port = server.server_address[1]
        url = f"http://127.0.0.1:{port}/v1/repos/search?q=abacus"
        payload = json.loads(
            urllib.request.urlopen(url, timeout=5).read().decode("utf-8")
        )
        assert payload["matches"][0]["repo_name"] == "abacus-app"
    finally:
        server.shutdown()
        thread.join(timeout=10.0)


def test_repo_resolve_ambiguous_returns_409(tmp_path: Path) -> None:
    app_repo = tmp_path / "platform" / "abacus-app"
    mod_repo = tmp_path / "shared" / "abacus-module"
    app_repo.mkdir(parents=True)
    mod_repo.mkdir(parents=True)
    (app_repo / ".git").mkdir()
    (mod_repo / ".git").mkdir()
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: platform",
                "      repos:",
                "        - name: abacus-app",
                "          path: platform/abacus-app",
                "          sync: true",
                "          tags:",
                "            code: abacus",
                "    - name: shared",
                "      repos:",
                "        - name: abacus-module",
                "          path: shared/abacus-module",
                "          sync: true",
                "          tags:",
                "            code: abacus",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    server = build_server(root=str(tmp_path), host="127.0.0.1", port=0)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        port = server.server_address[1]
        url = f"http://127.0.0.1:{port}/v1/repos/resolve?q=abacus"
        req = urllib.request.Request(url)
        try:
            urllib.request.urlopen(req, timeout=5)
        except urllib.error.HTTPError as exc:
            assert exc.code == 409
        else:
            raise AssertionError("expected HTTP 409")
    finally:
        server.shutdown()
        thread.join(timeout=10.0)


def test_unknown_path_returns_404(tmp_path: Path) -> None:
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: default",
                "      repos: []",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    server = build_server(root=str(tmp_path), host="127.0.0.1", port=0)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        port = server.server_address[1]
        url = f"http://127.0.0.1:{port}/v1/no-such"
        try:
            urllib.request.urlopen(url, timeout=5)
        except urllib.error.HTTPError as exc:
            assert exc.code == 404
        else:
            raise AssertionError("expected HTTP 404")
    finally:
        server.shutdown()
        thread.join(timeout=10.0)
`````

## File: tests/cli/commands/test_api.py
`````python
#!/usr/bin/env python
"""
CLI tests for metagit api commands.
"""

from pathlib import Path

from click.testing import CliRunner

from metagit.cli.main import cli


def test_api_cli_status_once_reports_bound_port(tmp_path: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        cli,
        ["api", "serve", "--root", str(tmp_path), "--status-once", "--port", "0"],
    )
    assert result.exit_code == 0
    assert "api_state=ready" in result.output
`````

## File: tests/cli/commands/test_config_patch.py
`````python
#!/usr/bin/env python

"""CLI tests for config patch/preview/tree commands."""

import json
from pathlib import Path

import yaml
from click.testing import CliRunner

from metagit.cli.main import cli


def _minimal_metagit(path: Path) -> None:
    path.write_text(
        yaml.safe_dump(
            {
                "name": "cli-patch-test",
                "kind": "application",
                "workspace": {"projects": []},
            }
        ),
        encoding="utf-8",
    )


def test_config_patch_single_op_save() -> None:
    runner = CliRunner()
    with runner.isolated_filesystem():
        _minimal_metagit(Path(".metagit.yml"))
        result = runner.invoke(
            cli,
            [
                "config",
                "-c",
                ".metagit.yml",
                "patch",
                "--op",
                "set",
                "--path",
                "name",
                "--value",
                "renamed",
                "--save",
            ],
        )
        assert result.exit_code == 0, result.output
        on_disk = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8"))
        assert on_disk["name"] == "renamed"


def test_config_patch_operations_file() -> None:
    runner = CliRunner()
    with runner.isolated_filesystem():
        _minimal_metagit(Path(".metagit.yml"))
        ops_path = Path("ops.json")
        ops_path.write_text(
            json.dumps(
                {
                    "operations": [
                        {"op": "append", "path": "workspace.projects"},
                        {
                            "op": "set",
                            "path": "workspace.projects[0].name",
                            "value": "from-file",
                        },
                    ]
                }
            ),
            encoding="utf-8",
        )
        result = runner.invoke(
            cli,
            [
                "config",
                "-c",
                ".metagit.yml",
                "patch",
                "--file",
                str(ops_path),
                "--save",
            ],
        )
        assert result.exit_code == 0, result.output
        on_disk = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8"))
        assert on_disk["workspace"]["projects"][0]["name"] == "from-file"


def test_config_preview_json() -> None:
    runner = CliRunner()
    with runner.isolated_filesystem():
        _minimal_metagit(Path(".metagit.yml"))
        result = runner.invoke(
            cli,
            [
                "config",
                "-c",
                ".metagit.yml",
                "preview",
                "--op",
                "set",
                "--path",
                "name",
                "--value",
                "draft-name",
                "--json",
            ],
        )
        assert result.exit_code == 0, result.output
        payload = json.loads(result.output)
        assert payload["draft"] is True
        assert "draft-name" in payload["yaml"]
`````

## File: tests/cli/commands/test_project_source.py
`````python
#!/usr/bin/env python
"""
CLI tests for project source sync commands.
"""

from click.testing import CliRunner

from metagit.cli.main import cli
from metagit.core.project.models import ProjectPath
from metagit.core.project.source_models import SourceSyncPlan
from metagit.core.project.source_sync import SourceSyncService
from metagit.core.workspace.models import WorkspaceProject


def test_project_source_sync_dry_run(monkeypatch, tmp_path) -> None:
    config_path = tmp_path / ".metagit.yml"
    config_path.write_text(
        """
name: test-project
workspace:
  projects:
    - name: default
      repos: []
""".strip()
    )

    monkeypatch.setattr(
        SourceSyncService,
        "discover",
        lambda self, spec: [],
    )
    monkeypatch.setattr(
        SourceSyncService,
        "plan",
        lambda self, spec, project, discovered, mode: SourceSyncPlan(
            discovered_count=0, unchanged=0
        ),
    )

    runner = CliRunner()
    result = runner.invoke(
        cli,
        [
            "project",
            "--config",
            str(config_path),
            "source",
            "sync",
            "--provider",
            "github",
            "--org",
            "metagit-ai",
            "--mode",
            "discover",
        ],
    )

    assert result.exit_code == 0


def test_project_source_sync_reconcile_requires_yes(monkeypatch, tmp_path) -> None:
    config_path = tmp_path / ".metagit.yml"
    config_path.write_text(
        """
name: test-project
workspace:
  projects:
    - name: default
      repos: []
""".strip()
    )

    monkeypatch.setattr(SourceSyncService, "discover", lambda self, spec: [])
    monkeypatch.setattr(
        SourceSyncService,
        "plan",
        lambda self, spec, project, discovered, mode: SourceSyncPlan(
            discovered_count=1,
            to_remove=[ProjectPath(name="old", url="https://example.com/repo.git")],
        ),
    )
    monkeypatch.setattr(
        SourceSyncService,
        "apply_plan",
        lambda self, project, plan, mode: WorkspaceProject(
            name=project.name, repos=project.repos
        ),
    )

    runner = CliRunner()
    result = runner.invoke(
        cli,
        [
            "project",
            "--config",
            str(config_path),
            "source",
            "sync",
            "--provider",
            "github",
            "--org",
            "metagit-ai",
            "--mode",
            "reconcile",
            "--apply",
        ],
    )

    assert result.exit_code != 0
    assert "Reconcile mode has removals" in result.output
`````

## File: tests/cli/commands/test_search.py
`````python
#!/usr/bin/env python
"""
CLI tests for metagit search / find.
"""

from pathlib import Path

from click.testing import CliRunner

from metagit.cli.main import cli


def test_search_command_returns_json_matches() -> None:
    runner = CliRunner()
    with runner.isolated_filesystem():
        Path(".metagit.yml").write_text(
            "\n".join(
                [
                    "name: workspace",
                    "kind: application",
                    "workspace:",
                    "  projects:",
                    "    - name: platform",
                    "      repos:",
                    "        - name: abacus-app",
                    "          path: platform/abacus-app",
                    "          sync: true",
                    "          tags:",
                    "            code: abacus",
                ]
            )
            + "\n",
            encoding="utf-8",
        )
        repo_dir = Path("platform") / "abacus-app"
        repo_dir.mkdir(parents=True)
        (repo_dir / ".git").mkdir()
        result = runner.invoke(
            cli,
            ["search", "abacus", "--json"],
            catch_exceptions=False,
        )

    assert result.exit_code == 0
    assert '"repo_name": "abacus-app"' in result.output


def test_find_alias_matches_search_command() -> None:
    runner = CliRunner()
    result = runner.invoke(cli, ["find", "--help"])
    assert result.exit_code == 0
    assert (
        "metagit search" in result.output
        or "Search managed repositories" in result.output
    )
`````

## File: tests/cli/commands/test_web.py
`````python
#!/usr/bin/env python
"""CLI tests for metagit web commands."""

from pathlib import Path

from click.testing import CliRunner

from metagit.cli.main import cli


def test_web_serve_status_once(tmp_path: Path) -> None:
    runner = CliRunner()
    (tmp_path / ".metagit.yml").write_text(
        "name: workspace\nkind: application\n",
        encoding="utf-8",
    )
    (tmp_path / "metagit.config.yaml").write_text(
        "\n".join(
            [
                "config:",
                "  workspace:",
                "    path: ./sync",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    result = runner.invoke(
        cli,
        [
            "--config",
            str(tmp_path / "metagit.config.yaml"),
            "web",
            "serve",
            "--root",
            str(tmp_path),
            "--status-once",
            "--port",
            "0",
        ],
    )
    assert result.exit_code == 0
    assert "web_state=ready" in result.output
    assert "url=http://" in result.output
`````

## File: tests/core/config/test_graph_cypher_export.py
`````python
#!/usr/bin/env python

"""Tests for workspace graph Cypher export."""

from pathlib import Path

from metagit.core.config.graph_cypher_export import GraphCypherExportService
from metagit.core.config.models import MetagitConfig


def test_export_manual_relationships_produces_cypher(tmp_path: Path) -> None:
    workspace_root = tmp_path / ".metagit"
    (workspace_root / "alpha" / "api").mkdir(parents=True)
    (workspace_root / "beta" / "lib").mkdir(parents=True)

    config = MetagitConfig(
        name="umbrella",
        graph={
            "relationships": [
                {
                    "from": {"project": "alpha", "repo": "api"},
                    "to": {"project": "beta", "repo": "lib"},
                    "type": "depends_on",
                    "id": "alpha-api-to-beta-lib",
                }
            ]
        },
        workspace={
            "projects": [
                {
                    "name": "alpha",
                    "repos": [{"name": "api", "url": "https://example.com/a.git"}],
                },
                {
                    "name": "beta",
                    "repos": [{"name": "lib", "url": "https://example.com/b.git"}],
                },
            ]
        },
    )

    result = GraphCypherExportService().export(
        config,
        str(workspace_root),
        gitnexus_repo="umbrella",
        include_structure=False,
        manual_only=True,
        with_schema=True,
    )

    assert result.ok is True
    assert result.gitnexus_repo == "umbrella"
    assert len(result.schema_statements) == 2
    assert any("MetagitEntity" in line for line in result.statements)
    assert any("depends_on" in line for line in result.statements)
    assert len(result.tool_calls) == len(result.schema_statements) + len(
        result.statements
    )
    assert result.tool_calls[0].tool == "gitnexus_cypher"
    assert result.tool_calls[0].arguments["repo"] == "umbrella"
    assert len(result.edges) == 1
    assert result.edges[0].id == "alpha-api-to-beta-lib"


def test_export_tool_calls_only_format() -> None:
    config = MetagitConfig(
        name="solo",
        graph={
            "relationships": [
                {
                    "from": {"project": "a"},
                    "to": {"project": "b"},
                    "type": "related",
                }
            ]
        },
        workspace={
            "projects": [
                {"name": "a", "repos": []},
                {"name": "b", "repos": []},
            ]
        },
    )
    result = GraphCypherExportService().export(
        config,
        "/tmp/unused",
        manual_only=True,
        with_schema=False,
    )
    assert len(result.tool_calls) >= 2
    assert all(call.arguments.get("query") for call in result.tool_calls)
`````

## File: tests/core/config/test_patch_service.py
`````python
#!/usr/bin/env python

"""Tests for ConfigPatchService."""

from pathlib import Path

import yaml

from metagit.core.config.patch_service import ConfigPatchService
from metagit.core.web.models import ConfigOpKind, ConfigOperation


def _write_metagit(path: Path, payload: dict) -> None:
    path.write_text(yaml.safe_dump(payload), encoding="utf-8")


def test_patch_metagit_set_name_dry_run(tmp_path: Path) -> None:
    config_path = tmp_path / ".metagit.yml"
    _write_metagit(
        config_path,
        {
            "name": "before",
            "kind": "application",
            "workspace": {"projects": []},
        },
    )
    service = ConfigPatchService()
    result = service.patch(
        "metagit",
        str(config_path),
        [ConfigOperation(op=ConfigOpKind.SET, path="name", value="after")],
        save=False,
    )
    assert not isinstance(result, Exception)
    assert result.ok is True
    assert result.saved is False
    assert result.validation_errors == []
    on_disk = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    assert on_disk["name"] == "before"


def test_patch_metagit_set_name_save(tmp_path: Path) -> None:
    config_path = tmp_path / ".metagit.yml"
    _write_metagit(
        config_path,
        {
            "name": "before",
            "kind": "application",
            "workspace": {"projects": []},
        },
    )
    service = ConfigPatchService()
    result = service.patch(
        "metagit",
        str(config_path),
        [ConfigOperation(op=ConfigOpKind.SET, path="name", value="after")],
        save=True,
    )
    assert not isinstance(result, Exception)
    assert result.ok is True
    assert result.saved is True
    on_disk = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    assert on_disk["name"] == "after"


def test_patch_append_workspace_project(tmp_path: Path) -> None:
    config_path = tmp_path / ".metagit.yml"
    _write_metagit(
        config_path,
        {
            "name": "workspace-test",
            "kind": "application",
            "workspace": {"projects": []},
        },
    )
    service = ConfigPatchService()
    result = service.patch(
        "metagit",
        str(config_path),
        [
            ConfigOperation(op=ConfigOpKind.APPEND, path="workspace.projects"),
            ConfigOperation(
                op=ConfigOpKind.SET,
                path="workspace.projects[0].name",
                value="new-project",
            ),
        ],
        save=True,
    )
    assert not isinstance(result, Exception)
    assert result.ok is True
    assert result.saved is True
    on_disk = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    assert on_disk["workspace"]["projects"][0]["name"] == "new-project"
`````

## File: tests/core/init/test_init_service.py
`````python
#!/usr/bin/env python
"""Tests for metagit init template service."""

from pathlib import Path

import yaml

from metagit.core.init.service import InitService


def test_list_templates_includes_hermes() -> None:
  service = InitService()
  ids = {item.id for item in service.list_templates()}
  assert "application" in ids
  assert "umbrella" in ids
  assert "hermes-orchestrator" in ids


def test_init_hermes_with_answers_file(tmp_path: Path) -> None:
  service = InitService()
  answers = tmp_path / "answers.yml"
  answers.write_text(
    yaml.safe_dump(
      {
        "name": "test-control",
        "description": "Test Hermes workspace",
        "url": "",
        "portfolio_repo_name": "api",
        "portfolio_repo_url": "https://github.com/example/api.git",
        "local_site_name": "site",
        "local_site_path": "/tmp/site",
      }
    ),
    encoding="utf-8",
  )
  target = tmp_path / "coordinator"
  target.mkdir()
  result = service.initialize(
    target,
    template_id="hermes-orchestrator",
    directory_name="coordinator",
    git_remote_url=None,
    answers_file=answers,
    no_prompt=True,
    force=True,
  )
  manifest = yaml.safe_load((target / ".metagit.yml").read_text(encoding="utf-8"))
  assert manifest["name"] == "test-control"
  assert manifest["kind"] == "umbrella"
  assert (target / "AGENTS.md").is_file()
  local = next(p for p in manifest["workspace"]["projects"] if p["name"] == "local")
  assert local["repos"][0]["path"] == "/tmp/site"
  assert result.metagit_yml.is_file()


def test_init_minimal_library_kind(tmp_path: Path) -> None:
  service = InitService()
  target = tmp_path / "lib"
  target.mkdir()
  service.initialize_minimal(
    target,
    kind="library",
    name="my-lib",
    description="A library.",
    url=None,
    force=True,
  )
  manifest = yaml.safe_load((target / ".metagit.yml").read_text(encoding="utf-8"))
  assert manifest["kind"] == "library"
  assert manifest["name"] == "my-lib"
`````

## File: tests/core/mcp/services/test_bootstrap_sampling.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.bootstrap_sampling
"""

from metagit.core.mcp.services.bootstrap_sampling import BootstrapSamplingService


def test_sampling_disabled_returns_plan_only_payload() -> None:
    service = BootstrapSamplingService(sampling_supported=False)

    result = service.generate(context={"repo_root": "/tmp/repo"}, confirm_write=False)

    assert result["mode"] == "plan_only"
    assert "prompt_package" in result
    assert result["write_target"] == ".metagit.generated.yml"


def test_sampling_success_returns_draft_yaml() -> None:
    def sampler(payload: dict[str, str]) -> str:
        _ = payload
        return "\n".join(
            [
                "name: generated",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: default",
                "      repos: []",
            ]
        ) + "\n"

    service = BootstrapSamplingService(sampling_supported=True, sampler=sampler)

    result = service.generate(context={"repo_root": "/tmp/repo"}, confirm_write=True)

    assert result["mode"] == "sampled"
    assert "draft_yaml" in result
    assert result["write_target"] == ".metagit.yml"
`````

## File: tests/core/mcp/services/test_cross_project_dependencies.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.cross_project_dependencies
"""

import json
import os
from pathlib import Path
from unittest.mock import MagicMock

from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.cross_project_dependencies import (
    CrossProjectDependencyService,
)
from metagit.core.project.models import ProjectPath
from metagit.core.workspace.models import Workspace, WorkspaceProject


def _workspace_config(tmp_path: Path) -> tuple[MetagitConfig, str]:
    root = tmp_path / "workspace"
    shared_url = "https://github.com/example/shared-lib.git"
    alpha_repo = root / "alpha" / "api"
    beta_repo = root / "beta" / "worker"
    alpha_repo.mkdir(parents=True)
    beta_repo.mkdir(parents=True)
    (alpha_repo / ".git").mkdir()
    (beta_repo / ".git").mkdir()
    relative_api = os.path.relpath(alpha_repo, beta_repo)
    (beta_repo / "package.json").write_text(
        json.dumps(
            {
                "name": "worker",
                "dependencies": {"api-client": f"file:{relative_api}"},
            }
        ),
        encoding="utf-8",
    )
    return (
        MetagitConfig(
            name="workspace",
            kind="application",
            workspace=Workspace(
                projects=[
                    WorkspaceProject(
                        name="alpha",
                        repos=[
                            ProjectPath(
                                name="api",
                                path="alpha/api",
                                url=shared_url,
                                sync=True,
                                tags={"depends_on": "beta"},
                            )
                        ],
                    ),
                    WorkspaceProject(
                        name="beta",
                        repos=[
                            ProjectPath(
                                name="worker",
                                path="beta/worker",
                                url=shared_url,
                                sync=True,
                            )
                        ],
                    ),
                ]
            ),
        ),
        str(root),
    )


def test_map_dependencies_finds_url_match_and_imports(tmp_path: Path) -> None:
    config, workspace_root = _workspace_config(tmp_path)
    registry = MagicMock()
    registry.summarize_for_paths.return_value = {
        str(tmp_path / "workspace" / "alpha" / "api"): "indexed",
        str(tmp_path / "workspace" / "beta" / "worker"): "missing",
    }
    service = CrossProjectDependencyService(registry=registry)

    result = service.map_dependencies(
        config=config,
        workspace_root=workspace_root,
        source_project="alpha",
        dependency_types=["declared", "shared_config", "imports"],
        depth=2,
    )

    assert result.ok is True
    edge_types = {edge.type for edge in result.edges}
    assert "url_match" in edge_types
    assert "declared" in edge_types
    assert result.impact_summary.affected_projects == ["beta"]


def test_map_dependencies_unknown_project(tmp_path: Path) -> None:
    config, workspace_root = _workspace_config(tmp_path)
    service = CrossProjectDependencyService(registry=MagicMock())

    result = service.map_dependencies(
        config=config,
        workspace_root=workspace_root,
        source_project="missing",
    )

    assert result.ok is False
    assert result.error == "project_not_found"


def test_map_dependencies_respects_depth(tmp_path: Path) -> None:
    config, workspace_root = _workspace_config(tmp_path)
    registry = MagicMock()
    registry.summarize_for_paths.return_value = {}
    service = CrossProjectDependencyService(registry=registry)

    shallow = service.map_dependencies(
        config=config,
        workspace_root=workspace_root,
        source_project="alpha",
        depth=1,
    )
    deep = service.map_dependencies(
        config=config,
        workspace_root=workspace_root,
        source_project="alpha",
        depth=3,
    )

    assert len(deep.nodes) >= len(shallow.nodes)
`````

## File: tests/core/mcp/services/test_import_hint_scanner.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.import_hint_scanner
"""

import json
from pathlib import Path

from metagit.core.mcp.services.import_hint_scanner import ImportHintScanner


def test_scan_package_json_file_dependency(tmp_path: Path) -> None:
    lib = tmp_path / "lib-repo"
    app = tmp_path / "app-repo"
    lib.mkdir()
    app.mkdir()
    (app / "package.json").write_text(
        json.dumps({"dependencies": {"lib": "file:../lib-repo"}}),
        encoding="utf-8",
    )
    path_to_id = {
        str(lib.resolve()): "repo:shared/lib-repo",
        str(app.resolve()): "repo:apps/app-repo",
    }
    scanner = ImportHintScanner()

    hints = scanner.scan_repo(repo_path=str(app), path_to_repo_id=path_to_id)

    assert hints
    assert hints[0]["to_id"] == "repo:shared/lib-repo"
`````

## File: tests/core/mcp/services/test_repo_ops.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.repo_ops
"""

from pathlib import Path

from git import Repo

from metagit.core.mcp.services.repo_ops import RepoOperationsService


def test_pull_requires_explicit_mutation_enable(tmp_path: Path) -> None:
    repo_dir = tmp_path / "repo"
    repo_dir.mkdir()
    Repo.init(repo_dir)
    service = RepoOperationsService()

    result = service.sync(repo_path=str(repo_dir), mode="pull", allow_mutation=False)

    assert result["ok"] is False
    assert "Mutation disabled" in str(result["error"])


def test_inspect_reports_repo_status(tmp_path: Path) -> None:
    repo_dir = tmp_path / "repo"
    repo_dir.mkdir()
    Repo.init(repo_dir)
    service = RepoOperationsService()

    result = service.inspect(repo_path=str(repo_dir))

    assert result["ok"] is True
`````

## File: tests/core/mcp/services/test_session_store.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.session_store
"""

import json
from pathlib import Path

import pytest

from metagit.core.mcp.services.session_store import SessionStore
from metagit.core.workspace.context_models import WorkspaceSessionMeta


def test_get_workspace_meta_returns_defaults_when_missing(tmp_path: Path) -> None:
  store = SessionStore(workspace_root=str(tmp_path))
  meta = store.get_workspace_meta()
  assert meta.active_project is None


def test_set_active_project_persists_workspace_meta(tmp_path: Path) -> None:
  store = SessionStore(workspace_root=str(tmp_path))
  meta = store.set_active_project(project_name="alpha")
  assert meta.active_project == "alpha"
  reloaded = store.get_workspace_meta()
  assert reloaded.active_project == "alpha"
  assert reloaded.last_switch_at is not None


def test_project_session_roundtrip(tmp_path: Path) -> None:
  store = SessionStore(workspace_root=str(tmp_path))
  session = store.update_project_session(
    project_name="alpha",
    recent_repos=["/tmp/repo-a"],
    agent_notes="working on auth",
  )
  reloaded = store.get_project_session(project_name="alpha")
  assert reloaded.recent_repos == session.recent_repos
  assert reloaded.agent_notes == "working on auth"


def test_corrupt_project_session_returns_empty(tmp_path: Path) -> None:
  store = SessionStore(workspace_root=str(tmp_path))
  store.ensure_dirs()
  path = store.sessions_dir / "alpha.json"
  path.write_text("{not-json", encoding="utf-8")
  session = store.get_project_session(project_name="alpha")
  assert session.project_name == "alpha"
  assert session.recent_repos == []


def test_env_override_secret_value_rejected(tmp_path: Path) -> None:
  store = SessionStore(workspace_root=str(tmp_path))
  with pytest.raises(ValueError):
    store.update_project_session(
      project_name="alpha",
      env_overrides={"METAGIT_TOKEN": "Bearer secret"},
    )


def test_save_workspace_meta_writes_json(tmp_path: Path) -> None:
  store = SessionStore(workspace_root=str(tmp_path))
  store.save_workspace_meta(meta=WorkspaceSessionMeta(active_project="beta"))
  raw = json.loads((store.sessions_dir / "_workspace.json").read_text(encoding="utf-8"))
  assert raw["active_project"] == "beta"
`````

## File: tests/core/mcp/services/test_upstream_hints.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.upstream_hints
"""

from metagit.core.mcp.services.upstream_hints import UpstreamHintService


def test_terraform_blocker_ranks_infra_repos_higher() -> None:
    service = UpstreamHintService()
    repo_context = [
        {
            "project_name": "platform",
            "repo_name": "shared-terraform-modules",
            "repo_path": "/tmp/shared-terraform-modules",
            "exists": True,
            "sync": True,
        },
        {
            "project_name": "ui",
            "repo_name": "frontend-app",
            "repo_path": "/tmp/frontend-app",
            "exists": True,
            "sync": False,
        },
    ]

    ranked = service.rank(
        blocker="terraform variable enable_private_endpoint is missing in module",
        repo_context=repo_context,
    )

    assert ranked[0]["repo_name"] == "shared-terraform-modules"
    assert ranked[0]["score"] > ranked[1]["score"]
`````

## File: tests/core/mcp/services/test_workspace_health.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_health
"""

from pathlib import Path
from unittest.mock import MagicMock, patch

from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.workspace_health import WorkspaceHealthService
from metagit.core.project.models import ProjectPath
from metagit.core.workspace.models import Workspace, WorkspaceProject


def _config(tmp_path: Path) -> tuple[MetagitConfig, str]:
    root = tmp_path / "workspace"
    present = root / "alpha" / "api"
    present.mkdir(parents=True)
    (present / ".git").mkdir()
    (root / ".metagit.yml").write_text(
        "name: workspace\nkind: application\nworkspace:\n  projects: []\n",
        encoding="utf-8",
    )
    return (
        MetagitConfig(
            name="workspace",
            kind="application",
            workspace=Workspace(
                projects=[
                    WorkspaceProject(
                        name="alpha",
                        repos=[
                            ProjectPath(
                                name="api",
                                path="alpha/api",
                                url="https://github.com/example/api.git",
                                sync=True,
                            ),
                            ProjectPath(
                                name="missing",
                                path="alpha/missing",
                                sync=True,
                            ),
                        ],
                    )
                ]
            ),
        ),
        str(root),
    )


def test_health_check_reports_missing_repo(tmp_path: Path) -> None:
    config, workspace_root = _config(tmp_path)
    registry = MagicMock()
    registry.summarize_for_paths.return_value = {str(tmp_path / "workspace" / "alpha" / "api"): "missing"}
    service = WorkspaceHealthService(registry=registry)

    result = service.check(
        config=config,
        workspace_root=workspace_root,
        check_gitnexus=True,
    )

    assert result.ok is True
    actions = {item.action for item in result.recommendations}
    assert "clone" in actions
    assert result.summary["repos_missing"] >= 1


@patch("metagit.core.mcp.services.workspace_health.inspect_repo_state")
def test_health_check_stale_branch_metrics_and_recommendations(
    mock_inspect: MagicMock, tmp_path: Path
) -> None:
    config, workspace_root = _config(tmp_path)
    registry = MagicMock()
    registry.summarize_for_paths.return_value = {}

    mock_inspect.return_value = {
        "ok": True,
        "branch": "feature/old",
        "dirty": False,
        "ahead": 0,
        "behind": 0,
        "head_commit_age_days": 400.0,
        "merge_base_age_days": 100.0,
    }

    service = WorkspaceHealthService(registry=registry)
    result = service.check(
        config=config,
        workspace_root=workspace_root,
        check_gitnexus=False,
        check_stale_branches=True,
        branch_head_warning_days=180.0,
        branch_head_critical_days=365.0,
        integration_stale_days=90.0,
    )

    repo = next(r for r in result.repos if r.repo_name == "api")
    assert repo.head_commit_age_days == 400.0
    assert repo.merge_base_age_days == 100.0
    assert result.summary["repos_branch_head_stale_critical"] == 1
    assert result.summary["repos_integration_stale"] == 1
    actions = {item.action for item in result.recommendations}
    assert "review_branch_age" in actions
    assert "reconcile_integration" in actions
`````

## File: tests/core/mcp/services/test_workspace_index.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_index
"""

from pathlib import Path

from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService


def test_workspace_index_resolves_repo_paths(tmp_path: Path) -> None:
    workspace_root = tmp_path / "workspace"
    workspace_root.mkdir()
    repo_path = workspace_root / "repo-a"
    repo_path.mkdir()

    config = MetagitConfig(
        name="test",
        kind="application",
        workspace={
            "projects": [
                {
                    "name": "default",
                    "repos": [
                        {
                            "name": "repo-a",
                            "path": "./repo-a",
                            "kind": "repository",
                            "sync": True,
                        }
                    ],
                }
            ]
        },
    )
    service = WorkspaceIndexService()

    rows = service.build_index(config=config, workspace_root=str(workspace_root))

    assert len(rows) == 1
    assert rows[0]["repo_name"] == "repo-a"
    assert rows[0]["exists"] is True
    assert rows[0]["sync"] is True
`````

## File: tests/core/mcp/services/test_workspace_semantic_search.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_semantic_search
"""

import json
from unittest.mock import MagicMock, patch

from metagit.core.mcp.services.workspace_semantic_search import (
    WorkspaceSemanticSearchService,
)


def test_search_across_repos_empty_query() -> None:
    service = WorkspaceSemanticSearchService(registry=MagicMock())
    result = service.search_across_repos(query="   ", repo_paths=["/a"])
    assert result["ok"] is False
    assert result["error"] == "empty_query"


@patch(
    "metagit.core.mcp.services.workspace_semantic_search.subprocess.run",
    autospec=True,
)
def test_search_runs_gitnexus_and_parses_json(mock_run: MagicMock) -> None:
    registry = MagicMock()
    registry.registry_name_for_path.return_value = "myrepo"

    proc = MagicMock()
    proc.returncode = 0
    proc.stdout = (
        '[gitnexus] ready\n{"processes": [{"name": "p1"}], "symbols": []}\n'
    )
    proc.stderr = ""
    mock_run.return_value = proc

    service = WorkspaceSemanticSearchService(registry=registry)
    out = service.search_across_repos(
        query="auth flow",
        repo_paths=["/checkout/foo"],
        limit_per_repo=3,
        timeout_seconds=60,
    )

    assert out["ok"] is True
    assert len(out["results"]) == 1
    row = out["results"][0]
    assert row["ok"] is True
    assert row["registry_name"] == "myrepo"
    assert row["data"] == {"processes": [{"name": "p1"}], "symbols": []}

    mock_run.assert_called_once()
    call_kw = mock_run.call_args.kwargs
    assert call_kw["cwd"] == "/checkout/foo"
    assert call_kw["timeout"] == 60
    cmd = mock_run.call_args.args[0]
    assert cmd[:4] == ["npx", "--yes", WorkspaceSemanticSearchService._gitnexus_pkg, "query"]
    assert "-r" in cmd and "myrepo" in cmd
    assert "-l" in cmd and "3" in cmd


def test_parse_query_json_finds_embedded_line() -> None:
    service = WorkspaceSemanticSearchService(registry=MagicMock())
    blob = "log line\n" + json.dumps({"processes": [], "note": "x"}) + "\n"
    parsed = service._parse_query_json(blob)
    assert parsed == {"processes": [], "note": "x"}
`````

## File: tests/core/mcp/services/test_workspace_snapshot.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_snapshot
"""

import json
from pathlib import Path

from git import Repo

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.mcp.services.project_context import ProjectContextService
from metagit.core.mcp.services.session_store import SessionStore
from metagit.core.mcp.services.workspace_snapshot import WorkspaceSnapshotService


def _write_workspace(tmp_path: Path) -> str:
  repo_path = tmp_path / "alpha" / "repo-one"
  repo_path.mkdir(parents=True)
  Repo.init(repo_path)
  (tmp_path / ".metagit.yml").write_text(
    "\n".join(
      [
        "name: workspace",
        "kind: application",
        "workspace:",
        "  projects:",
        "    - name: alpha",
        "      repos:",
        "        - name: repo-one",
        "          path: alpha/repo-one",
        "          sync: true",
      ]
    )
    + "\n",
    encoding="utf-8",
  )
  return str(tmp_path)


def _load_config(tmp_path: Path):
  manager = MetagitConfigManager(config_path=tmp_path / ".metagit.yml")
  loaded = manager.load_config()
  assert not isinstance(loaded, Exception)
  return loaded


def test_create_writes_snapshot_file(tmp_path: Path) -> None:
  root = _write_workspace(tmp_path)
  config = _load_config(tmp_path)
  service = WorkspaceSnapshotService()

  payload = service.create(
    config=config,
    workspace_root=root,
    label="before-switch",
  )

  snapshot_path = tmp_path / ".metagit" / "snapshots" / f"{payload['snapshot_id']}.json"
  assert snapshot_path.is_file()
  raw = json.loads(snapshot_path.read_text(encoding="utf-8"))
  assert raw["label"] == "before-switch"
  assert len(raw["repos"]) == 1


def test_restore_missing_snapshot_returns_error(tmp_path: Path) -> None:
  root = _write_workspace(tmp_path)
  config = _load_config(tmp_path)
  service = WorkspaceSnapshotService()

  result = service.restore(
    config=config,
    workspace_root=root,
    snapshot_id="missing-id",
  )

  assert result.ok is False
  assert result.error == "snapshot_not_found"


def test_restore_switches_active_project(tmp_path: Path) -> None:
  root = _write_workspace(tmp_path)
  config = _load_config(tmp_path)
  context = ProjectContextService()
  context.switch(config=config, workspace_root=root, project_name="alpha")
  snapshot_service = WorkspaceSnapshotService()
  created = snapshot_service.create(config=config, workspace_root=root)

  store = SessionStore(workspace_root=root)
  store.set_active_project(project_name="")
  cleared = store.get_workspace_meta()
  cleared.active_project = None
  store.save_workspace_meta(meta=cleared)

  restored = snapshot_service.restore(
    config=config,
    workspace_root=root,
    snapshot_id=created["snapshot_id"],
    switch_project=True,
  )

  assert restored.ok is True
  assert restored.context is not None
  assert restored.context.project_name == "alpha"
  meta = store.get_workspace_meta()
  assert meta.active_project == "alpha"
`````

## File: tests/core/mcp/services/test_workspace_sync.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_sync
"""

from pathlib import Path
from unittest.mock import MagicMock

from git import Repo

from metagit.core.mcp.services.workspace_sync import WorkspaceSyncService


def test_sync_many_dry_run_skips_git_calls(tmp_path: Path) -> None:
    repo_path = tmp_path / "alpha" / "repo-one"
    repo_path.mkdir(parents=True)
    Repo.init(repo_path)
    rows = [
        {
            "project_name": "alpha",
            "repo_name": "repo-one",
            "repo_path": str(repo_path),
            "exists": True,
            "is_git_repo": True,
            "url": None,
        }
    ]
    repo_ops = MagicMock()
    service = WorkspaceSyncService(repo_ops=repo_ops)

    payload = service.sync_many(rows, dry_run=True)

    assert payload["summary"]["dry_run"] is True
    assert payload["results"][0]["dry_run"] is True
    repo_ops.sync.assert_not_called()


def test_sync_many_only_if_missing_skips_existing(tmp_path: Path) -> None:
    repo_path = tmp_path / "alpha" / "repo-one"
    repo_path.mkdir(parents=True)
    Repo.init(repo_path)
    rows = [
        {
            "project_name": "alpha",
            "repo_name": "repo-one",
            "repo_path": str(repo_path),
            "exists": True,
            "is_git_repo": True,
            "url": None,
        }
    ]
    repo_ops = MagicMock()
    service = WorkspaceSyncService(repo_ops=repo_ops)

    payload = service.sync_many(rows, only_if="missing", dry_run=False)

    assert payload["results"][0]["skipped"] is True
    assert payload["results"][0]["skipped_reason"] == "already_present"
    repo_ops.sync.assert_not_called()
`````

## File: tests/core/mcp/services/test_workspace_template.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_template
"""

from pathlib import Path

from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.workspace_template import WorkspaceTemplateService
from metagit.core.project.models import ProjectPath
from metagit.core.workspace.models import Workspace, WorkspaceProject


def test_template_apply_dry_run_lists_files(tmp_path: Path) -> None:
    root = tmp_path / "workspace"
    repo = root / "alpha" / "api"
    repo.mkdir(parents=True)
    config = MetagitConfig(
        name="workspace",
        kind="application",
        workspace=Workspace(
            projects=[
                WorkspaceProject(
                    name="alpha",
                    repos=[
                        ProjectPath(
                            name="api",
                            path="alpha/api",
                            sync=True,
                        )
                    ],
                )
            ]
        ),
    )
    service = WorkspaceTemplateService()

    result = service.apply(
        config=config,
        workspace_root=str(root),
        template="agent-standard",
        target_projects=["alpha"],
        dry_run=True,
    )

    assert result["ok"] is True
    assert result["dry_run"] is True
    assert result["results"][0]["files"]


def test_template_apply_requires_confirm_when_not_dry_run(tmp_path: Path) -> None:
    root = tmp_path / "workspace"
    (root / "alpha" / "api").mkdir(parents=True)
    config = MetagitConfig(
        name="workspace",
        kind="application",
        workspace=Workspace(
            projects=[
                WorkspaceProject(
                    name="alpha",
                    repos=[ProjectPath(name="api", path="alpha/api", sync=True)],
                )
            ]
        ),
    )
    service = WorkspaceTemplateService()

    result = service.apply(
        config=config,
        workspace_root=str(root),
        template="agent-standard",
        target_projects=["alpha"],
        dry_run=False,
        confirm_apply=False,
    )

    assert result["ok"] is False
    assert result["error"] == "confirm_apply_required"
`````

## File: tests/core/mcp/test_gate.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.gate
"""

from pathlib import Path

from metagit.core.mcp.gate import WorkspaceGate
from metagit.core.mcp.models import McpActivationState


def test_missing_root_is_inactive_missing() -> None:
    gate = WorkspaceGate()

    result = gate.evaluate(root_path=None)

    assert result.state == McpActivationState.INACTIVE_MISSING_CONFIG


def test_missing_config_file_is_inactive_missing(tmp_path: Path) -> None:
    gate = WorkspaceGate()

    result = gate.evaluate(root_path=str(tmp_path))

    assert result.state == McpActivationState.INACTIVE_MISSING_CONFIG


def test_invalid_config_file_is_inactive_invalid(tmp_path: Path) -> None:
    config_path = tmp_path / ".metagit.yml"
    config_path.write_text("name:\n  - invalid\n", encoding="utf-8")
    gate = WorkspaceGate()

    result = gate.evaluate(root_path=str(tmp_path))

    assert result.state == McpActivationState.INACTIVE_INVALID_CONFIG


def test_valid_config_file_is_active(tmp_path: Path) -> None:
    config_path = tmp_path / ".metagit.yml"
    config_path.write_text(
        "\n".join(
            [
                "name: metagit-test",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: default",
                "      repos: []",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    gate = WorkspaceGate()

    result = gate.evaluate(root_path=str(tmp_path))

    assert result.state == McpActivationState.ACTIVE
`````

## File: tests/core/mcp/test_models.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.models
"""

from metagit.core.mcp.models import McpActivationState, WorkspaceStatus


def test_activation_state_values() -> None:
    assert McpActivationState.ACTIVE.value == "active"
    assert (
        McpActivationState.INACTIVE_MISSING_CONFIG.value
        == "inactive_missing_config"
    )
    assert (
        McpActivationState.INACTIVE_INVALID_CONFIG.value
        == "inactive_invalid_config"
    )


def test_workspace_status_model() -> None:
    status = WorkspaceStatus(
        state=McpActivationState.ACTIVE,
        root_path="/tmp/workspace",
        reason=None,
    )
    assert status.state == McpActivationState.ACTIVE
    assert status.root_path == "/tmp/workspace"
    assert status.reason is None
`````

## File: tests/core/mcp/test_resources.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.resources
"""

from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.resources import ResourcePublisher
from metagit.core.mcp.services.ops_log import OperationsLogService


def test_workspace_resources_available_when_active() -> None:
    ops = OperationsLogService()
    ops.append(action="sync", detail="repo-a fetch")
    publisher = ResourcePublisher(ops_log=ops)
    config = MetagitConfig(
        name="metagit-test",
        kind="application",
        workspace={"projects": [{"name": "default", "repos": []}]},
    )

    config_resource = publisher.get_resource(
        uri="metagit://workspace/config",
        config=config,
        repos_status=[],
    )
    repos_resource = publisher.get_resource(
        uri="metagit://workspace/repos/status",
        config=config,
        repos_status=[{"repo_name": "repo-a"}],
    )
    ops_resource = publisher.get_resource(uri="metagit://workspace/ops-log")

    assert config_resource["uri"] == "metagit://workspace/config"
    assert repos_resource["uri"] == "metagit://workspace/repos/status"
    assert len(ops_resource["data"]) == 1
`````

## File: tests/core/mcp/test_root_resolver.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.root_resolver
"""

from pathlib import Path

from metagit.core.mcp.root_resolver import WorkspaceRootResolver


def test_env_root_has_highest_precedence(monkeypatch, tmp_path: Path) -> None:
    env_root = tmp_path / "env-root"
    env_root.mkdir()
    monkeypatch.setenv("METAGIT_WORKSPACE_ROOT", str(env_root))
    resolver = WorkspaceRootResolver()

    result = resolver.resolve(cwd=str(tmp_path), cli_root=str(tmp_path / "cli-root"))

    assert result == str(env_root.resolve())


def test_cli_root_used_when_env_unset(monkeypatch, tmp_path: Path) -> None:
    cli_root = tmp_path / "cli-root"
    cli_root.mkdir()
    monkeypatch.delenv("METAGIT_WORKSPACE_ROOT", raising=False)
    resolver = WorkspaceRootResolver()

    result = resolver.resolve(cwd=str(tmp_path), cli_root=str(cli_root))

    assert result == str(cli_root.resolve())


def test_walk_up_finds_workspace_root(monkeypatch, tmp_path: Path) -> None:
    monkeypatch.delenv("METAGIT_WORKSPACE_ROOT", raising=False)
    root = tmp_path / "workspace-root"
    nested = root / "services" / "api"
    nested.mkdir(parents=True)
    (root / ".metagit.yml").write_text("name: test\nkind: application\n", encoding="utf-8")
    resolver = WorkspaceRootResolver()

    result = resolver.resolve(cwd=str(nested), cli_root=None)

    assert result == str(root.resolve())
`````

## File: tests/core/prompt/test_prompt_service.py
`````python
#!/usr/bin/env python
"""Tests for metagit prompt emission service."""

from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectPath
from metagit.core.prompt.catalog import is_kind_allowed, list_catalog
from metagit.core.prompt.service import PromptService, PromptServiceError
from metagit.core.workspace.models import Workspace, WorkspaceProject


def _sample_config() -> MetagitConfig:
    return MetagitConfig(
        name="ws",
        agent_instructions="File rules.",
        workspace=Workspace(
            agent_instructions="Workspace rules.",
            projects=[
                WorkspaceProject(
                    name="alpha",
                    agent_instructions="Project rules.",
                    repos=[
                        ProjectPath(
                            name="api",
                            path="alpha/api",
                            agent_instructions="Repo rules.",
                        )
                    ],
                )
            ],
        ),
    )


def test_catalog_lists_instructions_kind() -> None:
    kinds = [entry.kind for entry in list_catalog()]
    assert "instructions" in kinds
    assert "session-start" in kinds


def test_emit_instructions_workspace() -> None:
    result = PromptService().emit(
        _sample_config(),
        kind="instructions",
        scope="workspace",
        definition_path="/tmp/.metagit.yml",
        workspace_root="/tmp/sync",
    )
    assert result.ok
    assert "[FILE]" in result.text
    assert "[WORKSPACE]" in result.text
    assert "[PROJECT]" not in result.text


def test_emit_instructions_repo() -> None:
    result = PromptService().emit(
        _sample_config(),
        kind="instructions",
        scope="repo",
        definition_path="/tmp/.metagit.yml",
        workspace_root="/tmp/sync",
        project_name="alpha",
        repo_name="api",
    )
    assert len(result.instruction_layers) == 4
    assert "Repo rules." in result.text


def test_emit_session_start_includes_manifest_when_requested() -> None:
    result = PromptService().emit(
        _sample_config(),
        kind="session-start",
        scope="workspace",
        definition_path="/tmp/.metagit.yml",
        workspace_root="/tmp/sync",
        include_instructions=True,
    )
    assert "metagit workspace list" in result.text
    assert "[FILE]" in result.text


def test_emit_repo_enrich_includes_detect_commands() -> None:
    result = PromptService().emit(
        _sample_config(),
        kind="repo-enrich",
        scope="repo",
        definition_path="/tmp/.metagit.yml",
        workspace_root="/tmp/sync",
        project_name="alpha",
        repo_name="api",
        include_instructions=False,
    )
    assert "metagit detect repository" in result.text
    assert "merge" in result.text.lower()
    assert result.project_name == "alpha"
    assert result.repo_name == "api"


def test_kind_not_allowed_for_scope() -> None:
    try:
        PromptService().emit(
            _sample_config(),
            kind="session-start",
            scope="repo",
            definition_path="/tmp/.metagit.yml",
            workspace_root="/tmp/sync",
            project_name="alpha",
            repo_name="api",
        )
        raise AssertionError("expected PromptServiceError")
    except PromptServiceError:
        pass
    assert not is_kind_allowed("session-start", "repo")
`````

## File: tests/core/web/__init__.py
`````python

`````

## File: tests/core/web/test_config_preview.py
`````python
#!/usr/bin/env python
"""Tests for config YAML preview rendering."""

from metagit.core.appconfig.models import AppConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.web.config_preview import (
  redact_secrets,
  render_appconfig_yaml,
  render_metagit_yaml,
)


def test_render_metagit_yaml_normalized() -> None:
  config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
  rendered = render_metagit_yaml(config, style="normalized")
  assert "name: demo" in rendered
  assert "kind: application" in rendered


def test_render_appconfig_yaml_masks_secrets() -> None:
  config = AppConfig.model_validate(
    {
      "workspace": {"path": "./sync"},
      "providers": {
        "github": {"enabled": True, "api_token": "ghp_abcdefghijklmnop"},
      },
    }
  )
  rendered = render_appconfig_yaml(
    config,
    config_path="/tmp/metagit.config.yaml",
    style="normalized",
    mask_secrets=True,
  )
  assert "ghp_abcdefghijklmnop" not in rendered
  assert "***mnop" in rendered


def test_redact_secrets_nested() -> None:
  payload = {"providers": {"gitlab": {"api_token": "glpat-secret"}}}
  redacted = redact_secrets(payload)
  assert redacted["providers"]["gitlab"]["api_token"] == "***cret"
`````

## File: tests/core/web/test_graph_service.py
`````python
#!/usr/bin/env python
"""Tests for workspace graph web view builder."""

from pathlib import Path

from metagit.core.config.models import MetagitConfig
from metagit.core.web.graph_service import WorkspaceGraphService


def test_build_view_includes_manual_and_structure(tmp_path: Path) -> None:
    workspace_root = tmp_path / ".metagit"
    (workspace_root / "alpha" / "api").mkdir(parents=True)

    config = MetagitConfig(
        name="umbrella",
        graph={
            "relationships": [
                {
                    "from": {"project": "alpha", "repo": "api"},
                    "to": {"project": "beta", "repo": "lib"},
                    "type": "depends_on",
                    "label": "uses lib",
                }
            ]
        },
        workspace={
            "projects": [
                {
                    "name": "alpha",
                    "repos": [{"name": "api", "url": "https://example.com/a.git"}],
                },
                {
                    "name": "beta",
                    "repos": [{"name": "lib", "url": "https://example.com/b.git"}],
                },
            ]
        },
    )
    view = WorkspaceGraphService().build_view(
        config,
        str(workspace_root),
        include_inferred=False,
    )
    assert view.ok
    assert len(view.nodes) >= 4
    manual = [edge for edge in view.edges if edge.source == "manual"]
    assert len(manual) == 1
    assert manual[0].label == "uses lib"
    structure = [edge for edge in view.edges if edge.source == "structure"]
    assert len(structure) >= 1
`````

## File: tests/core/web/test_job_store.py
`````python
#!/usr/bin/env python
"""Unit tests for SyncJobStore."""

from metagit.core.web.job_store import SyncJobStore


def test_job_lifecycle_and_events() -> None:
    store = SyncJobStore()
    job_id = store.create_job()
    store.mark_running(job_id)
    store.append_event(job_id, {"type": "progress", "done": 1, "total": 2})
    store.complete(job_id, summary={"ok": 1}, results=[{"repo": "a"}])
    status = store.get(job_id)
    assert status is not None
    assert status.state == "completed"
    events = store.drain_events(job_id)
    assert len(events) == 1
`````

## File: tests/core/web/test_ops_handler.py
`````python
#!/usr/bin/env python
"""HTTP tests for web ops routes (v3 API)."""

import json
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path

from metagit.core.web.server import build_web_server


def _start_server(
  tmp_path: Path,
  *,
  manifest_extra: str = "",
) -> tuple[threading.Thread, str]:
  manifest_lines = [
    "name: workspace",
    "kind: application",
    "workspace:",
    "  projects:",
    "    - name: platform",
    "      repos: []",
  ]
  if manifest_extra:
    manifest_lines.extend(manifest_extra.strip().splitlines())
  (tmp_path / ".metagit.yml").write_text(
    "\n".join(manifest_lines) + "\n",
    encoding="utf-8",
  )
  (tmp_path / "metagit.config.yaml").write_text(
    "\n".join(
      [
        "config:",
        "  workspace:",
        "    path: ./sync",
      ]
    )
    + "\n",
    encoding="utf-8",
  )
  server = build_web_server(
    root=str(tmp_path),
    appconfig_path=str(tmp_path / "metagit.config.yaml"),
    host="127.0.0.1",
    port=0,
  )
  thread = threading.Thread(target=server.serve_forever, daemon=True)
  thread.start()
  port = server.server_address[1]
  base = f"http://127.0.0.1:{port}"
  return thread, base


def _post_json(url: str, payload: dict) -> tuple[int, dict]:
  body = json.dumps(payload).encode("utf-8")
  req = urllib.request.Request(
    url,
    data=body,
    method="POST",
    headers={"Content-Type": "application/json"},
  )
  try:
    with urllib.request.urlopen(req, timeout=10) as resp:
      return resp.status, json.loads(resp.read().decode("utf-8"))
  except urllib.error.HTTPError as exc:
    raw = exc.read().decode("utf-8")
    return exc.code, json.loads(raw) if raw else {}


def test_health_endpoint_returns_ok(tmp_path: Path) -> None:
  thread, base = _start_server(tmp_path)
  try:
    status, payload = _post_json(f"{base}/v3/ops/health", {})
    assert status == 200
    assert "ok" in payload
  finally:
    thread.join(timeout=0.1)


def test_prune_preview_empty(tmp_path: Path) -> None:
  thread, base = _start_server(tmp_path)
  try:
    status, payload = _post_json(
      f"{base}/v3/ops/prune/preview",
      {"project": "platform"},
    )
    assert status == 200
    assert payload["ok"] is True
    assert payload["candidates"] == []
  finally:
    thread.join(timeout=0.1)


def test_sync_dry_run_job_completes(tmp_path: Path) -> None:
  thread, base = _start_server(tmp_path)
  try:
    status, created = _post_json(
      f"{base}/v3/ops/sync",
      {"dry_run": True, "repos": ["all"]},
    )
    assert status == 202
    job_id = created["job_id"]
    assert job_id

    deadline = time.time() + 5.0
    final_state = ""
    while time.time() < deadline:
      with urllib.request.urlopen(
        f"{base}/v3/ops/sync/{job_id}",
        timeout=5,
      ) as resp:
        job_status = json.loads(resp.read().decode("utf-8"))
      final_state = job_status["state"]
      if final_state in ("completed", "failed"):
        break
      time.sleep(0.05)

    assert final_state == "completed"
  finally:
    thread.join(timeout=0.1)
`````

## File: tests/core/workspace/test_agent_instructions.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.workspace.agent_instructions
"""

from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectPath
from metagit.core.workspace.agent_instructions import AgentInstructionsResolver
from metagit.core.workspace.models import Workspace, WorkspaceProject


def test_file_level_instructions_without_workspace_block() -> None:
    config = MetagitConfig(
        name="standalone",
        agent_instructions="Controller rules at file scope.",
        workspace=None,
    )
    result = AgentInstructionsResolver().resolve(config)
    assert len(result.layers) == 1
    assert result.layers[0].layer == "file"
    assert "Controller rules" in result.effective


def test_legacy_agent_prompt_alias_on_load() -> None:
    config = MetagitConfig.model_validate(
        {
            "name": "legacy",
            "agent_prompt": "From deprecated key.",
            "workspace": {
                "projects": [
                    {
                        "name": "p1",
                        "repos": [],
                        "agent_prompt": "Project legacy.",
                    }
                ],
                "agent_prompt": "Workspace legacy.",
            },
        }
    )
    assert config.agent_instructions == "From deprecated key."
    assert config.workspace is not None
    assert config.workspace.agent_instructions == "Workspace legacy."
    assert config.workspace.projects[0].agent_instructions == "Project legacy."


def test_compose_all_layers_including_repo() -> None:
    config = MetagitConfig(
        name="ws",
        agent_instructions="File layer.",
        workspace=Workspace(
            agent_instructions="Workspace layer.",
            projects=[
                WorkspaceProject(
                    name="alpha",
                    agent_instructions="Project layer.",
                    repos=[
                        ProjectPath(
                            name="api",
                            path="alpha/api",
                            agent_instructions="Repo layer.",
                        )
                    ],
                )
            ],
        ),
    )
    project = config.workspace.projects[0]
    repo = project.repos[0]
    result = AgentInstructionsResolver().resolve(config, project=project, repo=repo)
    assert [layer.layer for layer in result.layers] == [
        "file",
        "workspace",
        "project",
        "repo",
    ]
    assert "[FILE]" in result.effective
    assert "[REPO]" in result.effective
    assert "Repo layer." in result.effective
`````

## File: tests/core/workspace/test_context_models.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.workspace.context_models
"""

import pytest

from metagit.core.workspace.context_models import (
  ProjectSession,
  validate_env_key,
  validate_env_value,
)


def test_validate_env_key_accepts_metagit_style_keys() -> None:
  assert validate_env_key("METAGIT_PROJECT") == "METAGIT_PROJECT"


def test_validate_env_key_rejects_lowercase() -> None:
  with pytest.raises(ValueError):
    validate_env_key("metagit_project")


def test_validate_env_value_rejects_bearer_token() -> None:
  with pytest.raises(ValueError):
    validate_env_value("Bearer abc123")


def test_project_session_rejects_long_agent_notes() -> None:
  with pytest.raises(ValueError):
    ProjectSession(project_name="alpha", agent_notes="x" * 5000)


def test_project_session_caps_recent_repos() -> None:
  session = ProjectSession(
    project_name="alpha",
    recent_repos=[f"/repo-{idx}" for idx in range(20)],
  )
  assert len(session.recent_repos) == 10
`````

## File: tests/core/workspace/test_dedupe_resolver.py
`````python
#!/usr/bin/env python
"""Tests for per-project dedupe resolution."""

from metagit.core.appconfig.models import WorkspaceDedupeConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectPath
from metagit.core.workspace.dedupe_resolver import (
    resolve_dedupe_for_layout,
    resolve_effective_dedupe,
    resolve_effective_dedupe_for_project,
)
from metagit.core.workspace.models import (
    ProjectDedupeOverride,
    Workspace,
    WorkspaceProject,
)


def _config_with_projects() -> MetagitConfig:
    return MetagitConfig(
        name="ws",
        workspace=Workspace(
            projects=[
                WorkspaceProject(
                    name="deduped",
                    repos=[ProjectPath(name="a", url="https://github.com/x/a.git")],
                ),
                WorkspaceProject(
                    name="plain",
                    dedupe=ProjectDedupeOverride(enabled=False),
                    repos=[ProjectPath(name="b", url="https://github.com/x/b.git")],
                ),
                WorkspaceProject(
                    name="force-dedupe",
                    dedupe=ProjectDedupeOverride(enabled=True),
                    repos=[ProjectPath(name="c", url="https://github.com/x/c.git")],
                ),
            ]
        ),
    )


def test_resolve_effective_dedupe_inherits_workspace_default() -> None:
    workspace_dedupe = WorkspaceDedupeConfig(enabled=True)
    assert resolve_effective_dedupe(workspace_dedupe, None) == workspace_dedupe


def test_resolve_effective_dedupe_project_disable_override() -> None:
    workspace_dedupe = WorkspaceDedupeConfig(enabled=True)
    project = WorkspaceProject(
        name="plain",
        dedupe=ProjectDedupeOverride(enabled=False),
        repos=[],
    )
    assert resolve_effective_dedupe(workspace_dedupe, project) is None


def test_resolve_effective_dedupe_project_enable_override() -> None:
    workspace_dedupe = WorkspaceDedupeConfig(enabled=False)
    project = WorkspaceProject(
        name="force",
        dedupe=ProjectDedupeOverride(enabled=True),
        repos=[],
    )
    assert resolve_effective_dedupe(workspace_dedupe, project) == workspace_dedupe


def test_resolve_effective_dedupe_for_project_by_name() -> None:
    config = _config_with_projects()
    workspace_dedupe = WorkspaceDedupeConfig(enabled=True)
    assert (
        resolve_effective_dedupe_for_project(workspace_dedupe, config, "plain") is None
    )
    assert (
        resolve_effective_dedupe_for_project(workspace_dedupe, config, "deduped")
        == workspace_dedupe
    )


def test_resolve_dedupe_for_layout_without_project() -> None:
    config = _config_with_projects()
    workspace_dedupe = WorkspaceDedupeConfig(enabled=False)
    assert resolve_dedupe_for_layout(workspace_dedupe, config, None) is None


def test_workspace_project_rejects_unknown_dedupe_keys() -> None:
    try:
        WorkspaceProject.model_validate(
            {
                "name": "bad",
                "repos": [],
                "dedupe": {"enabled": True, "canonical_dir": "_other"},
            }
        )
        raise AssertionError("expected validation error")
    except Exception:
        pass
`````

## File: tests/core/workspace/test_hydrate.py
`````python
#!/usr/bin/env python
"""Tests for symlink mount hydration."""

import os
from pathlib import Path

from metagit.core.appconfig.models import WorkspaceDedupeConfig
from metagit.core.project.manager import ProjectManager
from metagit.core.project.models import ProjectKind, ProjectPath
from metagit.core.workspace.hydrate import collect_file_copy_jobs, materialize_symlink_mount
from metagit.core.workspace.models import WorkspaceProject


class _DummyLogger:
  def set_level(self, _: str) -> None:
    return

  def warning(self, _: str) -> None:
    return

  def debug(self, _: str) -> None:
    return


def test_collect_file_copy_jobs_counts_nested_files(tmp_path: Path) -> None:
  root = tmp_path / "src"
  (root / "a").mkdir(parents=True)
  (root / "a" / "one.txt").write_text("1", encoding="utf-8")
  (root / "b" / "two.txt").parent.mkdir(parents=True, exist_ok=True)
  (root / "b" / "two.txt").write_text("2", encoding="utf-8")
  jobs = collect_file_copy_jobs(root)
  assert len(jobs) == 2


def test_materialize_symlink_mount_replaces_link_with_directory(tmp_path: Path) -> None:
  source = tmp_path / "canonical"
  source.mkdir()
  (source / "README.md").write_text("hi", encoding="utf-8")
  mount = tmp_path / "project" / "repo"
  mount.parent.mkdir(parents=True)
  os.symlink(source, mount, target_is_directory=True)

  changed, error = materialize_symlink_mount(mount, repo_label="repo")
  assert error is None
  assert changed is True
  assert mount.is_dir()
  assert not mount.is_symlink()
  assert (mount / "README.md").read_text(encoding="utf-8") == "hi"
  assert source.exists()


def test_project_sync_hydrate_after_deduped_symlink(tmp_path: Path) -> None:
  source = tmp_path / "user-site"
  source.mkdir()
  (source / "index.html").write_text("hello", encoding="utf-8")

  workspace_root = tmp_path / ".metagit"
  dedupe = WorkspaceDedupeConfig(enabled=True, canonical_dir="_canonical")
  manager = ProjectManager(workspace_root, _DummyLogger(), dedupe=dedupe)
  repo = ProjectPath(name="site", path=str(source), sync=True, kind=ProjectKind.WEBSITE)
  project = WorkspaceProject(name="local", repos=[repo])

  assert manager.sync(project) is True
  mount = workspace_root / "local" / "site"
  assert mount.is_symlink()

  assert manager.hydrate_project(project) is True
  assert mount.is_dir()
  assert not mount.is_symlink()
  assert (mount / "index.html").read_text(encoding="utf-8") == "hello"
`````

## File: tests/core/workspace/test_layout_service.py
`````python
#!/usr/bin/env python
"""Tests for workspace layout rename and move service."""

from pathlib import Path

import yaml

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.workspace.layout_service import WorkspaceLayoutService


def _setup_workspace(tmp_path: Path) -> tuple[Path, str, str]:
    sync_root = tmp_path / "sync"
    sync_root.mkdir()
    (sync_root / "alpha").mkdir()
    (sync_root / "alpha" / "svc-a").mkdir()
    (sync_root / "alpha" / "svc-a" / "README.md").write_text("demo", encoding="utf-8")
    manifest = {
        "name": "test",
        "kind": "application",
        "workspace": {
            "projects": [
                {
                    "name": "alpha",
                    "repos": [{"name": "svc-a", "path": "alpha/svc-a", "sync": True}],
                },
                {"name": "beta", "repos": []},
            ]
        },
    }
    config_path = tmp_path / ".metagit.yml"
    config_path.write_text(yaml.dump(manifest), encoding="utf-8")
    manager = MetagitConfigManager(str(config_path))
    loaded = manager.load_config()
    assert not isinstance(loaded, Exception)
    return loaded, str(config_path), str(sync_root)


def test_rename_project_moves_sync_folder(tmp_path: Path) -> None:
    config, config_path, sync_root = _setup_workspace(tmp_path)
    service = WorkspaceLayoutService()
    result = service.rename_project(
        config,
        config_path,
        sync_root,
        from_name="alpha",
        to_name="apps",
    )
    assert result.ok
    assert (Path(sync_root) / "apps").is_dir()
    assert not (Path(sync_root) / "alpha").exists()
    manager = MetagitConfigManager(config_path)
    reloaded = manager.load_config()
    assert not isinstance(reloaded, Exception)
    assert any(project.name == "apps" for project in reloaded.workspace.projects)


def test_rename_repo_moves_mount(tmp_path: Path) -> None:
    config, config_path, sync_root = _setup_workspace(tmp_path)
    service = WorkspaceLayoutService()
    result = service.rename_repo(
        config,
        config_path,
        sync_root,
        project_name="alpha",
        from_name="svc-a",
        to_name="svc-b",
    )
    assert result.ok
    assert (Path(sync_root) / "alpha" / "svc-b").exists()
    assert not (Path(sync_root) / "alpha" / "svc-a").exists()


def test_move_repo_between_projects(tmp_path: Path) -> None:
    config, config_path, sync_root = _setup_workspace(tmp_path)
    service = WorkspaceLayoutService()
    result = service.move_repo(
        config,
        config_path,
        sync_root,
        repo_name="svc-a",
        from_project="alpha",
        to_project="beta",
    )
    assert result.ok
    assert (Path(sync_root) / "beta" / "svc-a").exists()
    assert not (Path(sync_root) / "alpha" / "svc-a").exists()
    manager = MetagitConfigManager(config_path)
    reloaded = manager.load_config()
    assert not isinstance(reloaded, Exception)
    beta = next(p for p in reloaded.workspace.projects if p.name == "beta")
    assert any(repo.name == "svc-a" for repo in beta.repos)


def test_dry_run_does_not_mutate(tmp_path: Path) -> None:
    config, config_path, sync_root = _setup_workspace(tmp_path)
    service = WorkspaceLayoutService()
    result = service.rename_project(
        config,
        config_path,
        sync_root,
        from_name="alpha",
        to_name="apps",
        dry_run=True,
    )
    assert result.ok
    assert (Path(sync_root) / "alpha").exists()
    assert (result.data or {}).get("manifest_updated") is False
`````

## File: tests/scripts/test_prepush_gate_security.py
`````python
#!/usr/bin/env python

"""Tests for context-aware security steps in prepush-gate."""

import importlib.util
from pathlib import Path


def _prepush_gate_module():
    path = Path(__file__).resolve().parents[2] / "scripts" / "prepush-gate.py"
    spec = importlib.util.spec_from_file_location("prepush_gate", path)
    if spec is None or spec.loader is None:
        raise RuntimeError("failed to load prepush-gate.py")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def test_security_scan_plan_full_when_unknown() -> None:
    gate = _prepush_gate_module()
    assert gate.security_scan_plan(None) == (True, True, True)
    assert gate.security_scan_plan(set()) == (True, True, True)


def test_security_scan_plan_deps_triggers_sync() -> None:
    gate = _prepush_gate_module()
    assert gate.security_scan_plan({"pyproject.toml"}) == (True, True, True)
    assert gate.security_scan_plan({"uv.lock"}) == (True, True, True)


def test_security_scan_plan_src_without_sync() -> None:
    gate = _prepush_gate_module()
    assert gate.security_scan_plan({"src/metagit/cli/main.py"}) == (
        False,
        True,
        True,
    )


def test_security_scan_plan_skips_docs_only() -> None:
    gate = _prepush_gate_module()
    assert gate.security_scan_plan({"docs/install.md", "README.md"}) == (
        False,
        False,
        False,
    )
    assert gate.security_scan_plan({"web/src/App.tsx"}) == (False, False, False)
`````

## File: tests/test_config_example_generator.py
`````python
#!/usr/bin/env python
"""Tests for MetagitConfig exemplar generation."""

from metagit.core.config.example_generator import (
  ConfigExampleGenerator,
  load_example_overrides,
)
from metagit.core.config.models import MetagitConfig


def test_render_yaml_includes_header_and_workspace() -> None:
  generator = ConfigExampleGenerator(overrides=load_example_overrides())
  rendered = generator.render_yaml(include_workspace=True, comment_style="line")
  assert "NON-PRODUCTION EXEMPLAR" in rendered
  assert "workspace:" in rendered
  assert "hermes-control-plane" in rendered or "example-umbrella" in rendered


def test_build_merges_overrides() -> None:
  generator = ConfigExampleGenerator(overrides={"name": "override-name"})
  payload = generator.build(include_workspace=False)
  assert payload["name"] == "override-name"
  assert "workspace" not in payload


def test_generated_payload_validates_when_overrides_used() -> None:
  generator = ConfigExampleGenerator(overrides=load_example_overrides())
  payload = generator.build(include_workspace=True)
  config = MetagitConfig.model_validate(payload)
  assert config.name == payload["name"]
  assert config.workspace is not None
  assert any(project.name == "local" for project in config.workspace.projects)
`````

## File: tests/test_config_yaml_display.py
`````python
#!/usr/bin/env python
"""Tests for config YAML display helpers."""

from metagit.core.config.yaml_display import dump_config_dict


def test_dump_config_dict_uses_literal_block_for_multiline() -> None:
    rendered = dump_config_dict(
        {
            "name": "demo",
            "agent_instructions": "line one\nline two",
        }
    )
    assert "agent_instructions: |" in rendered
    assert "line one" in rendered
    assert "\\u2014" not in rendered
    rendered_unicode = dump_config_dict({"note": "status — ok"})
    assert "—" in rendered_unicode
    assert "\\u2014" not in rendered_unicode
`````

## File: tests/test_documentation_graph_models.py
`````python
#!/usr/bin/env python
"""Tests for documentation sources and manual graph relationships."""

from pathlib import Path

import yaml

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.cross_project_dependencies import (
    CrossProjectDependencyService,
)


def test_documentation_accepts_strings_and_dicts() -> None:
    config = MetagitConfig(
        name="demo",
        documentation=[
            "README.md",
            "https://example.com/docs",
            {
                "kind": "confluence",
                "url": "https://confluence.example.com/display/DOC",
                "tags": ["playbook", "tutorial"],
            },
            {
                "kind": "markdown",
                "path": "CHANGELOG.md",
                "metadata": {"ingest": "knowledge-graph"},
            },
        ],
    )
    assert len(config.documentation) == 4
    assert config.documentation[0].kind == "markdown"
    assert config.documentation[0].path == "README.md"
    assert config.documentation[1].kind == "web"
    assert config.documentation[2].tags == {
        "playbook": "true",
        "tutorial": "true",
    }
    assert config.documentation[3].metadata["ingest"] == "knowledge-graph"
    nodes = config.documentation_graph_nodes()
    assert nodes[2]["kind"] == "confluence"


def test_graph_relationships_and_export() -> None:
    config = MetagitConfig(
        name="umbrella",
        graph={
            "relationships": [
                {
                    "from": {"project": "alpha", "repo": "api"},
                    "to": {"project": "beta", "repo": "lib"},
                    "type": "depends_on",
                    "id": "alpha-api-to-beta-lib",
                    "label": "API uses shared lib",
                }
            ],
            "metadata": {"source": "manual"},
        },
        workspace={
            "projects": [
                {
                    "name": "alpha",
                    "repos": [
                        {
                            "name": "api",
                            "path": "alpha/api",
                            "url": "https://github.com/example/api.git",
                        }
                    ],
                },
                {
                    "name": "beta",
                    "repos": [
                        {
                            "name": "lib",
                            "path": "beta/lib",
                            "url": "https://github.com/example/lib.git",
                        }
                    ],
                },
            ]
        },
    )
    exported = config.graph_export_payload()
    assert exported["metadata"]["source"] == "manual"
    assert exported["relationships"][0]["id"] == "alpha-api-to-beta-lib"


def test_load_metagit_yml_documentation_block(tmp_path: Path) -> None:
    manifest = {
        "name": "metagit-cli",
        "documentation": [
            "README.md",
            {"kind": "web", "url": "https://metagit-ai.github.io/metagit-cli/"},
            {
                "kind": "confluence",
                "url": "https://confluence.example.com/display/METAGIT/Docs",
                "tags": {"playbook": "true"},
            },
        ],
        "workspace": {"projects": []},
    }
    path = tmp_path / ".metagit.yml"
    path.write_text(yaml.dump(manifest), encoding="utf-8")
    loaded = MetagitConfigManager(str(path)).load_config()
    assert not isinstance(loaded, Exception)
    assert loaded.documentation[0].path == "README.md"
    assert loaded.documentation[2].kind == "confluence"


def test_manual_graph_edges_in_dependency_map(tmp_path: Path) -> None:
    workspace_root = tmp_path / ".metagit"
    (workspace_root / "alpha" / "api").mkdir(parents=True)
    (workspace_root / "beta" / "lib").mkdir(parents=True)

    config = MetagitConfig(
        name="umbrella",
        graph={
            "relationships": [
                {
                    "from": {"project": "alpha", "repo": "api"},
                    "to": {"project": "beta", "repo": "lib"},
                    "type": "depends_on",
                }
            ]
        },
        workspace={
            "projects": [
                {
                    "name": "alpha",
                    "repos": [{"name": "api", "url": "https://example.com/a.git"}],
                },
                {
                    "name": "beta",
                    "repos": [{"name": "lib", "url": "https://example.com/b.git"}],
                },
            ]
        },
    )
    result = CrossProjectDependencyService().map_dependencies(
        config,
        str(workspace_root),
        "alpha",
        dependency_types=["manual"],
    )
    manual = [edge for edge in result.edges if edge.type == "manual"]
    assert len(manual) == 1
    assert manual[0].from_id == "repo:alpha/api"
    assert manual[0].to_id == "repo:beta/lib"
`````

## File: tests/test_project_manager_dedupe.py
`````python
#!/usr/bin/env python
"""Integration tests for workspace dedupe sync layout."""

import os
from pathlib import Path

from metagit.core.appconfig.models import WorkspaceDedupeConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.project.manager import ProjectManager
from metagit.core.project.models import ProjectKind, ProjectPath
from metagit.core.workspace.models import Workspace, WorkspaceProject


class _DummyLogger:
  def set_level(self, _: str) -> None:
    return

  def warning(self, _: str) -> None:
    return

  def debug(self, _: str) -> None:
    return


def test_deduped_local_path_creates_single_canonical_and_two_mounts(
  tmp_path: Path,
) -> None:
  source = tmp_path / "user-site"
  source.mkdir()
  (source / "index.html").write_text("hello", encoding="utf-8")

  workspace_root = tmp_path / ".metagit"
  dedupe = WorkspaceDedupeConfig(enabled=True, canonical_dir="_canonical")
  manager = ProjectManager(workspace_root, _DummyLogger(), dedupe=dedupe)

  repo = ProjectPath(name="site", path=str(source), sync=True, kind=ProjectKind.WEBSITE)
  project_a = WorkspaceProject(name="local", repos=[repo])
  project_b = WorkspaceProject(
    name="mirror",
    repos=[
      ProjectPath(
        name="site",
        path=str(source),
        sync=True,
        kind=ProjectKind.WEBSITE,
      )
    ],
  )

  assert manager.sync(project_a) is True
  assert manager.sync(project_b) is True

  mount_a = workspace_root / "local" / "site"
  mount_b = workspace_root / "mirror" / "site"
  canonical = workspace_root / "_canonical"
  canonical_dirs = [path for path in canonical.iterdir() if path.is_dir() or path.is_symlink()]
  assert len(canonical_dirs) == 1
  assert mount_a.is_symlink()
  assert mount_b.is_symlink()
  assert mount_a.resolve() == canonical_dirs[0].resolve()
  assert mount_b.resolve() == canonical_dirs[0].resolve()
  assert (mount_a / "index.html").read_text(encoding="utf-8") == "hello"


def test_deduped_remote_clone_is_shared(
  tmp_path: Path,
  monkeypatch,
) -> None:
  workspace_root = tmp_path / ".metagit"
  dedupe = WorkspaceDedupeConfig(enabled=True, canonical_dir="_canonical")

  def _fake_clone(url: str, target: str, progress=None) -> None:  # noqa: ARG001
    os.makedirs(target, exist_ok=True)
    Path(target, ".git").mkdir(exist_ok=True)

  monkeypatch.setattr(
    "metagit.core.project.manager.git.Repo.clone_from",
    _fake_clone,
  )

  manager = ProjectManager(workspace_root, _DummyLogger(), dedupe=dedupe)
  url = "https://github.com/example/remote.git"
  project_a = WorkspaceProject(
    name="p1",
    repos=[ProjectPath(name="remote", url=url, kind=ProjectKind.REPOSITORY)],
  )
  project_b = WorkspaceProject(
    name="p2",
    repos=[ProjectPath(name="remote", url=url, kind=ProjectKind.REPOSITORY)],
  )

  assert manager.sync(project_a) is True
  assert manager.sync(project_b) is True

  mount_a = workspace_root / "p1" / "remote"
  mount_b = workspace_root / "p2" / "remote"
  assert mount_a.is_symlink()
  assert mount_b.is_symlink()
  assert mount_a.resolve() == mount_b.resolve()
`````

## File: tests/test_project_manager_prune.py
`````python
#!/usr/bin/env python
"""Tests for unmanaged sync directory listing and prune helpers."""

from pathlib import Path

from metagit.core.config.models import MetagitConfig
from metagit.core.project.manager import ProjectManager
from metagit.core.project.models import ProjectKind, ProjectPath
from metagit.core.workspace.models import Workspace, WorkspaceProject


class _DummyLogger:
    def set_level(self, _: str) -> None:
        return

    def warning(self, _: str) -> None:
        return

    def debug(self, _: str) -> None:
        return


def _config_one_repo() -> MetagitConfig:
    return MetagitConfig(
        name="cfg",
        workspace=Workspace(
            projects=[
                WorkspaceProject(
                    name="platform",
                    repos=[
                        ProjectPath(
                            name="managed",
                            kind=ProjectKind.REPOSITORY,
                            url="https://example.com/managed.git",
                        )
                    ],
                )
            ]
        ),
    )


def test_list_unmanaged_sync_directories_excludes_managed(tmp_path: Path) -> None:
    workspace_root = tmp_path / ".metagit"
    proj = workspace_root / "platform"
    proj.mkdir(parents=True)
    (proj / "managed").mkdir()
    (proj / "orphan-dir").mkdir()

    mgr = ProjectManager(workspace_root, _DummyLogger())
    unmanaged = mgr.list_unmanaged_sync_directories(
        _config_one_repo(), "platform", ignore_hidden=True
    )
    assert [p.name for p in unmanaged] == ["orphan-dir"]


def test_list_unmanaged_respects_dot_directories_when_ignore_hidden(
    tmp_path: Path,
) -> None:
    workspace_root = tmp_path / ".metagit"
    proj = workspace_root / "platform"
    proj.mkdir(parents=True)
    (proj / "managed").mkdir()
    (proj / ".cache").mkdir()
    (proj / "orphan-dir").mkdir()

    mgr = ProjectManager(workspace_root, _DummyLogger())
    hidden_on = mgr.list_unmanaged_sync_directories(
        _config_one_repo(), "platform", ignore_hidden=True
    )
    assert [p.name for p in hidden_on] == ["orphan-dir"]

    hidden_off = mgr.list_unmanaged_sync_directories(
        _config_one_repo(), "platform", ignore_hidden=False
    )
    assert sorted(p.name for p in hidden_off) == [".cache", "orphan-dir"]


def test_select_repo_skips_dot_directories_when_ignore_hidden(
    tmp_path: Path, monkeypatch
) -> None:
    """Dot-directories should not appear in the fuzzy finder when ignore_hidden is true."""
    workspace_root = tmp_path / ".metagit"
    project_root = workspace_root / "platform"
    project_root.mkdir(parents=True)
    (project_root / "managed").mkdir()
    (project_root / ".venv").mkdir()

    captured: dict = {}

    class _DummyFinder:
        def __init__(self, config) -> None:
            captured["config"] = config

        def run(self):
            return None

    monkeypatch.setattr("metagit.core.project.manager.FuzzyFinder", _DummyFinder)

    mgr = ProjectManager(workspace_root, _DummyLogger())
    _ = mgr.select_repo(
        _config_one_repo(),
        "platform",
        show_preview=False,
        ignore_hidden=True,
    )
    names = [item.name for item in captured["config"].items]
    assert ".venv" not in names
    assert "managed" in names
`````

## File: tests/test_project_manager_select_repo.py
`````python
#!/usr/bin/env python
"""
Unit tests for ProjectManager.select_repo behavior.
"""

from pathlib import Path
from typing import Optional

from metagit.core.config.models import MetagitConfig
from metagit.core.project.manager import ProjectManager
from metagit.core.project.models import ProjectKind, ProjectPath
from metagit.core.workspace.models import Workspace, WorkspaceProject


class _DummyLogger:
  def set_level(self, _: str) -> None:
    return

  def warning(self, _: str) -> None:
    return

  def debug(self, _: str) -> None:
    return


def _build_metagit_config() -> MetagitConfig:
  return MetagitConfig(
    name="test-config",
    workspace=Workspace(
      projects=[
        WorkspaceProject(
          name="proj-one",
          repos=[
            ProjectPath(
              name="repo-a",
              description="Core repository",
              kind=ProjectKind.APPLICATION,
              path="/tmp/repo-a",
              url="https://example.com/repo-a.git",
              language="python",
              language_version="3.12",
              package_manager="uv",
              frameworks=["textual", "pydantic"],
              source_provider="github",
              source_namespace="org-a",
              protected=True,
              ref="services/repo-a",
            ),
            ProjectPath(name="missing-repo", description="Configured but missing"),
          ],
        )
      ]
    ),
  )


def test_select_repo_respects_gitignore_and_sets_total_count(tmp_path, monkeypatch) -> None:
  workspace_root = tmp_path / "workspace"
  project_root = workspace_root / "proj-one"
  project_root.mkdir(parents=True)
  (project_root / ".gitignore").write_text("ignored-repo\n", encoding="utf-8")
  (project_root / "repo-a").mkdir()
  (project_root / "ignored-repo").mkdir()

  captured = {}

  class _DummyFinder:
    def __init__(self, config) -> None:
      captured["config"] = config

    def run(self) -> Optional[ProjectPath]:
      return None

  monkeypatch.setattr("metagit.core.project.manager.FuzzyFinder", _DummyFinder)

  manager = ProjectManager(workspace_root, _DummyLogger())
  _ = manager.select_repo(_build_metagit_config(), "proj-one", show_preview=True)

  finder_config = captured["config"]
  item_names = [item.name for item in finder_config.items]
  assert "ignored-repo" not in item_names
  assert "repo-a" in item_names
  assert "missing-repo" in item_names
  assert finder_config.total_count == 2


def test_select_repo_preview_contains_extended_metadata(tmp_path, monkeypatch) -> None:
  workspace_root = tmp_path / "workspace"
  project_root = workspace_root / "proj-one"
  project_root.mkdir(parents=True)
  (project_root / "repo-a").mkdir()

  captured = {}

  class _DummyFinder:
    def __init__(self, config) -> None:
      captured["config"] = config

    def run(self) -> Optional[ProjectPath]:
      return None

  monkeypatch.setattr("metagit.core.project.manager.FuzzyFinder", _DummyFinder)

  manager = ProjectManager(workspace_root, _DummyLogger())
  _ = manager.select_repo(_build_metagit_config(), "proj-one", show_preview=True)

  target_item = next(
    item for item in captured["config"].items if item.name == "repo-a"
  )
  preview = target_item.description
  assert "Status: ✅ Managed" in preview
  assert "Path: /tmp/repo-a" in preview
  assert "URL: https://example.com/repo-a.git" in preview
  assert "Language: python" in preview
  assert "Language Version: 3.12" in preview
  assert "Package Manager: uv" in preview
  assert "Frameworks: textual, pydantic" in preview
  assert "Source Provider: github" in preview
  assert "Source Namespace: org-a" in preview
  assert "Protected: True" in preview
`````

## File: tests/test_project_source_models.py
`````python
#!/usr/bin/env python
"""
Tests for source sync input model validation.
"""

import pytest

from metagit.core.project.source_models import SourceProvider, SourceSpec


def test_source_spec_accepts_github_org() -> None:
    spec = SourceSpec(provider=SourceProvider.GITHUB, org="metagit-ai")
    assert spec.namespace_key == "metagit-ai"


def test_source_spec_rejects_invalid_github_scope() -> None:
    with pytest.raises(ValueError):
        SourceSpec(provider=SourceProvider.GITHUB, org="metagit-ai", user="zach")


def test_source_spec_accepts_gitlab_group() -> None:
    spec = SourceSpec(provider=SourceProvider.GITLAB, group="my-group/sub-group")
    assert spec.namespace_key == "my-group/sub-group"
`````

## File: tests/test_project_source_sync.py
`````python
#!/usr/bin/env python
"""
Tests for source sync planner and applier.
"""

from metagit.core.appconfig.models import AppConfig
from metagit.core.project.models import ProjectPath
from metagit.core.project.source_models import (
    DiscoveredRepo,
    SourceProvider,
    SourceSpec,
    SourceSyncMode,
)
from metagit.core.project.source_sync import SourceSyncService
from metagit.core.utils.logging import LoggerConfig, UnifiedLogger
from metagit.core.workspace.models import WorkspaceProject


def _service() -> SourceSyncService:
    return SourceSyncService(
        app_config=AppConfig(),
        logger=UnifiedLogger(LoggerConfig(log_level="ERROR", minimal_console=True)),
    )


def test_plan_additive_adds_missing_repo() -> None:
    service = _service()
    spec = SourceSpec(provider=SourceProvider.GITHUB, org="metagit-ai")
    project = WorkspaceProject(name="default", repos=[])
    discovered = [
        DiscoveredRepo(
            provider=SourceProvider.GITHUB,
            namespace="metagit-ai",
            full_name="metagit-ai/metagit-cli",
            name="metagit-cli",
            clone_url="https://github.com/metagit-ai/metagit-cli.git",
            repo_id="123",
        )
    ]
    plan = service.plan(spec, project, discovered, SourceSyncMode.ADDITIVE)
    assert len(plan.to_add) == 1
    assert len(plan.to_remove) == 0


def test_plan_reconcile_removes_unmatched_provider_managed_repo() -> None:
    service = _service()
    spec = SourceSpec(provider=SourceProvider.GITHUB, org="metagit-ai")
    project = WorkspaceProject(
        name="default",
        repos=[
            ProjectPath(
                name="old-repo",
                url="https://github.com/metagit-ai/old-repo.git",
                source_provider="github",
                source_namespace="metagit-ai",
                source_repo_id="1",
            )
        ],
    )
    discovered = [
        DiscoveredRepo(
            provider=SourceProvider.GITHUB,
            namespace="metagit-ai",
            full_name="metagit-ai/new-repo",
            name="new-repo",
            clone_url="https://github.com/metagit-ai/new-repo.git",
            repo_id="2",
        )
    ]
    plan = service.plan(spec, project, discovered, SourceSyncMode.RECONCILE)
    assert len(plan.to_add) == 1
    assert len(plan.to_remove) == 1


def test_apply_plan_reconcile_preserves_protected_repo() -> None:
    service = _service()
    project = WorkspaceProject(
        name="default",
        repos=[
            ProjectPath(
                name="protected-repo",
                url="https://github.com/metagit-ai/protected-repo.git",
                source_provider="github",
                source_namespace="metagit-ai",
                protected=True,
            )
        ],
    )
    plan = service.plan(
        SourceSpec(provider=SourceProvider.GITHUB, org="metagit-ai"),
        project,
        [],
        SourceSyncMode.RECONCILE,
    )
    updated = service.apply_plan(project, plan, SourceSyncMode.RECONCILE)
    assert len(updated.repos) == 1
    assert updated.repos[0].name == "protected-repo"
`````

## File: tests/test_utils_fuzzyfinder.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.utils.fuzzyfinder
"""

from metagit.core.utils import fuzzyfinder
from metagit.core.utils.fuzzyfinder import FuzzyFinderApp, FuzzyFinderConfig


def test_fuzzyfinder_basic():
    collection = ["apple", "banana", "grape", "apricot"]
    results = list(fuzzyfinder.fuzzyfinder("ap", collection))
    assert "apple" in results
    assert "apricot" in results
    assert "banana" not in results


def test_fuzzyfinder_empty():
    assert list(fuzzyfinder.fuzzyfinder("", ["a", "b"])) == ["a", "b"]
    assert list(fuzzyfinder.fuzzyfinder("x", [])) == []


def test_fuzzyfinder_no_match():
    collection = ["cat", "dog"]
    assert list(fuzzyfinder.fuzzyfinder("zebra", collection)) == []


def test_fuzzyfinder_app_search_not_capped_by_max_results():
    config = FuzzyFinderConfig(items=["a", "b", "c"], max_results=1)
    app = FuzzyFinderApp(config)
    results = app._search("")
    assert results == ["a", "b", "c"]
`````

## File: tests/test_workspace_dedupe.py
`````python
#!/usr/bin/env python
"""Tests for workspace repository deduplication helpers."""

import os
from pathlib import Path

from metagit.core.appconfig.models import WorkspaceDedupeConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectKind, ProjectPath
from metagit.core.workspace import workspace_dedupe
from metagit.core.workspace.models import Workspace, WorkspaceProject


def test_build_repo_identity_same_url_different_projects() -> None:
  url = "https://github.com/example/org-repo.git"
  left = ProjectPath(name="a", url=url)
  right = ProjectPath(name="b", url=url + "/")
  assert workspace_dedupe.build_repo_identity(left) == workspace_dedupe.build_repo_identity(
    right
  )


def test_build_repo_identity_branch_suffix_differs() -> None:
  base = ProjectPath(name="svc", url="https://github.com/example/svc.git")
  branched = ProjectPath(
    name="svc",
    url="https://github.com/example/svc.git",
    branches=["main", "release"],
  )
  assert (
    workspace_dedupe.build_repo_identity(base).repo_key
    != workspace_dedupe.build_repo_identity(branched).repo_key
  )


def test_find_duplicate_identities_reports_existing() -> None:
  shared = ProjectPath(
    name="shared",
    kind=ProjectKind.REPOSITORY,
    url="https://github.com/example/shared.git",
  )
  config = MetagitConfig(
    name="cfg",
    workspace=Workspace(
      projects=[
        WorkspaceProject(name="alpha", repos=[shared]),
        WorkspaceProject(name="beta", repos=[]),
      ]
    ),
  )
  incoming = ProjectPath(
    name="shared-copy",
    url="https://github.com/example/shared.git",
  )
  matches = workspace_dedupe.find_duplicate_identities(config, incoming)
  assert matches == [("alpha", "shared")]


def test_ensure_symlink_creates_and_repairs(tmp_path: Path) -> None:
  target = tmp_path / "canonical"
  target.mkdir()
  mount = tmp_path / "project" / "repo"
  changed, error = workspace_dedupe.ensure_symlink(mount, target)
  assert error is None
  assert changed is True
  assert mount.is_symlink()
  assert mount.resolve() == target.resolve()

  broken = tmp_path / "broken-link"
  if broken.exists() or broken.is_symlink():
    broken.unlink()
  os.symlink("missing-target", broken)
  changed_repair, repair_error = workspace_dedupe.ensure_symlink(broken, target)
  assert repair_error is None
  assert changed_repair is True
  assert broken.resolve() == target.resolve()


def test_list_orphan_canonical_dirs(tmp_path: Path) -> None:
  dedupe = WorkspaceDedupeConfig(enabled=True, canonical_dir="_canonical")
  workspace_root = tmp_path / ".metagit"
  canonical_root = workspace_root / "_canonical"
  (canonical_root / "used-key").mkdir(parents=True)
  (canonical_root / "orphan-key").mkdir(parents=True)
  references = {"used-key": [("alpha", "repo")]}
  orphans = workspace_dedupe.list_orphan_canonical_dirs(
    workspace_root,
    dedupe,
    references,
  )
  assert [path.name for path in orphans] == ["orphan-key"]
`````

## File: web/public/favicon.svg
`````xml
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
`````

## File: web/public/icons.svg
`````xml
<svg xmlns="http://www.w3.org/2000/svg">
  <symbol id="bluesky-icon" viewBox="0 0 16 17">
    <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
    <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
  </symbol>
  <symbol id="discord-icon" viewBox="0 0 20 19">
    <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
  </symbol>
  <symbol id="documentation-icon" viewBox="0 0 21 20">
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
  </symbol>
  <symbol id="github-icon" viewBox="0 0 19 19">
    <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
  </symbol>
  <symbol id="social-icon" viewBox="0 0 20 20">
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
  </symbol>
  <symbol id="x-icon" viewBox="0 0 19 19">
    <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
  </symbol>
</svg>
`````

## File: web/src/assets/react.svg
`````xml
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
`````

## File: web/src/assets/vite.svg
`````xml
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="29.961" y="-17.13" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-13.43" y="-22.082" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="34.321" y="-37.644" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="38.847" y="-10.552" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="22.45" y="-1.645" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="32.919" y="11.36" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter></defs></svg>
`````

## File: web/src/components/ConfigPreview.module.css
`````css
.previewPanel {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  padding: 1rem;
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  min-height: 12rem;
  max-height: calc(100vh - 12rem);
  transition: var(--transition-theme);
}

.header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem 1rem;
}

.title {
  margin: 0;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--color-text-subtle);
}

.controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.select {
  padding: 0.35rem 0.5rem;
  font-size: 0.85rem;
  color: var(--color-text);
  background: var(--color-bg);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
}

.badge {
  padding: 0.15rem 0.45rem;
  font-size: 0.7rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  border-radius: var(--radius-sm);
  background: var(--color-accent-muted);
  color: var(--color-accent);
}

.badgeInvalid {
  background: color-mix(in srgb, var(--color-danger) 18%, transparent);
  color: var(--color-danger);
}

.codeWrap {
  flex: 1;
  min-height: 0;
  overflow: auto;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg-code, var(--color-bg));
}

.code {
  margin: 0;
  padding: 0.85rem 1rem;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  line-height: 1.45;
  white-space: pre;
  color: var(--color-text);
}

.state {
  margin: 0;
  color: var(--color-text-muted);
  font-size: 0.9rem;
}

.errors {
  margin: 0;
  padding-left: 1.1rem;
  color: var(--color-danger);
  font-size: 0.85rem;
}
`````

## File: web/src/components/ConfigPreview.tsx
`````typescript
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import type { ConfigOperation, ConfigPreviewStyle } from '../api/client'
import { fetchConfigPreview, type ConfigTarget } from '../pages/configQueries'
import styles from './ConfigPreview.module.css'

interface ConfigPreviewProps {
  target: ConfigTarget
  pendingOps: ConfigOperation[]
}

export default function ConfigPreview({ target, pendingOps }: ConfigPreviewProps) {
  const [style, setStyle] = useState<ConfigPreviewStyle>('normalized')

  const operations = useMemo(
    () => (style === 'disk' ? [] : pendingOps),
    [pendingOps, style],
  )

  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['config-preview', target, style, operations],
    queryFn: () => fetchConfigPreview(target, style, operations),
  })

  return (
    <section className={styles.previewPanel} aria-label="YAML preview">
      <header className={styles.header}>
        <h3 className={styles.title}>YAML preview</h3>
        <div className={styles.controls}>
          <select
            className={styles.select}
            value={style}
            aria-label="Preview style"
            onChange={(event) =>
              setStyle(event.target.value as ConfigPreviewStyle)
            }
          >
            <option value="normalized">Normalized</option>
            <option value="minimal">Minimal (non-default)</option>
            <option value="disk">On disk</option>
          </select>
          {data?.draft ? <span className={styles.badge}>Draft</span> : null}
          {data && !data.ok ? (
            <span className={`${styles.badge} ${styles.badgeInvalid}`}>
              Invalid
            </span>
          ) : null}
        </div>
      </header>

      {isLoading ? <p className={styles.state}>Rendering preview…</p> : null}
      {isError ? (
        <p className={styles.state}>
          {error instanceof Error ? error.message : 'Preview failed'}
        </p>
      ) : null}

      {data?.validation_errors && data.validation_errors.length > 0 ? (
        <ul className={styles.errors}>
          {data.validation_errors.map((entry) => (
            <li key={`${entry.path}:${entry.message}`}>
              {entry.path ? `${entry.path}: ` : ''}
              {entry.message}
            </li>
          ))}
        </ul>
      ) : null}

      {data?.yaml ? (
        <div className={styles.codeWrap}>
          <pre className={styles.code}>{data.yaml}</pre>
        </div>
      ) : null}
    </section>
  )
}
`````

## File: web/src/components/FieldEditor.module.css
`````css
.panel {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding: 1.25rem;
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  transition: var(--transition-theme);
}

.empty {
  color: var(--color-text-muted);
  margin: 0;
}

.header {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.title {
  margin: 0;
  font-size: 1.1rem;
  font-weight: 600;
}

.path {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--color-text-subtle);
  word-break: break-all;
}

.meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  font-size: 0.8rem;
  color: var(--color-text-muted);
}

.badge {
  padding: 0.15rem 0.45rem;
  border-radius: var(--radius-sm);
  background: var(--color-bg-muted);
  border: 1px solid var(--color-border);
  font-family: var(--font-mono);
}

.description {
  margin: 0;
  color: var(--color-text-muted);
  font-size: 0.9rem;
  line-height: 1.5;
}

.hint {
  margin: 0;
  padding: 0.75rem 1rem;
  background: var(--color-bg-muted);
  border-radius: var(--radius-md);
  color: var(--color-text-muted);
  font-size: 0.9rem;
}

.field {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.label {
  font-size: 0.85rem;
  font-weight: 500;
  color: var(--color-text-muted);
}

.input,
.select,
.textarea {
  width: 100%;
  padding: 0.55rem 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg);
  color: var(--color-text);
  font: inherit;
  transition: var(--transition-theme);
}

.input:focus,
.select:focus,
.textarea:focus {
  outline: none;
  border-color: var(--color-accent);
  box-shadow: 0 0 0 3px var(--color-focus);
}

.input:disabled,
.select:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  padding-top: 0.25rem;
}

.button {
  padding: 0.5rem 1rem;
  border-radius: var(--radius-md);
  border: 1px solid var(--color-border);
  background: var(--color-bg-muted);
  color: var(--color-text);
  font: inherit;
  font-size: 0.875rem;
  cursor: pointer;
  transition: var(--transition-theme);
}

.button:hover:not(:disabled) {
  border-color: var(--color-border-strong);
}

.button:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.buttonPrimary {
  background: var(--color-accent);
  border-color: var(--color-accent);
  color: #fff;
}

.buttonPrimary:hover:not(:disabled) {
  background: var(--color-accent-hover);
  border-color: var(--color-accent-hover);
}

.errors {
  margin: 0;
  padding: 0.75rem 1rem;
  list-style: none;
  background: var(--color-danger-soft);
  border: 1px solid var(--color-danger);
  border-radius: var(--radius-md);
}

.errors li {
  color: var(--color-danger);
  font-size: 0.85rem;
  margin: 0.25rem 0;
}

.errors li:first-child {
  margin-top: 0;
}

.errors li:last-child {
  margin-bottom: 0;
}

.status {
  font-size: 0.8rem;
  color: var(--color-text-subtle);
}
`````

## File: web/src/components/GraphDiagram.module.css
`````css
.wrap {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 1.25rem;
  font-size: 0.8rem;
  color: var(--color-text-muted);
}

.legendItem {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}

.legendSwatch {
  width: 1.25rem;
  height: 0.2rem;
  border-radius: 2px;
}

.canvasScroll {
  overflow: auto;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg-elevated);
  min-height: 14rem;
  max-height: 28rem;
}

.canvas {
  display: block;
  min-width: 100%;
  height: auto;
}

.nodeProject {
  fill: var(--color-accent-soft);
  stroke: var(--color-accent);
  stroke-width: 1.5;
}

.nodeRepo {
  fill: var(--color-bg);
  stroke: var(--color-border-strong);
  stroke-width: 1;
}

.nodeLabel {
  fill: var(--color-text);
  font-size: 12px;
  font-family: var(--font-mono);
  pointer-events: none;
}

.edgeLabel {
  fill: var(--color-text-muted);
  font-size: 10px;
  font-family: var(--font-sans);
}

.empty {
  margin: 0;
  padding: 1.5rem;
  text-align: center;
  color: var(--color-text-muted);
  border: 1px dashed var(--color-border);
  border-radius: var(--radius-md);
}
`````

## File: web/src/components/GraphDiagram.tsx
`````typescript
import { useMemo } from 'react'
import type { GraphViewEdge, GraphViewNode } from '../api/client'
import styles from './GraphDiagram.module.css'

const COLUMN_WIDTH = 160
const PROJECT_HEIGHT = 44
const REPO_HEIGHT = 36
const REPO_GAP = 10
const PADDING = 32

export interface GraphDiagramProps {
  nodes: GraphViewNode[]
  edges: GraphViewEdge[]
  manualEdgeCount: number
  inferredEdgeCount: number
  structureEdgeCount: number
}

interface LayoutNode {
  node: GraphViewNode
  x: number
  y: number
  width: number
  height: number
}

function edgeStroke(source: GraphViewEdge['source'], type: string): string {
  if (source === 'manual') {
    return 'var(--graph-edge-manual)'
  }
  if (source === 'structure' || type === 'contains') {
    return 'var(--graph-edge-structure)'
  }
  return 'var(--graph-edge-inferred)'
}

function computeLayout(nodes: GraphViewNode[]): Map<string, LayoutNode> {
  const byProject = new Map<string, { project?: GraphViewNode; repos: GraphViewNode[] }>()
  for (const node of nodes) {
    const projectName = node.project_name ?? '_'
    const bucket = byProject.get(projectName) ?? { repos: [] }
    if (node.kind === 'project') {
      bucket.project = node
    } else {
      bucket.repos.push(node)
    }
    byProject.set(projectName, bucket)
  }

  const positions = new Map<string, LayoutNode>()
  let column = 0
  for (const [, bucket] of [...byProject.entries()].sort(([a], [b]) =>
    a.localeCompare(b),
  )) {
    const x = PADDING + column * COLUMN_WIDTH
    let y = PADDING
    if (bucket.project) {
      positions.set(bucket.project.id, {
        node: bucket.project,
        x,
        y,
        width: COLUMN_WIDTH - 16,
        height: PROJECT_HEIGHT,
      })
      y += PROJECT_HEIGHT + REPO_GAP
    }
    const repos = [...bucket.repos].sort((a, b) => a.label.localeCompare(b.label))
    for (const repo of repos) {
      positions.set(repo.id, {
        node: repo,
        x,
        y,
        width: COLUMN_WIDTH - 16,
        height: REPO_HEIGHT,
      })
      y += REPO_HEIGHT + REPO_GAP
    }
    column += 1
  }
  return positions
}

function center(layout: LayoutNode): { cx: number; cy: number } {
  return {
    cx: layout.x + layout.width / 2,
    cy: layout.y + layout.height / 2,
  }
}

export default function GraphDiagram({
  nodes,
  edges,
  manualEdgeCount,
  inferredEdgeCount,
  structureEdgeCount,
}: GraphDiagramProps) {
  const layout = useMemo(() => computeLayout(nodes), [nodes])

  const { width, height } = useMemo(() => {
    let maxX = 400
    let maxY = 200
    for (const item of layout.values()) {
      maxX = Math.max(maxX, item.x + item.width + PADDING)
      maxY = Math.max(maxY, item.y + item.height + PADDING)
    }
    return { width: maxX, height: maxY }
  }, [layout])

  if (nodes.length === 0) {
    return (
      <p className={styles.empty}>
        No graph nodes yet. Add workspace projects/repos or manual relationships in
        `.metagit.yml` under `graph`.
      </p>
    )
  }

  return (
    <div className={styles.wrap}>
      <div className={styles.legend} aria-label="Edge legend">
        <span className={styles.legendItem}>
          <span
            className={styles.legendSwatch}
            style={{ background: 'var(--graph-edge-manual)' }}
          />
          Manual ({manualEdgeCount})
        </span>
        <span className={styles.legendItem}>
          <span
            className={styles.legendSwatch}
            style={{ background: 'var(--graph-edge-inferred)' }}
          />
          Inferred ({inferredEdgeCount})
        </span>
        <span className={styles.legendItem}>
          <span
            className={styles.legendSwatch}
            style={{ background: 'var(--graph-edge-structure)' }}
          />
          Structure ({structureEdgeCount})
        </span>
      </div>
      <div className={styles.canvasScroll}>
        <svg
          className={styles.canvas}
          viewBox={`0 0 ${width} ${height}`}
          role="img"
          aria-label="Workspace relationship diagram"
        >
          <defs>
            <marker
              id="graph-arrow"
              markerWidth="8"
              markerHeight="8"
              refX="7"
              refY="4"
              orient="auto"
            >
              <path d="M0,0 L8,4 L0,8 z" fill="var(--color-text-muted)" />
            </marker>
          </defs>
          {edges.map((edge) => {
            const from = layout.get(edge.from_id)
            const to = layout.get(edge.to_id)
            if (!from || !to) {
              return null
            }
            const start = center(from)
            const end = center(to)
            const stroke = edgeStroke(edge.source, edge.type)
            const dash =
              edge.source === 'structure' || edge.type === 'contains'
                ? '6 4'
                : undefined
            return (
              <g key={edge.id}>
                <line
                  x1={start.cx}
                  y1={start.cy}
                  x2={end.cx}
                  y2={end.cy}
                  stroke={stroke}
                  strokeWidth={edge.source === 'manual' ? 2.5 : 1.5}
                  strokeDasharray={dash}
                  markerEnd="url(#graph-arrow)"
                  opacity={0.85}
                />
                {edge.label && edge.source === 'manual' ? (
                  <text
                    x={(start.cx + end.cx) / 2}
                    y={(start.cy + end.cy) / 2 - 6}
                    className={styles.edgeLabel}
                    textAnchor="middle"
                  >
                    {edge.label}
                  </text>
                ) : null}
              </g>
            )
          })}
          {[...layout.values()].map((item) => (
            <g key={item.node.id}>
              <rect
                x={item.x}
                y={item.y}
                width={item.width}
                height={item.height}
                rx={8}
                className={
                  item.node.kind === 'project'
                    ? styles.nodeProject
                    : styles.nodeRepo
                }
              />
              <text
                x={item.x + item.width / 2}
                y={item.y + item.height / 2 + 4}
                className={styles.nodeLabel}
                textAnchor="middle"
              >
                {item.node.label}
              </text>
            </g>
          ))}
        </svg>
      </div>
    </div>
  )
}
`````

## File: web/src/components/Layout.module.css
`````css
.shell {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  transition: var(--transition-theme);
}

.header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1rem 1.5rem;
  padding: 1rem 1.5rem;
  border-bottom: 1px solid var(--color-border);
  background: var(--color-bg-elevated);
  transition: var(--transition-theme);
}

.title {
  margin: 0;
  font-size: 1.25rem;
  font-weight: 600;
  color: var(--color-text);
}

.nav {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  flex: 1;
}

.navLink {
  color: var(--color-text-muted);
  text-decoration: none;
  padding: 0.35rem 0.65rem;
  border-radius: var(--radius-md);
  transition: var(--transition-theme);
}

.navLink:hover {
  color: var(--color-text);
  background: var(--color-tree-hover);
}

.navLinkActive {
  color: var(--color-text);
  background: var(--color-accent-soft);
}

.themeToggle {
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg-muted);
  color: var(--color-text);
  cursor: pointer;
  transition: var(--transition-theme);
}

.themeToggle:hover {
  border-color: var(--color-border-strong);
  background: var(--color-tree-hover);
}

.themeIcon {
  font-size: 1.1rem;
  line-height: 1;
}

.main {
  flex: 1;
  padding: 1.5rem;
  max-width: 80rem;
  width: 100%;
  margin: 0 auto;
}
`````

## File: web/src/components/OpsPanel.module.css
`````css
.panel {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding: 1.25rem;
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  transition: var(--transition-theme);
}

.heading {
  margin: 0;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--color-text-subtle);
}

.section {
  display: flex;
  flex-direction: column;
  gap: 0.65rem;
}

.sectionTitle {
  margin: 0;
  font-size: 0.95rem;
  font-weight: 600;
}

.field {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.label {
  font-size: 0.85rem;
  font-weight: 500;
  color: var(--color-text-muted);
}

.select {
  width: 100%;
  padding: 0.55rem 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg);
  color: var(--color-text);
}

.button {
  padding: 0.5rem 1rem;
  border-radius: var(--radius-md);
  border: 1px solid var(--color-border);
  background: var(--color-bg-muted);
  color: var(--color-text);
  cursor: pointer;
  transition: var(--transition-theme);
}

.button:hover:not(:disabled) {
  border-color: var(--color-border-strong);
}

.button:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.buttonPrimary {
  background: var(--color-accent);
  border-color: var(--color-accent);
  color: #fff;
}

.buttonPrimary:hover:not(:disabled) {
  background: var(--color-accent-hover);
  border-color: var(--color-accent-hover);
}

.buttonDanger {
  background: var(--color-danger);
  border-color: var(--color-danger);
  color: #fff;
}

.buttonDanger:hover:not(:disabled) {
  opacity: 0.9;
}

.hint {
  margin: 0;
  font-size: 0.8rem;
  color: var(--color-text-muted);
}

.candidateList {
  margin: 0;
  padding: 0;
  list-style: none;
  max-height: 10rem;
  overflow: auto;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg);
}

.candidateList li {
  padding: 0.45rem 0.65rem;
  border-bottom: 1px solid var(--color-border);
  font-family: var(--font-mono);
  font-size: 0.75rem;
  word-break: break-all;
}

.candidateList li:last-child {
  border-bottom: none;
}

.checkboxRow {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  font-size: 0.85rem;
  color: var(--color-text-muted);
}

.status {
  margin: 0;
  font-size: 0.8rem;
  color: var(--color-text-subtle);
}

.statusError {
  color: var(--color-danger);
}

.divider {
  height: 1px;
  background: var(--color-border);
  margin: 0.25rem 0;
}

.overlay {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: rgba(15, 23, 42, 0.45);
}

.modal {
  width: min(40rem, 100%);
  max-height: calc(100vh - 2rem);
  overflow: auto;
  padding: 1.25rem;
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-md);
}

.modalTitle {
  margin: 0 0 0.75rem;
  font-size: 1.15rem;
  font-weight: 600;
}

.summaryGrid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr));
  gap: 0.5rem;
  margin-bottom: 1rem;
}

.summaryChip {
  padding: 0.5rem 0.65rem;
  border-radius: var(--radius-md);
  background: var(--color-bg-muted);
  font-size: 0.8rem;
}

.summaryChip strong {
  display: block;
  font-size: 1rem;
}

.recommendations {
  margin: 0 0 1rem;
  padding: 0;
  list-style: none;
}

.recommendations li {
  margin: 0.35rem 0;
  padding: 0.5rem 0.65rem;
  border-radius: var(--radius-md);
  background: var(--color-bg-muted);
  font-size: 0.85rem;
}

.severityCritical {
  border-left: 3px solid var(--color-danger);
}

.severityWarning {
  border-left: 3px solid #d97706;
}

.severityInfo {
  border-left: 3px solid var(--color-accent);
}

.repoTable {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.8rem;
}

.repoTable th,
.repoTable td {
  padding: 0.4rem 0.5rem;
  text-align: left;
  border-bottom: 1px solid var(--color-border);
}

.repoTable th {
  color: var(--color-text-subtle);
  font-size: 0.72rem;
  text-transform: uppercase;
}

.modalActions {
  display: flex;
  justify-content: flex-end;
  margin-top: 1rem;
}
`````

## File: web/src/components/OpsPanel.tsx
`````typescript
import { useMemo, useState } from 'react'
import {
  ApiError,
  postHealth,
  postPrune,
  postPrunePreview,
  type PruneCandidate,
  type WorkspaceHealthResult,
  type WorkspaceProjectEntry,
} from '../api/client'
import styles from './OpsPanel.module.css'

export interface OpsPanelProps {
  projects: WorkspaceProjectEntry[]
  onWorkspaceRefresh?: () => void
}

export default function OpsPanel({ projects, onWorkspaceRefresh }: OpsPanelProps) {
  const [healthOpen, setHealthOpen] = useState(false)
  const [healthLoading, setHealthLoading] = useState(false)
  const [healthResult, setHealthResult] = useState<WorkspaceHealthResult | null>(null)
  const [healthError, setHealthError] = useState('')

  const [project, setProject] = useState('')
  const [candidates, setCandidates] = useState<PruneCandidate[]>([])
  const [pruneLoading, setPruneLoading] = useState(false)
  const [pruneMessage, setPruneMessage] = useState('')
  const [pruneError, setPruneError] = useState('')
  const [confirmPrune, setConfirmPrune] = useState(false)

  const projectOptions = useMemo(
    () => projects.filter((entry) => entry.name !== 'local'),
    [projects],
  )

  const selectedProject = project || projectOptions[0]?.name || ''

  const runHealth = async () => {
    setHealthLoading(true)
    setHealthError('')
    setHealthResult(null)
    try {
      const result = await postHealth({})
      setHealthResult(result)
      setHealthOpen(true)
    } catch (err) {
      setHealthError(err instanceof ApiError ? err.message : 'Health check failed.')
      setHealthOpen(true)
    } finally {
      setHealthLoading(false)
    }
  }

  const runPrunePreview = async () => {
    if (!selectedProject) {
      setPruneError('Select a project first.')
      return
    }
    setPruneLoading(true)
    setPruneError('')
    setPruneMessage('')
    setCandidates([])
    setConfirmPrune(false)
    try {
      const result = await postPrunePreview({ project: selectedProject })
      setCandidates(result.candidates ?? [])
      setPruneMessage(
        result.candidates?.length
          ? `${result.candidates.length} candidate(s) found.`
          : 'No unmanaged directories to prune.',
      )
    } catch (err) {
      setPruneError(err instanceof ApiError ? err.message : 'Prune preview failed.')
    } finally {
      setPruneLoading(false)
    }
  }

  const runPruneExecute = async () => {
    if (!selectedProject || candidates.length === 0 || !confirmPrune) {
      return
    }
    setPruneLoading(true)
    setPruneError('')
    setPruneMessage('')
    try {
      const result = await postPrune({
        project: selectedProject,
        paths: candidates.map((item) => item.path),
        force: true,
      })
      setPruneMessage(
        result.removed?.length
          ? `Removed ${result.removed.length} path(s).`
          : 'Prune completed with no removals.',
      )
      setCandidates([])
      setConfirmPrune(false)
      onWorkspaceRefresh?.()
    } catch (err) {
      setPruneError(err instanceof ApiError ? err.message : 'Prune failed.')
    } finally {
      setPruneLoading(false)
    }
  }

  return (
    <aside className={styles.panel} aria-label="Workspace operations">
      <h3 className={styles.heading}>Operations</h3>

      <div className={styles.section}>
        <h4 className={styles.sectionTitle}>Health</h4>
        <p className={styles.hint}>
          Run a workspace integrity check and review recommendations.
        </p>
        <button
          type="button"
          className={`${styles.button} ${styles.buttonPrimary}`}
          onClick={() => void runHealth()}
          disabled={healthLoading}
        >
          {healthLoading ? 'Checking…' : 'Health check'}
        </button>
      </div>

      <div className={styles.divider} />

      <div className={styles.section}>
        <h4 className={styles.sectionTitle}>Prune sync folders</h4>
        <div className={styles.field}>
          <label className={styles.label} htmlFor="prune-project">
            Project
          </label>
          <select
            id="prune-project"
            className={styles.select}
            value={selectedProject}
            onChange={(event) => {
              setProject(event.target.value)
              setCandidates([])
              setConfirmPrune(false)
              setPruneMessage('')
              setPruneError('')
            }}
            disabled={pruneLoading || projectOptions.length === 0}
          >
            {projectOptions.length === 0 ? (
              <option value="">No projects</option>
            ) : (
              projectOptions.map((entry) => (
                <option key={entry.name} value={entry.name}>
                  {entry.name}
                </option>
              ))
            )}
          </select>
        </div>
        <button
          type="button"
          className={styles.button}
          onClick={() => void runPrunePreview()}
          disabled={pruneLoading || !selectedProject}
        >
          Preview
        </button>
        {pruneMessage ? <p className={styles.status}>{pruneMessage}</p> : null}
        {pruneError ? <p className={`${styles.status} ${styles.statusError}`}>{pruneError}</p> : null}
        {candidates.length > 0 ? (
          <>
            <ul className={styles.candidateList}>
              {candidates.map((item) => (
                <li key={item.path}>{item.path}</li>
              ))}
            </ul>
            <label className={styles.checkboxRow}>
              <input
                type="checkbox"
                checked={confirmPrune}
                onChange={(event) => setConfirmPrune(event.target.checked)}
                disabled={pruneLoading}
              />
              I confirm deletion of the listed paths
            </label>
            <button
              type="button"
              className={`${styles.button} ${styles.buttonDanger}`}
              onClick={() => void runPruneExecute()}
              disabled={pruneLoading || !confirmPrune}
            >
              Execute prune
            </button>
          </>
        ) : null}
      </div>

      {healthOpen ? (
        <div
          className={styles.overlay}
          role="presentation"
          onClick={(event) => {
            if (event.target === event.currentTarget) {
              setHealthOpen(false)
            }
          }}
        >
          <div className={styles.modal} role="dialog" aria-modal="true">
            <h3 className={styles.modalTitle}>Health check results</h3>
            {healthError ? (
              <p className={`${styles.status} ${styles.statusError}`}>{healthError}</p>
            ) : null}
            {healthResult ? (
              <>
                {Object.keys(healthResult.summary).length > 0 ? (
                  <div className={styles.summaryGrid}>
                    {Object.entries(healthResult.summary).map(([key, value]) => (
                      <div key={key} className={styles.summaryChip}>
                        <strong>{value}</strong>
                        {key.replaceAll('_', ' ')}
                      </div>
                    ))}
                  </div>
                ) : null}
                {healthResult.recommendations.length > 0 ? (
                  <ul className={styles.recommendations}>
                    {healthResult.recommendations.map((item, index) => (
                      <li
                        key={`${item.action}-${index}`}
                        className={
                          item.severity === 'critical'
                            ? styles.severityCritical
                            : item.severity === 'warning'
                              ? styles.severityWarning
                              : styles.severityInfo
                        }
                      >
                        <strong>{item.severity}</strong> · {item.action}: {item.message}
                      </li>
                    ))}
                  </ul>
                ) : (
                  <p className={styles.status}>No recommendations.</p>
                )}
                {healthResult.repos.length > 0 ? (
                  <table className={styles.repoTable}>
                    <thead>
                      <tr>
                        <th>Project</th>
                        <th>Repo</th>
                        <th>Status</th>
                        <th>Branch</th>
                      </tr>
                    </thead>
                    <tbody>
                      {healthResult.repos.map((row) => (
                        <tr key={`${row.project_name}/${row.repo_name}`}>
                          <td>{row.project_name}</td>
                          <td>{row.repo_name}</td>
                          <td>{row.status}</td>
                          <td>{row.branch ?? '—'}</td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                ) : null}
              </>
            ) : null}
            <div className={styles.modalActions}>
              <button
                type="button"
                className={styles.button}
                onClick={() => setHealthOpen(false)}
              >
                Close
              </button>
            </div>
          </div>
        </div>
      ) : null}
    </aside>
  )
}
`````

## File: web/src/components/RepoTable.module.css
`````css
.tableWrap {
  overflow: auto;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  background: var(--color-bg-elevated);
  box-shadow: var(--shadow-sm);
  transition: var(--transition-theme);
}

.table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.875rem;
}

.table th {
  text-align: left;
  padding: 0.65rem 0.85rem;
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--color-text-subtle);
  background: var(--color-bg-muted);
  border-bottom: 1px solid var(--color-border);
}

.table td {
  padding: 0.55rem 0.85rem;
  border-bottom: 1px solid var(--color-border);
  vertical-align: middle;
}

.projectRow td {
  background: var(--color-bg-muted);
  border-bottom: 1px solid var(--color-border-strong);
}

.projectHeader {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 1rem;
}

.expandButton {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0;
  border: none;
  background: none;
  color: var(--color-text);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.expandButton:hover {
  color: var(--color-accent);
}

.projectMeta {
  color: var(--color-text-muted);
  font-size: 0.8rem;
  font-weight: 400;
}

.repoName {
  font-weight: 500;
}

.path {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--color-text-muted);
  word-break: break-all;
}

.badge {
  display: inline-block;
  padding: 0.15rem 0.5rem;
  border-radius: var(--radius-sm);
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.badgeSynced {
  background: rgba(5, 150, 105, 0.12);
  color: var(--color-success);
}

.badgeMissing {
  background: rgba(217, 119, 6, 0.14);
  color: #d97706;
}

[data-theme='dark'] .badgeMissing {
  color: #fbbf24;
}

.actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}

.button {
  padding: 0.35rem 0.65rem;
  border-radius: var(--radius-sm);
  border: 1px solid var(--color-border);
  background: var(--color-bg);
  color: var(--color-text);
  font-size: 0.8rem;
  cursor: pointer;
  transition: var(--transition-theme);
}

.button:hover {
  border-color: var(--color-border-strong);
  background: var(--color-bg-muted);
}

.buttonPrimary {
  background: var(--color-accent-soft);
  border-color: var(--color-accent);
  color: var(--color-accent);
}

.buttonPrimary:hover {
  background: var(--color-accent);
  color: #fff;
}

.empty {
  margin: 0;
  padding: 2rem 1rem;
  text-align: center;
  color: var(--color-text-muted);
}
`````

## File: web/src/components/RepoTable.tsx
`````typescript
import { useMemo, useState } from 'react'
import type { WorkspaceProjectEntry, WorkspaceRepoIndexRow } from '../api/client'
import { repoSelector, type StatusFilter } from '../pages/workspaceQueries'
import styles from './RepoTable.module.css'

export interface RepoTableProps {
  projects: WorkspaceProjectEntry[]
  reposIndex: WorkspaceRepoIndexRow[]
  statusFilter: StatusFilter
  search: string
  onSync: (repos: string[], title: string) => void
}

interface ProjectGroup {
  project: WorkspaceProjectEntry
  repos: WorkspaceRepoIndexRow[]
}

function matchesFilter(
  row: WorkspaceRepoIndexRow,
  statusFilter: StatusFilter,
): boolean {
  if (statusFilter === 'all') {
    return true
  }
  if (statusFilter === 'synced') {
    return row.status === 'synced'
  }
  return row.status === 'configured_missing'
}

function matchesSearch(row: WorkspaceRepoIndexRow, search: string): boolean {
  const needle = search.trim().toLowerCase()
  if (!needle) {
    return true
  }
  return (
    row.repo_name.toLowerCase().includes(needle) ||
    row.project_name.toLowerCase().includes(needle) ||
    (row.repo_path ?? '').toLowerCase().includes(needle)
  )
}

export default function RepoTable({
  projects,
  reposIndex,
  statusFilter,
  search,
  onSync,
}: RepoTableProps) {
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})

  const groups = useMemo(() => {
    const byProject = new Map<string, WorkspaceRepoIndexRow[]>()
    for (const row of reposIndex) {
      if (!matchesFilter(row, statusFilter) || !matchesSearch(row, search)) {
        continue
      }
      const list = byProject.get(row.project_name) ?? []
      list.push(row)
      byProject.set(row.project_name, list)
    }

    const ordered: ProjectGroup[] = []
    for (const project of projects) {
      const repos = byProject.get(project.name)
      if (!repos?.length) {
        continue
      }
      repos.sort((a, b) => a.repo_name.localeCompare(b.repo_name))
      ordered.push({ project, repos })
    }
    return ordered
  }, [projects, reposIndex, statusFilter, search])

  const toggleProject = (name: string) => {
    setCollapsed((current) => ({ ...current, [name]: !current[name] }))
  }

  if (groups.length === 0) {
    return <p className={styles.empty}>No repositories match the current filters.</p>
  }

  return (
    <div className={styles.tableWrap}>
      <table className={styles.table}>
        <thead>
          <tr>
            <th scope="col">Repository</th>
            <th scope="col">Status</th>
            <th scope="col">Path</th>
            <th scope="col">Actions</th>
          </tr>
        </thead>
        <tbody>
          {groups.map(({ project, repos }) => {
            const isCollapsed = collapsed[project.name] ?? false
            const selectors = repos.map((row) =>
              repoSelector(row.project_name, row.repo_name),
            )
            return (
              <ProjectSection
                key={project.name}
                project={project}
                repos={repos}
                collapsed={isCollapsed}
                onToggle={() => toggleProject(project.name)}
                onSync={onSync}
                onSyncAll={() =>
                  onSync(selectors, `Sync all in ${project.name} (${repos.length})`)
                }
              />
            )
          })}
        </tbody>
      </table>
    </div>
  )
}

interface ProjectSectionProps {
  project: WorkspaceProjectEntry
  repos: WorkspaceRepoIndexRow[]
  collapsed: boolean
  onToggle: () => void
  onSync: (repos: string[], title: string) => void
  onSyncAll: () => void
}

function ProjectSection({
  project,
  repos,
  collapsed,
  onToggle,
  onSync,
  onSyncAll,
}: ProjectSectionProps) {
  return (
    <>
      <tr className={styles.projectRow}>
        <td colSpan={4}>
          <div className={styles.projectHeader}>
            <button
              type="button"
              className={styles.expandButton}
              onClick={onToggle}
              aria-expanded={!collapsed}
            >
              <span aria-hidden>{collapsed ? '▸' : '▾'}</span>
              {project.name}
            </button>
            <span className={styles.projectMeta}>
              {repos.length} repo{repos.length === 1 ? '' : 's'}
              {project.description ? ` · ${project.description}` : ''}
            </span>
            <button type="button" className={styles.buttonPrimary} onClick={onSyncAll}>
              Sync all
            </button>
          </div>
        </td>
      </tr>
      {!collapsed
        ? repos.map((row) => (
            <tr key={`${row.project_name}/${row.repo_name}`}>
              <td className={styles.repoName}>{row.repo_name}</td>
              <td>
                <span
                  className={
                    row.status === 'synced'
                      ? `${styles.badge} ${styles.badgeSynced}`
                      : `${styles.badge} ${styles.badgeMissing}`
                  }
                >
                  {row.status === 'synced' ? 'synced' : 'missing'}
                </span>
              </td>
              <td className={styles.path}>{row.repo_path}</td>
              <td>
                <div className={styles.actions}>
                  <button
                    type="button"
                    className={styles.buttonPrimary}
                    onClick={() =>
                      onSync(
                        [repoSelector(row.project_name, row.repo_name)],
                        `${row.project_name}/${row.repo_name}`,
                      )
                    }
                  >
                    Sync
                  </button>
                </div>
              </td>
            </tr>
          ))
        : null}
    </>
  )
}
`````

## File: web/src/components/SyncDialog.module.css
`````css
.overlay {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: rgba(15, 23, 42, 0.45);
}

.dialog {
  width: min(32rem, 100%);
  max-height: calc(100vh - 2rem);
  overflow: auto;
  padding: 1.25rem;
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-md);
  transition: var(--transition-theme);
}

.title {
  margin: 0 0 0.25rem;
  font-size: 1.15rem;
  font-weight: 600;
}

.subtitle {
  margin: 0 0 1rem;
  color: var(--color-text-muted);
  font-size: 0.875rem;
}

.field {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  margin-bottom: 0.85rem;
}

.label {
  font-size: 0.85rem;
  font-weight: 500;
  color: var(--color-text-muted);
}

.select {
  width: 100%;
  padding: 0.55rem 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg);
  color: var(--color-text);
}

.checkboxRow {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 1rem;
  font-size: 0.9rem;
  color: var(--color-text-muted);
}

.actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  justify-content: flex-end;
}

.button {
  padding: 0.5rem 1rem;
  border-radius: var(--radius-md);
  border: 1px solid var(--color-border);
  background: var(--color-bg-muted);
  color: var(--color-text);
  cursor: pointer;
  transition: var(--transition-theme);
}

.button:hover:not(:disabled) {
  border-color: var(--color-border-strong);
}

.button:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.buttonPrimary {
  background: var(--color-accent);
  border-color: var(--color-accent);
  color: #fff;
}

.buttonPrimary:hover:not(:disabled) {
  background: var(--color-accent-hover);
  border-color: var(--color-accent-hover);
}

.status {
  margin: 0 0 1rem;
  padding: 0.75rem 1rem;
  background: var(--color-bg-muted);
  border-radius: var(--radius-md);
  font-size: 0.875rem;
  color: var(--color-text-muted);
}

.statusError {
  background: var(--color-danger-soft);
  color: var(--color-danger);
}

.summary {
  margin: 0 0 1rem;
  padding: 0;
  list-style: none;
  font-size: 0.85rem;
  color: var(--color-text-muted);
}

.summary li {
  margin: 0.2rem 0;
  font-family: var(--font-mono);
}
`````

## File: web/src/components/SyncDialog.tsx
`````typescript
import { useCallback, useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
  ApiError,
  getSyncJob,
  postSync,
  type SyncJobRequest,
} from '../api/client'
import { workspaceQueryKey } from '../pages/workspaceQueries'
import styles from './SyncDialog.module.css'

export interface SyncDialogProps {
  open: boolean
  title: string
  repos: string[]
  onClose: () => void
}

type Phase = 'idle' | 'running' | 'done' | 'error'

export default function SyncDialog({
  open,
  title,
  repos,
  onClose,
}: SyncDialogProps) {
  const queryClient = useQueryClient()
  const [mode, setMode] = useState<SyncJobRequest['mode']>('fetch')
  const [dryRun, setDryRun] = useState(false)
  const [phase, setPhase] = useState<Phase>('idle')
  const [jobId, setJobId] = useState<string | null>(null)
  const [message, setMessage] = useState('')
  const [summary, setSummary] = useState<Record<string, unknown> | null>(null)

  const reset = useCallback(() => {
    setPhase('idle')
    setJobId(null)
    setMessage('')
    setSummary(null)
  }, [])

  useEffect(() => {
    if (!open) {
      reset()
      setMode('fetch')
      setDryRun(false)
    }
  }, [open, reset])

  useEffect(() => {
    if (!jobId || phase !== 'running') {
      return undefined
    }

    let cancelled = false

    const poll = async () => {
      try {
        const status = await getSyncJob(jobId)
        if (cancelled) {
          return
        }
        if (status.state === 'completed') {
          setPhase('done')
          setSummary(status.summary)
          setMessage('Sync completed.')
          void queryClient.invalidateQueries({ queryKey: workspaceQueryKey })
          return
        }
        if (status.state === 'failed') {
          setPhase('error')
          setMessage(status.error ?? 'Sync failed.')
          return
        }
        setMessage(`Sync ${status.state}…`)
      } catch (err) {
        if (cancelled) {
          return
        }
        const text =
          err instanceof ApiError ? err.message : 'Failed to fetch sync status.'
        setPhase('error')
        setMessage(text)
      }
    }

    void poll()
    const timer = window.setInterval(() => {
      void poll()
    }, 1000)

    return () => {
      cancelled = true
      window.clearInterval(timer)
    }
  }, [jobId, phase, queryClient])

  const handleSubmit = async () => {
    reset()
    setPhase('running')
    setMessage('Starting sync job…')
    try {
      const response = await postSync({
        repos: repos.length > 0 ? repos : undefined,
        mode,
        dry_run: dryRun,
      })
      const id =
        typeof response.job_id === 'string'
          ? response.job_id
          : typeof response.status === 'object' &&
              response.status !== null &&
              'job_id' in response.status &&
              typeof (response.status as { job_id: unknown }).job_id === 'string'
            ? (response.status as { job_id: string }).job_id
            : null
      if (!id) {
        setPhase('error')
        setMessage('Server did not return a job id.')
        return
      }
      setJobId(id)
      setMessage('Sync running…')
    } catch (err) {
      setPhase('error')
      setMessage(err instanceof ApiError ? err.message : 'Failed to start sync.')
    }
  }

  if (!open) {
    return null
  }

  const busy = phase === 'running'

  return (
    <div
      className={styles.overlay}
      role="presentation"
      onClick={(event) => {
        if (event.target === event.currentTarget && !busy) {
          onClose()
        }
      }}
    >
      <div
        className={styles.dialog}
        role="dialog"
        aria-modal="true"
        aria-labelledby="sync-dialog-title"
      >
        <h3 id="sync-dialog-title" className={styles.title}>
          Sync repositories
        </h3>
        <p className={styles.subtitle}>{title}</p>

        {message ? (
          <p
            className={
              phase === 'error' ? `${styles.status} ${styles.statusError}` : styles.status
            }
          >
            {message}
          </p>
        ) : null}

        {summary ? (
          <ul className={styles.summary}>
            {Object.entries(summary).map(([key, value]) => (
              <li key={key}>
                {key}: {String(value)}
              </li>
            ))}
          </ul>
        ) : null}

        {phase === 'idle' || phase === 'error' ? (
          <>
            <div className={styles.field}>
              <label className={styles.label} htmlFor="sync-mode">
                Mode
              </label>
              <select
                id="sync-mode"
                className={styles.select}
                value={mode}
                onChange={(event) =>
                  setMode(event.target.value as SyncJobRequest['mode'])
                }
                disabled={busy}
              >
                <option value="fetch">fetch</option>
                <option value="pull">pull</option>
                <option value="clone">clone</option>
              </select>
            </div>
            <label className={styles.checkboxRow}>
              <input
                type="checkbox"
                checked={dryRun}
                onChange={(event) => setDryRun(event.target.checked)}
                disabled={busy}
              />
              Dry run
            </label>
          </>
        ) : null}

        <div className={styles.actions}>
          <button
            type="button"
            className={styles.button}
            onClick={onClose}
            disabled={busy}
          >
            {phase === 'done' ? 'Close' : 'Cancel'}
          </button>
          {phase === 'idle' || phase === 'error' ? (
            <button
              type="button"
              className={`${styles.button} ${styles.buttonPrimary}`}
              onClick={() => void handleSubmit()}
              disabled={busy || repos.length === 0}
            >
              Start sync
            </button>
          ) : null}
        </div>
      </div>
    </div>
  )
}
`````

## File: web/src/pages/graphQueries.ts
`````typescript
import { getWorkspaceGraph, type WorkspaceGraphView } from '../api/client'

export const graphQueryKey = (includeInferred: boolean, includeStructure: boolean) =>
  ['workspace-graph', includeInferred, includeStructure] as const

export async function fetchWorkspaceGraph(
  includeInferred: boolean,
  includeStructure: boolean,
): Promise<WorkspaceGraphView> {
  const view = await getWorkspaceGraph({
    includeInferred,
    includeStructure,
  })
  if (!view.ok) {
    throw new Error('Failed to load workspace graph')
  }
  return view
}
`````

## File: web/src/pages/workspaceQueries.ts
`````typescript
import { getWorkspace, type WorkspaceData } from '../api/client'

export const workspaceQueryKey = ['workspace'] as const

export async function fetchWorkspace(): Promise<WorkspaceData> {
  const envelope = await getWorkspace()
  if (!envelope.ok || !envelope.data) {
    const message = envelope.error?.message ?? 'Failed to load workspace'
    throw new Error(message)
  }
  return envelope.data
}

export type StatusFilter = 'all' | 'synced' | 'missing'

export function repoSelector(projectName: string, repoName: string): string {
  return `${projectName}/${repoName}`
}
`````

## File: web/src/theme/ThemeProvider.tsx
`````typescript
import { useEffect, type ReactNode } from 'react'
import { useThemeStore } from './useThemeStore'

interface ThemeProviderProps {
  children: ReactNode
}

export default function ThemeProvider({ children }: ThemeProviderProps) {
  const init = useThemeStore((state) => state.init)
  const syncSystemTheme = useThemeStore((state) => state.syncSystemTheme)

  useEffect(() => {
    init()
    const media = window.matchMedia('(prefers-color-scheme: dark)')
    const onChange = () => syncSystemTheme()
    media.addEventListener('change', onChange)
    return () => media.removeEventListener('change', onChange)
  }, [init, syncSystemTheme])

  return <>{children}</>
}
`````

## File: web/src/theme/useThemeStore.ts
`````typescript
import { create } from 'zustand'

export type ThemeMode = 'light' | 'dark' | 'system'

const STORAGE_KEY = 'metagit-web-theme'

function readStoredMode(): ThemeMode {
  if (typeof window === 'undefined') {
    return 'system'
  }
  const stored = window.localStorage.getItem(STORAGE_KEY)
  if (stored === 'light' || stored === 'dark' || stored === 'system') {
    return stored
  }
  return 'system'
}

function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
  if (mode === 'light' || mode === 'dark') {
    return mode
  }
  if (typeof window === 'undefined') {
    return 'light'
  }
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light'
}

function applyTheme(mode: ThemeMode): void {
  if (typeof document === 'undefined') {
    return
  }
  const resolved = resolveTheme(mode)
  document.documentElement.dataset.theme = resolved
}

interface ThemeState {
  mode: ThemeMode
  resolved: 'light' | 'dark'
  setMode: (mode: ThemeMode) => void
  toggleResolved: () => void
  init: () => void
  syncSystemTheme: () => void
}

export const useThemeStore = create<ThemeState>((set, get) => ({
  mode: readStoredMode(),
  resolved: resolveTheme(readStoredMode()),

  setMode: (mode) => {
    window.localStorage.setItem(STORAGE_KEY, mode)
    applyTheme(mode)
    set({ mode, resolved: resolveTheme(mode) })
  },

  toggleResolved: () => {
    const next = get().resolved === 'dark' ? 'light' : 'dark'
    get().setMode(next)
  },

  init: () => {
    const mode = readStoredMode()
    applyTheme(mode)
    set({ mode, resolved: resolveTheme(mode) })
  },

  syncSystemTheme: () => {
    if (get().mode === 'system') {
      applyTheme('system')
      set({ resolved: resolveTheme('system') })
    }
  },
}))

applyTheme(readStoredMode())
`````

## File: web/.gitignore
`````
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
`````

## File: web/eslint.config.js
`````javascript
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      js.configs.recommended,
      tseslint.configs.recommended,
      reactHooks.configs.flat.recommended,
      reactRefresh.configs.vite,
    ],
    languageOptions: {
      globals: globals.browser,
    },
  },
])
`````

## File: web/index.html
`````html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>web</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
`````

## File: web/package.json
`````json
{
  "name": "web",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@tanstack/react-query": "^5.100.11",
    "react": "^19.2.6",
    "react-dom": "^19.2.6",
    "react-router-dom": "^7.15.1",
    "zustand": "^5.0.13"
  },
  "devDependencies": {
    "@eslint/js": "^10.0.1",
    "@types/node": "^24.12.3",
    "@types/react": "^19.2.14",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^6.0.1",
    "eslint": "^10.3.0",
    "eslint-plugin-react-hooks": "^7.1.1",
    "eslint-plugin-react-refresh": "^0.5.2",
    "globals": "^17.6.0",
    "typescript": "~6.0.2",
    "typescript-eslint": "^8.59.2",
    "vite": "^8.0.12"
  }
}
`````

## File: web/README.md
`````markdown
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      // Other configs...

      // Remove tseslint.configs.recommended and replace with this
      tseslint.configs.recommendedTypeChecked,
      // Alternatively, use this for stricter rules
      tseslint.configs.strictTypeChecked,
      // Optionally, add this for stylistic rules
      tseslint.configs.stylisticTypeChecked,

      // Other configs...
    ],
    languageOptions: {
      parserOptions: {
        project: ['./tsconfig.node.json', './tsconfig.app.json'],
        tsconfigRootDir: import.meta.dirname,
      },
      // other options...
    },
  },
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      // Other configs...
      // Enable lint rules for React
      reactX.configs['recommended-typescript'],
      // Enable lint rules for React DOM
      reactDom.configs.recommended,
    ],
    languageOptions: {
      parserOptions: {
        project: ['./tsconfig.node.json', './tsconfig.app.json'],
        tsconfigRootDir: import.meta.dirname,
      },
      // other options...
    },
  },
])
```
`````

## File: web/tsconfig.app.json
`````json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "es2023",
    "lib": ["ES2023", "DOM"],
    "module": "esnext",
    "types": ["vite/client"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}
`````

## File: web/tsconfig.json
`````json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}
`````

## File: web/tsconfig.node.json
`````json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "es2023",
    "lib": ["ES2023"],
    "module": "esnext",
    "types": ["node"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,

    /* Linting */
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["vite.config.ts"]
}
`````

## File: web/vite.config.ts
`````typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  base: '/',
  build: {
    outDir: '../src/metagit/data/web',
    emptyOutDir: true,
  },
  server: {
    proxy: {
      '/v2': {
        target: 'http://127.0.0.1:8787',
        changeOrigin: true,
      },
      '/v3': {
        target: 'http://127.0.0.1:8787',
        changeOrigin: true,
      },
    },
  },
})
`````

## File: .env.example
`````
# Git Provider API Tokens
GITHUB_TOKEN="ghp_your_github_token_here"
GITHUB_URL="https://github.company.com/api/v3"
GITLAB_TOKEN="glpat_your_gitlab_token_here"
GITLAB_URL="https://gitlab.company.com/api/v4"

# Agent-optimized mode: no fuzzy finder, prompts, or editor (true/false)
# METAGIT_AGENT_MODE=false

METAGIT_LLM="openrouter"
METAGIT_LLM_MODEL="qwen/qwen2.5-vl-72b-instruct:free"
METAGIT_LLM_TOKEN=""

# OPENAI_API_KEY=""
# OPENROUTER_API_KEY=""
# OPENAI_MODEL_NAME="openrouter/nousresearch/deephermes-3-mistral-24b-preview:free"
# OPENAI_API_BASE="https://openrouter.ai/api/v1"
# OPENAI_PROXY_MODELS="openrouter/nousresearch/deephermes-3-mistral-24b-preview:free"

# GitLab Personal Access Token for repository metrics
#GITLAB_TOKEN="glpat_your_gitlab_token_here"
# Custom API URLs (for self-hosted instances)
#GITHUB_URL="https://github.company.com/api/v3"
#GITLAB_URL="https://gitlab.company.com/api/v4"
`````

## File: .gitsecrets.lock
`````
{
  "version": "1.0",
  "secrets": {},
  "secretfile": {
    "filename": "Secretfile.yml",
    "hash": "34ce5cc41aaab6f6832b541070a1aa8405b5f2262356b2e20caa49ba00c61209",
    "synced_at": "2026-05-06T16:59:13.263343+00:00",
    "var_files": [],
    "variables_hash": "cd258ea8b5adce1d02de944cab545b051be7a2d1da5f593790747636fa5f37da",
    "sync_identity": {
      "client": "cli",
      "secretzero_version": "0.13.3",
      "os_user": "zacharyloeber",
      "os_uid": 501,
      "os_euid": 501,
      "hostname": "Zacharys-MBP.home.loeber.live",
      "host_fqdn": "Zacharys-MBP.home.loeber.live",
      "platform": "macOS-26.3.1-arm64-arm-64bit",
      "git_user_name": "Zachary Loeber",
      "git_user_email": "zloeber@gmail.com",
      "git_commit_sha": "cafd6da"
    },
    "manifest_spec_version": "1"
  },
  "metadata": {}
}
`````

## File: .python-version
`````
3.12
`````

## File: configure.sh
`````bash
#!/usr/bin/env bash

# install mise if not already installed to local path
if ! command -v ~/.local/bin/mise &>/dev/null; then
    echo "mise is not installed. Would you like to install it now? (y/N)"
    read -r response
    if [[ "$response" =~ ^[Yy]$ ]]; then
        curl https://mise.run | sh
        echo ""
        echo "Load mise into your path (add to your ~/.zshrc profile to always be available): "
        echo '   echo 'eval "$(~/.local/bin/mise activate zsh)"' >> ~/.zshrc'
        # Load mise into the current script shell instead of zsh
        eval "$(~/.local/bin/mise activate bash)"
    else
        echo "Skipping mise installation."
        exit 1
    fi
else
    eval "$(~/.local/bin/mise activate bash)"
fi

mise install -y
uv sync
`````

## File: LICENSE.md
`````markdown
MIT License

Copyright (c) 2025 Zachary Loeber

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`````

## File: Secretfile.yml
`````yaml
variables:
  github_org: metagit-ai
  github_repo: metagit-cli

providers:
  github:
    kind: github
    auth:
      kind: token
      config:
        token: ${METAGIT_GITHUB_TOKEN}

secrets:
  # ── Release token with write access to contents & PRs ──────────────
  - name: release_token
    kind: static
    config:
      default: ${PAT_TOKEN}
    targets:
      - provider: github
        kind: github_secret
        config:
          owner: ${github_org}
          repo: ${github_repo}
          secret_name: PAT_TOKEN

metadata:
  project: metagit-cli
  owner: metagit-ai
  description: PyPI API tokens for metagit-cli package publishing
  environments:
  - testpypi
  - production
`````

## File: .cursor/skills/metagit-cli/SKILL.md
`````markdown
---
name: metagit-cli
description: CLI-only shortcuts for metagit agents — workspace catalog, discovery, prompts, sync, layout, and config. Use instead of MCP or HTTP API when operating from a shell or agent_mode session.
---

# Metagit CLI (agent shortcuts)

Use this skill when an agent should drive metagit **only through the `metagit` command**. Do not call MCP tools or `metagit api` from workflows covered here unless the user explicitly asks.

Set non-interactive defaults when automating:

```bash
export METAGIT_AGENT_MODE=true
```

Global flags (most commands):

- `-c path/to/metagit.config.yaml` — app config (default `metagit.config.yaml`)
- Workspace manifest: `--definition` / `-c` on catalog commands (default `.metagit.yml`)

---

## Manifest editing fast map (`.metagit.yml`)

Use this table first when changing a workspace manifest from the CLI. Prefer **catalog commands** for projects/repos; use **`config patch`** for everything else in the schema (documentation, graph, dedupe overrides, nested fields).

| Task | Command |
|------|---------|
| **Inspect** manifest on disk | `metagit config show -c .metagit.yml` |
| **Inspect** normalized model | `metagit config show -c .metagit.yml --normalized` |
| **Inspect** as JSON (agents) | `metagit config show -c .metagit.yml --json` |
| **Browse** fields / paths | `metagit config tree -c .metagit.yml` or `… --json` |
| **Validate** after edits | `metagit config validate -c .metagit.yml` |
| **Dry-run** schema change | `metagit config preview -c .metagit.yml --file ops.json` |
| **Apply** schema change | `metagit config patch -c .metagit.yml --file ops.json --save` |
| **Set one field** | `metagit config patch -c .metagit.yml --op set --path <path> --value <v> --save` |
| **Enable** optional block | `metagit config patch … --op enable --path <path> --save` |
| **Add list item** | `metagit config patch … --op append --path <list.path> --save` then `set` on `[index].field` |
| **Remove list item** | `metagit config patch … --op remove --path <list.path>[index] --save` |

### Catalog shortcuts (projects & repos)

| Task | Command |
|------|---------|
| List projects | `metagit workspace project list -c .metagit.yml --json` |
| List repos (all / one project) | `metagit workspace repo list -c .metagit.yml --json` / `… --project <p> --json` |
| Add project | `metagit workspace project add -c .metagit.yml --name <p> --json` |
| Remove project | `metagit workspace project remove -c .metagit.yml --name <p> --json` |
| Rename project (dry-run) | `metagit workspace project rename -c .metagit.yml --name <old> --new-name <new> --dry-run --json` |
| Add repo | `metagit workspace repo add -c .metagit.yml --project <p> --name <r> --url <url> --json` |
| Remove repo (manifest only) | `metagit workspace repo remove -c .metagit.yml --project <p> --name <r> --json` |
| Rename / move repo (dry-run) | `metagit workspace repo rename …` / `metagit workspace repo move … --dry-run --json` |
| Search before adding | `metagit search "<name>" -c .metagit.yml --json` |

Active project context (optional): `metagit project repo add --name <r> --url <url>` after `metagit project select`.

### Schema patch examples (`ops.json`)

Paths use dot/bracket notation (same as web Config Studio). `--value` accepts JSON for objects.

```json
{
  "operations": [
    { "op": "set", "path": "name", "value": "my-workspace" },
    { "op": "enable", "path": "documentation" },
    { "op": "append", "path": "documentation" },
    { "op": "set", "path": "documentation[0]", "value": { "kind": "markdown", "path": "AGENTS.md" } },
    { "op": "enable", "path": "graph" },
    { "op": "append", "path": "graph.relationships" },
    {
      "op": "set",
      "path": "graph.relationships[0]",
      "value": {
        "id": "api-depends-infra",
        "from": { "project": "platform", "repo": "api" },
        "to": { "project": "infra", "repo": "terraform" },
        "type": "depends_on"
      }
    },
    { "op": "set", "path": "workspace.projects[0].dedupe.enabled", "value": false }
  ]
}
```

```bash
metagit config preview -c .metagit.yml --file ops.json
metagit config patch -c .metagit.yml --file ops.json --save
metagit config validate -c .metagit.yml
```

### App config vs manifest

| File | Scope | Edit via |
|------|-------|----------|
| `.metagit.yml` | Workspace manifest (projects, repos, docs, graph) | `metagit config …` + `metagit workspace …` |
| `metagit.config.yaml` | Tooling (paths, dedupe default, providers, profiles) | `metagit appconfig …` |

```bash
metagit appconfig show --format minimal-yaml
metagit appconfig patch --op set --path workspace.dedupe.enabled --value false --save
metagit config providers --show
```

### After every manifest edit

1. `metagit config validate -c .metagit.yml`
2. `metagit workspace list -c .metagit.yml --json` (sanity-check catalog)
3. If repos changed on disk: `metagit project sync` or `metagit project sync --hydrate`

### Export manual graph to GitNexus (Cypher)

| Task | Command |
|------|---------|
| Full export bundle (JSON) | `metagit config graph export -c .metagit.yml --json` |
| Raw Cypher script | `metagit config graph export -c .metagit.yml --format cypher -o graph.cypher` |
| MCP tool_calls only | `metagit config graph export -c .metagit.yml --format tool-calls` |
| Manual edges only | `metagit config graph export -c .metagit.yml --manual-only --format tool-calls` |
| MCP from agent | `metagit_export_workspace_graph_cypher` |

Ingest workflow: run `schema_statements` once via `gitnexus_cypher`, then each statement in `tool_calls` (or pipe `--format cypher`). Overlay tables: `MetagitEntity`, `MetagitLink`.

---

## Prompt commands (all kinds)

List built-in prompt kinds:

```bash
metagit prompt list
metagit prompt list --json
```

Emit prompts (`--text-only` for paste into agent context; `--json` for structured output; `--no-instructions` to omit manifest layers):

| Scope | Command | Default kind |
|-------|---------|--------------|
| Workspace | `metagit prompt workspace -c <definition> -k <kind>` | `instructions` |
| Project | `metagit prompt project -p <project> -c <definition> -k <kind>` | `instructions` |
| Repo | `metagit prompt repo -p <project> -n <repo> -c <definition> -k <kind>` | `instructions` |

### Prompt kinds by scope

| Kind | Workspace | Project | Repo | Purpose |
|------|:---------:|:-------:|:----:|---------|
| `instructions` | yes | yes | yes | Composed `agent_instructions` from manifest layers |
| `session-start` | yes | — | — | Session bootstrap checklist |
| `catalog-edit` | yes | yes | — | Search-before-create; catalog registration |
| `health-preflight` | yes | yes | — | Pre-work workspace/repo status pass |
| `sync-safe` | yes | yes | yes | Guarded sync rules |
| `subagent-handoff` | — | yes | yes | Delegate single-repo work |
| `layout-change` | yes | yes | yes | Rename/move dry-run workflow |
| `repo-enrich` | — | — | yes | **Discover + merge** workspace repo entry |

### Prompt shortcuts (copy-paste)

```bash
# Session bootstrap
metagit prompt workspace -k session-start --text-only -c .metagit.yml

# Composed instructions at each level
metagit prompt workspace -k instructions --text-only -c .metagit.yml
metagit prompt project -p default -k instructions --text-only -c .metagit.yml
metagit prompt repo -p default -n my-api -k instructions --text-only -c .metagit.yml

# Repo catalog enrichment (detect + merge manifest entry)
metagit prompt repo -p default -n my-api -k repo-enrich --text-only -c .metagit.yml

# Catalog registration discipline
metagit prompt workspace -k catalog-edit --text-only -c .metagit.yml

# Safe sync reminder
metagit prompt repo -p default -n my-api -k sync-safe --text-only -c .metagit.yml

# Subagent handoff
metagit prompt repo -p default -n my-api -k subagent-handoff --text-only -c .metagit.yml
```

---

## Repo enrich workflow (`repo-enrich`)

Run the prompt, then execute its steps:

```bash
metagit prompt repo -p <project> -n <repo> -k repo-enrich --text-only -c .metagit.yml
```

Typical discovery chain on the checkout:

```bash
cd "$(metagit search '<repo>' -c .metagit.yml --path-only)"
metagit detect repository -p . -o json
metagit detect repo -p . -o yaml
metagit detect repo_map -p . -o json
```

Provider metadata (dry-run):

```bash
metagit project source sync --provider github --org <org> --mode discover --no-apply
```

After merging fields into `workspace.projects[].repos[]`:

```bash
metagit config validate -c .metagit.yml
metagit workspace repo list --project <project> --json
```

---

## Workspace and catalog

Per-project dedupe override in `.metagit.yml` (overrides `workspace.dedupe.enabled` in `metagit.config.yaml` for that project only):

```yaml
workspace:
  projects:
    - name: local
      dedupe:
        enabled: false
      repos: []
```

```bash
metagit appconfig show --format json
metagit config info -c .metagit.yml
metagit config show -c .metagit.yml
metagit config validate -c .metagit.yml

metagit workspace list -c .metagit.yml --json
metagit workspace project list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --project <name> --json

metagit workspace project add --name <name> --json
metagit workspace repo add --project <name> --name <repo> --url <url> --json
metagit workspace project remove --name <name> --json
metagit workspace repo remove --project <name> --name <repo> --json
```

Search managed repos (always before creating entries):

```bash
metagit search "<query>" -c .metagit.yml --json
metagit search "<query>" -c .metagit.yml --path-only
metagit search "<query>" -c .metagit.yml --tag tier=1 --project <name>
```

---

## Project operations

```bash
metagit project list --config .metagit.yml --all --json
metagit project add --name <name> --json
metagit project remove --name <name> --json
metagit project rename --name <old> --new-name <new> --dry-run --json
metagit project select
metagit project sync
metagit project sync --hydrate   # symlink mounts → full directory copies (per-file progress)

metagit project repo list --json
metagit project repo add --project <name> --name <repo> --url <url>
metagit project repo remove --name <repo> --json
metagit project repo rename --name <old> --new-name <new> --dry-run --json
metagit project repo move --name <repo> --to-project <other> --dry-run --json
metagit project repo prune --project <name> --dry-run

metagit project source sync --provider github --org <org> --mode discover --no-apply
metagit project source sync --provider github --org <org> --mode additive --apply
```

Layout (manifest + disk; always dry-run first):

```bash
metagit workspace project rename --name <old> --new-name <new> --dry-run --json
metagit workspace repo rename --project <p> --name <old> --new-name <new> --dry-run --json
metagit workspace repo move --project <p> --name <repo> --to-project <other> --dry-run --json
```

---

## Discovery and local metadata

```bash
metagit detect project -p <path> -o yaml
metagit detect repo -p <path> -o yaml
metagit detect repo_map -p <path> -o json
metagit detect repository -p <path> -o json
metagit detect repository -p <path> -o metagit
# --save only with operator approval (blocked in agent_mode)
```

Bootstrap new trees:

```bash
metagit init --kind application
metagit init --kind umbrella --template hermes-orchestrator
```

---

## Selection and scope

```bash
metagit workspace select --project <name>
metagit project select
metagit project repo select
```

---

## Config and appconfig (reference)

See **Manifest editing fast map** above for day-to-day manifest work. Additional commands:

```bash
metagit config info -c .metagit.yml
metagit config example
metagit config schema
metagit appconfig validate
metagit appconfig get --name config.workspace.path
metagit appconfig tree --json
metagit appconfig preview --file ops.json
metagit appconfig patch --file ops.json --save
metagit config providers --show
```

---

## Records, skills, version

```bash
metagit record search "<query>"
metagit skills list
metagit skills show metagit-cli
metagit skills install --skill metagit-cli
metagit version
metagit info
```

---

## Agent habits

1. **Search before create** — `metagit search` then catalog add.
2. **Validate after manifest edits** — `metagit config validate`.
3. **Emit prompts instead of rewriting playbooks** — `metagit prompt … --text-only`.
4. **Enrich stale repo entries** — `metagit prompt repo … -k repo-enrich` then detect + merge.
5. **Dry-run layout** — always `--dry-run --json` before apply.
6. **Prefer `METAGIT_AGENT_MODE=true`** in CI and agent loops to skip fuzzy finder and confirm dialogs.

## Related bundled skills

Use topic skills when you need deeper playbooks (some mention MCP): `metagit-projects`, `metagit-workspace-scope`, `metagit-workspace-sync`, `metagit-config-refresh`. This skill is the **CLI-only** index and prompt reference.
`````

## File: .cursor/skills/metagit-control-center/scripts/control-cycle.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-infra}"

"$(dirname "$0")/../../metagit-gating/scripts/gate-status.zsh" "$ROOT"

if [[ -n "$QUERY" ]]; then
  "$(dirname "$0")/../../metagit-upstream-scan/scripts/upstream-scan.zsh" "$ROOT" "$QUERY" "$PRESET" 20
else
  echo "status=ok\tmessage=no-query-provided"
fi
`````

## File: .cursor/skills/metagit-control-center/SKILL.md
`````markdown
---
name: metagit-control-center
description: Use when running metagit as an MCP control center for multi-repo awareness, guarded sync, and operational knowledge across ongoing agent tasks.
---

# Metagit Control Center Skill

Use this skill when an agent should actively coordinate repository context and task execution across a workspace.

## Purpose

Provide a repeatable control-center workflow where Metagit MCP guides awareness, synchronization, and operational continuity over multiple related repositories.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for control-center cycles:
- `./scripts/control-cycle.zsh [root_path] ["query"] [preset]`

Wrapper behavior:
- runs gating status first
- optionally runs upstream discovery for blocker queries
- emits compact, machine-readable lines

## Core Workflows

### 1) Session Initialization
- Validate active workspace gate.
- Read `metagit://workspace/config` and `metagit://workspace/repos/status`.
- Call `metagit_project_context_switch` when the objective is tied to a workspace project.
- Run `metagit_workspace_health_check` or read `metagit://workspace/health` for maintenance signals.
- Identify stale repos and unresolved blockers from prior activity.

### 2) Active Task Support
- For each coding objective, map impacted repos.
- Use workspace search and upstream hints before broad exploration.
- Sync only repos that are required by the active objective.

### 3) Guarded Synchronization
- Default to `fetch` for visibility.
- Use `pull` or `clone` only with explicit permission and rationale.
- Track sync outcomes in operations log resource.

### 4) Operational Memory
- Before switching projects: `metagit_session_update` (notes + recent repos), optional `metagit_workspace_state_snapshot`.
- After returning: `metagit_workspace_state_restore` when a snapshot was taken (metadata only; git tree is unchanged).

Maintain bounded local records of:
- sync actions
- issue signatures searched
- candidate upstream repos identified
- unresolved dependencies and follow-ups

## Decision Guidelines

- Use metagit search first when blocker appears external to current repo.
- Prefer deterministic evidence over speculative jumps.
- Keep operations minimal and auditable.

## Output Contract

For each control-center cycle, provide:
- current objective
- repositories examined
- actions taken (or intentionally deferred)
- next recommended step

## Safety Rules

- Never mutate repositories without explicit authorization.
- Never broaden scope beyond configured workspace boundaries.
- Always preserve a clear audit trail of control actions.
`````

## File: .cursor/skills/metagit-gitnexus/SKILL.md
`````markdown
---
name: metagit-gitnexus
description: Run gitnexus analysis for a target workspace and selected project repositories before graph-dependent tasks. Use when index staleness is detected or cross-repo graph results are needed.
---

# Running GitNexus Analysis

Use this skill whenever GitNexus context is stale or missing for target repositories.

## Local Wrapper (Use First)

- `zsh ./skills/metagit-gitnexus/scripts/analyze-targets.zsh <workspace_root> [project_name]`

## Workflow

1. Analyze the current repository where the command is run.
2. Resolve target project repositories from `.metagit.yml`.
3. Run `npx gitnexus analyze` in each local repository path found.
4. Report per-repository success/failure and next actions.

## Manual workspace graph (`.metagit.yml` → Cypher)

Export manifest `graph.relationships` for GitNexus overlay ingest:

```bash
metagit config graph export -c .metagit.yml --format tool-calls
# or MCP: metagit_export_workspace_graph_cypher
```

Run returned `gitnexus_cypher` tool calls against the umbrella repo index (`--gitnexus-repo` when names differ). Schema DDL (`MetagitEntity` / `MetagitLink`) runs once per index.

## Output Contract

Return:
- analyzed repositories
- failures and reasons
- whether graph queries are safe to run now

## Safety

- Skip repositories that do not exist locally.
- Do not mutate repo content; analysis should be read-only indexing.
`````

## File: .cursor/skills/metagit-repo-impact/SKILL.md
`````markdown
---
name: metagit-repo-impact
description: Plan repository change impact before edits by combining metagit workspace context and graph-based dependency analysis. Use when a change may affect multiple repositories.
---

# Planning Repo Impact

Use this skill before risky or cross-repo modifications.

## Workflow

1. Identify target symbols/files for change.
2. Use metagit workspace context to bound repository scope.
3. Use graph impact tooling to estimate blast radius.
4. Produce a change plan with test and rollback focus.

## Commands

- `metagit workspace select --project <name>`
- MCP `metagit_cross_project_dependencies` with `source_project` and `dependency_types` before large cross-project edits
- MCP `metagit_project_context_switch` to bound scope to one workspace project
- `npx gitnexus analyze` on affected repos when `graph_status` is `stale` or `missing`
- `npx gitnexus query` / `gitnexus impact` for symbol-level analysis after indexes are fresh

## Output Contract

Return:
- impacted repositories and interfaces
- highest-risk change points
- minimum validation plan

## Safety

- Do not execute mutating steps in this planning phase.
`````

## File: .cursor/skills/metagit-workspace-scope/SKILL.md
`````markdown
---
name: metagit-workspace-scope
description: Discover active metagit workspace scope, project boundaries, and repository status. Use when an agent starts work in a multi-repo workspace and needs fast, scoped context before editing.
---

# Discovering Workspace Scope

Use this skill at session start for workspace-aware tasks.

## Workflow

1. Run workspace gate and status checks first.
2. Read configured projects and repository status from metagit resources/tools.
3. Build a compact map of active project names, repo names, and sync state.
4. Report a bounded scope for the current objective.

## Commands

- `zsh ./skills/metagit-gating/scripts/gate-status.zsh [root_path]`
- `metagit workspace select --project <name>` (interactive repo picker)
- MCP `metagit_project_context_switch` with `project_name` (structured context + session restore)
- MCP `metagit_workspace_state_snapshot` before leaving a project for a long switch

## Output Contract

Return:
- active/inactive workspace state and reason
- candidate project to operate on
- top repositories to inspect first

## Safety

- Keep operations read-only in this step.
- Do not sync or mutate repositories without explicit request.
`````

## File: .cursor/skills/metagit-workspace-sync/SKILL.md
`````markdown
---
name: metagit-workspace-sync
description: Sync workspace repositories safely using metagit with scoped fetch, pull, or clone actions. Use when repository content must be refreshed for implementation.
---

# Syncing Workspace Repositories

Use this skill when repository state must be updated.

## Workflow

1. Confirm active workspace and target project.
2. Start with read-only status and fetch where possible.
3. Use pull/clone only when required for the current objective.
4. Summarize which repositories changed and what remains stale.

## Commands

- `metagit project sync --project <name>`
- MCP `metagit_workspace_sync` for batch fetch/pull/clone (`repos`, `only_if`, `dry_run`, `max_parallel`)
- MCP `metagit_repo_sync` for a single repository (requires `allow_mutation` for pull/clone)
- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- repositories synced
- sync mode used (fetch/pull/clone)
- failures and retry guidance

## Safety

- Limit sync to repos defined in `.metagit.yml`.
- Avoid broad synchronization if only a subset is needed.
`````

## File: .github/workflows/semantic-release.yaml
`````yaml
name: Semantic Release
run-name: Semantic Release - ${{ github.ref_name }}

on:
  push:
    branches:
      - main
  workflow_dispatch:

env:
  PROJECT_NAME: metagit-cli

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: write

jobs:
  semantic-release:
    name: Generate Semantic Release Tag
    runs-on: ubuntu-latest
    environment:
      name: production

    steps:
      - name: Checkout code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          token: ${{ secrets.PAT_TOKEN }}

      - name: Set up Git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

      - name: Get previous tag
        id: prev_tag
        run: |
          PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
          echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT

      - name: Parse commits and determine version bump
        id: version
        run: |
          PREV_TAG=${{ steps.prev_tag.outputs.tag }}
          PREV_VERSION="${PREV_TAG#v}"

          if git rev-parse "${{ steps.prev_tag.outputs.tag }}" >/dev/null 2>&1; then
            COMMITS=$(git log "${{ steps.prev_tag.outputs.tag }}..HEAD" --pretty=format:%s)
          else
            COMMITS=$(git log --pretty=format:%s)
          fi

          echo "Commits since last release:"
          echo "$COMMITS"

          MAJOR_BUMP=0
          MINOR_BUMP=0
          PATCH_BUMP=0

          echo "$COMMITS" | while IFS= read -r line; do
            if [[ "$line" =~ ^BREAKING\ CHANGE|^feat\!: ]]; then
              MAJOR_BUMP=1
            elif [[ "$line" =~ ^feat: ]]; then
              MINOR_BUMP=1
            elif [[ "$line" =~ ^fix:|^refactor:|^perf: ]]; then
              PATCH_BUMP=1
            fi
          done

          if [[ "$COMMITS" =~ BREAKING\ CHANGE|feat\!: ]]; then
            MAJOR_BUMP=1
          elif [[ "$COMMITS" =~ feat: ]]; then
            MINOR_BUMP=1
          elif [[ "$COMMITS" =~ fix:|refactor:|perf: ]]; then
            PATCH_BUMP=1
          fi

          IFS='.' read -r MAJOR MINOR PATCH <<< "$PREV_VERSION"
          MAJOR=${MAJOR:-0}
          MINOR=${MINOR:-0}
          PATCH=${PATCH:-0}

          if [[ $MAJOR_BUMP -eq 1 ]]; then
            MAJOR=$((MAJOR + 1))
            MINOR=0
            PATCH=0
          elif [[ $MINOR_BUMP -eq 1 ]]; then
            MINOR=$((MINOR + 1))
            PATCH=0
          elif [[ $PATCH_BUMP -eq 1 ]] || git rev-parse "${{ steps.prev_tag.outputs.tag }}" >/dev/null 2>&1 && [[ "$PREV_TAG" != "0.0.0" ]]; then
            PATCH=$((PATCH + 1))
          else
            if [[ "$PREV_TAG" == "0.0.0" ]] && [[ $PATCH_BUMP -eq 0 ]] && [[ $MINOR_BUMP -eq 0 ]] && [[ $MAJOR_BUMP -eq 0 ]]; then
              PATCH=1
            fi
          fi

          NEW_VERSION="$MAJOR.$MINOR.$PATCH"
          echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
          echo "New version: $NEW_VERSION"

      - name: Check if tag already exists
        id: check_tag
        run: |
          if git rev-parse "refs/tags/${{ steps.version.outputs.new_version }}" >/dev/null 2>&1; then
            echo "tag_exists=true" >> $GITHUB_OUTPUT
            echo "Tag ${{ steps.version.outputs.new_version }} already exists"
          else
            echo "tag_exists=false" >> $GITHUB_OUTPUT
          fi

      - name: Create and push tag
        if: steps.check_tag.outputs.tag_exists == 'false'
        run: |
          git tag -a "${{ steps.version.outputs.new_version }}" -m "Release ${{ steps.version.outputs.new_version }}"
          git push origin "${{ steps.version.outputs.new_version }}"
          echo "Created and pushed tag: ${{ steps.version.outputs.new_version }}"

      - name: Create Release Notes
        if: steps.check_tag.outputs.tag_exists == 'false'
        id: release_notes
        run: |
          PREV_TAG=${{ steps.prev_tag.outputs.tag }}
          NEW_VERSION="${{ steps.version.outputs.new_version }}"

          if git rev-parse "$PREV_TAG" >/dev/null 2>&1; then
            CHANGELOG=$(git log "$PREV_TAG..HEAD" --pretty=format:"- %s (%h)")
          else
            CHANGELOG=$(git log --pretty=format:"- %s (%h)")
          fi

          echo "changelog<<EOF" >> $GITHUB_OUTPUT
          echo "$CHANGELOG" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      - name: Create GitHub Release
        if: steps.check_tag.outputs.tag_exists == 'false'
        uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
        with:
          tag_name: ${{ steps.version.outputs.new_version }}
          name: Release ${{ steps.version.outputs.new_version }}
          body: |
            ## What's Changed

            ${{ steps.release_notes.outputs.changelog }}

            **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.tag }}...${{ steps.version.outputs.new_version }}
          draft: false
          prerelease: false

      - name: Log Output
        run: |
          echo "Previous tag: ${{ steps.prev_tag.outputs.tag }}"
          echo "New version: ${{ steps.version.outputs.new_version }}"
          echo "Tag exists: ${{ steps.check_tag.outputs.tag_exists }}"
`````

## File: .github/workflows/test.yaml
`````yaml
name: Test

on: [push, pull_request]

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

env:
  PROJECT_PATH: src/metagit

permissions:
  contents: read

jobs:
  test:
    name: Test - ${{ matrix.os }} - ${{ matrix.python-version }} - ${{ github.ref_name }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        python-version: ["3.13"]
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip
          cache-dependency-path: setup.py

      - name: Set up environment
        run: |
          pip install uv
          uv venv
          uv pip install -e ".[test]"

      - name: Unit Tests
        run: |
          uv run pytest --maxfail=1 --disable-warnings -v tests

      - name: Linting
        if: matrix.os != 'windows-latest'
        run: |
          uv run ruff check ${{ env.PROJECT_PATH }}
          uv run ruff format --check ${{ env.PROJECT_PATH }}
      # - name: Type Checking
      #   if: matrix.os != 'windows-latest'
      #   run: |
      #     mypy metagit
      - name: Build Check
        run: |
          uv sync
          uv build
`````

## File: .mex/context/architecture.md
`````markdown
---
name: architecture
description: How the major pieces of this project connect and flow. Load when working on system design, integrations, or understanding how components interact.
triggers:
  - "architecture"
  - "system design"
  - "how does X connect to Y"
  - "integration"
  - "flow"
edges:
  - target: context/stack.md
    condition: when specific technology details are needed
  - target: context/decisions.md
    condition: when understanding why the architecture is structured this way
  - target: context/conventions.md
    condition: when implementing changes across CLI, core services, and tests
  - target: context/mcp-runtime.md
    condition: when task scope includes MCP tools, resources, stdio runtime, or sampling
last_updated: 2026-05-12
---

# Architecture

## System Overview
User runs `metagit` CLI command via `src/metagit/cli/main.py` and command modules under `src/metagit/cli/commands/`.
CLI command loads app config (`metagit.config.yaml`) through `metagit.core.appconfig` and initializes `UnifiedLogger`.
Command handlers call core managers/services in `src/metagit/core/*` (config manager, detection manager, workspace/project helpers, record managers).
For project metadata, `.metagit.yml` is loaded/validated via `MetagitConfigManager` and Pydantic models in `metagit.core.config.models`.
For MCP mode, `metagit mcp serve` enters `MetagitMcpRuntime`, evaluates workspace gate state, exposes tools/resources, and dispatches calls to MCP services (including `metagit_repo_search` for managed-repo-only lookup via `ManagedRepoSearchService`).
For a **local** JSON HTTP surface (development and agents), `metagit api serve` serves `src/metagit/core/api/server.py` endpoints backed by the same search service — not a hosted production API.
Testing flow is pytest-driven from `tests/` with focused unit tests per core module and integration checks for cross-module behavior.

## Key Components
- **CLI command layer (`src/metagit/cli/commands/*.py`)** — routes user actions (`config`, `detect`, `project`, `record`, `workspace`, `mcp`, `search`/`find`, `api`), depends on Click context + core managers.
- **Config subsystem (`metagit.core.config.*`)** — loads/creates/saves `.metagit.yml` and validates schema via Pydantic models; foundational for workspace and MCP gating behavior.
- **Detection subsystem (`metagit.core.detect.*`)** — infers repository metadata (language/framework/dependencies) and feeds generated config/context output.
- **Record subsystem (`metagit.core.record.*`)** — manages normalized records and conversions; used for storage/search flows beyond raw config files.
- **MCP runtime (`metagit.core.mcp.*`)** — stdio JSON-RPC server for tools/resources with state-aware gating, workspace path search/index, managed-repo search (`metagit_repo_search`), upstream hints, repo ops, and bootstrap sampling flow.
- **Managed repo search (`metagit.core.project.search_service`, `search_models`)** — ranks `.metagit.yml` workspace repos with tags/status; shared by CLI, MCP, and the local HTTP API.
- **Local HTTP API (`metagit.core.api.server`)** — optional `ThreadingHTTPServer` with read-only JSON routes for the same managed-repo search and resolve semantics.

## External Dependencies
- **Git providers (GitHub/GitLab APIs via provider modules)** — used for metadata/provider operations; provider wiring is optional/config-driven.
- **Git repository access (`GitPython`)** — used for repo introspection and sync-like operations (`inspect`, `fetch`, `pull`, `clone`) in project/MCP flows.
- **YAML/Pydantic stack (`PyYAML` + `pydantic`)** — enforces `.metagit.yml` and app config shape; all config entry points depend on this validation boundary.
- **MCP client host (Cursor/other MCP-compatible runtime)** — provides stdio transport and optional sampling capability (`sampling/createMessage`) for bootstrap generation.

## What Does NOT Exist Here
- No production-grade multi-tenant HTTP deployment; the bundled HTTP server is for local use alongside the CLI and MCP.
- No relational application database backing core runtime flows; state is file/workspace/repo driven.
- No centralized remote orchestration control plane for enterprise-wide repo execution in this repository.
- No full SBOM pipeline; project focuses on situational awareness metadata, not exhaustive dependency inventory.
`````

## File: .mex/AGENTS.md
`````markdown
---
name: agents
description: Always-loaded project anchor. Read this first. Contains project identity, non-negotiables, commands, and pointer to ROUTER.md for full context.
last_updated: 2026-05-05
---

# Metagit CLI

## What This Is
Metagit CLI manages and validates repository/workspace metadata (`.metagit.yml`) and exposes that context to humans and agents, including via MCP runtime tooling.

## Non-Negotiables
- Never commit secrets, tokens, or credential material into repo-tracked files.
- Keep `.metagit.yml` / config model compatibility intact; do not bypass validation paths.
- Keep mutating repo operations explicitly guarded (especially MCP sync modes).
- Put business logic in `src/metagit/core/*`; keep CLI command handlers thin.
- Run lint + relevant tests before claiming a change is complete.
- Default commit messages to patch semantics (`fix:`). Use `feat:` only for additive backward-compatible changes, and use major/breaking prefixes only when schema/config compatibility is intentionally broken.

## Commands
- Setup: `./configure.sh && task install`
- Lint: `task lint`
- Format: `task format`
- Tests: `task test`
- Build: `task build`
- MCP runtime: `uv run metagit mcp serve --status-once`

## Scaffold Growth
After every task: if no pattern exists for the task type you just completed, create one. If a pattern or context file is now out of date, update it. The scaffold grows from real work, not just setup. See the GROW step in `ROUTER.md` for details.

## Navigation
At the start of every session, read `ROUTER.md` before doing anything else.
For full project context, patterns, and task guidance — everything is there.
`````

## File: .mex/SETUP.md
`````markdown
# Setup — Populate This Scaffold

This file contains the prompts to populate the scaffold. It is NOT the dev environment setup — for that, see `context/setup.md` after population.

This scaffold is currently empty. Follow the steps below to populate it for your project.

## Recommended: Use setup.sh

```bash
.mex/setup.sh
```

The script handles everything automatically:
1. Detects your project state (existing codebase, fresh project, or partial)
2. Asks which AI tool you use and copies the right config file
3. Pre-scans your codebase with `mex init` to build a structured brief (~5-8k tokens vs ~50k from AI exploration)
4. Builds and runs the population prompt — or prints it for manual paste

If you want to populate manually instead, use the prompts below.

## Detecting Your State

**Existing codebase?** Follow Option A.
**Fresh project, nothing built yet?** Follow Option B.
**Partially built?** Follow Option A — the agent will flag empty slots it cannot fill yet.

---

## Option A — Existing Codebase

Paste the following prompt into your agent:

---

**SETUP PROMPT — copy everything between the lines:**

```
You are going to populate an AI context scaffold for this project.
The scaffold lives in the root of this repository.

Read the following files in order before doing anything else:
1. ROUTER.md — understand the scaffold structure
2. context/architecture.md — read the annotation comments to understand what belongs there
3. context/stack.md — same
4. context/conventions.md — same
5. context/decisions.md — same
6. context/setup.md — same

Then explore this codebase:
- Read the main entry point(s)
- Read the folder structure
- Read 2-3 representative files from each major layer
- Read any existing README or documentation

PASS 1 — Populate knowledge files:

Populate each context/ file by replacing the annotation comments
with real content from this codebase. Follow the annotation instructions exactly.
For each slot:
- Use the actual names, patterns, and structures from this codebase
- Do not use generic examples
- Do not leave any slot empty — if you cannot determine the answer,
  write "[TO DETERMINE]" and explain what information is needed
- Keep length within the guidance given in each annotation

Then assess: does this project have domains complex enough that cramming
them into architecture.md would make it too long or too shallow?
If yes, create additional domain-specific context files in context/.
Examples: a project with a complex auth system gets context/auth.md.
A data pipeline gets context/ingestion.md. A project with Stripe gets
context/payments.md. Use the same YAML frontmatter format (name,
description, triggers, edges, last_updated). Only create these for
domains that have real depth — not for simple integrations that fit
in a few lines of architecture.md.

After populating context/ files, update ROUTER.md:
- Fill in the Current Project State section based on what you found
- Add rows to the routing table for any domain-specific context files you created

Update AGENTS.md:
- Fill in the project name, one-line description, non-negotiables, and commands

PASS 2 — Generate starter patterns:

Read patterns/README.md for the format and categories.

Generate 3-5 starter patterns for the most common and most dangerous task
types in this project. Focus on:
- The 1-2 tasks a developer does most often (e.g., add endpoint, add component)
- The 1-2 integrations with the most non-obvious gotchas
- 1 debug pattern for the most common failure boundary

Each pattern should be specific to this project — real file paths, real gotchas,
real verify steps derived from the code you read in Pass 1.
Use the format in patterns/README.md. Name descriptively (e.g., add-endpoint.md).

Do NOT try to generate a pattern for every possible task type. The scaffold
grows incrementally — the behavioural contract (step 5: GROW) will create
new patterns from real work as the project evolves. Setup just seeds the most
critical ones.

After generating patterns, update patterns/INDEX.md with a row for each
pattern file you created. For multi-section patterns, add one row per task
section using anchor links (see INDEX.md annotation for format).

PASS 3 — Wire the web:

Re-read every file you just wrote (context/ files, pattern files, ROUTER.md).
For each file, add or update the `edges` array in the YAML frontmatter.
Each edge should point to another scaffold file that is meaningfully related,
with a `condition` explaining when an agent should follow that edge.

Rules for edges:
- Every context/ file should have at least 2 edges
- Every pattern file should have at least 1 edge (usually to the relevant context file)
- Edges should be bidirectional where it makes sense (if A links to B, consider B linking to A)
- Use relative paths (e.g., context/stack.md, patterns/add-endpoint.md)
- Pattern files can edge to other patterns (e.g., debug pattern → related task pattern)

Important: only write content derived from the codebase.
Do not include system-injected text (dates, reminders, etc.)
in any scaffold file.

When done, confirm which files were populated and flag any slots
you could not fill with confidence.
```

---

## Option B — Fresh Project

Paste the following prompt into your agent:

---

**SETUP PROMPT — copy everything between the lines:**

```
You are going to populate an AI context scaffold for a project that
is just starting. Nothing is built yet.

Read the following files in order before doing anything else:
1. ROUTER.md — understand the scaffold structure
2. All files in context/ — read the annotation comments in each

Then ask me the following questions one section at a time.
Wait for my answer before moving to the next section:

1. What does this project do? (one sentence)
2. What are the hard rules — things that must never happen in this codebase?
3. What is the tech stack? (language, framework, database, key libraries)
4. Why did you choose this stack over alternatives?
5. How will the major pieces connect? Describe the flow of a typical request/action.
6. What patterns do you want to enforce from day one?
7. What are you deliberately NOT building or using?

After I answer, populate the context/ files based on my answers.
For any slot you cannot fill yet, write "[TO BE DETERMINED]" and note
what needs to be decided before it can be filled.

Then assess: based on my answers, does this project have domains complex
enough that cramming them into architecture.md would make it too long
or too shallow? If yes, create additional domain-specific context files
in context/. Examples: a project with a complex auth system gets
context/auth.md. A data pipeline gets context/ingestion.md. A project
with Stripe gets context/payments.md. Use the same YAML frontmatter
format (name, description, triggers, edges, last_updated). Only create
these for domains that have real depth — not for simple integrations
that fit in a few lines of architecture.md. For fresh projects, mark
domain-specific unknowns with "[TO BE DETERMINED — populate after first
implementation]".

Update ROUTER.md current state to reflect that this is a new project.
Add rows to the routing table for any domain-specific context files you created.
Update AGENTS.md with the project name, description, non-negotiables, and commands.

Read patterns/README.md for the format and categories.

Generate 2-3 starter patterns for the most obvious task types you can
anticipate for this stack. Focus on the tasks a developer will do first.
Mark unknowns with "[VERIFY AFTER FIRST IMPLEMENTATION]".

Do NOT try to anticipate every possible pattern. The scaffold grows
incrementally — the behavioural contract (step 5: GROW) will create
new patterns from real work as the project evolves. Setup just seeds
the most critical ones.

After generating patterns, update patterns/INDEX.md with a row for each
pattern file you created.

PASS 3 — Wire the web:

Re-read every file you just wrote (context/ files, pattern files, ROUTER.md).
For each file, add or update the `edges` array in the YAML frontmatter.
Each edge should point to another scaffold file that is meaningfully related,
with a `condition` explaining when an agent should follow that edge.

Rules for edges:
- Every context/ file should have at least 2 edges
- Every pattern file should have at least 1 edge
- Edges should be bidirectional where it makes sense
- Use relative paths (e.g., context/stack.md, patterns/add-endpoint.md)

Important: only write content derived from your answers.
Do not include system-injected text (dates, reminders, etc.)
in any scaffold file.
```

---

## After Setup

**Verify** by starting a fresh session and asking your agent:
"Read `.mex/ROUTER.md` and tell me what you now know about this project."

A well-populated scaffold should give the agent enough to:
- Describe the architecture without looking at code
- Name the non-negotiable conventions
- Know which files to load for any given task type
- Know which patterns exist for common task types

## Keeping It Fresh

Once the scaffold is populated, use these to keep it aligned with your codebase:

- **`mex check`** — detect drift (zero tokens, zero AI)
- **`.mex/sync.sh`** — interactive drift check + targeted or full resync
- **`mex watch`** — auto drift score after every commit

## Commit Prefix Policy

When committing scaffold updates, default to `fix:` (patch intent). Use `feat:` only for additive backward-compatible updates. Use breaking markers (`type(scope)!:` or `BREAKING CHANGE:`) only for intentional schema/config compatibility breaks.
`````

## File: .mex/SYNC.md
`````markdown
# Sync — Realign This Scaffold

## Recommended: Use sync.sh

```bash
.mex/sync.sh
```

The script runs drift detection first, shows you exactly what's wrong, then offers:
1. **Targeted sync** — AI fixes only the flagged files (fastest, cheapest)
2. **Full resync** — AI re-reads everything and updates all scaffold files
3. **Prompt export** — shows the prompts for manual paste
4. **Exit** — fix it yourself

## Quick Check

```bash
mex check              # full drift report
mex check --quiet      # one-liner: "drift score 85/100 (1 error)"
mex sync --dry-run     # preview targeted fix prompts
```

## Manual Resync

If you prefer to paste a prompt manually, or don't have the CLI built:

---

**SYNC PROMPT — copy everything between the lines:**

```
You are going to resync the AI context scaffold for this project.
The scaffold may be out of date — the codebase has changed since it was last populated.

First, read all files in context/ to understand the current scaffold state.
Then explore what has changed in the codebase since the scaffold was last updated.
Check the last_updated dates in the YAML frontmatter of each file.

For each context/ file:
1. Compare the current scaffold content to the actual codebase
2. Identify what has changed, been added, or been removed
3. Update the file to reflect the current state

Critical rules for updating:
- Use surgical, targeted edits — NOT full file rewrites. Read the existing content,
  identify what changed, and update only those sections.
- PRESERVE YAML frontmatter structure. Never delete or rewrite the entire frontmatter block.
  Edit individual fields only. The edges, triggers, name, and description fields must
  survive every sync. If you need to update edges, add or remove individual entries —
  do not replace the entire array.
- In context/decisions.md: NEVER delete existing decisions.
  If a decision has changed, mark the old entry as "Superseded by [new decision title]"
  and add the new decision above it with today's date.
- In all other files: update content to reflect current reality
- Update last_updated in the YAML frontmatter of every file you change
- After updating each file, update ROUTER.md Current Project State
- Commit-message semantics: default to `fix:` for patch-level updates; use `feat:` only for additive backward-compatible changes; use breaking markers only when intentionally breaking schema/config compatibility.

When done, report:
- Which files were updated and what changed
- Any decisions that were superseded
- Any slots that could not be filled with confidence
```
`````

## File: docs/install.md
`````markdown
# Installation and usage guide

This guide covers practical ways to install and run Metagit locally, plus how to enable project skills for AI-agent workflows.

## Requirements

- Python 3.12+
- `uv`
- `git`

Optional:

- Docker (for container usage)
- `gitleaks` (for local security checks in QA workflows)

## Option 1: global CLI install with uv (recommended)

Install Metagit globally as a tool:

```bash
uv tool install metagit-cli
```

Upgrade later:

```bash
uv tool install -U metagit-cli
```

Verify:

```bash
metagit version
metagit --help
```

## Option 2: install from source (local development)

Clone and bootstrap:

```bash
git clone https://github.com/metagit-ai/metagit-cli.git
cd metagit-cli
uv sync
```

Install as an editable local package:

```bash
uv pip install -e .
```

Build a wheel and install that wheel:

```bash
task build
uv tool install dist/metagit-*-py3-none-any.whl
```

## Option 3: container usage

Run via container image:

```bash
docker pull ghcr.io/metagit-ai/metagit-cli:latest
docker run --rm ghcr.io/metagit-ai/metagit-cli:latest --help
```

## First local use

In a target Git repository:

```bash
metagit init --list-templates          # bundled profiles (application, umbrella, hermes-orchestrator, …)
metagit init ./my-coordinator --template hermes-orchestrator --create
metagit init --target ../hermes-control-plane --template hermes-orchestrator --no-prompt \
  --answers-file examples/hermes-orchestrator/answers.example.yml
metagit init --kind service --minimal  # any ProjectKind without a bundled template
```

This creates `.metagit.yml` and updates `.gitignore`.

Useful first checks:

```bash
metagit config validate
metagit info
```

## Local MCP runtime usage

Start MCP runtime in current workspace:

```bash
metagit mcp serve
```

Run one-shot status snapshot:

```bash
metagit mcp serve --status-once
```

Pin a specific workspace root:

```bash
metagit mcp serve --root /path/to/workspace
```

## AI-agent skill setup for this project

If you cloned this repository and want skills available for local agent discovery:

```bash
task skills:sync
task generate:schema
```

One-shot combined:

```bash
task skills:sync generate:schema
```

This syncs project skills into `.cursor/skills/` and regenerates config schemas.

## Session closeout checks

Before push or hand-off:

```bash
task skills:sync generate:schema
task qa:prepush
```

## Troubleshooting

- If `metagit` fails after tool install, reinstall:
  - `uv tool install -U --reinstall metagit-cli`
- If command not found, ensure `uv` tool bin path is on `PATH`.
- If provider-backed features fail, configure API tokens in app config or environment:
  - `METAGIT_GITHUB_API_TOKEN`
  - `METAGIT_GITLAB_API_TOKEN`
`````

## File: examples/enhanced_fuzzyfinder_demo.py
`````python
#!/usr/bin/env python
"""
Example demonstrating the enhanced FuzzyFinder features:
- Optional item opacity
- Vertical scrolling for large lists
- Enhanced navigation (Page Up/Down, Home/End)
"""

import sys
from pathlib import Path

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.utils.fuzzyfinder import FuzzyFinder, FuzzyFinderConfig


def main():
    """Demonstrate enhanced FuzzyFinder features."""

    # Create a larger dataset to show scrolling
    programming_languages = [
        "Python",
        "JavaScript",
        "TypeScript",
        "Java",
        "C++",
        "C#",
        "Go",
        "Rust",
        "Ruby",
        "PHP",
        "Swift",
        "Kotlin",
        "Scala",
        "Clojure",
        "Haskell",
        "Erlang",
        "Elixir",
        "Dart",
        "Lua",
        "Perl",
        "R",
        "MATLAB",
        "Julia",
        "F#",
        "OCaml",
        "Assembly",
        "C",
        "Objective-C",
        "Pascal",
        "Fortran",
        "COBOL",
        "Ada",
    ]

    print("Enhanced FuzzyFinder Demo")
    print("=" * 30)
    print(f"Dataset: {len(programming_languages)} programming languages")
    print()
    print("New Features:")
    print("1. ✨ Item opacity (80% transparency)")
    print("2. 📜 Vertical scrolling for long lists")
    print("3. 🚀 Enhanced navigation:")
    print("   - Arrow keys: Move up/down")
    print("   - Page Up/Down: Jump by 10 items")
    print("   - Home/End: Jump to first/last")
    print("4. 🎯 Auto-scroll to highlighted item")
    print()

    # Configure with new features
    config = FuzzyFinderConfig(
        items=programming_languages,
        prompt_text="Search programming languages: ",
        max_results=25,  # Show many results to demonstrate scrolling
        score_threshold=40.0,
        item_opacity=0.8,  # 80% opacity for subtle transparency
        highlight_color="bold white bg:#2563eb",  # Nice blue highlight
        normal_color="white",
        sort_items=True,
    )

    # Create and run the fuzzy finder
    finder = FuzzyFinder(config)

    print("Starting enhanced FuzzyFinder...")
    print("Try typing partial names like 'py', 'java', 'rust', etc.")

    try:
        result = finder.run()

        if result:
            print(f"\n🎉 You selected: {result}")
        else:
            print("\n👋 No selection made.")

    except KeyboardInterrupt:
        print("\n👋 Exited by user.")


if __name__ == "__main__":
    main()
`````

## File: examples/fuzzyfinder_simple_colors.py
`````python
#!/usr/bin/env python
"""
Simple example showing how to use custom colors in FuzzyFinder.
This demonstrates coloring items based on their type or priority.
"""

import sys
from pathlib import Path

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.utils.fuzzyfinder import FuzzyFinder, FuzzyFinderConfig


def main():
    """Demonstrate custom colors with a simple task list."""

    # Create a list of tasks with different types
    tasks = [
        "bug_fix_login",
        "bug_fix_payment",
        "feature_dashboard",
        "feature_notifications",
        "docs_api_reference",
        "docs_user_guide",
        "test_user_flow",
        "test_integration",
    ]

    # Define colors based on task type (prefix)
    task_colors = {
        "bug_fix_login": "bold red",  # Critical bugs in red
        "bug_fix_payment": "bold red",  # Critical bugs in red
        "feature_dashboard": "green",  # Features in green
        "feature_notifications": "green",  # Features in green
        "docs_api_reference": "blue",  # Documentation in blue
        "docs_user_guide": "blue",  # Documentation in blue
        "test_user_flow": "cyan",  # Tests in cyan
        "test_integration": "cyan",  # Tests in cyan
    }

    print("🎨 Custom Colors FuzzyFinder Example")
    print("=" * 40)
    print("Each task type has its own color:")
    print("🔴 Bugs: Red")
    print("🟢 Features: Green")
    print("🔵 Documentation: Blue")
    print("🟦 Tests: Cyan")
    print()

    # Configure FuzzyFinder with custom colors
    config = FuzzyFinderConfig(
        items=tasks,
        prompt_text="🔍 Search tasks: ",
        custom_colors=task_colors,
        max_results=10,
        highlight_color="bold white bg:#4400aa",
        item_opacity=0.9,
    )

    # Run the finder
    finder = FuzzyFinder(config)

    print("Starting FuzzyFinder with custom colors...")
    print("Notice how each task type is colored differently!")

    try:
        result = finder.run()

        if result:
            task_type = result.split("_")[0]
            print(f"\n✅ Selected: {result}")
            print(f"📋 Task type: {task_type}")
        else:
            print("\n👋 No selection made.")

    except KeyboardInterrupt:
        print("\n👋 Exited by user.")


if __name__ == "__main__":
    main()
`````

## File: examples/get_git_files.py
`````python
#!/usr/bin/env python
"""
List git files in a repository.
"""

import sys
from pathlib import Path

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.utils.files import list_git_files


def main():
    """List all git files in the current repository."""

    # Get the current working directory
    repo_path = Path.cwd()

    print(f"Listing git files in repository: {repo_path}")

    # List git files
    git_files = list_git_files(repo_path)

    if not git_files:
        print("No git files found.")
        return

    print(f"Found {len(git_files)} git files:")
    for file in git_files:
        print(f"- {file}")


if __name__ == "__main__":
    main()
`````

## File: scripts/prepush-gate.py
`````python
#!/usr/bin/env python3
"""Cross-agent pre-push quality gate runner."""

from __future__ import annotations

import argparse
import shutil
import subprocess
import sys
from pathlib import Path


def run_step(name: str, cmd: list[str], logs_dir: Path, shell: bool = False) -> bool:
    logs_dir.mkdir(parents=True, exist_ok=True)
    log_path = logs_dir / f"{name}.log"
    print(f"==> {name}")
    with log_path.open("w", encoding="utf-8") as log_file:
        completed = subprocess.run(
            cmd if not shell else " ".join(cmd),
            stdout=log_file,
            stderr=subprocess.STDOUT,
            shell=shell,
            text=True,
            check=False,
        )
    if completed.returncode == 0:
        print(f"PASS: {name}")
        return True
    print(f"FAIL: {name}")
    print(f"--- last output ({log_path}) ---")
    lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
    for line in lines[-40:]:
        print(line)
    print("--- end output ---")
    return False


def resolve_pytest_cmd() -> list[str]:
    if shutil.which("uv"):
        return ["uv", "run", "pytest", "tests/integration", "-v"]
    return [sys.executable, "-m", "pytest", "tests/integration", "-v"]


SECURITY_DEP_FILES = frozenset({"pyproject.toml", "uv.lock"})
SECURITY_SRC_PREFIX = "src/"


def _git_lines(args: list[str]) -> list[str] | None:
    if not shutil.which("git") or not Path(".git").exists():
        return None
    completed = subprocess.run(
        ["git", *args],
        capture_output=True,
        text=True,
        check=False,
    )
    if completed.returncode != 0:
        return None
    return [line.strip() for line in completed.stdout.splitlines() if line.strip()]


def changed_paths_for_security() -> set[str] | None:
    """Collect changed paths; None means run the full security pipeline."""
    chunks: list[str] = []
    for spec in (
        ["diff", "--name-only", "HEAD"],
        ["diff", "--cached", "--name-only"],
    ):
        lines = _git_lines(spec)
        if lines is None:
            return None
        chunks.extend(lines)
    upstream = _git_lines(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
    if upstream:
        branch_diff = _git_lines(["diff", "--name-only", f"{upstream[0]}...HEAD"])
        if branch_diff is None:
            return None
        chunks.extend(branch_diff)
    return set(chunks)


def security_scan_plan(changed: set[str] | None) -> tuple[bool, bool, bool]:
    """Return (sync_deps, pip_audit, bandit) for context-aware security."""
    if changed is None or not changed:
        return (True, True, True)
    deps = any(path in SECURITY_DEP_FILES for path in changed)
    src = any(path.startswith(SECURITY_SRC_PREFIX) for path in changed)
    if deps:
        return (True, True, True)
    if src:
        return (False, True, True)
    return (False, False, False)


def run_security_scan(logs_dir: Path) -> bool:
    """Run pip-audit/bandit when lockfile or src/ changed; skip for docs-only diffs."""
    changed = changed_paths_for_security()
    sync_deps, pip_audit, bandit = security_scan_plan(changed)
    if not (sync_deps or pip_audit or bandit):
        print("SKIP: security_scan (no changes under src/, pyproject.toml, or uv.lock)")
        return True

    failed = False
    if sync_deps:
        failed |= not run_step(
            "security_sync",
            ["uv", "sync", "--frozen", "--all-extras"],
            logs_dir,
        )
    if pip_audit:
        failed |= not run_step("security_audit", ["uv", "run", "pip-audit"], logs_dir)
    if bandit:
        failed |= not run_step(
            "security_bandit",
            ["uv", "run", "bandit", "-r", "src", "-ll"],
            logs_dir,
        )
    return not failed


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--loop", action="store_true")
    parser.add_argument("--rounds", type=int, default=1)
    parser.add_argument("--strict", action="store_true")
    args = parser.parse_args()

    if args.rounds < 1:
        print("ERROR: --rounds must be >= 1")
        return 2

    if not Path("Taskfile.yml").exists():
        print("ERROR: run this script from repo root")
        return 2

    if not shutil.which("task"):
        print("ERROR: task is required but not found in PATH")
        return 2

    logs_dir = Path(".metagit")
    round_idx = 1
    while True:
        print(f"\n### QA ROUND {round_idx} ###")
        failed = False

        failed |= not run_step("format", ["task", "format"], logs_dir)
        failed |= not run_step("lint_fix", ["task", "lint:fix"], logs_dir)
        failed |= not run_step("lint", ["task", "lint"], logs_dir)
        failed |= not run_step("unit_tests", ["task", "test"], logs_dir)
        failed |= not run_step("e2e_tests", resolve_pytest_cmd(), logs_dir)
        failed |= not run_security_scan(logs_dir)

        if shutil.which("gitleaks"):
            security_ok = run_step(
                "security_gitleaks", ["task", "secret:search"], logs_dir
            )
            if args.strict and not security_ok:
                failed = True
        else:
            print("SKIP: security_gitleaks (gitleaks not installed)")

        if not failed:
            print(f"ROUND {round_idx}: PASS")
            print("\nAll pre-push checks passed.")
            return 0

        print(f"ROUND {round_idx}: FAIL")
        if not args.loop or round_idx >= args.rounds:
            print(f"\nStopped after {round_idx} round(s) with failures.")
            return 1
        round_idx += 1
        print("\nRetrying QA pipeline...")


if __name__ == "__main__":
    raise SystemExit(main())
`````

## File: skills/metagit-control-center/scripts/control-cycle.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-infra}"

"$(dirname "$0")/../../metagit-gating/scripts/gate-status.zsh" "$ROOT"

if [[ -n "$QUERY" ]]; then
  "$(dirname "$0")/../../metagit-upstream-scan/scripts/upstream-scan.zsh" "$ROOT" "$QUERY" "$PRESET" 20
else
  echo "status=ok\tmessage=no-query-provided"
fi
`````

## File: skills/metagit-control-center/SKILL.md
`````markdown
---
name: metagit-control-center
description: Use when running metagit as an MCP control center for multi-repo awareness, guarded sync, and operational knowledge across ongoing agent tasks.
---

# Metagit Control Center Skill

Use this skill when an agent should actively coordinate repository context and task execution across a workspace.

## Purpose

Provide a repeatable control-center workflow where Metagit MCP guides awareness, synchronization, and operational continuity over multiple related repositories.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for control-center cycles:
- `./scripts/control-cycle.zsh [root_path] ["query"] [preset]`

Wrapper behavior:
- runs gating status first
- optionally runs upstream discovery for blocker queries
- emits compact, machine-readable lines

## Core Workflows

### 1) Session Initialization
- Validate active workspace gate.
- Read `metagit://workspace/config` and `metagit://workspace/repos/status`.
- Call `metagit_project_context_switch` when the objective is tied to a workspace project.
- Run `metagit_workspace_health_check` or read `metagit://workspace/health` for maintenance signals.
- Identify stale repos and unresolved blockers from prior activity.

### 2) Active Task Support
- For each coding objective, map impacted repos.
- Use workspace search and upstream hints before broad exploration.
- Sync only repos that are required by the active objective.

### 3) Guarded Synchronization
- Default to `fetch` for visibility.
- Use `pull` or `clone` only with explicit permission and rationale.
- Track sync outcomes in operations log resource.

### 4) Operational Memory
- Before switching projects: `metagit_session_update` (notes + recent repos), optional `metagit_workspace_state_snapshot`.
- After returning: `metagit_workspace_state_restore` when a snapshot was taken (metadata only; git tree is unchanged).

Maintain bounded local records of:
- sync actions
- issue signatures searched
- candidate upstream repos identified
- unresolved dependencies and follow-ups

## Decision Guidelines

- Use metagit search first when blocker appears external to current repo.
- Prefer deterministic evidence over speculative jumps.
- Keep operations minimal and auditable.

## Output Contract

For each control-center cycle, provide:
- current objective
- repositories examined
- actions taken (or intentionally deferred)
- next recommended step

## Safety Rules

- Never mutate repositories without explicit authorization.
- Never broaden scope beyond configured workspace boundaries.
- Always preserve a clear audit trail of control actions.
`````

## File: skills/metagit-gitnexus/SKILL.md
`````markdown
---
name: metagit-gitnexus
description: Run gitnexus analysis for a target workspace and selected project repositories before graph-dependent tasks. Use when index staleness is detected or cross-repo graph results are needed.
---

# Running GitNexus Analysis

Use this skill whenever GitNexus context is stale or missing for target repositories.

## Local Wrapper (Use First)

- `zsh ./skills/metagit-gitnexus/scripts/analyze-targets.zsh <workspace_root> [project_name]`

## Workflow

1. Analyze the current repository where the command is run.
2. Resolve target project repositories from `.metagit.yml`.
3. Run `npx gitnexus analyze` in each local repository path found.
4. Report per-repository success/failure and next actions.

## Manual workspace graph (`.metagit.yml` → Cypher)

Export manifest `graph.relationships` for GitNexus overlay ingest:

```bash
metagit config graph export -c .metagit.yml --format tool-calls
# or MCP: metagit_export_workspace_graph_cypher
```

Run returned `gitnexus_cypher` tool calls against the umbrella repo index (`--gitnexus-repo` when names differ). Schema DDL (`MetagitEntity` / `MetagitLink`) runs once per index.

## Output Contract

Return:
- analyzed repositories
- failures and reasons
- whether graph queries are safe to run now

## Safety

- Skip repositories that do not exist locally.
- Do not mutate repo content; analysis should be read-only indexing.
`````

## File: skills/metagit-repo-impact/SKILL.md
`````markdown
---
name: metagit-repo-impact
description: Plan repository change impact before edits by combining metagit workspace context and graph-based dependency analysis. Use when a change may affect multiple repositories.
---

# Planning Repo Impact

Use this skill before risky or cross-repo modifications.

## Workflow

1. Identify target symbols/files for change.
2. Use metagit workspace context to bound repository scope.
3. Use graph impact tooling to estimate blast radius.
4. Produce a change plan with test and rollback focus.

## Commands

- `metagit workspace select --project <name>`
- MCP `metagit_cross_project_dependencies` with `source_project` and `dependency_types` before large cross-project edits
- MCP `metagit_project_context_switch` to bound scope to one workspace project
- `npx gitnexus analyze` on affected repos when `graph_status` is `stale` or `missing`
- `npx gitnexus query` / `gitnexus impact` for symbol-level analysis after indexes are fresh

## Output Contract

Return:
- impacted repositories and interfaces
- highest-risk change points
- minimum validation plan

## Safety

- Do not execute mutating steps in this planning phase.
`````

## File: skills/metagit-workspace-scope/SKILL.md
`````markdown
---
name: metagit-workspace-scope
description: Discover active metagit workspace scope, project boundaries, and repository status. Use when an agent starts work in a multi-repo workspace and needs fast, scoped context before editing.
---

# Discovering Workspace Scope

Use this skill at session start for workspace-aware tasks.

## Workflow

1. Run workspace gate and status checks first.
2. Read configured projects and repository status from metagit resources/tools.
3. Build a compact map of active project names, repo names, and sync state.
4. Report a bounded scope for the current objective.

## Commands

- `zsh ./skills/metagit-gating/scripts/gate-status.zsh [root_path]`
- `metagit workspace select --project <name>` (interactive repo picker)
- MCP `metagit_project_context_switch` with `project_name` (structured context + session restore)
- MCP `metagit_workspace_state_snapshot` before leaving a project for a long switch

## Output Contract

Return:
- active/inactive workspace state and reason
- candidate project to operate on
- top repositories to inspect first

## Safety

- Keep operations read-only in this step.
- Do not sync or mutate repositories without explicit request.
`````

## File: skills/metagit-workspace-sync/SKILL.md
`````markdown
---
name: metagit-workspace-sync
description: Sync workspace repositories safely using metagit with scoped fetch, pull, or clone actions. Use when repository content must be refreshed for implementation.
---

# Syncing Workspace Repositories

Use this skill when repository state must be updated.

## Workflow

1. Confirm active workspace and target project.
2. Start with read-only status and fetch where possible.
3. Use pull/clone only when required for the current objective.
4. Summarize which repositories changed and what remains stale.

## Commands

- `metagit project sync --project <name>`
- MCP `metagit_workspace_sync` for batch fetch/pull/clone (`repos`, `only_if`, `dry_run`, `max_parallel`)
- MCP `metagit_repo_sync` for a single repository (requires `allow_mutation` for pull/clone)
- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- repositories synced
- sync mode used (fetch/pull/clone)
- failures and retry guidance

## Safety

- Limit sync to repos defined in `.metagit.yml`.
- Avoid broad synchronization if only a subset is needed.
`````

## File: src/metagit/cli/commands/mcp.py
`````python
#!/usr/bin/env python
"""
MCP command group for Metagit CLI.
"""

from typing import Optional

import click

from metagit.core.mcp.runtime import MetagitMcpRuntime
from metagit.core.skills import (
    SUPPORTED_TARGETS,
    install_mcp_for_targets,
    resolve_targets,
)


@click.group()
def mcp() -> None:
    """Metagit MCP server commands."""


@mcp.command("serve")
@click.option("--root", default=None, help="Optional workspace root override.")
@click.option(
    "--status-once",
    is_flag=True,
    default=False,
    help="Print one-shot status and exit (for diagnostics/tests).",
)
@click.pass_context
def serve(ctx: click.Context, root: Optional[str], status_once: bool) -> None:
    """Start MCP runtime over stdio."""
    runtime = MetagitMcpRuntime(root=root)
    if status_once:
        snapshot = runtime.status_snapshot()
        click.echo(
            f"mcp_state={snapshot['state']} root={snapshot['root'] or 'none'} tools={snapshot['tools']}"
        )
        return

    logger = ctx.obj.get("logger") if ctx.obj else None
    if logger:
        logger.info("Metagit MCP stdio runtime initialized.")
    runtime.run_stdio()


@mcp.command("install")
@click.option(
    "--scope",
    type=click.Choice(["project", "user"]),
    default="user",
    show_default=True,
    help="Install to local project config or user-global location.",
)
@click.option(
    "--target",
    "targets",
    multiple=True,
    type=click.Choice(SUPPORTED_TARGETS),
    help="Explicit target to install (repeatable). If omitted, auto-detect targets.",
)
@click.option(
    "--disable-target",
    "disable_targets",
    multiple=True,
    type=click.Choice(SUPPORTED_TARGETS),
    help="Disable one or more auto-detected targets.",
)
@click.option(
    "--server-name",
    default="metagit",
    show_default=True,
    help="MCP server key to write in target config files.",
)
@click.pass_context
def install(
    ctx: click.Context,
    scope: str,
    targets: list[str],
    disable_targets: list[str],
    server_name: str,
) -> None:
    """Install metagit MCP server entry into supported agent configs."""
    logger = ctx.obj["logger"] if ctx.obj else None
    selected_targets = resolve_targets(
        mode="mcp",
        scope=scope,
        enable_targets=list(targets),
        disable_targets=list(disable_targets),
    )
    if not selected_targets:
        if logger:
            logger.warning(
                "No targets selected. Use --target to choose targets explicitly."
            )
        else:
            click.echo(
                "No targets selected. Use --target to choose targets explicitly."
            )
        return
    results = install_mcp_for_targets(
        targets=selected_targets, scope=scope, server_name=server_name
    )
    for result in results:
        if logger:
            logger.success(f"[{result.target}] {result.details} -> {result.path}")
        else:
            click.echo(f"[{result.target}] {result.details} -> {result.path}")
`````

## File: src/metagit/cli/commands/search.py
`````python
#!/usr/bin/env python
"""
Managed workspace repository search (`.metagit.yml` corpus only).
"""

from pathlib import Path

import click

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.project.search_service import ManagedRepoSearchService


def _parse_tag_filters(tag_values: tuple[str, ...]) -> dict[str, str] | None:
    """Parse repeated `--tag key=value` into a tag filter dict."""
    if not tag_values:
        return None
    parsed: dict[str, str] = {}
    for item in tag_values:
        if "=" not in item:
            raise click.ClickException(f"Invalid --tag (expected key=value): {item!r}")
        key, value = item.split("=", 1)
        parsed[key] = value
    return parsed


@click.command("search")
@click.argument("query")
@click.option(
    "--definition",
    "definition_path",
    default=".metagit.yml",
    show_default=True,
    help="Path to the Metagit workspace definition file",
)
@click.option(
    "--project", default=None, help="Limit to a single workspace project name"
)
@click.option(
    "--exact", is_flag=True, default=False, help="Require exact repository name match"
)
@click.option(
    "--synced-only",
    is_flag=True,
    default=False,
    help="Only include repositories that exist on disk as git checkouts",
)
@click.option(
    "--tag",
    "tag_values",
    multiple=True,
    help="Filter by tag (repeatable), e.g. --tag domain=terraform-module",
)
@click.option(
    "--status",
    "status_values",
    multiple=True,
    help="Filter by repo status (repeatable), e.g. --status synced",
)
@click.option(
    "--sort",
    default="score",
    type=click.Choice(["score", "project", "name"], case_sensitive=False),
    show_default=True,
)
@click.option("--limit", default=10, type=int, show_default=True)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print matches as JSON"
)
@click.option(
    "--path-only",
    is_flag=True,
    default=False,
    help="Resolve to a single local path (errors if ambiguous or not found)",
)
@click.pass_context
def search(
    ctx: click.Context,
    query: str,
    definition_path: str,
    project: str | None,
    exact: bool,
    synced_only: bool,
    tag_values: tuple[str, ...],
    status_values: tuple[str, ...],
    sort: str,
    limit: int,
    as_json: bool,
    path_only: bool,
) -> None:
    """Search managed repositories declared in `.metagit.yml` (alias: `metagit find`)."""
    _ = ctx
    manager = MetagitConfigManager(definition_path)
    config = manager.load_config()
    if isinstance(config, Exception):
        raise click.ClickException(str(config))

    workspace_root = str(Path(definition_path).resolve().parent)
    tag_filters = _parse_tag_filters(tag_values)
    service = ManagedRepoSearchService()
    status_filter = list(status_values) if status_values else None
    result = service.search(
        config=config,
        workspace_root=workspace_root,
        query=query,
        project=project,
        exact=exact,
        synced_only=synced_only,
        tags=tag_filters,
        status=status_filter,
        sort=sort,
        limit=limit,
    )
    if as_json:
        click.echo(result.model_dump_json(indent=2))
        return
    if path_only:
        resolved = service.resolve_one(
            config=config,
            workspace_root=workspace_root,
            query=query,
            project=project,
            exact=exact,
            synced_only=synced_only,
            tags=tag_filters,
            status=status_filter,
            sort=sort,
        )
        if resolved.error:
            raise click.ClickException(resolved.error.message)
        if resolved.match is None:
            raise click.ClickException("No managed repository matched the query.")
        click.echo(resolved.match.status.resolved_path)
        return
    if not result.matches:
        raise click.ClickException(f"No managed repository matched '{query}'.")
    for index, match in enumerate(result.matches, start=1):
        click.echo(f"{index}. project={match.project_name} repo={match.repo_name}")
        click.echo(f"   path={match.status.resolved_path}")
        click.echo(
            "   status="
            f"{match.status.status} exists={match.status.exists} "
            f"git={match.status.is_git_repo} sync={match.status.sync_enabled}"
        )
        click.echo(f"   matched={','.join(match.match_reasons)}")
`````

## File: src/metagit/cli/commands/skills.py
`````python
#!/usr/bin/env python
"""
Skills command group for bundled skill management.
"""

from typing import List

import click

from metagit.core.skills import (
    SUPPORTED_TARGETS,
    install_skills_for_targets,
    list_bundled_skills,
    resolve_skill_names,
    resolve_targets,
    skill_markdown,
)


@click.group(name="skills", invoke_without_command=True)
@click.pass_context
def skills(ctx: click.Context) -> None:
    """Bundled skill management commands."""
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())
        return


@skills.command("list")
@click.pass_context
def skills_list(ctx: click.Context) -> None:
    """List bundled skills available for install."""
    logger = ctx.obj["logger"]
    bundled = list_bundled_skills()
    if not bundled:
        logger.warning("No bundled skills found in package data.")
        return
    logger.info("Bundled skills:")
    for skill_name in bundled:
        logger.echo(f"- {skill_name}")


@skills.command("show")
@click.argument("skill_name", required=False)
@click.pass_context
def skills_show(ctx: click.Context, skill_name: str | None) -> None:
    """Show a bundled skill document."""
    logger = ctx.obj["logger"]
    if not skill_name:
        skill_names = list_bundled_skills()
        if not skill_names:
            logger.warning("No bundled skills found in package data.")
            return
        logger.info("Available skills:")
        for item in skill_names:
            logger.echo(f"- {item}")
        logger.info("Use `metagit skills show <name>` to print SKILL.md content.")
        return
    content = skill_markdown(skill_name)
    if not content:
        logger.error(f"Skill '{skill_name}' not found.")
        ctx.abort()
    logger.echo(content)


@skills.command("install")
@click.option(
    "--scope",
    type=click.Choice(["project", "user"]),
    default="user",
    show_default=True,
    help="Install to local project config or user-global location.",
)
@click.option(
    "--target",
    "targets",
    multiple=True,
    type=click.Choice(SUPPORTED_TARGETS),
    help="Explicit target to install (repeatable). If omitted, auto-detect targets.",
)
@click.option(
    "--disable-target",
    "disable_targets",
    multiple=True,
    type=click.Choice(SUPPORTED_TARGETS),
    help="Disable one or more auto-detected targets.",
)
@click.option(
    "--skill",
    "skills",
    multiple=True,
    help="Install only this bundled skill (repeatable). Omit to install all.",
)
@click.option(
    "--dry-run",
    is_flag=True,
    default=False,
    help="Show what would be installed without writing files.",
)
@click.pass_context
def skills_install(
    ctx: click.Context,
    scope: str,
    targets: List[str],
    disable_targets: List[str],
    skills: List[str],
    dry_run: bool,
) -> None:
    """Install bundled skills into supported agent targets."""
    logger = ctx.obj["logger"]
    selected_targets = resolve_targets(
        mode="skills",
        scope=scope,
        enable_targets=list(targets),
        disable_targets=list(disable_targets),
    )
    if not selected_targets:
        logger.warning(
            "No targets selected. Use --target to choose targets explicitly."
        )
        return
    try:
        selected_skills = resolve_skill_names(list(skills) if skills else None)
    except ValueError as exc:
        message = str(exc)
        logger.error(message)
        logger.echo(message)
        ctx.abort()
    results = install_skills_for_targets(
        targets=selected_targets,
        scope=scope,
        skill_names=selected_skills if skills else None,
        dry_run=dry_run,
    )
    for result in results:
        line = f"[{result.target}] {result.details} -> {result.path}"
        if result.dry_run:
            logger.echo(f"(dry run) {line}")
        elif result.applied:
            logger.success(line)
        else:
            logger.warning(line)
`````

## File: src/metagit/cli/json_output.py
`````python
#!/usr/bin/env python
"""
Shared JSON output helpers for agentic CLI use.
"""

from __future__ import annotations

import json
from typing import Any

import click
from pydantic import BaseModel


def emit_json(payload: BaseModel | dict[str, Any]) -> None:
    """Print a JSON document to stdout."""
    if isinstance(payload, BaseModel):
        data: dict[str, Any] = payload.model_dump(mode="json")
    else:
        data = payload
    click.echo(json.dumps(data, indent=2, default=str))


def exit_on_catalog_mutation(
    result: BaseModel,
    *,
    as_json: bool,
) -> None:
    """Emit mutation result and exit non-zero when ok is false."""
    if as_json:
        emit_json(result)
    else:
        ok = bool(getattr(result, "ok", True))
        error = getattr(result, "error", None)
        if not ok and error is not None:
            click.echo(f"Error ({error.kind}): {error.message}", err=True)
        elif ok:
            entity = getattr(result, "entity", "entity")
            operation = getattr(result, "operation", "updated")
            name = getattr(result, "repo_name", None) or getattr(
                result, "project_name", ""
            )
            click.echo(f"{entity} {operation}: {name}")
    if not bool(getattr(result, "ok", True)):
        raise SystemExit(1)


def exit_on_layout_mutation(
    result: BaseModel,
    *,
    as_json: bool,
) -> None:
    """Emit layout mutation result; show plan summary on dry-run."""
    if as_json:
        emit_json(result)
    else:
        ok = bool(getattr(result, "ok", True))
        error = getattr(result, "error", None)
        data = getattr(result, "data", None) or {}
        if not ok and error is not None:
            click.echo(f"Error ({error.kind}): {error.message}", err=True)
        elif ok:
            operation = getattr(result, "operation", "updated")
            entity = getattr(result, "entity", "entity")
            name = getattr(result, "repo_name", None) or getattr(
                result, "project_name", ""
            )
            if data.get("dry_run"):
                click.echo(f"dry-run {entity} {operation}: {name}")
                for step in data.get("disk_steps", []):
                    click.echo(
                        f"  {step.get('action')}: {step.get('source')} -> {step.get('target')}"
                    )
            else:
                click.echo(f"{entity} {operation}: {name}")
                for warning in data.get("warnings", []):
                    click.echo(f"  warning: {warning}")
    if not bool(getattr(result, "ok", True)):
        raise SystemExit(1)
`````

## File: src/metagit/core/config/example_generator.py
`````python
#!/usr/bin/env python
"""
Generate a documented YAML exemplar for MetagitConfig with field descriptions as comments.
"""

from __future__ import annotations

import enum
import types
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Union, get_args, get_origin

import yaml
from pydantic import BaseModel
from pydantic.fields import FieldInfo

from metagit import DATA_PATH
from metagit.core.config.models import MetagitConfig
from metagit.core.utils.yaml_class import yaml as metagit_yaml


def load_example_overrides() -> dict[str, Any]:
    """Load optional nested overrides for the generated exemplar."""
    override_path = Path(DATA_PATH) / "config-example-overrides.yml"
    if not override_path.is_file():
        return {}
    with override_path.open("r", encoding="utf-8") as handle:
        loaded = metagit_yaml.safe_load(handle)
    return loaded if isinstance(loaded, dict) else {}


def deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
    """Recursively merge overrides into base (overrides win)."""
    merged = dict(base)
    for key, value in overrides.items():
        if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
            merged[key] = deep_merge(merged[key], value)
        else:
            merged[key] = value
    return merged


class ConfigExampleGenerator:
    """Build a representative MetagitConfig-shaped dict for documentation."""

    def __init__(self, overrides: dict[str, Any] | None = None) -> None:
        self._overrides = overrides or {}

    def build(self, *, include_workspace: bool = True) -> dict[str, Any]:
        """Return a nested dict suitable for YAML serialization."""
        payload = self._sample_model(MetagitConfig)
        if not include_workspace:
            payload.pop("workspace", None)
        return deep_merge(payload, self._overrides)

    def render_yaml(
        self,
        *,
        include_workspace: bool = True,
        comment_style: str = "line",
    ) -> str:
        """Render YAML with per-field description comments."""
        payload = self.build(include_workspace=include_workspace)
        header = (
            "# NON-PRODUCTION EXEMPLAR — generated by `metagit config example`.\n"
            "# Field descriptions come from Pydantic models; edit before use.\n"
        )
        if comment_style != "line":
            return header + yaml.dump(
                payload,
                default_flow_style=False,
                sort_keys=False,
                allow_unicode=True,
            )
        lines = header.splitlines(keepends=True)
        lines.append("\n")
        lines.extend(
            self._render_mapping(
                payload,
                MetagitConfig,
                indent=0,
            )
        )
        return "".join(lines)

    def _render_mapping(
        self,
        data: dict[str, Any],
        model: type[BaseModel],
        *,
        indent: int,
    ) -> list[str]:
        """Render one mapping using model field order and descriptions."""
        pad = "  " * indent
        lines: list[str] = []
        fields = model.model_fields
        for key, value in data.items():
            if key not in fields:
                lines.extend(self._render_key_value(key, value, indent=indent))
                continue
            field_info = fields[key]
            description = field_info.description
            if description:
                for desc_line in str(description).splitlines():
                    lines.append(f"{pad}# {desc_line}\n")
            annotation = field_info.annotation
            nested_model = self._nested_model_for_annotation(annotation)
            if isinstance(value, dict) and nested_model is not None:
                lines.append(f"{pad}{key}:\n")
                lines.extend(
                    self._render_mapping(value, nested_model, indent=indent + 1)
                )
            elif isinstance(value, list) and value and nested_model is not None:
                lines.append(f"{pad}{key}:\n")
                for item in value:
                    if isinstance(item, dict):
                        lines.append(f"{pad}  -\n")
                        lines.extend(
                            self._render_mapping(item, nested_model, indent=indent + 2)
                        )
                    else:
                        lines.append(f"{pad}  - {self._format_scalar(item)}\n")
            else:
                lines.extend(self._render_key_value(key, value, indent=indent))
        return lines

    def _render_key_value(self, key: str, value: Any, *, indent: int) -> list[str]:
        pad = "  " * indent
        if isinstance(value, dict):
            lines = [f"{pad}{key}:\n"]
            for sub_key, sub_value in value.items():
                lines.extend(
                    self._render_key_value(sub_key, sub_value, indent=indent + 1)
                )
            return lines
        if isinstance(value, list):
            lines = [f"{pad}{key}:\n"]
            for item in value:
                if isinstance(item, (dict, list)):
                    lines.append(
                        f"{pad}  - {yaml.dump(item, default_flow_style=True).strip()}\n"
                    )
                else:
                    lines.append(f"{pad}  - {self._format_scalar(item)}\n")
            return lines
        return [f"{pad}{key}: {self._format_scalar(value)}\n"]

    def _format_scalar(self, value: Any) -> str:
        if value is None:
            return "null"
        if isinstance(value, bool):
            return "true" if value else "false"
        if isinstance(value, (int, float)):
            return str(value)
        if isinstance(value, datetime):
            return value.isoformat()
        escaped = str(value).replace("\n", "\\n")
        if (
            any(ch in escaped for ch in ":{}[]&*#?|-<>=!%@")
            or escaped.startswith(" ")
            or not escaped
        ):
            return yaml.dump(escaped, default_flow_style=True).strip()
        return escaped

    def _sample_model(self, model: type[BaseModel]) -> dict[str, Any]:
        payload: dict[str, Any] = {}
        for name, field_info in model.model_fields.items():
            payload[name] = self._sample_field(name, field_info, model)
        return payload

    def _sample_field(
        self,
        name: str,
        field_info: FieldInfo,
        model: type[BaseModel],
    ) -> Any:
        if field_info.default_factory is not None and name in {
            "artifacts",
            "paths",
            "dependencies",
        }:
            inner = self._sample_annotation(field_info.annotation, name)
            return [inner] if isinstance(inner, dict) else inner
        annotation = field_info.annotation
        return self._sample_annotation(annotation, name)

    def _sample_annotation(self, annotation: Any, field_name: str) -> Any:
        origin = get_origin(annotation)
        if origin is Union or isinstance(annotation, types.UnionType):
            args = [arg for arg in get_args(annotation) if arg is not type(None)]
            if not args:
                return None
            return self._sample_annotation(args[0], field_name)

        if origin is list:
            inner_args = get_args(annotation)
            inner = inner_args[0] if inner_args else Any
            sampled = self._sample_annotation(inner, field_name)
            return [sampled] if sampled is not None else []

        nested = self._nested_model_for_annotation(annotation)
        if nested is not None:
            return self._sample_model(nested)

        if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
            return next(iter(annotation)).value

        return self._scalar_for_name(field_name)

    def _nested_model_for_annotation(
        self,
        annotation: Any,
    ) -> type[BaseModel] | None:
        origin = get_origin(annotation)
        if origin is Union or isinstance(annotation, types.UnionType):
            for arg in get_args(annotation):
                nested = self._nested_model_for_annotation(arg)
                if nested is not None:
                    return nested
            return None
        if origin is list:
            args = get_args(annotation)
            if args:
                return self._nested_model_for_annotation(args[0])
            return None
        if isinstance(annotation, type) and issubclass(annotation, BaseModel):
            return annotation
        return None

    def _scalar_for_name(self, field_name: str) -> Any:
        lowered = field_name.lower()
        if lowered in {"email"}:
            return "maintainer@example.com"
        if lowered in {"url", "forked_from"}:
            return "https://example.com/org/repo"
        if lowered.endswith("_at") or lowered in {"created_at", "last_commit_at"}:
            return datetime(2026, 1, 1, tzinfo=timezone.utc).isoformat()
        if lowered in {"name"}:
            return "example-name"
        if lowered in {"description", "agent_instructions", "agent_prompt"}:
            return "Example text for documentation; replace before production use."
        if lowered in {"ref", "pattern", "definition", "file", "location", "role"}:
            return "example"
        if lowered in {"language_version"}:
            return "3.12"
        if lowered in {"language"}:
            return "python"
        if lowered in {"package_manager"}:
            return "uv"
        if lowered in {"tags"}:
            return {"example": "documentation"}
        if lowered in {"metadata"}:
            return {"source": "metagit-config-example"}
        if lowered in {"sync"}:
            return True
        if lowered in {"protected", "archived", "template", "has_ci", "has_tests"}:
            return False
        if lowered in {"enabled"}:
            return False
        if lowered in {
            "stars",
            "forks",
            "open_issues",
            "contributors",
            "open",
            "merged_last_30d",
        }:
            return 0
        return "example-value"
`````

## File: src/metagit/core/detect/detectors/docker.py
`````python
import yaml
from typing import Optional
from metagit.core.detect.models import ProjectScanContext, DiscoveryResult


class DockerDetector:
    name = "DockerDetector"

    def should_run(self, ctx: ProjectScanContext) -> bool:
        return any(
            "dockerfile" in p.name.lower() or "docker-compose" in p.name.lower()
            for p in ctx.all_files
        )

    def run(self, ctx: ProjectScanContext) -> Optional[DiscoveryResult]:
        dockerfiles = [p for p in ctx.all_files if "dockerfile" in p.name.lower()]
        composefiles = [
            p
            for p in ctx.all_files
            if "docker-compose" in p.name.lower() and p.suffix in {".yml", ".yaml"}
        ]

        containers = []
        for dockerfile in dockerfiles:
            base_image = None
            exposed_ports = []
            entrypoint = None
            cmd = None

            try:
                with dockerfile.open("r", encoding="utf-8") as f:
                    for line in f:
                        line = line.strip()
                        if line.upper().startswith("FROM"):
                            base_image = line.split(None, 1)[1]
                        elif line.upper().startswith("EXPOSE"):
                            exposed_ports.extend(line.split()[1:])
                        elif line.upper().startswith("ENTRYPOINT"):
                            entrypoint = line[len("ENTRYPOINT") :].strip()
                        elif line.upper().startswith("CMD"):
                            cmd = line[len("CMD") :].strip()
            except Exception:
                continue  # corrupt file

            containers.append(
                {
                    "file": str(dockerfile.relative_to(ctx.root_path)),
                    "base_image": base_image,
                    "exposed_ports": exposed_ports,
                    "entrypoint": entrypoint,
                    "cmd": cmd,
                }
            )

        services = []
        for composefile in composefiles:
            try:
                with composefile.open("r", encoding="utf-8") as f:
                    data = yaml.safe_load(f)
                    if not isinstance(data, dict):
                        continue
                    for svc_name, svc_def in data.get("services", {}).items():
                        services.append(
                            {
                                "name": svc_name,
                                "image": svc_def.get("image"),
                                "build": svc_def.get("build"),
                                "ports": svc_def.get("ports", []),
                            }
                        )
            except Exception:
                continue

        if not containers and not services:
            return None

        return DiscoveryResult(
            name="Dockerized Project",
            description="Detected Dockerfile(s) or Compose configuration indicating containerization",
            tags=["docker", "container", "service"],
            confidence=0.95,
            data={
                "containers": containers,
                "compose_services": services,
            },
        )
`````

## File: src/metagit/core/detect/detectors/python.py
`````python
import os
import tomli
from pathlib import Path
from typing import Optional
from metagit.core.detect.models import ProjectScanContext, DiscoveryResult


class PythonDetector:
    name = "PythonDetector"

    def should_run(self, ctx: ProjectScanContext) -> bool:
        indicators = ["pyproject.toml", "requirements.txt", "setup.py"]
        return any((ctx.root_path / name).exists() for name in indicators)

    def run(self, ctx: ProjectScanContext) -> Optional[DiscoveryResult]:
        if not self.should_run(ctx):
            return None

        dependencies = self._get_dependencies(ctx.root_path)

        return DiscoveryResult(
            name="Python Project",
            description="Detected a Python project using modern or legacy packaging standards",
            tags=["python"],
            confidence=0.98,
            data={"dependencies": dependencies},
        )

    def _get_dependencies(self, root: Path) -> list[str]:
        deps = []

        pyproject = os.path.join(root, "pyproject.toml")
        if os.path.exists(pyproject):
            with open(pyproject, "rb") as f:
                data = tomli.load(f)
                # Handle both Poetry and PEP 621
                try:
                    deps.extend(data["tool"]["poetry"]["dependencies"].keys())
                except KeyError:
                    pass
                try:
                    deps.extend(data["project"]["dependencies"])
                except KeyError:
                    pass

        reqs = root / "requirements.txt"
        if reqs.exists():
            with reqs.open("r") as f:
                deps.extend(
                    [
                        line.strip().split("==")[0]
                        for line in f
                        if line.strip() and not line.startswith("#")
                    ]
                )

        setup = root / "setup.py"
        if setup.exists():
            deps.append(
                "[from setup.py]"
            )  # You could parse with `ast`, but keep it simple here

        return sorted(set(deps))
`````

## File: src/metagit/core/detect/detectors/terraform.py
`````python
import hcl2
from typing import Optional
from metagit.core.detect.models import ProjectScanContext, DiscoveryResult


def classify_module_source(source: str) -> str:
    source = source.strip()
    if source.startswith(("./", "../")) or not any(
        source.startswith(p) for p in ("git::", "http", "s3::", "gcs::", "terraform-")
    ):
        return "local"
    return "remote"


class TerraformDetector:
    name = "TerraformDetector"

    def should_run(self, ctx: ProjectScanContext) -> bool:
        return any(p.suffix == ".tf" for p in ctx.all_files)

    def run(self, ctx: ProjectScanContext) -> Optional[DiscoveryResult]:
        provider_set = set()
        module_sources = []
        backend_type = None

        for tf_file in [p for p in ctx.all_files if p.suffix == ".tf"]:
            with tf_file.open("r", encoding="utf-8") as f:
                try:
                    parsed = hcl2.load(f)
                except Exception:
                    continue

                # Providers
                for block in parsed.get("provider", []):
                    for provider_name in block:
                        provider_set.add(provider_name)

                # Modules
                for block in parsed.get("module", []):
                    for module_name, attrs in block.items():
                        source = attrs.get("source")
                        if source:
                            module_sources.append(
                                {
                                    "name": module_name,
                                    "source": source,
                                    "type": classify_module_source(source),
                                }
                            )

                # Terraform block (for backend)
                for block in parsed.get("terraform", []):
                    backend = block.get("backend")
                    if backend and isinstance(backend, dict):
                        backend_type = next(iter(backend.keys()), None)

        if not provider_set and not module_sources:
            return None

        return DiscoveryResult(
            name="Terraform Root Module",
            description="Detected a Terraform root module",
            tags=["terraform", "iac", "root-module"],
            confidence=0.95,
            data={
                "providers": sorted(provider_set),
                "modules": module_sources,
                "backend": backend_type,
            },
        )
`````

## File: src/metagit/core/mcp/services/bootstrap_sampling.py
`````python
#!/usr/bin/env python
"""
Sampling-assisted bootstrap service for `.metagit.yml`.
"""

from typing import Callable, Optional

from metagit.core.config.models import MetagitConfig
from metagit.core.utils.yaml_class import yaml


class BootstrapSamplingService:
    """Generate `.metagit.yml` using sampling when available, otherwise fallback."""

    def __init__(
        self,
        sampling_supported: bool,
        sampler: Optional[Callable[[dict[str, str]], str]] = None,
    ) -> None:
        self.sampling_supported = sampling_supported
        self._sampler = sampler

    def generate(
        self, context: dict[str, str], confirm_write: bool = False
    ) -> dict[str, str]:
        """Generate config draft and return write guidance."""
        if not self.sampling_supported or self._sampler is None:
            return {
                "mode": "plan_only",
                "prompt_package": self._build_prompt(context=context),
                "write_target": ".metagit.generated.yml",
            }

        errors: list[str] = []
        for _ in range(3):
            prompt = self._build_prompt(context=context, validation_errors=errors)
            draft_yaml = self._sampler({"prompt": prompt})
            validation = self._validate_yaml(draft_yaml=draft_yaml)
            if validation["valid"]:
                return {
                    "mode": "sampled",
                    "draft_yaml": draft_yaml,
                    "write_target": ".metagit.yml"
                    if confirm_write
                    else ".metagit.generated.yml",
                }
            errors = [validation["error"]]

        return {
            "mode": "plan_only",
            "prompt_package": self._build_prompt(
                context=context, validation_errors=errors
            ),
            "write_target": ".metagit.generated.yml",
        }

    def _build_prompt(
        self, context: dict[str, str], validation_errors: Optional[list[str]] = None
    ) -> str:
        errors = "\n".join(validation_errors or [])
        return (
            "Create a valid .metagit.yml using this context.\n"
            f"Context: {context}\n"
            f"Validation errors to fix: {errors if errors else 'none'}\n"
            "Output only YAML."
        )

    def _validate_yaml(self, draft_yaml: str) -> dict[str, str | bool]:
        try:
            loaded = yaml.safe_load(draft_yaml)
            MetagitConfig(**loaded)
            return {"valid": True, "error": ""}
        except Exception as exc:
            return {"valid": False, "error": str(exc)}
`````

## File: src/metagit/core/mcp/services/cross_project_dependencies.py
`````python
#!/usr/bin/env python
"""
Map cross-project dependencies from workspace configuration and import hints.
"""

from __future__ import annotations

from collections import deque
from typing import Any, Optional
from urllib.parse import urlparse

from metagit.core.config.graph_resolver import resolve_graph_endpoint_id
from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.gitnexus_registry import GitNexusRegistryAdapter
from metagit.core.mcp.services.import_hint_scanner import ImportHintScanner
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.project.models import ProjectPath
from metagit.core.workspace.dependency_models import (
    CrossProjectDependencyResult,
    DependencyEdge,
    DependencyNode,
    ImpactSummary,
)


class CrossProjectDependencyService:
    """Build a dependency graph across workspace projects."""

    _valid_types = {
        "declared",
        "imports",
        "shared_config",
        "url_match",
        "ref",
        "manual",
    }

    def __init__(
        self,
        index_service: Optional[WorkspaceIndexService] = None,
        registry: Optional[GitNexusRegistryAdapter] = None,
        import_scanner: Optional[ImportHintScanner] = None,
    ) -> None:
        self._index = index_service or WorkspaceIndexService()
        self._registry = registry or GitNexusRegistryAdapter()
        self._import_scanner = import_scanner or ImportHintScanner()

    def map_dependencies(
        self,
        config: MetagitConfig,
        workspace_root: str,
        source_project: str,
        *,
        dependency_types: Optional[list[str]] = None,
        depth: int = 2,
        include_external_repos: bool = False,
    ) -> CrossProjectDependencyResult:
        """Return dependency nodes and edges reachable from a source project."""
        if not config.workspace:
            return CrossProjectDependencyResult(
                ok=False,
                error="workspace_not_configured",
                source_project=source_project,
            )
        project_names = {project.name for project in config.workspace.projects}
        if source_project not in project_names:
            return CrossProjectDependencyResult(
                ok=False,
                error="project_not_found",
                source_project=source_project,
            )

        selected_types = self._normalize_types(dependency_types=dependency_types)
        rows = self._index.build_index(config=config, workspace_root=workspace_root)
        nodes, path_to_id, _id_to_node = self._build_nodes(config=config, rows=rows)
        edges = self._collect_edges(
            config=config,
            rows=rows,
            path_to_id=path_to_id,
            selected_types=selected_types,
            include_external=include_external_repos,
        )

        source_id = f"project:{source_project}"
        filtered_nodes, filtered_edges = self._filter_by_depth(
            source_id=source_id,
            nodes=nodes,
            edges=edges,
            depth=max(1, depth),
        )
        graph_status = self._registry.summarize_for_paths(
            repo_paths=[
                node.repo_path
                for node in filtered_nodes
                if node.repo_path and node.kind == "repo"
            ]
        )
        for node in filtered_nodes:
            if node.repo_path and node.kind == "repo":
                node.gitnexus_status = graph_status.get(node.repo_path)
                node.gitnexus_indexed = node.gitnexus_status in {
                    "indexed",
                    "stale",
                }

        impact = self._build_impact_summary(
            source_project=source_project,
            edges=filtered_edges,
            nodes=filtered_nodes,
            graph_status=graph_status,
            selected_types=selected_types,
        )

        return CrossProjectDependencyResult(
            ok=True,
            source_project=source_project,
            dependency_types=sorted(selected_types),
            depth=depth,
            graph_status=graph_status,
            nodes=filtered_nodes,
            edges=filtered_edges,
            impact_summary=impact,
        )

    def _normalize_types(self, dependency_types: Optional[list[str]]) -> set[str]:
        """Normalize requested dependency type filters."""
        if not dependency_types:
            return {"declared", "imports", "shared_config"}
        selected = {item.lower() for item in dependency_types}
        unknown = selected - self._valid_types
        if unknown:
            selected = selected & self._valid_types
        return selected or {"declared", "imports", "shared_config"}

    def _build_nodes(
        self,
        config: MetagitConfig,
        rows: list[dict[str, Any]],
    ) -> tuple[list[DependencyNode], dict[str, str], dict[str, DependencyNode]]:
        """Build project and repo nodes from workspace index rows."""
        nodes: list[DependencyNode] = []
        id_to_node: dict[str, DependencyNode] = {}
        path_to_id: dict[str, str] = {}
        project_names = {row["project_name"] for row in rows}
        if config.dependencies or config.components:
            project_names.add("local")
        for project_name in sorted(project_names):
            node_id = f"project:{project_name}"
            node = DependencyNode(
                id=node_id,
                kind="project",
                label=project_name,
                project_name=project_name,
            )
            nodes.append(node)
            id_to_node[node_id] = node
        for row in rows:
            repo_path = str(row.get("repo_path", ""))
            node_id = f"repo:{row['project_name']}/{row['repo_name']}"
            node = DependencyNode(
                id=node_id,
                kind="repo",
                label=str(row.get("repo_name", "")),
                project_name=str(row.get("project_name", "")),
                repo_path=repo_path,
            )
            nodes.append(node)
            id_to_node[node_id] = node
            if repo_path:
                path_to_id[repo_path] = node_id
        return nodes, path_to_id, id_to_node

    def _collect_edges(
        self,
        config: MetagitConfig,
        rows: list[dict[str, Any]],
        path_to_id: dict[str, str],
        selected_types: set[str],
        include_external: bool,
    ) -> list[DependencyEdge]:
        """Collect dependency edges from all enabled collectors."""
        edges: list[DependencyEdge] = []
        project_names = {
            project.name
            for project in (config.workspace.projects if config.workspace else [])
        }

        if selected_types.intersection({"declared", "ref"}):
            edges.extend(
                self._declared_edges(
                    config=config,
                    rows=rows,
                    project_names=project_names,
                    include_external=include_external,
                )
            )

        if selected_types.intersection({"shared_config", "url_match"}):
            edges.extend(
                self._shared_config_edges(rows=rows, include_external=include_external)
            )

        if "imports" in selected_types:
            edges.extend(
                self._import_edges(
                    rows=rows,
                    path_to_id=path_to_id,
                )
            )

        edges.extend(
            self._manual_graph_edges(
                config=config,
                rows=rows,
                project_names=project_names,
            )
        )

        return self._dedupe_edges(edges=edges)

    def _manual_graph_edges(
        self,
        config: MetagitConfig,
        rows: list[dict[str, Any]],
        project_names: set[str],
    ) -> list[DependencyEdge]:
        """Edges from top-level graph.relationships in .metagit.yml."""
        if config.graph is None or not config.graph.relationships:
            return []
        edges: list[DependencyEdge] = []
        for rel in config.graph.relationships:
            from_id = resolve_graph_endpoint_id(
                rel.from_endpoint,
                rows=rows,
                project_names=project_names,
            )
            to_id = resolve_graph_endpoint_id(
                rel.to,
                rows=rows,
                project_names=project_names,
            )
            if not from_id or not to_id:
                continue
            evidence = ["manifest graph.relationships"]
            if rel.id:
                evidence.append(f"id={rel.id}")
            if rel.label:
                evidence.append(f"label={rel.label}")
            if rel.description:
                evidence.append(rel.description)
            edges.append(
                DependencyEdge(
                    from_id=from_id,
                    to_id=to_id,
                    type="manual",
                    evidence=evidence,
                )
            )
        return edges

    def _declared_edges(
        self,
        config: MetagitConfig,
        rows: list[dict[str, Any]],
        project_names: set[str],
        include_external: bool,
    ) -> list[DependencyEdge]:
        """Edges from explicit refs and root-level dependency declarations."""
        edges: list[DependencyEdge] = []
        repo_by_name: dict[str, list[dict[str, Any]]] = {}
        for row in rows:
            repo_by_name.setdefault(str(row.get("repo_name", "")), []).append(row)

        for row in rows:
            from_id = f"repo:{row['project_name']}/{row['repo_name']}"
            tags = row.get("tags") or {}
            for key, value in tags.items():
                if (
                    str(key).lower() in {"project", "depends_on"}
                    and value in project_names
                ):
                    edges.append(
                        DependencyEdge(
                            from_id=from_id,
                            to_id=f"project:{value}",
                            type="declared",
                            evidence=[f"tag {key}={value}"],
                        )
                    )

        for project in config.workspace.projects if config.workspace else []:
            for repo in project.repos:
                ref_target = self._ref_target(repo=repo, project_names=project_names)
                if ref_target:
                    edges.append(
                        DependencyEdge(
                            from_id=f"project:{project.name}",
                            to_id=f"project:{ref_target}",
                            type="ref",
                            evidence=[f"{project.name}/{repo.name}.ref={repo.ref}"],
                        )
                    )

        for dep in (config.dependencies or []) + (config.components or []):
            ref_target = self._ref_target(repo=dep, project_names=project_names)
            if ref_target:
                edges.append(
                    DependencyEdge(
                        from_id="project:local",
                        to_id=f"project:{ref_target}",
                        type="declared",
                        evidence=[f"config dependency ref={dep.ref}"],
                    )
                )
            matched = repo_by_name.get(dep.name, [])
            for row in matched:
                if row["project_name"] in project_names:
                    edges.append(
                        DependencyEdge(
                            from_id="project:local",
                            to_id=f"repo:{row['project_name']}/{row['repo_name']}",
                            type="declared",
                            evidence=[f"config dependency name={dep.name}"],
                        )
                    )

        if include_external:
            return edges
        return [
            edge
            for edge in edges
            if self._edge_is_internal(edge=edge, project_names=project_names)
        ]

    def _shared_config_edges(
        self,
        rows: list[dict[str, Any]],
        include_external: bool,
    ) -> list[DependencyEdge]:
        """Edges from shared URLs and configured path references."""
        edges: list[DependencyEdge] = []
        by_url: dict[str, list[dict[str, Any]]] = {}
        for row in rows:
            url = row.get("url")
            if not url:
                continue
            normalized = self._normalize_url(str(url))
            by_url.setdefault(normalized, []).append(row)

        for url, grouped in by_url.items():
            if len(grouped) < 2:
                continue
            for idx, source in enumerate(grouped):
                for target in grouped[idx + 1 :]:
                    if source["project_name"] == target["project_name"]:
                        continue
                    edges.append(
                        DependencyEdge(
                            from_id=f"project:{source['project_name']}",
                            to_id=f"project:{target['project_name']}",
                            type="url_match",
                            evidence=[f"shared url {url}"],
                        )
                    )

        configured_paths: dict[str, list[dict[str, Any]]] = {}
        for row in rows:
            configured = row.get("configured_path")
            if not configured:
                continue
            configured_paths.setdefault(str(configured), []).append(row)
        for path, grouped in configured_paths.items():
            if len(grouped) < 2:
                continue
            for idx, source in enumerate(grouped):
                for target in grouped[idx + 1 :]:
                    edges.append(
                        DependencyEdge(
                            from_id=f"repo:{source['project_name']}/{source['repo_name']}",
                            to_id=f"repo:{target['project_name']}/{target['repo_name']}",
                            type="shared_config",
                            evidence=[f"shared configured_path {path}"],
                        )
                    )

        if include_external:
            return edges
        return edges

    def _import_edges(
        self,
        rows: list[dict[str, Any]],
        path_to_id: dict[str, str],
    ) -> list[DependencyEdge]:
        """Edges from manifest import hints between repositories."""
        edges: list[DependencyEdge] = []
        for row in rows:
            if not row.get("exists"):
                continue
            repo_path = str(row.get("repo_path", ""))
            from_id = f"repo:{row['project_name']}/{row['repo_name']}"
            hints = self._import_scanner.scan_repo(
                repo_path=repo_path,
                path_to_repo_id=path_to_id,
            )
            for hint in hints:
                to_id = hint.get("to_id")
                if not to_id or to_id == from_id:
                    continue
                edges.append(
                    DependencyEdge(
                        from_id=from_id,
                        to_id=str(to_id),
                        type="import",
                        evidence=list(hint.get("evidence") or []),
                    )
                )
        return edges

    def _filter_by_depth(
        self,
        source_id: str,
        nodes: list[DependencyNode],
        edges: list[DependencyEdge],
        depth: int,
    ) -> tuple[list[DependencyNode], list[DependencyEdge]]:
        """Keep nodes and edges within N project hops from the source."""
        adjacency: dict[str, set[str]] = {}
        for edge in edges:
            adjacency.setdefault(edge.from_id, set()).add(edge.to_id)
            adjacency.setdefault(edge.to_id, set()).add(edge.from_id)

        visited = {source_id}
        queue: deque[tuple[str, int]] = deque([(source_id, 0)])
        if source_id.startswith("project:"):
            source_project = source_id.split(":", 1)[1]
            for node in nodes:
                if node.kind == "repo" and node.project_name == source_project:
                    if node.id not in visited:
                        visited.add(node.id)
                        queue.append((node.id, 0))
        while queue:
            node_id, distance = queue.popleft()
            if distance >= depth:
                continue
            for neighbor in adjacency.get(node_id, set()):
                if neighbor in visited:
                    continue
                visited.add(neighbor)
                queue.append((neighbor, distance + 1))

        filtered_edges = [
            edge for edge in edges if edge.from_id in visited and edge.to_id in visited
        ]
        filtered_nodes = [node for node in nodes if node.id in visited]
        return filtered_nodes, filtered_edges

    def _build_impact_summary(
        self,
        source_project: str,
        edges: list[DependencyEdge],
        nodes: list[DependencyNode],
        graph_status: dict[str, str],
        selected_types: set[str],
    ) -> ImpactSummary:
        """Summarize risk and affected projects."""
        affected_projects = sorted(
            {
                node.project_name
                for node in nodes
                if node.project_name and node.project_name != source_project
            }
        )
        affected_repos = sorted(
            {
                node.label
                for node in nodes
                if node.kind == "repo" and node.project_name != source_project
            }
        )
        notes: list[str] = []
        stale_count = sum(1 for status in graph_status.values() if status == "stale")
        missing_count = sum(
            1 for status in graph_status.values() if status == "missing"
        )
        if stale_count:
            notes.append(
                f"{stale_count} repositories have stale GitNexus indexes; run gitnexus analyze."
            )
        if missing_count:
            notes.append(f"{missing_count} repositories are not indexed in GitNexus.")
        if "imports" in selected_types:
            notes.append(
                "Import edges use manifest scanning; run GitNexus analyze for symbol-level graphs."
            )

        risk = "low"
        if len(affected_projects) >= 3 or len(edges) >= 8:
            risk = "high"
        elif len(affected_projects) >= 1 or len(edges) >= 3:
            risk = "medium"

        return ImpactSummary(
            risk=risk,
            affected_projects=affected_projects,
            affected_repos=affected_repos,
            edge_count=len(edges),
            notes=notes,
        )

    def _ref_target(self, repo: ProjectPath, project_names: set[str]) -> Optional[str]:
        """Resolve a ProjectPath ref to a workspace project name."""
        if repo.ref and repo.ref in project_names:
            return repo.ref
        return None

    def _edge_is_internal(self, edge: DependencyEdge, project_names: set[str]) -> bool:
        """Return whether an edge stays inside configured workspace projects."""
        for node_id in (edge.from_id, edge.to_id):
            if node_id.startswith("project:"):
                project = node_id.split(":", 1)[1]
                if project != "local" and project not in project_names:
                    return False
        return True

    def _normalize_url(self, url: str) -> str:
        """Normalize repository URLs for comparison."""
        cleaned = url.strip().lower().rstrip("/")
        if cleaned.endswith(".git"):
            cleaned = cleaned[:-4]
        parsed = urlparse(cleaned)
        if parsed.netloc:
            return f"{parsed.netloc}{parsed.path.rstrip('/')}"
        return cleaned

    def _dedupe_edges(self, edges: list[DependencyEdge]) -> list[DependencyEdge]:
        """Remove duplicate edges while preserving evidence."""
        merged: dict[tuple[str, str, str], DependencyEdge] = {}
        for edge in edges:
            key = (edge.from_id, edge.to_id, edge.type)
            if key not in merged:
                merged[key] = edge
                continue
            existing = merged[key]
            combined = list(dict.fromkeys(existing.evidence + edge.evidence))
            merged[key] = DependencyEdge(
                from_id=edge.from_id,
                to_id=edge.to_id,
                type=edge.type,
                evidence=combined,
            )
        return list(merged.values())
`````

## File: src/metagit/core/mcp/services/project_context.py
`````python
#!/usr/bin/env python
"""
Project context switching and environment export for MCP tools.
"""

import os
from typing import Any, Optional

from metagit.core.config.models import MetagitConfig, Variable, VariableKind
from metagit.core.mcp.services.repo_git_stats import inspect_repo_state
from metagit.core.mcp.services.session_store import SessionStore
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.workspace.agent_instructions import AgentInstructionsResolver
from metagit.core.workspace.context_models import (
    ProjectContextBundle,
    ProjectContextEnv,
    ProjectContextSession,
    ProjectRepoContext,
    validate_env_value,
)
from metagit.core.workspace.models import WorkspaceProject

_INSPECT_LIMIT = 20
_EXPORTABLE_VARIABLE_KINDS = {
    VariableKind.STRING,
    VariableKind.INTEGER,
    VariableKind.BOOLEAN,
}


class ProjectContextService:
    """Switch active workspace project and build agent context bundles."""

    def __init__(
        self,
        index_service: Optional[WorkspaceIndexService] = None,
        instructions_resolver: Optional[AgentInstructionsResolver] = None,
    ) -> None:
        self._index = index_service or WorkspaceIndexService()
        self._instructions = instructions_resolver or AgentInstructionsResolver()

    def switch(
        self,
        config: MetagitConfig,
        workspace_root: str,
        project_name: str,
        *,
        setup_env: bool = True,
        restore_session: bool = True,
        save_previous: bool = True,
        primary_repo: Optional[str] = None,
    ) -> ProjectContextBundle:
        """Switch to a workspace project and return a context bundle."""
        project = self._find_project(config=config, project_name=project_name)
        if project is None:
            return ProjectContextBundle(
                ok=False,
                error="project_not_found",
                project_name=project_name,
                workspace_root=workspace_root,
            )

        store = SessionStore(workspace_root=workspace_root)
        prior_meta = store.get_workspace_meta()
        if save_previous and prior_meta.active_project:
            prior_session = store.get_project_session(
                project_name=prior_meta.active_project
            )
            store.save_project_session(session=prior_session)

        bundle = self._build_bundle(
            config=config,
            workspace_root=workspace_root,
            project=project,
            project_name=project_name,
            setup_env=setup_env,
            restore_session=restore_session,
            primary_repo=primary_repo,
        )
        store.set_active_project(project_name=project_name)
        return bundle

    def show(
        self,
        config: MetagitConfig,
        workspace_root: str,
        project_name: Optional[str] = None,
    ) -> ProjectContextBundle:
        """Return context for active or named project without changing active project."""
        store = SessionStore(workspace_root=workspace_root)
        meta = store.get_workspace_meta()
        target = project_name or meta.active_project
        if not target:
            return ProjectContextBundle(
                ok=False,
                error="no_active_project",
                workspace_root=workspace_root,
            )
        project = self._find_project(config=config, project_name=target)
        if project is None:
            return ProjectContextBundle(
                ok=False,
                error="project_not_found",
                project_name=target,
                workspace_root=workspace_root,
            )
        return self._build_bundle(
            config=config,
            workspace_root=workspace_root,
            project=project,
            project_name=target,
            setup_env=True,
            restore_session=True,
            primary_repo=None,
        )

    def _build_bundle(
        self,
        config: MetagitConfig,
        workspace_root: str,
        project: WorkspaceProject,
        project_name: str,
        *,
        setup_env: bool,
        restore_session: bool,
        primary_repo: Optional[str],
    ) -> ProjectContextBundle:
        """Build a project context bundle without updating active project."""
        store = SessionStore(workspace_root=workspace_root)
        rows = self._index.build_index(config=config, workspace_root=workspace_root)
        project_rows = [row for row in rows if row["project_name"] == project_name]
        inspect_truncated = len(project_rows) > _INSPECT_LIMIT
        inspect_rows = project_rows[:_INSPECT_LIMIT]

        repo_contexts: list[ProjectRepoContext] = []
        for row in inspect_rows:
            repo_contexts.append(self._build_repo_context(row=row, project=project))

        session_state = ProjectContextSession(restored=False)
        if restore_session:
            saved = store.get_project_session(project_name=project_name)
            session_state = ProjectContextSession(
                restored=True,
                recent_repos=list(saved.recent_repos),
                primary_repo_path=saved.primary_repo_path,
                agent_notes=saved.agent_notes,
            )

        env_bundle = (
            self._build_env(
                config=config,
                workspace_root=workspace_root,
                project_name=project_name,
                repo_paths=[
                    row["repo_path"] for row in project_rows if row.get("exists")
                ],
                session_overrides=store.get_project_session(
                    project_name=project_name
                ).env_overrides
                if restore_session
                else {},
            )
            if setup_env
            else ProjectContextEnv()
        )

        suggested = self._resolve_suggested_cwd(
            project_rows=project_rows,
            session_primary=session_state.primary_repo_path,
            primary_repo=primary_repo,
            recent_repos=session_state.recent_repos,
        )
        focus_repo_entry = None
        focus_repo_name: Optional[str] = None
        if primary_repo:
            focus_row = self._match_repo_target(rows=project_rows, target=primary_repo)
            if focus_row:
                focus_repo_name = str(focus_row.get("repo_name", "")) or None
                focus_repo_entry = self._instructions.find_repo(
                    project,
                    repo_name=focus_repo_name,
                    repo_path=str(focus_row.get("repo_path", "")),
                )
        elif suggested:
            focus_row = self._match_repo_target(rows=project_rows, target=suggested)
            if focus_row:
                focus_repo_name = str(focus_row.get("repo_name", "")) or None
                focus_repo_entry = self._instructions.find_repo(
                    project,
                    repo_name=focus_repo_name,
                    repo_path=suggested,
                )
        instructions = self._instructions.resolve(
            config,
            project=project,
            repo=focus_repo_entry,
        )

        return ProjectContextBundle(
            ok=True,
            project_name=project_name,
            workspace_root=workspace_root,
            project_description=project.description,
            agent_instructions=project.agent_instructions,
            instruction_layers=instructions.layers,
            effective_agent_instructions=instructions.effective,
            focus_repo_name=focus_repo_name,
            repos=repo_contexts,
            env=env_bundle,
            session=session_state,
            suggested_cwd=suggested,
            inspect_truncated=inspect_truncated,
        )

    def list_env_export_keys(
        self,
        config: MetagitConfig,
        workspace_root: str,
        project_name: str,
    ) -> list[str]:
        """Return sorted env export keys for a project without switching context."""
        project = self._find_project(config=config, project_name=project_name)
        if project is None:
            return []
        bundle = self._build_bundle(
            config=config,
            workspace_root=workspace_root,
            project=project,
            project_name=project_name,
            setup_env=True,
            restore_session=False,
            primary_repo=None,
        )
        return sorted(bundle.env.export.keys())

    def update_session(
        self,
        config: MetagitConfig,
        workspace_root: str,
        project_name: str,
        *,
        recent_repos: Optional[list[str]] = None,
        primary_repo_path: Optional[str] = None,
        agent_notes: Optional[str] = None,
        env_overrides: Optional[dict[str, str]] = None,
    ) -> dict[str, Any]:
        """Persist session fields for a project."""
        if self._find_project(config=config, project_name=project_name) is None:
            return {"ok": False, "error": "project_not_found"}
        store = SessionStore(workspace_root=workspace_root)
        session = store.update_project_session(
            project_name=project_name,
            recent_repos=recent_repos,
            primary_repo_path=primary_repo_path,
            agent_notes=agent_notes,
            env_overrides=env_overrides,
        )
        return {
            "ok": True,
            "project_name": project_name,
            "session": session.model_dump(mode="json"),
        }

    def _find_project(
        self, config: MetagitConfig, project_name: str
    ) -> Optional[WorkspaceProject]:
        """Locate workspace project by name."""
        if not config.workspace:
            return None
        for project in config.workspace.projects:
            if project.name == project_name:
                return project
        return None

    def _build_repo_context(
        self,
        row: dict[str, Any],
        project: WorkspaceProject,
    ) -> ProjectRepoContext:
        """Build repo context with optional git inspect."""
        exists = bool(row.get("exists"))
        repo_name = str(row.get("repo_name", ""))
        repo_entry = self._instructions.find_repo(
            project,
            repo_name=repo_name,
            repo_path=str(row.get("repo_path", "")),
        )
        branch: Optional[str] = None
        dirty: Optional[bool] = None
        inspect_error: Optional[str] = None
        if exists and row.get("is_git_repo"):
            inspected = inspect_repo_state(repo_path=str(row["repo_path"]))
            if inspected.get("ok"):
                branch = str(inspected["branch"]) if inspected.get("branch") else None
                dirty = (
                    bool(inspected["dirty"])
                    if inspected.get("dirty") is not None
                    else None
                )
            else:
                inspect_error = str(inspected.get("error", "inspect failed"))
        return ProjectRepoContext(
            repo_name=repo_name,
            repo_path=str(row.get("repo_path", "")),
            configured_path=row.get("configured_path"),
            exists=exists,
            branch=branch,
            dirty=dirty,
            tags=dict(row.get("tags") or {}),
            agent_instructions=repo_entry.agent_instructions if repo_entry else None,
            inspect_error=inspect_error,
        )

    def _build_env(
        self,
        config: MetagitConfig,
        workspace_root: str,
        project_name: str,
        repo_paths: list[str],
        session_overrides: dict[str, str],
    ) -> ProjectContextEnv:
        """Build safe environment exports and hints."""
        exports: dict[str, str] = {
            "METAGIT_WORKSPACE_ROOT": workspace_root,
            "METAGIT_PROJECT": project_name,
            "METAGIT_PROJECT_REPOS": ",".join(repo_paths),
        }
        hints: list[str] = []
        for variable in config.variables or []:
            if not isinstance(variable, Variable):
                continue
            if variable.kind not in _EXPORTABLE_VARIABLE_KINDS:
                hints.append(
                    f"Variable {variable.name} ({variable.kind}) is not auto-exported; resolve {variable.ref} manually."
                )
                continue
            try:
                exports[variable.name] = validate_env_value(str(variable.ref))
            except ValueError:
                hints.append(
                    f"Variable {variable.name} was skipped because its ref looks sensitive."
                )
        for key, value in session_overrides.items():
            exports[key] = value
        return ProjectContextEnv(export=exports, hints=hints)

    def _resolve_suggested_cwd(
        self,
        project_rows: list[dict[str, Any]],
        session_primary: Optional[str],
        primary_repo: Optional[str],
        recent_repos: list[str],
    ) -> Optional[str]:
        """Pick a suggested working directory for agents."""
        if primary_repo:
            match = self._match_repo_target(rows=project_rows, target=primary_repo)
            if match:
                return str(match.get("repo_path"))
        if session_primary and os.path.isdir(session_primary):
            return session_primary
        for recent in recent_repos:
            if os.path.isdir(recent):
                return recent
        for row in project_rows:
            if row.get("exists"):
                return str(row.get("repo_path"))
        return None

    def _match_repo_target(
        self, rows: list[dict[str, Any]], target: str
    ) -> Optional[dict[str, Any]]:
        """Match repo by name or resolved path."""
        normalized = target.strip()
        for row in rows:
            if row.get("repo_name") == normalized:
                return row
            if str(row.get("repo_path", "")) == normalized:
                return row
            if normalized in str(row.get("repo_path", "")):
                return row
        return None
`````

## File: src/metagit/core/mcp/services/repo_ops.py
`````python
#!/usr/bin/env python
"""
Repository inspection and guarded synchronization operations.
"""

import os
from typing import Optional

from git import Repo


class RepoOperationsService:
    """Provide safe repo inspect/sync operations for MCP tools."""

    def inspect(self, repo_path: str) -> dict[str, str | bool]:
        """Inspect repository branch and dirty state."""
        if not os.path.isdir(repo_path):
            return {"ok": False, "error": "Repository path does not exist."}
        try:
            repo = Repo(repo_path)
            return {
                "ok": True,
                "branch": str(repo.active_branch.name)
                if not repo.head.is_detached
                else "DETACHED",
                "dirty": repo.is_dirty(untracked_files=True),
            }
        except Exception as exc:
            return {"ok": False, "error": str(exc)}

    def sync(
        self,
        repo_path: str,
        mode: str = "fetch",
        allow_mutation: bool = False,
        origin_url: Optional[str] = None,
    ) -> dict[str, str | bool]:
        """Synchronize repository with mutation guardrails."""
        normalized_mode = mode.lower()
        if normalized_mode not in {"fetch", "pull", "clone"}:
            return {"ok": False, "error": "Unsupported sync mode."}

        if normalized_mode in {"pull", "clone"} and not allow_mutation:
            return {
                "ok": False,
                "error": "Mutation disabled for pull/clone operations.",
            }

        try:
            if normalized_mode == "clone":
                if not origin_url:
                    return {
                        "ok": False,
                        "error": "origin_url is required for clone mode.",
                    }
                Repo.clone_from(origin_url, repo_path)
                return {"ok": True, "mode": "clone"}

            repo = Repo(repo_path)
            origin = repo.remote(name="origin")
            if normalized_mode == "fetch":
                origin.fetch()
                return {"ok": True, "mode": "fetch"}

            origin.pull()
            return {"ok": True, "mode": "pull"}
        except Exception as exc:
            return {"ok": False, "error": str(exc)}
`````

## File: src/metagit/core/mcp/services/session_store.py
`````python
#!/usr/bin/env python
"""
Persist workspace and per-project session state under .metagit/sessions/.
"""

import json
import os
import re
from pathlib import Path
from typing import Optional

from metagit.core.workspace.context_models import (
    ProjectSession,
    WorkspaceSessionMeta,
    utc_now_iso,
    validate_env_key,
    validate_env_value,
)

_PROJECT_FILE_PATTERN = re.compile(r"^[\w.-]+$")


class SessionStore:
    """Read and write session JSON under the workspace .metagit directory."""

    def __init__(self, workspace_root: str) -> None:
        self._workspace_root = str(Path(workspace_root).expanduser().resolve())
        self._sessions_dir = Path(self._workspace_root) / ".metagit" / "sessions"
        self._workspace_meta_path = self._sessions_dir / "_workspace.json"

    @property
    def sessions_dir(self) -> Path:
        """Return the sessions directory path."""
        return self._sessions_dir

    def ensure_dirs(self) -> None:
        """Create session directories with restrictive permissions when possible."""
        self._sessions_dir.mkdir(parents=True, exist_ok=True)
        try:
            os.chmod(self._sessions_dir, 0o700)
        except OSError:
            pass

    def get_workspace_meta(self) -> WorkspaceSessionMeta:
        """Load workspace session metadata or return defaults."""
        payload = self._read_json(path=self._workspace_meta_path)
        if not payload:
            return WorkspaceSessionMeta()
        return WorkspaceSessionMeta.model_validate(payload)

    def save_workspace_meta(self, meta: WorkspaceSessionMeta) -> None:
        """Persist workspace session metadata."""
        self.ensure_dirs()
        self._write_json(
            path=self._workspace_meta_path, payload=meta.model_dump(mode="json")
        )

    def set_active_project(self, project_name: str) -> WorkspaceSessionMeta:
        """Set active project on workspace metadata."""
        meta = self.get_workspace_meta()
        meta.active_project = project_name
        meta.last_switch_at = utc_now_iso()
        self.save_workspace_meta(meta=meta)
        return meta

    def get_project_session(self, project_name: str) -> ProjectSession:
        """Load a project session or return an empty session."""
        path = self._project_session_path(project_name=project_name)
        payload = self._read_json(path=path)
        if not payload:
            return ProjectSession(project_name=project_name)
        session = ProjectSession.model_validate(payload)
        session.project_name = project_name
        return session

    def save_project_session(self, session: ProjectSession) -> None:
        """Persist a project session."""
        self.ensure_dirs()
        session.updated_at = utc_now_iso()
        path = self._project_session_path(project_name=session.project_name)
        self._write_json(path=path, payload=session.model_dump(mode="json"))

    def update_project_session(
        self,
        project_name: str,
        *,
        recent_repos: Optional[list[str]] = None,
        primary_repo_path: Optional[str] = None,
        agent_notes: Optional[str] = None,
        env_overrides: Optional[dict[str, str]] = None,
        last_snapshot_id: Optional[str] = None,
    ) -> ProjectSession:
        """Merge updates into a project session."""
        session = self.get_project_session(project_name=project_name)
        if recent_repos is not None:
            session.recent_repos = recent_repos
        if primary_repo_path is not None:
            session.primary_repo_path = primary_repo_path
        if agent_notes is not None:
            session.agent_notes = agent_notes
        if env_overrides is not None:
            merged = dict(session.env_overrides)
            for key, value in env_overrides.items():
                merged[validate_env_key(key)] = validate_env_value(value)
            session.env_overrides = merged
        if last_snapshot_id is not None:
            session.last_snapshot_id = last_snapshot_id
        self.save_project_session(session=session)
        return session

    def rename_project_session(self, from_name: str, to_name: str) -> bool:
        """
        Rename a per-project session file when a workspace project is renamed.

        Returns True when a session file was migrated.
        """
        old_path = self._project_session_path(project_name=from_name)
        new_path = self._project_session_path(project_name=to_name)
        if not old_path.is_file():
            return False
        self.ensure_dirs()
        if new_path.exists():
            new_path.unlink()
        old_path.rename(new_path)
        session = self.get_project_session(project_name=to_name)
        session.project_name = to_name
        self.save_project_session(session=session)
        return True

    def link_snapshot(self, snapshot_id: str, project_name: Optional[str]) -> None:
        """Record snapshot id on workspace and optional project session."""
        meta = self.get_workspace_meta()
        meta.last_snapshot_id = snapshot_id
        self.save_workspace_meta(meta=meta)
        if project_name:
            self.update_project_session(
                project_name=project_name,
                last_snapshot_id=snapshot_id,
            )

    def _project_session_path(self, project_name: str) -> Path:
        """Resolve sanitized per-project session file path."""
        if not _PROJECT_FILE_PATTERN.match(project_name):
            raise ValueError(f"Invalid project name for session file: {project_name}")
        return self._sessions_dir / f"{project_name}.json"

    def _read_json(self, path: Path) -> Optional[dict]:
        """Read JSON object from path."""
        if not path.is_file():
            return None
        try:
            raw = path.read_text(encoding="utf-8")
            data = json.loads(raw)
            return data if isinstance(data, dict) else None
        except (OSError, json.JSONDecodeError):
            return None

    def _write_json(self, path: Path, payload: dict) -> None:
        """Write JSON object to path."""
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
        try:
            os.chmod(path, 0o600)
        except OSError:
            pass
`````

## File: src/metagit/core/mcp/services/upstream_hints.py
`````python
#!/usr/bin/env python
"""
Upstream issue hint ranking service.
"""

from typing import Any


class UpstreamHintService:
    """Rank repositories likely to contain upstream root causes."""

    _category_terms: dict[str, list[str]] = {
        "terraform": ["terraform", "module", "variable", ".tf", "provider"],
        "docker": ["docker", "dockerfile", "image", "from", "container"],
        "infra": ["infra", "network", "cluster", "vpc", "subnet"],
        "ci": ["workflow", "pipeline", "actions", "runner", "build"],
    }

    def rank(
        self,
        blocker: str,
        repo_context: list[dict[str, Any]],
    ) -> list[dict[str, Any]]:
        """Return ranked repository candidates for the blocker description."""
        blocker_lower = blocker.lower()
        results: list[dict[str, Any]] = []

        for repo in repo_context:
            score = self._score_repo(blocker_lower=blocker_lower, repo=repo)
            results.append(
                {
                    "repo_name": repo.get("repo_name"),
                    "repo_path": repo.get("repo_path"),
                    "score": score,
                    "rationale": self._rationale(
                        blocker_lower=blocker_lower, repo=repo
                    ),
                }
            )

        return sorted(results, key=lambda row: row["score"], reverse=True)

    def _score_repo(self, blocker_lower: str, repo: dict[str, Any]) -> float:
        score = 0.0
        name = str(repo.get("repo_name", "")).lower()
        project = str(repo.get("project_name", "")).lower()
        path = str(repo.get("repo_path", "")).lower()

        for terms in self._category_terms.values():
            if any(term in blocker_lower for term in terms):
                for term in terms:
                    if term in name:
                        score += 2.0
                    if term in project:
                        score += 1.0
                    if term in path:
                        score += 1.0

        if repo.get("sync") is True:
            score += 0.2
        if repo.get("exists") is True:
            score += 0.1
        return score

    def _rationale(self, blocker_lower: str, repo: dict[str, Any]) -> str:
        name = str(repo.get("repo_name", "unknown"))
        if "terraform" in blocker_lower:
            return f"{name} aligns with terraform-related blocker terms."
        if "docker" in blocker_lower:
            return f"{name} aligns with docker-related blocker terms."
        return f"{name} is ranked by metadata overlap with blocker text."
`````

## File: src/metagit/core/project/search_service.py
`````python
#!/usr/bin/env python
"""
Search and resolve managed workspace repositories from `.metagit.yml` only.
"""

from typing import Any

from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.project.search_models import (
    ManagedRepoError,
    ManagedRepoMatch,
    ManagedRepoResolveResult,
    ManagedRepoSearchResult,
    ManagedRepoStatus,
)


class ManagedRepoSearchService:
    """Match queries against configured workspace repos using WorkspaceIndexService rows."""

    def __init__(self) -> None:
        self._index = WorkspaceIndexService()

    def search(
        self,
        config: MetagitConfig,
        workspace_root: str,
        query: str,
        *,
        project: str | None = None,
        exact: bool = False,
        synced_only: bool = False,
        tags: dict[str, str] | None = None,
        status: list[str] | None = None,
        has_url: bool | None = None,
        sync_enabled: bool | None = None,
        sort: str = "score",
        limit: int = 10,
    ) -> ManagedRepoSearchResult:
        """Return ranked matches for a free-text query and optional filters."""
        rows = self._index.build_index(config=config, workspace_root=workspace_root)
        status_filter = set(status) if status else None
        matches: list[ManagedRepoMatch] = []
        for row in rows:
            if not self._row_passes_filters(
                row=row,
                project=project,
                synced_only=synced_only,
                tags=tags,
                status_filter=status_filter,
                has_url=has_url,
                sync_enabled=sync_enabled,
            ):
                continue
            score, reasons = self._match_row(row=row, query=query, exact=exact)
            if score <= 0:
                continue
            matches.append(self._to_match(row=row, score=score, reasons=reasons))
        ordered = self._sort_matches(matches=matches, sort=sort)
        return ManagedRepoSearchResult(query=query, matches=ordered[:limit])

    def resolve_one(
        self,
        config: MetagitConfig,
        workspace_root: str,
        query: str,
        *,
        project: str | None = None,
        exact: bool = False,
        synced_only: bool = True,
        tags: dict[str, str] | None = None,
        status: list[str] | None = None,
        has_url: bool | None = None,
        sync_enabled: bool | None = None,
        sort: str = "score",
    ) -> ManagedRepoResolveResult:
        """Return a single best match or a structured not_found / ambiguous error."""
        result = self.search(
            config=config,
            workspace_root=workspace_root,
            query=query,
            project=project,
            exact=exact,
            synced_only=synced_only,
            tags=tags,
            status=status,
            has_url=has_url,
            sync_enabled=sync_enabled,
            sort=sort,
            limit=25,
        )
        if not result.matches:
            return ManagedRepoResolveResult(
                error=ManagedRepoError(
                    kind="not_found",
                    message="No managed repository matched the query.",
                )
            )
        if len(result.matches) > 1:
            return ManagedRepoResolveResult(
                error=ManagedRepoError(
                    kind="ambiguous_match",
                    message="Search matched more than one managed repository.",
                    matches=result.matches,
                )
            )
        return ManagedRepoResolveResult(match=result.matches[0])

    def _to_match(
        self,
        *,
        row: dict[str, Any],
        score: int,
        reasons: list[str],
    ) -> ManagedRepoMatch:
        status = ManagedRepoStatus(
            resolved_path=row["repo_path"],
            exists=row["exists"],
            is_git_repo=row["is_git_repo"],
            sync_enabled=bool(row["sync"]),
            status=row["status"],
        )
        return ManagedRepoMatch(
            project_name=row["project_name"],
            repo_name=row["repo_name"],
            url=row.get("url"),
            configured_path=row.get("configured_path"),
            tags=dict(row.get("tags") or {}),
            status=status,
            match_reasons=list(reasons),
            score=score,
        )

    def _row_passes_filters(
        self,
        *,
        row: dict[str, Any],
        project: str | None,
        synced_only: bool,
        tags: dict[str, str] | None,
        status_filter: set[str] | None,
        has_url: bool | None,
        sync_enabled: bool | None,
    ) -> bool:
        """Return whether an index row satisfies structural filters."""
        if project and row["project_name"] != project:
            return False
        if synced_only and row["status"] != "synced":
            return False
        if status_filter and row["status"] not in status_filter:
            return False
        row_url = row.get("url")
        if has_url is True and not row_url:
            return False
        if has_url is False and row_url:
            return False
        row_sync = bool(row.get("sync"))
        if sync_enabled is True and not row_sync:
            return False
        if sync_enabled is False and row_sync:
            return False
        row_tags = row.get("tags") or {}
        if tags and any(row_tags.get(key) != value for key, value in tags.items()):
            return False
        return True

    def _sort_matches(
        self, *, matches: list[ManagedRepoMatch], sort: str
    ) -> list[ManagedRepoMatch]:
        """Sort matches by score, project, or repository name."""
        normalized = sort.lower()
        if normalized == "name":
            return sorted(
                matches,
                key=lambda item: (item.repo_name.lower(), -item.score),
            )
        if normalized == "project":
            return sorted(
                matches,
                key=lambda item: (
                    item.project_name.lower(),
                    item.repo_name.lower(),
                    -item.score,
                ),
            )
        return sorted(
            matches,
            key=lambda item: (-item.score, item.project_name, item.repo_name),
        )

    def _match_row(
        self, *, row: dict[str, Any], query: str, exact: bool
    ) -> tuple[int, list[str]]:
        reasons: list[str] = []
        q = query.strip()
        if not q or q == "*":
            return 1, ["filter:match"]
        name = row.get("repo_name") or ""
        if exact:
            if name == q:
                return 100, ["repo_name:exact"]
            return 0, []
        score = 0
        q_lower = q.lower()
        name_lower = name.lower()
        if name_lower == q_lower:
            score += 100
            reasons.append("repo_name:exact")
        elif q_lower in name_lower:
            score += 60
            reasons.append("repo_name:substring")
        url = row.get("url") or ""
        if url and q_lower in str(url).lower():
            score += 30
            reasons.append("url:substring")
        for key, val in (row.get("tags") or {}).items():
            if q_lower in str(key).lower():
                score += 15
                reasons.append(f"tag_key:{key}")
            if q_lower in str(val).lower():
                score += 25
                reasons.append(f"tag_value:{key}")
        project_name = row.get("project_name") or ""
        if q_lower in project_name.lower():
            score += 10
            reasons.append("project_name:substring")
        return score, reasons
`````

## File: src/metagit/core/project/source_sync.py
`````python
#!/usr/bin/env python
"""
Provider-backed recursive repository discovery and workspace planning.
"""

from __future__ import annotations

from typing import Dict, List, Optional, Tuple, Union

import requests

from metagit.core.appconfig.models import AppConfig
from metagit.core.project.models import ProjectPath, ProjectKind
from metagit.core.project.source_models import (
    DiscoveredRepo,
    SourceProvider,
    SourceSpec,
    SourceSyncMode,
    SourceSyncPlan,
)
from metagit.core.utils.common import normalize_git_url
from metagit.core.utils.logging import UnifiedLogger
from metagit.core.workspace.models import WorkspaceProject


class SourceSyncService:
    """Discovers repositories from providers and builds/apply sync plans."""

    def __init__(self, app_config: AppConfig, logger: UnifiedLogger):
        self._app_config = app_config
        self._logger = logger

    def discover(self, spec: SourceSpec) -> Union[List[DiscoveredRepo], Exception]:
        try:
            if spec.provider == SourceProvider.GITHUB:
                return self._discover_github(spec)
            if spec.provider == SourceProvider.GITLAB:
                return self._discover_gitlab(spec)
            return Exception(f"Unsupported provider: {spec.provider}")
        except Exception as exc:
            return exc

    def plan(
        self,
        spec: SourceSpec,
        project: WorkspaceProject,
        discovered: List[DiscoveredRepo],
        mode: SourceSyncMode,
    ) -> SourceSyncPlan:
        discovered_project_paths = [self._to_project_path(repo) for repo in discovered]
        discovered_by_url = {
            self._normalized_url(path.url): path
            for path in discovered_project_paths
            if path.url
        }
        existing_by_url = {
            self._normalized_url(repo.url): repo for repo in project.repos if repo.url
        }

        plan = SourceSyncPlan(discovered_count=len(discovered))

        for url_key, new_repo in discovered_by_url.items():
            existing = existing_by_url.get(url_key)
            if existing is None:
                plan.to_add.append(new_repo)
                continue
            if self._needs_update(existing, new_repo):
                plan.to_update.append(new_repo)
            else:
                plan.unchanged += 1

        if mode == SourceSyncMode.RECONCILE:
            for repo in project.repos:
                if not repo.url:
                    continue
                if bool(repo.protected):
                    continue
                if repo.source_provider != spec.provider.value:
                    continue
                if repo.source_namespace != spec.namespace_key:
                    continue
                url_key = self._normalized_url(repo.url)
                if url_key not in discovered_by_url:
                    plan.to_remove.append(repo)

        return plan

    def apply_plan(
        self, project: WorkspaceProject, plan: SourceSyncPlan, mode: SourceSyncMode
    ) -> WorkspaceProject:
        if mode == SourceSyncMode.DISCOVER:
            return project

        repos: List[ProjectPath] = list(project.repos)
        repo_index: Dict[str, int] = {}
        for index, repo in enumerate(repos):
            if repo.url:
                repo_index[self._normalized_url(repo.url)] = index

        for candidate in plan.to_add:
            repos.append(candidate)

        for candidate in plan.to_update:
            if not candidate.url:
                continue
            url_key = self._normalized_url(candidate.url)
            if url_key in repo_index:
                repos[repo_index[url_key]] = candidate

        remove_keys = set()
        for candidate in plan.to_remove:
            if candidate.url:
                remove_keys.add(self._normalized_url(candidate.url))

        if remove_keys:
            repos = [
                repo
                for repo in repos
                if not repo.url or self._normalized_url(repo.url) not in remove_keys
            ]

        return WorkspaceProject(
            name=project.name,
            description=project.description,
            agent_instructions=project.agent_instructions,
            repos=repos,
        )

    def _discover_github(
        self, spec: SourceSpec
    ) -> Union[List[DiscoveredRepo], Exception]:
        provider_cfg = self._app_config.providers.github
        if not provider_cfg.enabled:
            return Exception("GitHub provider is disabled in app config")
        if not provider_cfg.api_token:
            return Exception("GitHub API token is not configured")

        session = requests.Session()
        session.headers.update(
            {
                "Authorization": f"token {provider_cfg.api_token}",
                "Accept": "application/vnd.github+json",
            }
        )

        scope_kind = "orgs" if spec.org else "users"
        scope_value = spec.org if spec.org else spec.user
        endpoint = f"{provider_cfg.base_url}/{scope_kind}/{scope_value}/repos"

        discovered: List[DiscoveredRepo] = []
        page = 1
        while True:
            response = session.get(
                endpoint,
                params={"per_page": 100, "page": page, "type": "all"},
                timeout=30,
            )
            response.raise_for_status()
            items = response.json()
            if not items:
                break
            for repo in items:
                candidate = DiscoveredRepo(
                    provider=SourceProvider.GITHUB,
                    namespace=spec.namespace_key,
                    full_name=repo.get("full_name", ""),
                    name=repo.get("name", ""),
                    clone_url=repo.get("clone_url", ""),
                    default_branch=repo.get("default_branch"),
                    description=repo.get("description"),
                    repo_id=str(repo.get("id")) if repo.get("id") is not None else None,
                    archived=bool(repo.get("archived", False)),
                    fork=bool(repo.get("fork", False)),
                    private=repo.get("private"),
                )
                if self._include_candidate(spec, candidate):
                    discovered.append(candidate)
            page += 1
        return discovered

    def _discover_gitlab(
        self, spec: SourceSpec
    ) -> Union[List[DiscoveredRepo], Exception]:
        provider_cfg = self._app_config.providers.gitlab
        if not provider_cfg.enabled:
            return Exception("GitLab provider is disabled in app config")
        if not provider_cfg.api_token:
            return Exception("GitLab API token is not configured")

        session = requests.Session()
        session.headers.update({"Authorization": f"Bearer {provider_cfg.api_token}"})

        group_ref = requests.utils.quote(spec.group or "", safe="")
        endpoint = f"{provider_cfg.base_url}/groups/{group_ref}/projects"

        discovered: List[DiscoveredRepo] = []
        page = 1
        while True:
            response = session.get(
                endpoint,
                params={
                    "per_page": 100,
                    "page": page,
                    "include_subgroups": "true" if spec.recursive else "false",
                    "with_shared": "false",
                },
                timeout=30,
            )
            response.raise_for_status()
            items = response.json()
            if not items:
                break
            for repo in items:
                candidate = DiscoveredRepo(
                    provider=SourceProvider.GITLAB,
                    namespace=spec.namespace_key,
                    full_name=repo.get("path_with_namespace", ""),
                    name=repo.get("path", ""),
                    clone_url=repo.get("http_url_to_repo", ""),
                    default_branch=repo.get("default_branch"),
                    description=repo.get("description"),
                    repo_id=str(repo.get("id")) if repo.get("id") is not None else None,
                    archived=bool(repo.get("archived", False)),
                    fork=repo.get("forked_from_project") is not None,
                    private=(repo.get("visibility") == "private"),
                )
                if self._include_candidate(spec, candidate):
                    discovered.append(candidate)
            page += 1
        return discovered

    def _include_candidate(self, spec: SourceSpec, candidate: DiscoveredRepo) -> bool:
        if not spec.include_archived and candidate.archived:
            return False
        if not spec.include_forks and candidate.fork:
            return False
        if spec.path_prefix:
            return candidate.full_name.startswith(spec.path_prefix)
        return True

    def _to_project_path(self, repo: DiscoveredRepo) -> ProjectPath:
        return ProjectPath(
            name=repo.name,
            description=repo.description,
            kind=ProjectKind.REPOSITORY,
            url=repo.clone_url,
            sync=True,
            source_provider=repo.provider.value,
            source_namespace=repo.namespace,
            source_repo_id=repo.repo_id,
        )

    def _needs_update(self, current: ProjectPath, incoming: ProjectPath) -> bool:
        tracked_fields: List[Tuple[Optional[str], Optional[str]]] = [
            (current.name, incoming.name),
            (current.description, incoming.description),
            (current.source_provider, incoming.source_provider),
            (current.source_namespace, incoming.source_namespace),
            (current.source_repo_id, incoming.source_repo_id),
        ]
        return any(lhs != rhs for lhs, rhs in tracked_fields)

    def _normalized_url(self, url: Optional[object]) -> str:
        if not url:
            return ""
        return normalize_git_url(str(url))
`````

## File: src/metagit/core/skills/__init__.py
`````python
#!/usr/bin/env python
"""
Skills installation helpers.
"""

from metagit.core.skills.installer import (
    SUPPORTED_TARGETS,
    autodetect_targets,
    install_mcp_for_targets,
    install_skills_for_targets,
    list_bundled_skills,
    resolve_skill_names,
    resolve_targets,
    skill_markdown,
)

__all__ = [
    "SUPPORTED_TARGETS",
    "autodetect_targets",
    "install_mcp_for_targets",
    "install_skills_for_targets",
    "list_bundled_skills",
    "resolve_skill_names",
    "resolve_targets",
    "skill_markdown",
]
`````

## File: src/metagit/core/web/ops_handler.py
`````python
#!/usr/bin/env python
"""HTTP handlers for workspace ops routes (health, prune, sync)."""

from __future__ import annotations

import json
import re
import threading
import time
from pathlib import Path
from typing import Any, BinaryIO, Callable
from urllib.parse import parse_qs

from pydantic import ValidationError

from metagit.core.appconfig import load_config as load_appconfig
from metagit.core.appconfig.models import AppConfig
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.workspace_health import WorkspaceHealthService
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.mcp.services.workspace_sync import WorkspaceSyncService
from metagit.core.project.manager import project_manager_from_app
from metagit.core.utils.logging import LoggerConfig, UnifiedLogger
from metagit.core.web.graph_service import WorkspaceGraphService
from metagit.core.web.job_store import SyncJobStore
from metagit.core.web.models import SyncJobRequest

JsonResponder = Callable[[int, dict[str, Any]], None]

_SYNC_JOB_PATH = re.compile(
    r"^/v3/ops/sync/(?P<job_id>[0-9a-f]{32})$",
)
_SYNC_EVENTS_PATH = re.compile(
    r"^/v3/ops/sync/(?P<job_id>[0-9a-f]{32})/events$",
)

_JOB_STORE = SyncJobStore()


class OpsWebHandler:
    """Route workspace health, prune, and sync operations for the web HTTP API."""

    def __init__(
        self,
        *,
        root: str,
        config_path: str,
        appconfig_path: str,
        workspace_root: str,
        job_store: SyncJobStore | None = None,
    ) -> None:
        self._root = str(Path(root).resolve())
        self._config_path = str(Path(config_path).resolve())
        self._appconfig_path = str(Path(appconfig_path).resolve())
        self._workspace_root = str(Path(workspace_root).resolve())
        self._job_store = job_store or _JOB_STORE
        self._health = WorkspaceHealthService()
        self._index = WorkspaceIndexService()
        self._sync = WorkspaceSyncService()
        self._graph = WorkspaceGraphService()
        self._logger = UnifiedLogger(
            LoggerConfig(log_level="ERROR", minimal_console=True)
        )

    def handle(
        self,
        method: str,
        path: str,
        query: str,
        body: bytes,
        respond: JsonResponder,
    ) -> bool:
        """Dispatch JSON ops routes; return True when handled."""
        parsed_path = path if path.startswith("/") else f"/{path}"

        if method == "GET" and parsed_path == "/v3/ops/graph":
            self._get_graph(query, respond)
            return True

        if method == "POST" and parsed_path == "/v3/ops/health":
            self._post_health(body, respond)
            return True

        if method == "POST" and parsed_path == "/v3/ops/prune/preview":
            self._post_prune_preview(body, respond)
            return True

        if method == "POST" and parsed_path == "/v3/ops/prune":
            self._post_prune(body, respond)
            return True

        if method == "POST" and parsed_path == "/v3/ops/sync":
            self._post_sync(body, respond)
            return True

        status_match = _SYNC_JOB_PATH.match(parsed_path)
        if method == "GET" and status_match is not None:
            self._get_sync_status(status_match.group("job_id"), respond)
            return True

        return False

    def sync_events_job_id(self, method: str, path: str) -> str | None:
        """Return job id when path is a sync SSE events route."""
        if method != "GET":
            return None
        parsed_path = path if path.startswith("/") else f"/{path}"
        events_match = _SYNC_EVENTS_PATH.match(parsed_path)
        return None if events_match is None else events_match.group("job_id")

    def stream_sync_events(self, job_id: str, stream: BinaryIO) -> None:
        """Write server-sent events for a sync job until it finishes."""
        self._stream_sync_events(job_id, stream)

    def _post_health(self, body: bytes, respond: JsonResponder) -> None:
        config = self._load_metagit(respond)
        if config is None:
            return
        app_config = self._load_appconfig(respond)
        if app_config is None:
            return
        payload = self._parse_body(body, respond, required=False) or {}
        project_raw = payload.get("project")
        project_name = str(project_raw).strip() if project_raw else None
        if project_name == "":
            project_name = None
        dedupe = (
            app_config.workspace.dedupe if app_config.workspace is not None else None
        )
        result = self._health.check(
            config=config,
            workspace_root=self._workspace_root,
            check_git_status=bool(payload.get("check_git_status", True)),
            check_dependencies=bool(payload.get("check_dependencies", True)),
            check_stale_branches=bool(payload.get("check_stale_branches", True)),
            check_gitnexus=bool(payload.get("check_gitnexus", True)),
            project_name=project_name,
            dedupe=dedupe,
        )
        respond(200, result.model_dump(mode="json"))

    def _get_graph(self, query: str, respond: JsonResponder) -> None:
        config = self._load_metagit(respond)
        if config is None:
            return
        params = parse_qs(query.lstrip("?"))
        include_inferred = (
            params.get("include_inferred", ["true"])[0].lower() != "false"
        )
        include_structure = (
            params.get("include_structure", ["true"])[0].lower() != "false"
        )
        view = self._graph.build_view(
            config,
            self._workspace_root,
            include_inferred=include_inferred,
            include_structure=include_structure,
        )
        respond(200, view.model_dump(mode="json"))

    def _post_prune_preview(self, body: bytes, respond: JsonResponder) -> None:
        payload = self._parse_body(body, respond, required=True)
        if payload is None:
            return
        project = str(payload.get("project", "")).strip()
        if not project:
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_body",
                        "message": "project is required",
                    },
                },
            )
            return
        if project == "local":
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_project",
                        "message": "local project is not supported for prune",
                    },
                },
            )
            return
        config = self._load_metagit(respond)
        if config is None:
            return
        app_config = self._load_appconfig(respond)
        if app_config is None:
            return
        include_hidden = bool(payload.get("include_hidden", False))
        ignore_hidden = (
            False if include_hidden else bool(app_config.workspace.ui_ignore_hidden)
        )
        try:
            project_manager = project_manager_from_app(
                app_config,
                self._logger,
                metagit_config=config,
                project_name=project,
            )
        except Exception as exc:
            respond(
                500,
                {
                    "ok": False,
                    "error": {"kind": "project_error", "message": str(exc)},
                },
            )
            return
        candidates = project_manager.list_unmanaged_sync_directories(
            config,
            project,
            ignore_hidden=ignore_hidden,
        )
        respond(
            200,
            {
                "ok": True,
                "candidates": [
                    {"path": str(path.resolve()), "name": path.name}
                    for path in candidates
                ],
            },
        )

    def _post_prune(self, body: bytes, respond: JsonResponder) -> None:
        payload = self._parse_body(body, respond, required=True)
        if payload is None:
            return
        project = str(payload.get("project", "")).strip()
        if not project:
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_body",
                        "message": "project is required",
                    },
                },
            )
            return
        raw_paths = payload.get("paths")
        if not isinstance(raw_paths, list) or not raw_paths:
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_body",
                        "message": "paths must be a non-empty list",
                    },
                },
            )
            return
        dry_run = bool(payload.get("dry_run", False))
        force = bool(payload.get("force", False))
        config = self._load_metagit(respond)
        if config is None:
            return
        app_config = self._load_appconfig(respond)
        if app_config is None:
            return
        try:
            project_manager = project_manager_from_app(
                app_config,
                self._logger,
                metagit_config=config,
                project_name=project,
            )
        except Exception as exc:
            respond(
                500,
                {
                    "ok": False,
                    "error": {"kind": "project_error", "message": str(exc)},
                },
            )
            return
        project_sync = (Path(self._workspace_root) / project).resolve()
        resolved_paths: list[Path] = []
        for item in raw_paths:
            candidate = Path(str(item)).expanduser()
            resolved = (
                candidate.resolve()
                if candidate.is_absolute()
                else (project_sync / candidate).resolve()
            )
            if project_sync != resolved and project_sync not in resolved.parents:
                respond(
                    400,
                    {
                        "ok": False,
                        "error": {
                            "kind": "invalid_path",
                            "message": f"path must be under project sync folder: {item}",
                        },
                    },
                )
                return
            if not resolved.exists():
                respond(
                    400,
                    {
                        "ok": False,
                        "error": {
                            "kind": "missing_path",
                            "message": f"path does not exist: {resolved}",
                        },
                    },
                )
                return
            resolved_paths.append(resolved)

        if dry_run or not force:
            respond(
                200,
                {
                    "ok": True,
                    "dry_run": dry_run,
                    "force": force,
                    "removed": [],
                    "paths": [str(path) for path in resolved_paths],
                },
            )
            return

        removed: list[str] = []
        errors: list[dict[str, str]] = []
        for path in resolved_paths:
            try:
                project_manager.remove_sync_directory(path)
                removed.append(str(path))
            except OSError as exc:
                errors.append({"path": str(path), "message": str(exc)})
        respond(
            200 if not errors else 500,
            {
                "ok": len(errors) == 0,
                "dry_run": False,
                "force": True,
                "removed": removed,
                "errors": errors,
            },
        )

    def _post_sync(self, body: bytes, respond: JsonResponder) -> None:
        config = self._load_metagit(respond)
        if config is None:
            return
        payload = self._parse_body(body, respond, required=True)
        if payload is None:
            return
        try:
            request = SyncJobRequest.model_validate(payload)
        except ValidationError as exc:
            respond(
                400,
                {
                    "ok": False,
                    "error": {"kind": "invalid_body", "message": str(exc)},
                },
            )
            return
        job_id = self._job_store.create_job()
        thread = threading.Thread(
            target=self._run_sync_job,
            args=(job_id, config, request),
            daemon=True,
        )
        thread.start()
        status = self._job_store.get(job_id)
        respond(
            202,
            {
                "ok": True,
                "job_id": job_id,
                "status": status.model_dump(mode="json") if status else None,
            },
        )

    def _get_sync_status(self, job_id: str, respond: JsonResponder) -> None:
        status = self._job_store.get(job_id)
        if status is None:
            respond(
                404,
                {
                    "ok": False,
                    "error": {"kind": "not_found", "message": "Unknown sync job"},
                },
            )
            return
        respond(200, status.model_dump(mode="json"))

    def _stream_sync_events(self, job_id: str, stream: BinaryIO) -> None:
        while True:
            events = self._job_store.drain_events(job_id)
            for event in events:
                payload = json.dumps(event, separators=(",", ":"))
                stream.write(f"data: {payload}\n\n".encode("utf-8"))
                stream.flush()
            status = self._job_store.get(job_id)
            if status is None:
                payload = json.dumps(
                    {"type": "error", "message": "Unknown sync job"},
                    separators=(",", ":"),
                )
                stream.write(f"data: {payload}\n\n".encode("utf-8"))
                stream.flush()
                return
            if status.state in ("completed", "failed"):
                return
            time.sleep(0.05)

    def _run_sync_job(
        self,
        job_id: str,
        config: MetagitConfig,
        request: SyncJobRequest,
    ) -> None:
        self._job_store.mark_running(job_id)
        self._job_store.append_event(job_id, {"type": "started", "job_id": job_id})
        rows = self._index.build_index(
            config=config,
            workspace_root=self._workspace_root,
        )
        try:
            payload = self._sync.sync_many(
                rows,
                repos=request.repos,
                mode=request.mode,
                allow_mutation=request.allow_mutation,
                max_parallel=request.max_parallel,
                dry_run=request.dry_run,
            )
        except Exception as exc:
            self._job_store.fail(job_id, str(exc))
            self._job_store.append_event(
                job_id,
                {"type": "failed", "job_id": job_id, "error": str(exc)},
            )
            return
        if not payload.get("ok", False):
            error = str(payload.get("error", "sync failed"))
            self._job_store.fail(job_id, error)
            self._job_store.append_event(
                job_id,
                {"type": "failed", "job_id": job_id, "error": error},
            )
            return
        summary = payload.get("summary")
        results = payload.get("results")
        self._job_store.complete(
            job_id,
            summary=dict(summary) if isinstance(summary, dict) else {},
            results=list(results) if isinstance(results, list) else [],
        )
        self._job_store.append_event(
            job_id,
            {
                "type": "completed",
                "job_id": job_id,
                "summary": summary if isinstance(summary, dict) else {},
            },
        )

    def _load_metagit(self, respond: JsonResponder) -> MetagitConfig | None:
        manager = MetagitConfigManager(self._config_path)
        loaded = manager.load_config()
        if isinstance(loaded, Exception):
            respond(
                500,
                {
                    "ok": False,
                    "error": {"kind": "config_error", "message": str(loaded)},
                },
            )
            return None
        return loaded

    def _load_appconfig(self, respond: JsonResponder) -> AppConfig | None:
        loaded = load_appconfig(self._appconfig_path)
        if isinstance(loaded, Exception):
            respond(
                500,
                {
                    "ok": False,
                    "error": {"kind": "config_error", "message": str(loaded)},
                },
            )
            return None
        return loaded

    def _parse_body(
        self,
        body: bytes,
        respond: JsonResponder,
        *,
        required: bool,
    ) -> dict[str, Any] | None:
        if not body:
            if required:
                respond(
                    400,
                    {
                        "ok": False,
                        "error": {
                            "kind": "invalid_body",
                            "message": "JSON body required",
                        },
                    },
                )
                return None
            return {}
        try:
            parsed = json.loads(body.decode("utf-8"))
        except json.JSONDecodeError as exc:
            respond(
                400,
                {"ok": False, "error": {"kind": "invalid_json", "message": str(exc)}},
            )
            return None
        if not isinstance(parsed, dict):
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_body",
                        "message": "expected JSON object",
                    },
                },
            )
            return None
        return parsed
`````

## File: src/metagit/core/workspace/catalog_models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for workspace catalog list and mutation results.
"""

from typing import Any, Literal, Optional

from pydantic import BaseModel, Field

from metagit.core.project.models import ProjectPath
from metagit.core.workspace.models import Workspace


class CatalogError(BaseModel):
    """Structured catalog operation error."""

    kind: str
    message: str


class CatalogResult(BaseModel):
    """Uniform envelope for agentic CLI, MCP, and API responses."""

    ok: bool = True
    error: Optional[CatalogError] = None
    data: Optional[dict[str, Any]] = None


class WorkspaceSummary(BaseModel):
    """Workspace section of a manifest with project roll-up."""

    definition_path: str
    workspace_root: str
    file_name: str
    file_description: Optional[str] = None
    file_agent_instructions: Optional[str] = None
    workspace: Optional[Workspace] = None
    project_count: int = 0
    repo_count: int = 0


class ProjectListEntry(BaseModel):
    """Project row for list operations."""

    name: str
    description: Optional[str] = None
    agent_instructions: Optional[str] = None
    dedupe_enabled: Optional[bool] = Field(
        default=None,
        description=(
            "When set in the manifest, overrides app-config workspace.dedupe.enabled "
            "for this project"
        ),
    )
    repo_count: int = 0


class RepoListEntry(BaseModel):
    """Repository row for list operations."""

    project_name: str
    repo: ProjectPath
    configured_path: Optional[str] = None
    repo_path: Optional[str] = None
    exists: Optional[bool] = None
    status: Optional[str] = None


class CatalogMutationResult(BaseModel):
    """Result of add/remove mutations."""

    ok: bool = True
    error: Optional[CatalogError] = None
    entity: Literal["project", "repo"] = "project"
    operation: Literal["add", "remove"] = "add"
    project_name: str = ""
    repo_name: Optional[str] = None
    config_path: str = ""
`````

## File: src/metagit/core/workspace/context_models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for workspace project context and snapshot persistence.
"""

import re
from datetime import datetime, timezone
from typing import Optional

from pydantic import BaseModel, Field, field_validator

from metagit.core.workspace.agent_instructions import AgentInstructionLayer

_ENV_KEY_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{0,63}$")
_SECRET_VALUE_PATTERNS = (
    re.compile(r"AKIA[0-9A-Z]{16}"),
    re.compile(r"Bearer\s+", re.IGNORECASE),
    re.compile(r"-----BEGIN"),
)
_MAX_AGENT_NOTES = 4096
_MAX_RECENT_REPOS = 10


def utc_now_iso() -> str:
    """Return current UTC timestamp in ISO8601 format."""
    return datetime.now(timezone.utc).isoformat()


def validate_env_key(key: str) -> str:
    """Validate environment variable key naming."""
    if not _ENV_KEY_PATTERN.match(key):
        raise ValueError(f"Invalid environment key: {key}")
    return key


def validate_env_value(value: str) -> str:
    """Reject values that look like secrets."""
    for pattern in _SECRET_VALUE_PATTERNS:
        if pattern.search(value):
            raise ValueError("Environment value appears to contain secret material.")
    return value


class WorkspaceSessionMeta(BaseModel):
    """Workspace-level session metadata."""

    active_project: Optional[str] = None
    last_switch_at: Optional[str] = None
    last_snapshot_id: Optional[str] = None


class ProjectSession(BaseModel):
    """Per-project persisted session state."""

    project_name: str
    updated_at: str = Field(default_factory=utc_now_iso)
    recent_repos: list[str] = Field(default_factory=list)
    primary_repo_path: Optional[str] = None
    agent_notes: Optional[str] = None
    env_overrides: dict[str, str] = Field(default_factory=dict)
    last_snapshot_id: Optional[str] = None

    @field_validator("agent_notes")
    @classmethod
    def validate_agent_notes(cls, value: Optional[str]) -> Optional[str]:
        """Bound agent notes length."""
        if value is not None and len(value) > _MAX_AGENT_NOTES:
            raise ValueError(f"agent_notes exceeds {_MAX_AGENT_NOTES} characters.")
        return value

    @field_validator("recent_repos")
    @classmethod
    def validate_recent_repos(cls, value: list[str]) -> list[str]:
        """Cap recent repo list length."""
        return value[:_MAX_RECENT_REPOS]

    @field_validator("env_overrides")
    @classmethod
    def validate_env_overrides(cls, value: dict[str, str]) -> dict[str, str]:
        """Validate non-secret environment overrides."""
        validated: dict[str, str] = {}
        for key, env_value in value.items():
            validated[validate_env_key(key)] = validate_env_value(env_value)
        return validated


class ProjectRepoContext(BaseModel):
    """Repository row included in a project context bundle."""

    repo_name: str
    repo_path: str
    configured_path: Optional[str] = None
    exists: bool = False
    branch: Optional[str] = None
    dirty: Optional[bool] = None
    tags: dict[str, str] = Field(default_factory=dict)
    agent_instructions: Optional[str] = None
    inspect_error: Optional[str] = None


class ProjectContextEnv(BaseModel):
    """Environment exports and hints for agents."""

    export: dict[str, str] = Field(default_factory=dict)
    hints: list[str] = Field(default_factory=list)


class ProjectContextSession(BaseModel):
    """Session slice returned with a context bundle."""

    restored: bool = False
    recent_repos: list[str] = Field(default_factory=list)
    primary_repo_path: Optional[str] = None
    agent_notes: Optional[str] = None


class ProjectContextBundle(BaseModel):
    """Result of switching or showing project context."""

    ok: bool = True
    error: Optional[str] = None
    project_name: str = ""
    workspace_root: str = ""
    project_description: Optional[str] = None
    agent_instructions: Optional[str] = None
    instruction_layers: list[AgentInstructionLayer] = Field(default_factory=list)
    effective_agent_instructions: str = ""
    focus_repo_name: Optional[str] = None
    repos: list[ProjectRepoContext] = Field(default_factory=list)
    env: ProjectContextEnv = Field(default_factory=ProjectContextEnv)
    session: ProjectContextSession = Field(default_factory=ProjectContextSession)
    suggested_cwd: Optional[str] = None
    inspect_truncated: bool = False


class SnapshotRepoState(BaseModel):
    """Git state for one repository in a snapshot."""

    project_name: str
    repo_name: str
    repo_path: str
    branch: Optional[str] = None
    dirty: bool = False
    ahead: Optional[int] = None
    behind: Optional[int] = None
    uncommitted_count: Optional[int] = None
    inspect_error: Optional[str] = None


class WorkspaceSnapshot(BaseModel):
    """Immutable workspace state manifest."""

    snapshot_id: str
    created_at: str = Field(default_factory=utc_now_iso)
    active_project: Optional[str] = None
    label: Optional[str] = None
    repos: list[SnapshotRepoState] = Field(default_factory=list)
    env_key_names: list[str] = Field(default_factory=list)
    session_ref: Optional[str] = None


class WorkspaceSnapshotRestoreResult(BaseModel):
    """Result of restoring a workspace snapshot."""

    ok: bool = True
    error: Optional[str] = None
    snapshot_id: str = ""
    context: Optional[ProjectContextBundle] = None
    notes: list[str] = Field(default_factory=list)
`````

## File: src/metagit/core/workspace/dependency_models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for cross-project dependency graph results.
"""

from typing import Literal, Optional

from pydantic import BaseModel, Field

DependencyEdgeType = Literal[
    "declared",
    "import",
    "shared_config",
    "url_match",
    "ref",
    "manual",
]


class DependencyNode(BaseModel):
    """Node in a workspace dependency graph."""

    id: str
    kind: Literal["project", "repo"]
    label: str
    project_name: Optional[str] = None
    repo_path: Optional[str] = None
    gitnexus_indexed: Optional[bool] = None
    gitnexus_status: Optional[str] = None


class DependencyEdge(BaseModel):
    """Directed dependency edge with evidence."""

    from_id: str
    to_id: str
    type: DependencyEdgeType
    evidence: list[str] = Field(default_factory=list)


class ImpactSummary(BaseModel):
    """High-level impact assessment for dependency exploration."""

    risk: Literal["low", "medium", "high"] = "low"
    affected_projects: list[str] = Field(default_factory=list)
    affected_repos: list[str] = Field(default_factory=list)
    edge_count: int = 0
    notes: list[str] = Field(default_factory=list)


class CrossProjectDependencyResult(BaseModel):
    """Result of cross-project dependency mapping."""

    ok: bool = True
    error: Optional[str] = None
    source_project: str = ""
    dependency_types: list[str] = Field(default_factory=list)
    depth: int = 1
    graph_status: dict[str, str] = Field(default_factory=dict)
    nodes: list[DependencyNode] = Field(default_factory=list)
    edges: list[DependencyEdge] = Field(default_factory=list)
    impact_summary: ImpactSummary = Field(default_factory=ImpactSummary)
`````

## File: src/metagit/data/skills/metagit-control-center/scripts/control-cycle.zsh
`````zsh
#!/usr/bin/env zsh
set -euo pipefail

ROOT="${1:-$PWD}"
QUERY="${2:-}"
PRESET="${3:-infra}"

"$(dirname "$0")/../../metagit-gating/scripts/gate-status.zsh" "$ROOT"

if [[ -n "$QUERY" ]]; then
  "$(dirname "$0")/../../metagit-upstream-scan/scripts/upstream-scan.zsh" "$ROOT" "$QUERY" "$PRESET" 20
else
  echo "status=ok\tmessage=no-query-provided"
fi
`````

## File: src/metagit/data/skills/metagit-control-center/SKILL.md
`````markdown
---
name: metagit-control-center
description: Use when running metagit as an MCP control center for multi-repo awareness, guarded sync, and operational knowledge across ongoing agent tasks.
---

# Metagit Control Center Skill

Use this skill when an agent should actively coordinate repository context and task execution across a workspace.

## Purpose

Provide a repeatable control-center workflow where Metagit MCP guides awareness, synchronization, and operational continuity over multiple related repositories.

## Local Script Wrapper (Use First)

Use this token-efficient wrapper for control-center cycles:
- `./scripts/control-cycle.zsh [root_path] ["query"] [preset]`

Wrapper behavior:
- runs gating status first
- optionally runs upstream discovery for blocker queries
- emits compact, machine-readable lines

## Core Workflows

### 1) Session Initialization
- Validate active workspace gate.
- Read `metagit://workspace/config` and `metagit://workspace/repos/status`.
- Call `metagit_project_context_switch` when the objective is tied to a workspace project.
- Run `metagit_workspace_health_check` or read `metagit://workspace/health` for maintenance signals.
- Identify stale repos and unresolved blockers from prior activity.

### 2) Active Task Support
- For each coding objective, map impacted repos.
- Use workspace search and upstream hints before broad exploration.
- Sync only repos that are required by the active objective.

### 3) Guarded Synchronization
- Default to `fetch` for visibility.
- Use `pull` or `clone` only with explicit permission and rationale.
- Track sync outcomes in operations log resource.

### 4) Operational Memory
- Before switching projects: `metagit_session_update` (notes + recent repos), optional `metagit_workspace_state_snapshot`.
- After returning: `metagit_workspace_state_restore` when a snapshot was taken (metadata only; git tree is unchanged).

Maintain bounded local records of:
- sync actions
- issue signatures searched
- candidate upstream repos identified
- unresolved dependencies and follow-ups

## Decision Guidelines

- Use metagit search first when blocker appears external to current repo.
- Prefer deterministic evidence over speculative jumps.
- Keep operations minimal and auditable.

## Output Contract

For each control-center cycle, provide:
- current objective
- repositories examined
- actions taken (or intentionally deferred)
- next recommended step

## Safety Rules

- Never mutate repositories without explicit authorization.
- Never broaden scope beyond configured workspace boundaries.
- Always preserve a clear audit trail of control actions.
`````

## File: src/metagit/data/skills/metagit-gitnexus/SKILL.md
`````markdown
---
name: metagit-gitnexus
description: Run gitnexus analysis for a target workspace and selected project repositories before graph-dependent tasks. Use when index staleness is detected or cross-repo graph results are needed.
---

# Running GitNexus Analysis

Use this skill whenever GitNexus context is stale or missing for target repositories.

## Local Wrapper (Use First)

- `zsh ./skills/metagit-gitnexus/scripts/analyze-targets.zsh <workspace_root> [project_name]`

## Workflow

1. Analyze the current repository where the command is run.
2. Resolve target project repositories from `.metagit.yml`.
3. Run `npx gitnexus analyze` in each local repository path found.
4. Report per-repository success/failure and next actions.

## Manual workspace graph (`.metagit.yml` → Cypher)

Export manifest `graph.relationships` for GitNexus overlay ingest:

```bash
metagit config graph export -c .metagit.yml --format tool-calls
# or MCP: metagit_export_workspace_graph_cypher
```

Run returned `gitnexus_cypher` tool calls against the umbrella repo index (`--gitnexus-repo` when names differ). Schema DDL (`MetagitEntity` / `MetagitLink`) runs once per index.

## Output Contract

Return:
- analyzed repositories
- failures and reasons
- whether graph queries are safe to run now

## Safety

- Skip repositories that do not exist locally.
- Do not mutate repo content; analysis should be read-only indexing.
`````

## File: src/metagit/data/skills/metagit-projects/SKILL.md
`````markdown
---
name: metagit-projects
description: Ongoing workspace and project management for OpenClaw and Hermes agents. Use when starting work, organizing repos, or before creating a new project folder so existing metagit projects are reused instead of duplicated.
---

# Ongoing Project Management

Use this skill when the user starts new work, reorganizes repositories, or asks you to create a project folder. Metagit is the source of truth for what already exists in the workspace.

## Concepts

Metagit uses a three-level hierarchy (see project terminology docs):

| Level | Meaning |
|-------|---------|
| **Workspace** | Root folder where projects are synced (from app config `workspace.path`, often `./.metagit/`). Holds many projects. |
| **Project** | Named group of one or more Git repositories. Multi-repo products are one project; unrelated repos can also share a workspace under different project names. |
| **Repo** | A single Git repository entry under `workspace.projects[].repos` in `.metagit.yml`. |

A **project** is not always “one product.” It is whatever grouping helps the user and agents reason about related (or intentionally grouped) repositories. A workspace may contain unrelated projects side by side (for example `default`, `client-a`, `experiments`).

The umbrella `.metagit.yml` (workspace definition, often `kind: umbrella`) lives in a coordinating repository or central config checkout. Application repos may have their own `.metagit.yml` for metadata mode.

## Mandatory: check before creating folders

**Never** create a new project directory or clone into the workspace until you have checked metagit for an existing match.

1. **Locate the workspace definition**
   - Prefer the user’s umbrella `.metagit.yml` if known.
   - Otherwise use `.metagit.yml` in the current repo with `--definition /path/to/.metagit.yml`.

2. **List configured projects and repo counts**
   ```bash
   metagit config info --config-path /path/to/.metagit.yml
   metagit project list --config /path/to/.metagit.yml --project default
   ```
   Repeat `--project` for each project name returned by `config info`.

3. **Search managed repos by name, URL fragment, or tag**
   ```bash
   metagit search "<proposed-name-or-url>" --definition /path/to/.metagit.yml
   metagit search "<name>" --definition /path/to/.metagit.yml --json
   ```

4. **Inspect on disk** (workspace path from app config, default `./.metagit/`)
   - Expected layout: `{workspace.path}/{project_name}/{repo_name}/`
   - If the directory already exists, **reuse it**; do not create a parallel tree.

5. **Decide**
   - **Match found** → use existing project/repo; run `metagit project sync` only if the user wants checkouts refreshed.
   - **No match** → proceed with registration steps below (still add to workspace; do not leave orphan folders).

## Registering new work in the workspace

### New repository in an existing project

From the directory containing the workspace `.metagit.yml` (or pass `--config`):

```bash
metagit project repo add --project <project_name> --prompt
# or non-interactive:
metagit project repo add --project <project_name> --name <repo> --url <git-url>
metagit config validate --config-path .metagit.yml
metagit project sync --project <project_name>
```

In the new application repo (if applicable):

```bash
cd /path/to/new/repo
metagit init
metagit detect repo --force   # optional: enrich .metagit.yml
```

### New project group (new `workspace.projects[]` entry)

There is no separate `project create` CLI today. Add a project block to `.metagit.yml`:

```yaml
workspace:
  projects:
    - name: my-new-project
      description: Short purpose for agents and humans
      repos: []
```

Then validate, add repos, and sync:

```bash
metagit config validate --config-path .metagit.yml
metagit project repo add --project my-new-project --prompt
metagit project sync --project my-new-project
```

Choose a **distinct project name**; avoid duplicating an existing `workspace.projects[].name`.

### New umbrella workspace

When bootstrapping a workspace coordinator repo:

```bash
metagit init --kind umbrella
metagit project repo add --project default --prompt
metagit project sync
```

## Ongoing session habits

At the start of sustained work:

1. Run **metagit-workspace-scope** (or `metagit mcp serve --status-once` when MCP is available).
2. Confirm **active project** matches the user’s intent (`metagit workspace select --project <name>` when switching).
3. Use **`metagit search`** before assuming a repo is missing or lives elsewhere.
4. For multi-repo tasks, prefer **metagit-control-center** or **metagit-multi-repo** over ad-hoc cloning.

When the user names a target folder:

- Resolve it against managed config first.
- If unmanaged but present on disk under the project sync folder, report it and offer to add via `metagit project repo add` rather than recreating.

## OpenClaw and Hermes setup

Install bundled skills (including this one) for agent hosts:

```bash
metagit skills list
metagit skills install --scope user --target openclaw --target hermes
metagit mcp install --scope user --target openclaw --target hermes
```

Use `--scope project` when installing into a specific umbrella repository checkout.

## Output contract

After project-management actions, report:

- workspace definition path used
- whether the target was **existing** or **newly registered**
- project name and repo name(s) affected
- sync status if `project sync` was run
- recommended next command (`workspace select`, `project select`, or `detect`)

## Rename and move (layout)

Use layout commands when reorganizing project or repo names. **Always dry-run first.**

```bash
metagit workspace project rename old-name new-name --dry-run --json
metagit workspace repo rename -p <project> old-repo new-repo --dry-run --json
metagit workspace repo move -p <from> -n <repo> --to-project <to> --dry-run --json
```

MCP: `metagit_workspace_project_rename`, `metagit_workspace_repo_rename`, `metagit_workspace_repo_move`.

- Renames update `.metagit.yml` and synced folders under `workspace.path` when they exist.
- Moves relocate repo mounts between projects (dedupe mode relinks symlinks to canonical checkouts).
- Use `--manifest-only` to change the manifest without touching disk.
- Run `metagit config validate` after any layout change.

## Safety

- Do not clone, delete, or overwrite sync directories without explicit user approval.
- Do not edit `.metagit.yml` without validating afterward (`metagit config validate`).
- Prefer reusing configured repos over creating duplicate checkouts.
- Keep unrelated experiments in separate `workspace.projects` entries when the user wants clear boundaries.
`````

## File: src/metagit/data/skills/metagit-repo-impact/SKILL.md
`````markdown
---
name: metagit-repo-impact
description: Plan repository change impact before edits by combining metagit workspace context and graph-based dependency analysis. Use when a change may affect multiple repositories.
---

# Planning Repo Impact

Use this skill before risky or cross-repo modifications.

## Workflow

1. Identify target symbols/files for change.
2. Use metagit workspace context to bound repository scope.
3. Use graph impact tooling to estimate blast radius.
4. Produce a change plan with test and rollback focus.

## Commands

- `metagit workspace select --project <name>`
- MCP `metagit_cross_project_dependencies` with `source_project` and `dependency_types` before large cross-project edits
- MCP `metagit_project_context_switch` to bound scope to one workspace project
- `npx gitnexus analyze` on affected repos when `graph_status` is `stale` or `missing`
- `npx gitnexus query` / `gitnexus impact` for symbol-level analysis after indexes are fresh

## Output Contract

Return:
- impacted repositories and interfaces
- highest-risk change points
- minimum validation plan

## Safety

- Do not execute mutating steps in this planning phase.
`````

## File: src/metagit/data/skills/metagit-workspace-scope/SKILL.md
`````markdown
---
name: metagit-workspace-scope
description: Discover active metagit workspace scope, project boundaries, and repository status. Use when an agent starts work in a multi-repo workspace and needs fast, scoped context before editing.
---

# Discovering Workspace Scope

Use this skill at session start for workspace-aware tasks.

## Workflow

1. Run workspace gate and status checks first.
2. Read configured projects and repository status from metagit resources/tools.
3. Build a compact map of active project names, repo names, and sync state.
4. Report a bounded scope for the current objective.

## Commands

- `zsh ./skills/metagit-gating/scripts/gate-status.zsh [root_path]`
- `metagit workspace select --project <name>` (interactive repo picker)
- MCP `metagit_project_context_switch` with `project_name` (structured context + session restore)
- MCP `metagit_workspace_state_snapshot` before leaving a project for a long switch

## Output Contract

Return:
- active/inactive workspace state and reason
- candidate project to operate on
- top repositories to inspect first

## Safety

- Keep operations read-only in this step.
- Do not sync or mutate repositories without explicit request.
`````

## File: src/metagit/data/skills/metagit-workspace-sync/SKILL.md
`````markdown
---
name: metagit-workspace-sync
description: Sync workspace repositories safely using metagit with scoped fetch, pull, or clone actions. Use when repository content must be refreshed for implementation.
---

# Syncing Workspace Repositories

Use this skill when repository state must be updated.

## Workflow

1. Confirm active workspace and target project.
2. Start with read-only status and fetch where possible.
3. Use pull/clone only when required for the current objective.
4. Summarize which repositories changed and what remains stale.

## Commands

- `metagit project sync --project <name>`
- MCP `metagit_workspace_sync` for batch fetch/pull/clone (`repos`, `only_if`, `dry_run`, `max_parallel`)
- MCP `metagit_repo_sync` for a single repository (requires `allow_mutation` for pull/clone)
- `zsh ./skills/metagit-control-center/scripts/control-cycle.zsh [root_path] ["query"] [preset]`

## Output Contract

Return:
- repositories synced
- sync mode used (fetch/pull/clone)
- failures and retry guidance

## Safety

- Limit sync to repos defined in `.metagit.yml`.
- Avoid broad synchronization if only a subset is needed.
`````

## File: src/metagit/data/config-example-overrides.yml
`````yaml
# Merged last when generating docs/reference/metagit-config.full-example.yml
name: example-umbrella
description: Example umbrella workspace for documentation (not for production).
kind: umbrella
agent_instructions: |
  Hermes controller example: search the workspace before creating folders or clones.
  Use metagit_workspace_status and metagit_workspace_health_check at session start.
url: https://github.com/example-org/hermes-control-plane.git
workspace:
  description: Portfolio of git repos and local publish paths managed by metagit.
  agent_instructions: |
    Validate after manifest edits (metagit config validate). Default sync is fetch-only;
  projects:
    - name: portfolio
      description: Git-backed applications and services.
      agent_instructions: |
        Delegate single-repo work to subagents with effective_agent_instructions.
      repos:
        - name: example-service
          description: Sample git-backed service.
          url: https://github.com/example-org/example-service.git
          sync: true
          agent_instructions: |
            Run tests before opening PRs; keep tags updated in this manifest.
    - name: local
      description: Local-only paths for static sites (no git remotes).
      dedupe:
        enabled: false
      agent_instructions: |
        Do not git clone or pull repos in this project; paths are symlinks to user dirs.
        Dedupe is disabled for this project (overrides workspace.dedupe in metagit.config.yaml).
      repos:
        - name: example-site
          description: Sample static site published from a local folder.
          path: ~/Sites/example-site
          sync: true
          kind: website
          agent_instructions: |
            Document build and publish steps here; update path if the site moves.
`````

## File: tests/cli/commands/test_init.py
`````python
#!/usr/bin/env python
"""
CLI tests for metagit init command.
"""

import subprocess
from pathlib import Path

import yaml
from click.testing import CliRunner

from metagit.cli.commands.init import _resolve_project_metadata, resolve_target_dir
from metagit.cli.main import cli


def test_resolve_project_metadata_non_git_directory(tmp_path: Path) -> None:
  project_dir = tmp_path / "my-project"
  project_dir.mkdir()
  name, url = _resolve_project_metadata(project_dir)
  assert name == "my-project"
  assert url is None


def test_resolve_project_metadata_git_repo_without_remote(tmp_path: Path) -> None:
  project_dir = tmp_path / "local-repo"
  project_dir.mkdir()
  subprocess.run(["git", "init"], cwd=project_dir, check=True, capture_output=True)
  name, url = _resolve_project_metadata(project_dir)
  assert name == "local-repo"
  assert url is None


def test_init_succeeds_outside_git_repository() -> None:
  runner = CliRunner()
  with runner.isolated_filesystem():
    result = runner.invoke(
      cli,
      ["init", "--kind", "application", "--skip-gitignore", "--no-prompt"],
    )
    assert result.exit_code == 0, result.output
    config_path = Path(".metagit.yml")
    assert config_path.is_file()
    loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    assert loaded["name"] == Path.cwd().name
    assert loaded["kind"] == "application"


def test_init_list_templates() -> None:
  runner = CliRunner()
  result = runner.invoke(cli, ["init", "--list-templates"])
  assert result.exit_code == 0, result.output
  assert "hermes-orchestrator" in result.output
  assert "application" in result.output


def test_init_hermes_template_no_prompt() -> None:
  runner = CliRunner()
  with runner.isolated_filesystem():
    answers = {
      "name": "hermes-test",
      "description": "Test workspace",
      "url": "",
      "portfolio_repo_name": "api",
      "portfolio_repo_url": "https://github.com/example/api.git",
      "local_site_name": "site",
      "local_site_path": "~/Sites/site",
    }
    answers_path = Path("answers.yml")
    answers_path.write_text(yaml.safe_dump(answers), encoding="utf-8")
    result = runner.invoke(
      cli,
      [
        "init",
        "--template",
        "hermes-orchestrator",
        "--answers-file",
        str(answers_path),
        "--no-prompt",
        "--skip-gitignore",
      ],
    )
    assert result.exit_code == 0, result.output
    loaded = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8"))
    assert loaded["name"] == "hermes-test"
    assert Path("AGENTS.md").is_file()


def test_resolve_target_dir_create(tmp_path: Path) -> None:
  target = tmp_path / "new-coordinator"
  resolved = resolve_target_dir(str(target), create=True)
  assert resolved == target.resolve()
  assert target.is_dir()


def test_init_writes_to_target_folder() -> None:
  runner = CliRunner()
  with runner.isolated_filesystem():
    target = Path("coordinator")
    target.mkdir()
    result = runner.invoke(
      cli,
      [
        "init",
        str(target),
        "--kind",
        "application",
        "--no-prompt",
        "--skip-gitignore",
      ],
    )
    assert result.exit_code == 0, result.output
    assert (target / ".metagit.yml").is_file()
    assert not Path(".metagit.yml").exists()


def test_init_target_option_overrides_positional(tmp_path: Path) -> None:
  runner = CliRunner()
  chosen = tmp_path / "chosen"
  ignored = tmp_path / "ignored"
  chosen.mkdir()
  ignored.mkdir()
  result = runner.invoke(
    cli,
    [
      "init",
      str(ignored),
      "--target",
      str(chosen),
      "--minimal",
      "--kind",
      "application",
      "--no-prompt",
      "--skip-gitignore",
    ],
  )
  assert result.exit_code == 0, result.output
  assert (chosen / ".metagit.yml").is_file()
  assert not (ignored / ".metagit.yml").exists()


def test_init_minimal_service_kind() -> None:
  runner = CliRunner()
  with runner.isolated_filesystem():
    result = runner.invoke(
      cli,
      [
        "init",
        "--kind",
        "service",
        "--minimal",
        "--no-prompt",
        "--description",
        "A microservice",
        "--skip-gitignore",
      ],
    )
    assert result.exit_code == 0, result.output
    loaded = yaml.safe_load(Path(".metagit.yml").read_text(encoding="utf-8"))
    assert loaded["kind"] == "service"
`````

## File: tests/cli/commands/test_mcp.py
`````python
#!/usr/bin/env python
"""
CLI tests for metagit mcp commands.
"""

import json
from pathlib import Path

from click.testing import CliRunner

from metagit.cli.main import cli


def test_mcp_serve_accepts_root_option(tmp_path) -> None:
    runner = CliRunner()

    result = runner.invoke(
        cli, ["mcp", "serve", "--root", str(tmp_path), "--status-once"]
    )

    assert result.exit_code == 0
    assert "mcp_state=" in result.output


def test_mcp_install_project_target_updates_config() -> None:
    runner = CliRunner()
    with runner.isolated_filesystem():
        result = runner.invoke(
            cli,
            ["mcp", "install", "--scope", "project", "--target", "opencode"],
        )

        assert result.exit_code == 0
        config_data = json.loads(Path(".opencode/mcp.json").read_text(encoding="utf-8"))
        assert "mcpServers" in config_data
        assert "metagit" in config_data["mcpServers"]
`````

## File: tests/cli/commands/test_project_repo.py
`````python
#!/usr/bin/env python
"""CLI tests for metagit project repo prune."""

from pathlib import Path

from click.testing import CliRunner

from metagit.cli.main import cli


def test_project_repo_prune_dry_run_lists_unmanaged(tmp_path: Path) -> None:
    runner = CliRunner()
    workspace = tmp_path / ".metagit"
    platform = workspace / "platform"
    platform.mkdir(parents=True)
    (platform / "managed").mkdir()
    (platform / "leftover").mkdir()

    metagit_yml = tmp_path / ".metagit.yml"
    metagit_yml.write_text(
        "\n".join(
            [
                "name: test",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: platform",
                "      repos:",
                "        - name: managed",
                "          url: https://example.com/managed.git",
            ]
        )
        + "\n",
        encoding="utf-8",
    )

    app_cfg = tmp_path / "metagit.config.yaml"
    app_cfg.write_text(
        "\n".join(
            [
                "config:",
                "  description: test",
                "  workspace:",
                "    path: " + str(workspace).replace("\\", "/"),
                "    default_project: platform",
                "    ui_ignore_hidden: true",
            ]
        )
        + "\n",
        encoding="utf-8",
    )

    result = runner.invoke(
        cli,
        [
            "--config",
            str(app_cfg),
            "project",
            "-c",
            str(metagit_yml),
            "--project",
            "platform",
            "repo",
            "prune",
            "--dry-run",
        ],
        catch_exceptions=False,
    )
    assert result.exit_code == 0
    assert "leftover" in result.output
    assert "Prune context:" in result.output
    assert "workspace.path (sync root):" in result.output
    assert "project: platform" in result.output
    assert "project sync folder:" in result.output


def test_project_repo_prune_force_removes_without_prompt(tmp_path: Path) -> None:
    runner = CliRunner()
    workspace = tmp_path / ".metagit"
    platform = workspace / "platform"
    platform.mkdir(parents=True)
    (platform / "managed").mkdir()
    leftover = platform / "leftover"
    leftover.mkdir()

    metagit_yml = tmp_path / ".metagit.yml"
    metagit_yml.write_text(
        "\n".join(
            [
                "name: test",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: platform",
                "      repos:",
                "        - name: managed",
                "          url: https://example.com/managed.git",
            ]
        )
        + "\n",
        encoding="utf-8",
    )

    app_cfg = tmp_path / "metagit.config.yaml"
    app_cfg.write_text(
        "\n".join(
            [
                "config:",
                "  description: test",
                "  workspace:",
                "    path: " + str(workspace).replace("\\", "/"),
                "    default_project: platform",
                "    ui_ignore_hidden: true",
            ]
        )
        + "\n",
        encoding="utf-8",
    )

    result = runner.invoke(
        cli,
        [
            "--config",
            str(app_cfg),
            "project",
            "-c",
            str(metagit_yml),
            "--project",
            "platform",
            "repo",
            "prune",
            "--force",
        ],
        catch_exceptions=False,
    )
    assert result.exit_code == 0
    assert "--force" in result.output
    assert not leftover.exists()
    assert (platform / "managed").exists()
`````

## File: tests/cli/commands/test_skills.py
`````python
#!/usr/bin/env python
"""
CLI tests for metagit skills commands.
"""

from pathlib import Path

from click.testing import CliRunner

from metagit.cli.main import cli


def test_skills_list_displays_bundled_skills() -> None:
    runner = CliRunner()
    result = runner.invoke(cli, ["skills", "list"])
    assert result.exit_code == 0
    assert "metagit-gitnexus" in result.output


def test_skills_install_project_target_creates_skill_directory() -> None:
    runner = CliRunner()
    with runner.isolated_filesystem():
        result = runner.invoke(
            cli,
            ["skills", "install", "--scope", "project", "--target", "opencode"],
        )
        assert result.exit_code == 0
        destination = Path(".opencode/skills")
        assert destination.exists()
        assert any(item.is_dir() for item in destination.iterdir())


def test_skills_install_single_skill_only() -> None:
    runner = CliRunner()
    with runner.isolated_filesystem():
        result = runner.invoke(
            cli,
            [
                "skills",
                "install",
                "--scope",
                "project",
                "--target",
                "opencode",
                "--skill",
                "metagit-projects",
            ],
        )
        assert result.exit_code == 0
        assert "Installed skill 'metagit-projects'" in result.output
        installed = [
            p.name for p in Path(".opencode/skills").iterdir() if p.is_dir()
        ]
        assert installed == ["metagit-projects"]


def test_skills_install_dry_run_does_not_write_files() -> None:
    runner = CliRunner()
    with runner.isolated_filesystem():
        result = runner.invoke(
            cli,
            [
                "skills",
                "install",
                "--scope",
                "project",
                "--target",
                "opencode",
                "--skill",
                "metagit-projects",
                "--dry-run",
            ],
        )
        assert result.exit_code == 0
        assert "dry run" in result.output
        assert "Would install skill 'metagit-projects'" in result.output
        assert not Path(".opencode/skills").exists()


def test_skills_install_unknown_skill_fails() -> None:
    runner = CliRunner()
    result = runner.invoke(
        cli,
        [
            "skills",
            "install",
            "--target",
            "opencode",
            "--skill",
            "not-a-skill",
        ],
    )
    assert result.exit_code != 0
    assert "Unknown skill" in result.output
`````

## File: tests/core/mcp/services/test_project_context.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.project_context
"""

from pathlib import Path

from git import Repo

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig, Variable, VariableKind
from metagit.core.mcp.services.project_context import ProjectContextService
from metagit.core.mcp.services.session_store import SessionStore


def _write_multi_project_workspace(tmp_path: Path) -> str:
  alpha_repo = tmp_path / "alpha" / "repo-one"
  alpha_repo.mkdir(parents=True)
  Repo.init(alpha_repo)
  beta_repo = tmp_path / "beta" / "repo-two"
  beta_repo.mkdir(parents=True)
  Repo.init(beta_repo)
  (tmp_path / ".metagit.yml").write_text(
    "\n".join(
      [
        "name: workspace",
        "kind: application",
        "variables:",
        "  - name: METAGIT_APP_ENV",
        "    kind: string",
        "    ref: development",
        "workspace:",
        "  projects:",
        "    - name: alpha",
        "      agent_instructions: Focus on alpha services",
        "      repos:",
        "        - name: repo-one",
        "          path: alpha/repo-one",
        "          sync: true",
        "    - name: beta",
        "      repos:",
        "        - name: repo-two",
        "          path: beta/repo-two",
        "          sync: true",
      ]
    )
    + "\n",
    encoding="utf-8",
  )
  return str(tmp_path)


def _load_config(tmp_path: Path) -> MetagitConfig:
  manager = MetagitConfigManager(config_path=tmp_path / ".metagit.yml")
  loaded = manager.load_config()
  assert not isinstance(loaded, Exception)
  return loaded


def test_switch_sets_active_project_and_returns_repos(tmp_path: Path) -> None:
  root = _write_multi_project_workspace(tmp_path)
  config = _load_config(tmp_path)
  service = ProjectContextService()

  bundle = service.switch(
    config=config,
    workspace_root=root,
    project_name="alpha",
  )

  assert bundle.ok is True
  assert bundle.project_name == "alpha"
  assert bundle.agent_instructions == "Focus on alpha services"
  assert any(layer.layer == "project" for layer in bundle.instruction_layers)
  assert "Focus on alpha services" in bundle.effective_agent_instructions
  assert len(bundle.repos) == 1
  assert bundle.repos[0].repo_name == "repo-one"
  assert bundle.repos[0].branch is not None
  meta = SessionStore(workspace_root=root).get_workspace_meta()
  assert meta.active_project == "alpha"


def test_switch_unknown_project_returns_error(tmp_path: Path) -> None:
  root = _write_multi_project_workspace(tmp_path)
  config = _load_config(tmp_path)
  service = ProjectContextService()

  bundle = service.switch(
    config=config,
    workspace_root=root,
    project_name="missing",
  )

  assert bundle.ok is False
  assert bundle.error == "project_not_found"


def test_env_export_includes_metagit_and_config_variables(tmp_path: Path) -> None:
  root = _write_multi_project_workspace(tmp_path)
  config = _load_config(tmp_path)
  service = ProjectContextService()

  bundle = service.switch(
    config=config,
    workspace_root=root,
    project_name="alpha",
    restore_session=False,
  )

  assert bundle.env.export["METAGIT_PROJECT"] == "alpha"
  assert bundle.env.export["METAGIT_APP_ENV"] == "development"


def test_update_session_persists_notes(tmp_path: Path) -> None:
  root = _write_multi_project_workspace(tmp_path)
  config = _load_config(tmp_path)
  service = ProjectContextService()

  result = service.update_session(
    config=config,
    workspace_root=root,
    project_name="alpha",
    agent_notes="paused auth work",
  )

  assert result["ok"] is True
  session = SessionStore(workspace_root=root).get_project_session(project_name="alpha")
  assert session.agent_notes == "paused auth work"


def test_env_export_skips_sensitive_variable_ref(tmp_path: Path) -> None:
  config = MetagitConfig(
    name="workspace",
    kind="application",
    variables=[
      Variable(name="METAGIT_API_TOKEN", kind=VariableKind.STRING, ref="Bearer abc"),
    ],
    workspace={"projects": [{"name": "alpha", "repos": []}]},
  )
  service = ProjectContextService()
  bundle = service.switch(
    config=config,
    workspace_root=str(tmp_path),
    project_name="alpha",
    restore_session=False,
  )
  assert "METAGIT_API_TOKEN" not in bundle.env.export
  assert any("METAGIT_API_TOKEN" in hint for hint in bundle.env.hints)
`````

## File: tests/core/workspace/test_catalog_service.py
`````python
#!/usr/bin/env python
"""Tests for workspace catalog list and mutation service."""

from pathlib import Path

import yaml

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.workspace.catalog_service import WorkspaceCatalogService


def _write_manifest(tmp_path: Path, projects: list[dict] | None = None) -> tuple[Path, str]:
    manifest = {
        "name": "test-workspace",
        "kind": "application",
        "workspace": {"projects": projects or []},
    }
    config_path = tmp_path / ".metagit.yml"
    config_path.write_text(yaml.dump(manifest), encoding="utf-8")
    manager = MetagitConfigManager(str(config_path))
    loaded = manager.load_config()
    assert not isinstance(loaded, Exception)
    return loaded, str(config_path)


def test_list_projects_and_repos(tmp_path: Path) -> None:
    config, config_path = _write_manifest(
        tmp_path,
        [
            {
                "name": "platform",
                "repos": [
                    {"name": "svc-a", "path": "platform/svc-a", "sync": True},
                ],
            }
        ],
    )
    service = WorkspaceCatalogService()
    projects = service.list_projects(config)
    assert projects.data["project_count"] == 1
    repos = service.list_repos(config, str(tmp_path), project_name="platform")
    assert repos.data["repo_count"] == 1
    workspace = service.list_workspace(config, config_path, str(tmp_path))
    assert workspace.data["summary"]["repo_count"] == 1


def test_add_and_remove_project(tmp_path: Path) -> None:
    config, config_path = _write_manifest(tmp_path)
    service = WorkspaceCatalogService()
    added = service.add_project(config, config_path, name="infra")
    assert added.ok
    manager = MetagitConfigManager(config_path)
    reloaded = manager.load_config()
    assert not isinstance(reloaded, Exception)
    assert any(project.name == "infra" for project in reloaded.workspace.projects)
    removed = service.remove_project(reloaded, config_path, name="infra")
    assert removed.ok


def test_add_and_remove_repo(tmp_path: Path) -> None:
    config, config_path = _write_manifest(
        tmp_path,
        [{"name": "platform", "repos": []}],
    )
    service = WorkspaceCatalogService()
    built = service.build_repo_from_fields(
        name="svc-b",
        path="platform/svc-b",
        sync=True,
    )
    assert not isinstance(built, Exception)
    from metagit.core.workspace.catalog_models import CatalogError

    assert not isinstance(built, CatalogError)
    added = service.add_repo(
        config,
        config_path,
        project_name="platform",
        repo=built,
    )
    assert added.ok
    manager = MetagitConfigManager(config_path)
    reloaded = manager.load_config()
    assert not isinstance(reloaded, Exception)
    removed = service.remove_repo(
        reloaded,
        config_path,
        project_name="platform",
        repo_name="svc-b",
    )
    assert removed.ok


def test_add_repo_rejects_duplicate_identity(tmp_path: Path) -> None:
    from metagit.core.project.models import ProjectPath
    from metagit.core.workspace.catalog_models import CatalogError

    config, config_path = _write_manifest(
        tmp_path,
        [
            {
                "name": "alpha",
                "repos": [
                    {
                        "name": "svc",
                        "url": "https://github.com/example/svc.git",
                    }
                ],
            },
            {"name": "beta", "repos": []},
        ],
    )
    service = WorkspaceCatalogService()
    built = service.build_repo_from_fields(
        name="svc-copy",
        url="https://github.com/example/svc.git",
    )
    assert not isinstance(built, CatalogError)
    result = service.add_repo(
        config,
        config_path,
        project_name="beta",
        repo=built,
    )
    assert not result.ok
    assert result.error is not None
    assert result.error.kind == "duplicate_identity"
`````

## File: tests/integration/test_mcp_workspace_flow.py
`````python
#!/usr/bin/env python
"""
Integration tests for MCP workspace activation flow.
"""

from pathlib import Path

from click.testing import CliRunner

from metagit.cli.main import cli
from metagit.core.mcp.runtime import MetagitMcpRuntime


def test_end_to_end_workspace_activation_and_discovery(tmp_path: Path) -> None:
    runner = CliRunner()
    workspace_root = tmp_path / "workspace"
    workspace_root.mkdir()

    inactive_result = runner.invoke(
        cli,
        ["mcp", "serve", "--root", str(workspace_root), "--status-once"],
    )
    assert inactive_result.exit_code == 0
    assert "mcp_state=inactive_missing_config" in inactive_result.output

    (workspace_root / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: e2e-workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: default",
                "      repos: []",
            ]
        )
        + "\n",
        encoding="utf-8",
    )

    active_result = runner.invoke(
        cli,
        ["mcp", "serve", "--root", str(workspace_root), "--status-once"],
    )
    assert active_result.exit_code == 0
    assert "mcp_state=active" in active_result.output

    runtime = MetagitMcpRuntime(root=str(workspace_root))
    tools_response = runtime._handle_request(
        {"jsonrpc": "2.0", "id": 20, "method": "tools/list", "params": {}}
    )
    assert tools_response is not None
    tool_names = [item["name"] for item in tools_response["result"]["tools"]]
    assert "metagit_repo_search" in tool_names
`````

## File: tests/test_appconfig_display.py
`````python
#!/usr/bin/env python
"""Tests for appconfig show display and agent_mode."""

import os

from metagit.core.appconfig.agent_mode import resolve_agent_mode
from metagit.core.appconfig.display import build_appconfig_payload, render_appconfig_show
from metagit.core.appconfig.models import AppConfig


def test_appconfig_show_includes_dedupe_and_agent_mode() -> None:
    config = AppConfig()
    payload = build_appconfig_payload(config, config_path="/tmp/metagit.config.yaml")
    assert payload["agent_mode"] is False
    assert payload["config"]["workspace"]["dedupe"]["enabled"] is False


def test_metagit_agent_mode_env_overrides_config(monkeypatch) -> None:
    monkeypatch.setenv("METAGIT_AGENT_MODE", "true")
    config = AppConfig(agent_mode=False)
    config = AppConfig._override_from_environment(config)
    assert resolve_agent_mode(config) is True


def test_render_appconfig_show_json() -> None:
    rendered = render_appconfig_show(
        AppConfig(),
        config_path="metagit.config.yaml",
        output_format="json",
    )
    assert '"agent_mode"' in rendered
    assert '"dedupe"' in rendered
`````

## File: tests/test_project_search_service.py
`````python
#!/usr/bin/env python
"""
Unit tests for ManagedRepoSearchService.
"""

from pathlib import Path

from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectPath
from metagit.core.project.search_service import ManagedRepoSearchService
from metagit.core.workspace.models import Workspace, WorkspaceProject


def _config(tmp_path: Path) -> MetagitConfig:
    workspace_root = tmp_path / "workspace"
    app_repo = workspace_root / "platform" / "abacus-app"
    module_repo = workspace_root / "shared" / "abacus-module"
    app_repo.mkdir(parents=True)
    module_repo.mkdir(parents=True)
    (app_repo / ".git").mkdir()
    (module_repo / ".git").mkdir()
    return MetagitConfig(
        name="workspace",
        workspace=Workspace(
            projects=[
                WorkspaceProject(
                    name="platform",
                    repos=[
                        ProjectPath(
                            name="abacus-app",
                            path="platform/abacus-app",
                            url="https://github.com/example/abacus-app.git",
                            sync=True,
                            tags={"code": "abacus", "domain": "terraform"},
                        )
                    ],
                ),
                WorkspaceProject(
                    name="shared",
                    repos=[
                        ProjectPath(
                            name="abacus-module",
                            path="shared/abacus-module",
                            url="https://github.com/example/abacus-module.git",
                            sync=True,
                            tags={"code": "abacus", "domain": "terraform-module"},
                        )
                    ],
                ),
            ]
        ),
    )


def test_search_prioritizes_exact_repo_name(tmp_path: Path) -> None:
    service = ManagedRepoSearchService()
    result = service.search(
        config=_config(tmp_path),
        workspace_root=str(tmp_path / "workspace"),
        query="abacus-app",
    )
    assert result.matches[0].repo_name == "abacus-app"
    assert "repo_name:exact" in result.matches[0].match_reasons


def test_search_can_filter_by_tag(tmp_path: Path) -> None:
    service = ManagedRepoSearchService()
    result = service.search(
        config=_config(tmp_path),
        workspace_root=str(tmp_path / "workspace"),
        query="abacus",
        tags={"domain": "terraform-module"},
    )
    assert [match.repo_name for match in result.matches] == ["abacus-module"]


def test_search_filters_by_status_and_has_url(tmp_path: Path) -> None:
    service = ManagedRepoSearchService()
    missing_repo = tmp_path / "workspace" / "platform" / "missing-app"
    missing_repo.mkdir(parents=True)
    config = _config(tmp_path)
    config.workspace.projects[0].repos.append(
        ProjectPath(
            name="missing-app",
            path="platform/missing-app",
            sync=True,
        )
    )
    result = service.search(
        config=config,
        workspace_root=str(tmp_path / "workspace"),
        query="*",
        status=["configured_missing"],
        has_url=False,
        sort="name",
    )
    assert [match.repo_name for match in result.matches] == ["missing-app"]


def test_search_sorts_by_project_name(tmp_path: Path) -> None:
    service = ManagedRepoSearchService()
    result = service.search(
        config=_config(tmp_path),
        workspace_root=str(tmp_path / "workspace"),
        query="abacus",
        sort="project",
    )
    project_names = [match.project_name for match in result.matches]
    assert project_names == sorted(project_names)


def test_resolve_one_returns_ambiguous_match(tmp_path: Path) -> None:
    service = ManagedRepoSearchService()
    resolved = service.resolve_one(
        config=_config(tmp_path),
        workspace_root=str(tmp_path / "workspace"),
        query="abacus",
        synced_only=True,
    )
    assert resolved.error is not None
    assert resolved.error.kind == "ambiguous_match"
`````

## File: tests/test_skills_installer.py
`````python
#!/usr/bin/env python
"""
Unit tests for skills installer helpers.
"""

import pytest

from metagit.core.skills.installer import (
    autodetect_targets,
    install_skills_for_targets,
    resolve_skill_names,
    resolve_targets,
)


def test_resolve_targets_respects_disable() -> None:
    targets = resolve_targets(
        mode="skills",
        scope="project",
        enable_targets=["opencode", "hermes"],
        disable_targets=["hermes"],
    )
    assert targets == ["opencode"]


def test_autodetect_targets_project_scope(monkeypatch, tmp_path) -> None:
    project_root = tmp_path / "project"
    project_root.mkdir()
    (project_root / ".opencode").mkdir()
    monkeypatch.chdir(project_root)
    detected = autodetect_targets(mode="skills", scope="project")
    assert "opencode" in detected


def test_resolve_skill_names_rejects_unknown() -> None:
    with pytest.raises(ValueError, match="Unknown skill"):
        resolve_skill_names(["not-a-real-skill"])


def test_install_skills_for_targets_single_skill(monkeypatch, tmp_path) -> None:
    project_root = tmp_path / "project"
    project_root.mkdir()
    (project_root / ".opencode").mkdir()
    monkeypatch.chdir(project_root)
    results = install_skills_for_targets(
        targets=["opencode"],
        scope="project",
        skill_names=["metagit-gitnexus"],
    )
    destination = project_root / ".opencode" / "skills"
    assert results[0].applied is True
    assert "metagit-gitnexus" in results[0].details
    installed = [p.name for p in destination.iterdir() if p.is_dir()]
    assert installed == ["metagit-gitnexus"]


def test_install_skills_dry_run_writes_nothing(monkeypatch, tmp_path) -> None:
    project_root = tmp_path / "project"
    project_root.mkdir()
    (project_root / ".opencode").mkdir()
    monkeypatch.chdir(project_root)
    results = install_skills_for_targets(
        targets=["opencode"],
        scope="project",
        skill_names=["metagit-gitnexus"],
        dry_run=True,
    )
    destination = project_root / ".opencode" / "skills"
    assert results[0].dry_run is True
    assert results[0].details.startswith("Would install")
    assert not destination.exists()
`````

## File: tests/test_workspace_index_service.py
`````python
#!/usr/bin/env python
"""
Unit tests for WorkspaceIndexService managed repo rows (tags, status).
"""

from pathlib import Path

from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService


def test_build_index_synced_git_repo_with_tags_and_paths(tmp_path: Path) -> None:
    workspace_root = tmp_path / "workspace"
    workspace_root.mkdir()
    repo_dir = workspace_root / "svc-auth"
    repo_dir.mkdir()
    (repo_dir / ".git").mkdir()

    config = MetagitConfig(
        name="test",
        kind="application",
        workspace={
            "projects": [
                {
                    "name": "platform",
                    "repos": [
                        {
                            "name": "svc-auth",
                            "path": "./svc-auth",
                            "kind": "repository",
                            "url": "https://example.com/org/svc-auth.git",
                            "sync": True,
                            "tags": {"tier": "1", "domain": "auth"},
                        }
                    ],
                }
            ]
        },
    )
    service = WorkspaceIndexService()
    rows = service.build_index(config=config, workspace_root=str(workspace_root))

    assert len(rows) == 1
    row = rows[0]
    assert row["project_name"] == "platform"
    assert row["repo_name"] == "svc-auth"
    assert row["configured_path"] == "./svc-auth"
    assert row["tags"] == {"tier": "1", "domain": "auth"}
    assert row["exists"] is True
    assert row["is_git_repo"] is True
    assert row["status"] == "synced"
    assert row["url"] == "https://example.com/org/svc-auth.git"
    assert row["sync"] is True


def test_build_index_url_only_repo_uses_project_mount_path(tmp_path: Path) -> None:
    workspace_root = tmp_path / ".metagit"
    workspace_root.mkdir()
    repo_dir = workspace_root / "platform" / "svc-auth"
    repo_dir.mkdir(parents=True)
    (repo_dir / ".git").mkdir()

    config = MetagitConfig(
        name="test",
        kind="application",
        workspace={
            "projects": [
                {
                    "name": "platform",
                    "repos": [
                        {
                            "name": "svc-auth",
                            "url": "https://example.com/org/svc-auth.git",
                            "sync": True,
                        }
                    ],
                }
            ]
        },
    )
    service = WorkspaceIndexService()
    rows = service.build_index(config=config, workspace_root=str(workspace_root))

    assert len(rows) == 1
    row = rows[0]
    assert row["configured_path"] is None
    assert row["repo_path"] == str(repo_dir.resolve())
    assert row["exists"] is True
    assert row["status"] == "synced"


def test_build_index_missing_path_is_configured_missing(tmp_path: Path) -> None:
    workspace_root = tmp_path / "workspace"
    workspace_root.mkdir()

    config = MetagitConfig(
        name="test",
        kind="application",
        workspace={
            "projects": [
                {
                    "name": "default",
                    "repos": [
                        {
                            "name": "ghost-repo",
                            "path": "./not-on-disk",
                            "kind": "repository",
                            "sync": False,
                        }
                    ],
                }
            ]
        },
    )
    service = WorkspaceIndexService()
    rows = service.build_index(config=config, workspace_root=str(workspace_root))

    assert len(rows) == 1
    row = rows[0]
    assert row["exists"] is False
    assert row["is_git_repo"] is False
    assert row["status"] == "configured_missing"
`````

## File: web/src/components/Layout.tsx
`````typescript
import { NavLink, Outlet } from 'react-router-dom'
import { useThemeStore } from '../theme/useThemeStore'
import styles from './Layout.module.css'

const navLinkClass = ({ isActive }: { isActive: boolean }) =>
  isActive ? `${styles.navLink} ${styles.navLinkActive}` : styles.navLink

export default function Layout() {
  const resolved = useThemeStore((state) => state.resolved)
  const toggleResolved = useThemeStore((state) => state.toggleResolved)

  return (
    <div className={styles.shell}>
      <header className={styles.header}>
        <h1 className={styles.title}>Metagit Web</h1>
        <nav className={styles.nav} aria-label="Main">
          <NavLink to="/workspace" className={navLinkClass}>
            Workspace
          </NavLink>
          <NavLink to="/config/metagit" className={navLinkClass}>
            Metagit config
          </NavLink>
          <NavLink to="/config/appconfig" className={navLinkClass}>
            App config
          </NavLink>
        </nav>
        <button
          type="button"
          className={styles.themeToggle}
          onClick={toggleResolved}
          aria-label={`Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`}
          title={`Switch to ${resolved === 'dark' ? 'light' : 'dark'} theme`}
        >
          {resolved === 'dark' ? (
            <span className={styles.themeIcon} aria-hidden>
              ☀
            </span>
          ) : (
            <span className={styles.themeIcon} aria-hidden>
              ☾
            </span>
          )}
        </button>
      </header>
      <main className={styles.main}>
        <Outlet />
      </main>
    </div>
  )
}
`````

## File: web/src/components/SchemaTree.module.css
`````css
.tree {
  list-style: none;
  margin: 0;
  padding: 0;
}

.nested {
  margin-left: 1rem;
  padding-left: 0.5rem;
  border-left: 1px solid var(--color-border);
}

.row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.35rem 0.5rem;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: var(--transition-theme);
}

.row:hover {
  background: var(--color-tree-hover);
}

.rowSelected {
  background: var(--color-tree-selected);
}

.rowDisabled {
  opacity: 0.5;
}

.checkbox {
  flex-shrink: 0;
  width: 1rem;
  height: 1rem;
  accent-color: var(--color-accent);
}

.checkboxPlaceholder {
  width: 1rem;
  flex-shrink: 0;
}

.label {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
}

.key {
  font-weight: 500;
  color: var(--color-text);
}

.type {
  font-size: 0.75rem;
  color: var(--color-text-subtle);
  font-family: var(--font-mono);
}

.required {
  font-size: 0.65rem;
  color: var(--color-accent);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.state {
  margin: 1rem 0;
  color: var(--color-text-muted);
}

.error {
  color: var(--color-danger);
}

.expandBtn {
  flex-shrink: 0;
  width: 1.5rem;
  height: 1.5rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  background: var(--color-bg-muted);
  color: var(--color-text-muted);
  cursor: pointer;
  font-size: 0.85rem;
  line-height: 1;
  padding: 0;
}

.expandBtn:hover {
  border-color: var(--color-border-strong);
  color: var(--color-text);
}

.listBtn {
  flex-shrink: 0;
  width: 1.5rem;
  height: 1.5rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  background: var(--color-bg-muted);
  color: var(--color-accent);
  cursor: pointer;
  font-size: 0.95rem;
  line-height: 1;
  padding: 0;
}

.listBtn:hover {
  border-color: var(--color-accent);
}

.listBtnDanger {
  color: var(--color-danger);
}

.listBtnDanger:hover {
  border-color: var(--color-danger);
}

.count {
  font-size: 0.7rem;
  color: var(--color-text-subtle);
}
`````

## File: web/src/components/SchemaTree.tsx
`````typescript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useState } from 'react'
import type { ConfigOperation, SchemaFieldNode } from '../api/client'
import {
  configTreeQueryKey,
  fetchConfigTree,
  patchConfigTree,
  type ConfigTarget,
} from '../pages/configQueries'
import styles from './SchemaTree.module.css'

interface SchemaTreeProps {
  target: ConfigTarget
  selectedPath: string | null
  onSelect: (node: SchemaFieldNode) => void
  onOperationApplied?: (op: ConfigOperation) => void
}

function isOptionalToggleable(node: SchemaFieldNode): boolean {
  return !node.required && node.path !== ''
}

function displayType(node: SchemaFieldNode): string {
  return node.type_label ?? node.type
}

function isListItemNode(node: SchemaFieldNode): boolean {
  return /^\[\d+\]$/.test(node.key)
}

export default function SchemaTree({
  target,
  selectedPath,
  onSelect,
  onOperationApplied,
}: SchemaTreeProps) {
  const queryClient = useQueryClient()
  const queryKey = configTreeQueryKey(target)

  const { data, isLoading, isError, error } = useQuery({
    queryKey,
    queryFn: () => fetchConfigTree(target),
  })

  const mutation = useMutation({
    mutationFn: (op: ConfigOperation) => patchConfigTree(target, [op], false),
    onSuccess: (response, op) => {
      queryClient.setQueryData(queryKey, response)
      onOperationApplied?.(op)
    },
  })

  const handleToggle = useCallback(
    (node: SchemaFieldNode, checked: boolean) => {
      mutation.mutate({
        op: checked ? 'enable' : 'disable',
        path: node.path,
      })
    },
    [mutation],
  )

  const handleAppend = useCallback(
    (node: SchemaFieldNode) => {
      mutation.mutate({ op: 'append', path: node.path })
    },
    [mutation],
  )

  const handleRemove = useCallback(
    (node: SchemaFieldNode) => {
      mutation.mutate({ op: 'remove', path: node.path })
    },
    [mutation],
  )

  if (isLoading) {
    return <p className={styles.state}>Loading schema…</p>
  }

  if (isError) {
    return (
      <p className={`${styles.state} ${styles.error}`}>
        {error instanceof Error ? error.message : 'Failed to load schema'}
      </p>
    )
  }

  if (!data?.tree) {
    return <p className={styles.state}>No schema data</p>
  }

  return (
    <ul className={styles.tree} role="tree" aria-label="Configuration fields">
      <TreeNodes
        nodes={data.tree.children ?? []}
        selectedPath={selectedPath}
        onSelect={onSelect}
        onToggle={handleToggle}
        onAppend={handleAppend}
        onRemove={handleRemove}
        mutationPending={mutation.isPending}
      />
    </ul>
  )
}

interface TreeNodesProps {
  nodes: SchemaFieldNode[]
  selectedPath: string | null
  onSelect: (node: SchemaFieldNode) => void
  onToggle: (node: SchemaFieldNode, checked: boolean) => void
  onAppend: (node: SchemaFieldNode) => void
  onRemove: (node: SchemaFieldNode) => void
  mutationPending: boolean
}

function TreeNodes({
  nodes,
  selectedPath,
  onSelect,
  onToggle,
  onAppend,
  onRemove,
  mutationPending,
}: TreeNodesProps) {
  return (
    <>
      {nodes.map((node) => (
        <TreeNode
          key={node.path || node.key}
          node={node}
          selectedPath={selectedPath}
          onSelect={onSelect}
          onToggle={onToggle}
          onAppend={onAppend}
          onRemove={onRemove}
          mutationPending={mutationPending}
        />
      ))}
    </>
  )
}

interface TreeNodeProps {
  node: SchemaFieldNode
  selectedPath: string | null
  onSelect: (node: SchemaFieldNode) => void
  onToggle: (node: SchemaFieldNode, checked: boolean) => void
  onAppend: (node: SchemaFieldNode) => void
  onRemove: (node: SchemaFieldNode) => void
  mutationPending: boolean
}

function TreeNode({
  node,
  selectedPath,
  onSelect,
  onToggle,
  onAppend,
  onRemove,
  mutationPending,
}: TreeNodeProps) {
  const [expanded, setExpanded] = useState(true)
  const hasChildren = (node.children?.length ?? 0) > 0
  const showToggle = isOptionalToggleable(node)
  const isSelected = selectedPath === node.path
  const isArray = node.type === 'array'
  const isListItem = isListItemNode(node)
  const rowClass = [
    styles.row,
    isSelected ? styles.rowSelected : '',
    node.enabled === false ? styles.rowDisabled : '',
  ]
    .filter(Boolean)
    .join(' ')

  return (
    <li role="treeitem" aria-expanded={hasChildren ? expanded : undefined}>
      <div
        className={rowClass}
        onClick={() => onSelect(node)}
        onKeyDown={(event) => {
          if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault()
            onSelect(node)
          }
        }}
        role="button"
        tabIndex={0}
      >
        {showToggle ? (
          <input
            type="checkbox"
            className={styles.checkbox}
            checked={node.enabled ?? false}
            disabled={mutationPending}
            aria-label={`${node.enabled ? 'Disable' : 'Enable'} ${node.key}`}
            onClick={(event) => event.stopPropagation()}
            onChange={(event) => {
              event.stopPropagation()
              onToggle(node, event.target.checked)
            }}
          />
        ) : (
          <span className={styles.checkboxPlaceholder} aria-hidden />
        )}
        <span className={styles.label}>
          <span className={styles.key}>{node.key}</span>
          <span className={styles.type}>{displayType(node)}</span>
          {node.required ? <span className={styles.required}>required</span> : null}
          {isArray && node.enabled ? (
            <span className={styles.count}>
              {node.item_count ?? 0} item{(node.item_count ?? 0) === 1 ? '' : 's'}
            </span>
          ) : null}
        </span>
        {node.can_append ? (
          <button
            type="button"
            className={styles.listBtn}
            title="Add item"
            aria-label={`Add ${displayType(node)} item`}
            disabled={mutationPending}
            onClick={(event) => {
              event.stopPropagation()
              onAppend(node)
            }}
          >
            +
          </button>
        ) : null}
        {isListItem ? (
          <button
            type="button"
            className={`${styles.listBtn} ${styles.listBtnDanger}`}
            title="Remove item"
            aria-label={`Remove ${node.path}`}
            disabled={mutationPending}
            onClick={(event) => {
              event.stopPropagation()
              onRemove(node)
            }}
          >
            ×
          </button>
        ) : null}
        {hasChildren ? (
          <button
            type="button"
            className={styles.expandBtn}
            aria-label={expanded ? 'Collapse' : 'Expand'}
            onClick={(event) => {
              event.stopPropagation()
              setExpanded((value) => !value)
            }}
          >
            {expanded ? '−' : '+'}
          </button>
        ) : null}
      </div>
      {hasChildren && expanded ? (
        <ul className={`${styles.tree} ${styles.nested}`}>
          <TreeNodes
            nodes={node.children ?? []}
            selectedPath={selectedPath}
            onSelect={onSelect}
            onToggle={onToggle}
            onAppend={onAppend}
            onRemove={onRemove}
            mutationPending={mutationPending}
          />
        </ul>
      ) : null}
    </li>
  )
}
`````

## File: web/src/pages/AppconfigPage.tsx
`````typescript
import ConfigPage from './ConfigPage'

export default function AppconfigPage() {
  return <ConfigPage target="appconfig" title="App config" />
}
`````

## File: web/src/pages/ConfigPage.module.css
`````css
.page {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.header {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.75rem 1.5rem;
}

.title {
  margin: 0;
  font-size: 1.5rem;
  font-weight: 600;
}

.subtitle {
  margin: 0.25rem 0 0;
  color: var(--color-text-muted);
  font-size: 0.9rem;
  font-family: var(--font-mono);
  word-break: break-all;
}

.layout {
  display: grid;
  grid-template-columns: minmax(14rem, 0.9fr) minmax(16rem, 1fr) minmax(16rem, 1fr);
  gap: 1.25rem;
  align-items: start;
}

@media (max-width: 1200px) {
  .layout {
    grid-template-columns: minmax(14rem, 1fr) minmax(16rem, 1.1fr);
  }

  .layout > :last-child {
    grid-column: 1 / -1;
  }
}

@media (max-width: 900px) {
  .layout {
    grid-template-columns: 1fr;
  }

  .layout > :last-child {
    grid-column: auto;
  }
}

.treePanel {
  padding: 1rem;
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  max-height: calc(100vh - 12rem);
  overflow: auto;
  transition: var(--transition-theme);
}

.treeHeading {
  margin: 0 0 0.75rem;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--color-text-subtle);
}
`````

## File: web/src/pages/ConfigPage.tsx
`````typescript
import { useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import type { ConfigOperation, SchemaFieldNode } from '../api/client'
import ConfigPreview from '../components/ConfigPreview'
import FieldEditor from '../components/FieldEditor'
import SchemaTree from '../components/SchemaTree'
import {
  configTreeQueryKey,
  fetchConfigTree,
  type ConfigTarget,
} from './configQueries'
import styles from './ConfigPage.module.css'

interface ConfigPageProps {
  target: ConfigTarget
  title: string
}

function findNodeByPath(
  root: SchemaFieldNode | undefined,
  path: string | null,
): SchemaFieldNode | null {
  if (!root || path === null) {
    return null
  }
  if (path === '' || path === root.path) {
    return root
  }
  for (const child of root.children ?? []) {
    const found = findNodeByPath(child, path)
    if (found) {
      return found
    }
  }
  return null
}

function mergePendingOp(
  pending: ConfigOperation[],
  op: ConfigOperation,
): ConfigOperation[] {
  const next = [...pending]
  const index = next.findIndex((item) => item.path === op.path)
  if (index >= 0) {
    next[index] = op
  } else {
    next.push(op)
  }
  return next
}

export default function ConfigPage({ target, title }: ConfigPageProps) {
  const [selectedPath, setSelectedPath] = useState<string | null>(null)
  const [pendingOps, setPendingOps] = useState<ConfigOperation[]>([])

  const { data } = useQuery({
    queryKey: configTreeQueryKey(target),
    queryFn: () => fetchConfigTree(target),
  })

  const selectedNode = useMemo(
    () => findNodeByPath(data?.tree, selectedPath),
    [data?.tree, selectedPath],
  )

  const handleSelect = useCallback((node: SchemaFieldNode) => {
    setSelectedPath(node.path)
  }, [])

  const handleOperationApplied = useCallback((op: ConfigOperation) => {
    setPendingOps((current) => mergePendingOp(current, op))
  }, [])

  return (
    <section className={styles.page}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>{title}</h2>
          {data?.config_path ? (
            <p className={styles.subtitle}>{data.config_path}</p>
          ) : null}
        </div>
      </header>

      <div className={styles.layout}>
        <aside className={styles.treePanel}>
          <h3 className={styles.treeHeading}>Schema</h3>
          <SchemaTree
            target={target}
            selectedPath={selectedPath}
            onSelect={handleSelect}
            onOperationApplied={handleOperationApplied}
          />
        </aside>
        <FieldEditor
          target={target}
          node={selectedNode}
          pendingOps={pendingOps}
          onPendingChange={setPendingOps}
        />
        <ConfigPreview target={target} pendingOps={pendingOps} />
      </div>
    </section>
  )
}
`````

## File: web/src/pages/configQueries.ts
`````typescript
import {
  getAppconfigTree,
  getMetagitConfigTree,
  patchAppconfig,
  patchMetagitConfig,
  postConfigPreview,
  type ConfigOperation,
  type ConfigPreviewResponse,
  type ConfigPreviewStyle,
  type ConfigTreeResponse,
} from '../api/client'

export type ConfigTarget = 'metagit' | 'appconfig'

export const configTreeQueryKey = (target: ConfigTarget) =>
  ['config-tree', target] as const

export function fetchConfigTree(target: ConfigTarget): Promise<ConfigTreeResponse> {
  return target === 'metagit' ? getMetagitConfigTree() : getAppconfigTree()
}

export function patchConfigTree(
  target: ConfigTarget,
  ops: ConfigOperation[],
  save: boolean,
): Promise<ConfigTreeResponse> {
  return target === 'metagit'
    ? patchMetagitConfig(ops, save)
    : patchAppconfig(ops, save)
}

export function fetchConfigPreview(
  target: ConfigTarget,
  style: ConfigPreviewStyle,
  operations: ConfigOperation[],
): Promise<ConfigPreviewResponse> {
  return postConfigPreview(target, style, operations)
}
`````

## File: web/src/pages/MetagitConfigPage.tsx
`````typescript
import ConfigPage from './ConfigPage'

export default function MetagitConfigPage() {
  return <ConfigPage target="metagit" title="Metagit config" />
}
`````

## File: web/src/pages/WorkspacePage.module.css
`````css
.page {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.header {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.75rem 1.5rem;
}

.title {
  margin: 0;
  font-size: 1.5rem;
  font-weight: 600;
}

.subtitle {
  margin: 0.25rem 0 0;
  color: var(--color-text-muted);
  font-size: 0.9rem;
  font-family: var(--font-mono);
  word-break: break-all;
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.chip {
  padding: 0.35rem 0.75rem;
  border-radius: var(--radius-md);
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  font-size: 0.85rem;
  color: var(--color-text-muted);
  transition: var(--transition-theme);
}

.chip strong {
  color: var(--color-text);
  margin-right: 0.25rem;
}

.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem 1rem;
}

.tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}

.tab {
  padding: 0.4rem 0.85rem;
  border-radius: var(--radius-md);
  border: 1px solid var(--color-border);
  background: var(--color-bg-elevated);
  color: var(--color-text-muted);
  font-size: 0.85rem;
  cursor: pointer;
  transition: var(--transition-theme);
}

.tab:hover {
  border-color: var(--color-border-strong);
  color: var(--color-text);
}

.tabActive {
  background: var(--color-accent-soft);
  border-color: var(--color-accent);
  color: var(--color-accent);
}

.search {
  flex: 1;
  min-width: 12rem;
  max-width: 22rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg-elevated);
  color: var(--color-text);
}

.search:focus {
  outline: none;
  border-color: var(--color-accent);
  box-shadow: 0 0 0 3px var(--color-focus);
}

.layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(14rem, 18rem);
  gap: 1.25rem;
  align-items: start;
}

@media (max-width: 960px) {
  .layout {
    grid-template-columns: 1fr;
  }
}

.loading,
.error {
  margin: 0;
  padding: 1rem;
  border-radius: var(--radius-md);
}

.loading {
  color: var(--color-text-muted);
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
}

.error {
  color: var(--color-danger);
  background: var(--color-danger-soft);
  border: 1px solid var(--color-danger);
}

.graphFilters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 1.25rem;
  align-items: center;
}

.checkLabel {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.85rem;
  color: var(--color-text-muted);
  cursor: pointer;
}

.graphPanel {
  min-width: 0;
}
`````

## File: web/src/theme/tokens.css
`````css
:root {
  color-scheme: light dark;

  --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial,
    sans-serif;
  --font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
    monospace;

  --radius-sm: 0.25rem;
  --radius-md: 0.5rem;
  --radius-lg: 0.75rem;

  --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
  --shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08);

  --transition-theme: color 0.2s ease, background-color 0.2s ease,
    border-color 0.2s ease, box-shadow 0.2s ease;
}

:root,
[data-theme='light'] {
  --color-bg: #f6f7fb;
  --color-bg-elevated: #ffffff;
  --color-bg-muted: #eef1f6;
  --color-border: #d8dee9;
  --color-border-strong: #c5cedb;
  --color-text: #0f172a;
  --color-text-muted: #5b6475;
  --color-text-subtle: #7c8798;
  --color-accent: #4f46e5;
  --color-accent-hover: #4338ca;
  --color-accent-soft: rgba(79, 70, 229, 0.12);
  --color-danger: #dc2626;
  --color-danger-soft: rgba(220, 38, 38, 0.1);
  --color-success: #059669;
  --color-focus: rgba(79, 70, 229, 0.35);
  --color-tree-hover: rgba(79, 70, 229, 0.08);
  --color-tree-selected: rgba(79, 70, 229, 0.16);
  --graph-edge-manual: #7c3aed;
  --graph-edge-inferred: #0ea5e9;
  --graph-edge-structure: #94a3b8;
}

[data-theme='dark'] {
  --color-bg: #0f1117;
  --color-bg-elevated: #171a22;
  --color-bg-muted: #1f2430;
  --color-border: #2a3140;
  --color-border-strong: #3a4356;
  --color-text: rgba(255, 255, 255, 0.92);
  --color-text-muted: rgba(255, 255, 255, 0.65);
  --color-text-subtle: rgba(255, 255, 255, 0.45);
  --color-accent: #818cf8;
  --color-accent-hover: #a5b4fc;
  --color-accent-soft: rgba(129, 140, 248, 0.18);
  --color-danger: #f87171;
  --color-danger-soft: rgba(248, 113, 113, 0.15);
  --color-success: #34d399;
  --color-focus: rgba(129, 140, 248, 0.4);
  --color-tree-hover: rgba(129, 140, 248, 0.12);
  --color-tree-selected: rgba(129, 140, 248, 0.22);
  --graph-edge-manual: #a78bfa;
  --graph-edge-inferred: #38bdf8;
  --graph-edge-structure: #64748b;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --color-bg: #0f1117;
    --color-bg-elevated: #171a22;
    --color-bg-muted: #1f2430;
    --color-border: #2a3140;
    --color-border-strong: #3a4356;
    --color-text: rgba(255, 255, 255, 0.92);
    --color-text-muted: rgba(255, 255, 255, 0.65);
    --color-text-subtle: rgba(255, 255, 255, 0.45);
    --color-accent: #818cf8;
    --color-accent-hover: #a5b4fc;
    --color-accent-soft: rgba(129, 140, 248, 0.18);
    --color-danger: #f87171;
    --color-danger-soft: rgba(248, 113, 113, 0.15);
    --color-success: #34d399;
    --color-focus: rgba(129, 140, 248, 0.4);
    --color-tree-hover: rgba(129, 140, 248, 0.12);
    --color-tree-selected: rgba(129, 140, 248, 0.22);
    --graph-edge-manual: #a78bfa;
    --graph-edge-inferred: #38bdf8;
    --graph-edge-structure: #64748b;
  }
}
`````

## File: web/src/App.css
`````css
/* Route-level styles live in page/component CSS modules. */
`````

## File: web/src/App.tsx
`````typescript
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import Layout from './components/Layout'
import ThemeProvider from './theme/ThemeProvider'
import AppconfigPage from './pages/AppconfigPage'
import MetagitConfigPage from './pages/MetagitConfigPage'
import WorkspacePage from './pages/WorkspacePage'
import './App.css'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      retry: 1,
    },
  },
})

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>
        <BrowserRouter>
          <Routes>
            <Route element={<Layout />}>
              <Route index element={<Navigate to="/workspace" replace />} />
              <Route path="/workspace" element={<WorkspacePage />} />
              <Route path="/config/metagit" element={<MetagitConfigPage />} />
              <Route path="/config/appconfig" element={<AppconfigPage />} />
            </Route>
          </Routes>
        </BrowserRouter>
      </ThemeProvider>
    </QueryClientProvider>
  )
}
`````

## File: web/src/index.css
`````css
* {
  box-sizing: border-box;
}

html {
  transition: var(--transition-theme);
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
  font-family: var(--font-sans);
  line-height: 1.5;
  font-weight: 400;
  color: var(--color-text);
  background-color: var(--color-bg);
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  transition: var(--transition-theme);
}

#root {
  min-height: 100vh;
}

button,
input,
select,
textarea {
  font: inherit;
}
`````

## File: web/src/main.tsx
`````typescript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './theme/tokens.css'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
`````

## File: .metagit.example.yml
`````yaml
name: metagit-cli
description: Metagit-ai multi-repo management tool
url: https://github.com/metagit-ai/metagit-cli
kind: cli
documentation:
  - https://metagit-ai.github.io/metagit-cli/
license:
  kind: Apache-2.0
  file: LICENSE
maintainers:
  - name: Zachary Loeber
    email: zloeber@gmail.com
    role: author
branch_strategy: githubflow
taskers:
  - kind: Taskfile
paths:
  - name: metagit-cli
    description: The main CLI application
    kind: cli
    path: src/metagit
    language: python
    language_version: "3.13"
    package_manager: uv
    frameworks:
      - click
      - pydantic
      - loguru
cicd:
  platform: GitHub
  pipelines:
    - name: test
      ref: .github/workflows/test.yaml
    - name: release
      ref: .github/workflows/release.yaml
    - name: docker
      ref: .github/workflows/docker.yaml
    - name: docs
      ref: .github/workflows/docs.yaml
artifacts:
  - type: docker
    definition: Dockerfile
    location: ghcr.io/metagit-ai/metagit-cli
    version_strategy: semver
  - type: python_package
    definition: pyproject.toml
    location: https://pypi.org/project/metagit-cli/
    version_strategy: semver
deployment:
  strategy: pipeline
observability:
  logging_provider: console
  monitoring_providers:
    - sentry
secrets_management:
  - github_secrets
secrets:
  - name: GITHUB_TOKEN
    kind: access_token
    ref: ${{ secrets.GITHUB_TOKEN }}
  - name: PYPI_API_TOKEN
    kind: api_key
    ref: ${{ secrets.PYPI_API_TOKEN }}
`````

## File: AGENTS.md
`````markdown
<!-- gitnexus:start -->
# GitNexus — Code Intelligence

This project is indexed by GitNexus as **metagit-cli** (3672 symbols, 5447 relationships, 73 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.

> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

## Always Do

- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.

## Repo quality gate

- **`task qa:prepush`** from the repo root must run successfully before you report implementation work as complete whenever this session modified files in the tree (see `scripts/prepush-gate.py`). Fix failures and re-run. Skip only when the session made no edits or the user waived the gate in this thread.

## Never Do

- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.

## Resources

| Resource | Use for |
|----------|---------|
| `gitnexus://repo/metagit-cli/context` | Codebase overview, check index freshness |
| `gitnexus://repo/metagit-cli/clusters` | All functional areas |
| `gitnexus://repo/metagit-cli/processes` | All execution flows |
| `gitnexus://repo/metagit-cli/process/{name}` | Step-by-step execution trace |

## CLI

| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
| Work in the Examples area (87 symbols) | `.claude/skills/generated/examples/SKILL.md` |
| Work in the Mcp area (40 symbols) | `.claude/skills/generated/mcp/SKILL.md` |
| Work in the Commands area (38 symbols) | `.claude/skills/generated/commands/SKILL.md` |
| Work in the Record area (26 symbols) | `.claude/skills/generated/record/SKILL.md` |
| Work in the Services area (20 symbols) | `.claude/skills/generated/services/SKILL.md` |
| Work in the Providers area (16 symbols) | `.claude/skills/generated/providers/SKILL.md` |
| Work in the Cluster_45 area (11 symbols) | `.claude/skills/generated/cluster-45/SKILL.md` |
| Work in the Cluster_53 area (11 symbols) | `.claude/skills/generated/cluster-53/SKILL.md` |
| Work in the Cluster_41 area (8 symbols) | `.claude/skills/generated/cluster-41/SKILL.md` |
| Work in the Detect area (8 symbols) | `.claude/skills/generated/detect/SKILL.md` |
| Work in the Gitcache area (8 symbols) | `.claude/skills/generated/gitcache/SKILL.md` |
| Work in the Detectors area (7 symbols) | `.claude/skills/generated/detectors/SKILL.md` |
| Work in the Config area (6 symbols) | `.claude/skills/generated/config/SKILL.md` |
| Work in the Project area (6 symbols) | `.claude/skills/generated/project/SKILL.md` |
| Work in the Tests area (6 symbols) | `.claude/skills/generated/tests/SKILL.md` |
| Work in the Appconfig area (5 symbols) | `.claude/skills/generated/appconfig/SKILL.md` |
| Work in the Cluster_58 area (5 symbols) | `.claude/skills/generated/cluster-58/SKILL.md` |
| Work in the Cli area (4 symbols) | `.claude/skills/generated/cli/SKILL.md` |
| Work in the Cluster_52 area (4 symbols) | `.claude/skills/generated/cluster-52/SKILL.md` |

<!-- gitnexus:end -->
`````

## File: .cursor/skills/metagit-release-audit/SKILL.md
`````markdown
---
name: metagit-release-audit
description: Mandatory before calling work complete when the session changed repo files. Runs format, lint, tests, integration tests, context-aware pip-audit/bandit, and optional gitleaks via task qa:prepush. Use before push, release, or hand-off.
---

# Auditing Release Readiness

Use this skill **whenever** your session added or edited tracked files in this repository and you are about to hand off or say the task is done—not only “release” workflows. Read-only Q&A with no writes can skip it.

## Workflow

1. Run the pre-push quality gate.
2. Capture failing stage logs and iterate fixes.
3. Re-run until all required checks pass.
4. Return a short readiness summary.

## Commands

- `task qa:prepush`
- `task qa:prepush:loop -- 3` (optional bounded retry loop)

## Output Contract

Return:
- pass/fail status by stage (including `security_sync` / `security_audit` / `security_bandit` when triggered)
- unresolved blockers (if any)
- push readiness recommendation

Security in the gate is context-aware: lockfile changes run `uv sync --frozen --all-extras` + `pip-audit` + `bandit`; `src/` changes run `pip-audit` + `bandit`; docs-only diffs skip security. Use `task security:scan` for a full manual run.

## Safety

- Do not claim readiness unless checks are actually green.
`````

## File: .mex/context/mcp-runtime.md
`````markdown
---
name: mcp-runtime
description: Metagit MCP runtime architecture, tool/resource dispatch, gating, and sampling behavior.
triggers:
  - "mcp runtime"
  - "tools/list"
  - "tools/call"
  - "resources/read"
  - "sampling"
  - "stdio framing"
edges:
  - target: context/architecture.md
    condition: when MCP changes affect broader CLI/core system flow
  - target: context/conventions.md
    condition: when implementing or reviewing runtime/service code patterns
  - target: context/stack.md
    condition: when protocol/library/runtime version constraints matter
  - target: patterns/add-mcp-tool.md
    condition: when adding a new MCP tool or changing tool schemas
  - target: patterns/debug-mcp-runtime.md
    condition: when MCP message loop, framing, or tool dispatch fails
last_updated: 2026-05-15
---

# MCP Runtime

## Overview
- Entry command is `metagit mcp serve` from `src/metagit/cli/commands/mcp.py`.
- Runtime implementation lives in `src/metagit/core/mcp/runtime.py`.
- Runtime uses stdio JSON-RPC with MCP framing (`Content-Length` header + body).
- Gate state is resolved from workspace root + `.metagit.yml` validation before exposing tools.

## Active Runtime Services
- `WorkspaceRootResolver` + `WorkspaceGate` + `ToolRegistry` for state-aware tool visibility.
- Workspace services: index, path-based workspace search (`metagit_workspace_search`), GitNexus semantic query fan-out (`metagit_workspace_semantic_search`), upstream hints.
- `ManagedRepoSearchService` for `metagit_repo_search` (managed `.metagit.yml` repos only, with tags/status).
- Repo ops service for inspect/sync with mutation guardrails.
- Project context service (`metagit_project_context_switch`, `metagit_session_update`) with session store under `.metagit/sessions/`.
- Workspace snapshot service (`metagit_workspace_state_snapshot`, `metagit_workspace_state_restore`) under `.metagit/snapshots/`.
- Workspace search uses ripgrep when `rg` is on PATH; `metagit_workspace_sync` batches guarded fetch/pull/clone across index rows.
- `metagit_cross_project_dependencies` combines config-declared edges, manifest import hints, shared URL/path detection, and GitNexus per-repo index status.
- Phase 3: `metagit_workspace_health_check` (branch-age / integration staleness when enabled), `metagit_workspace_discover`, `metagit_project_template_apply`, resources `metagit://workspace/health` and `metagit://workspace/context`.
- Resource publisher for config/repo-status/ops-log resources.
- Bootstrap sampling service with fallback and optional client sampling flow.

## Protocol Notes
- Supported methods: `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `ping`.
- Invalid tool arguments normalize to JSON-RPC error `-32602` with `data.kind=invalid_arguments`.
- Runtime tracks client sampling capability from initialize params and can call `sampling/createMessage`.

## Guardrails
- Inactive gate state only exposes safe baseline tools.
- Mutating sync operations require explicit opt-in arguments/flags.
- Tool schemas are explicitly published in `tools/list` and should stay aligned with dispatcher validation.
`````

## File: docs/reference/metagit-config.full-example.yml
`````yaml
# NON-PRODUCTION EXEMPLAR — generated by `metagit config example`.
# Field descriptions come from Pydantic models; edit before use.

# Project name
name: example-umbrella
...
# Project description
description: Example umbrella workspace for documentation (not for production).
# Optional instructions for controller agents; applies with or without a workspace block
agent_instructions: 'Hermes controller example: search the workspace before creating folders or clones.\nUse
  metagit_workspace_status and metagit_workspace_health_check at session start.\n'
# Project URL
url: https://github.com/example-org/hermes-control-plane.git
...
# Project kind. This is used to determine the type of project and the best way to manage it.
kind: umbrella
# Documentation sources: bare strings (path or URL) or objects with kind, path/url, tags, and metadata for knowledge-graph ingestion
documentation:
  -
    # Source type (markdown, web, confluence, sharepoint, wiki, api, other)
    kind: example-value
...
    # Repo-relative or absolute path to a documentation file
    path: example-value
...
    # Remote documentation URL
    url: https://example.com/org/repo
...
    # Human-readable title
    title: example-value
...
    # Short summary for indexing and graph nodes
    description: Example text for documentation; replace before production use.
    # Flat metadata tags for filtering and graph edges
    tags:
      example: documentation
    # Extensible key-value payload for downstream graph ingestors
    metadata:
      source: metagit-config-example
...
# Manual cross-repo relationships and graph metadata for exports and GitNexus-style dependency maps
graph:
  # Manually entered cross-repo or cross-project edges
  relationships:
    -
      # Stable identifier for exports and graph merges
      id: example-value
...
      # Relationship source
      from_endpoint:
        # Workspace project name
        project: example-value
...
        # Repository name under the project
        repo: example-value
...
        # Optional file or directory path within the repo
        path: example-value
...
      # Relationship target
      to:
        # Workspace project name
        project: example-value
...
        # Repository name under the project
        repo: example-value
...
        # Optional file or directory path within the repo
        path: example-value
...
      # Relationship type (depends_on, documents, consumes, owns, related, …)
      type: example-value
...
      # Short label for graph UIs
      label: example-value
...
      # Longer explanation for agents and exports
      description: Example text for documentation; replace before production use.
      # Flat tags for filtering graph exports
      tags:
        example: documentation
      # Extensible payload for GitNexus or other graph ingestors
      metadata:
        source: metagit-config-example
...
  # Graph-level metadata for export pipelines
  metadata:
    source: metagit-config-example
...
# License information
license:
  # License type
  kind: None
  # License file path
  file: example
# Project maintainers
maintainers:
  -
    # Maintainer name
    name: example-name
...
    # Maintainer email
    email: maintainer@example.com
...
    # Maintainer role
    role: example
# Branch strategy used by the project.
branch_strategy: trunk
# Task management tools employed by the project.
taskers:
  -
    # Tasker type
    kind: Taskfile
# Branch naming patterns used by the project.
branch_naming:
  -
    # Branch strategy
    kind: trunk
    # Branch naming pattern
    pattern: example
# Generated artifacts from the project.
artifacts:
  -
    # Artifact type
    type: docker
    # Artifact definition
    definition: example
    # Artifact location
    location: example
    # Version strategy
    version_strategy: semver
# Secrets management tools employed by the project.
secrets_management:
  - example-value
...
# Secret definitions
secrets:
  -
    # Secret name
    name: example-name
...
    # Secret type
    kind: remote_jwt
    # Secret reference
    ref: example
# Variable definitions
variables:
  -
    # Variable name
    name: example-name
...
    # Variable type
    kind: string
    # Variable reference
    ref: example
# CI/CD configuration
cicd:
  # CI/CD platform
  platform: GitHub
  # List of pipelines
  pipelines:
    -
      # Pipeline name
      name: example-name
...
      # Pipeline reference
      ref: example
      # Pipeline variables
      variables:
        - example-value
...
# Deployment configuration
deployment:
  # Deployment strategy
  strategy: blue/green
  # Deployment environments
  environments:
    -
      # Environment name
      name: example-name
...
      # Environment URL
      url: https://example.com/org/repo
...
  # Infrastructure configuration
  infrastructure:
    # Provisioning tool
    provisioning_tool: Terraform
    # Hosting platform
    hosting: EC2
# Observability configuration
observability:
  # Logging provider
  logging_provider: console
  # Monitoring providers
  monitoring_providers:
    - prometheus
  # Alerting channels
  alerting_channels:
    -
      # Alerting channel name
      name: example-name
...
      # Alerting channel type
      type: slack
      # Alerting channel URL
      url: https://example.com/org/repo
...
  # Monitoring dashboards
  dashboards:
    -
      # Dashboard name
      name: example-name
...
      # Dashboard tool
      tool: example-value
...
      # Dashboard URL
      url: https://example.com/org/repo
...
# Important local project paths. In a monorepo, this would include any sub-projects typically found being built in the CICD pipelines.
paths:
  -
    # Friendly name for the path or project
    name: example-name
...
    # Short description of the path or project
    description: Example text for documentation; replace before production use.
    # Project kind
    kind: monorepo
    # Reference in the current project for the target project, used in dependencies
    ref: example
    # Project path
    path: example-value
...
    # Project branches
    branches:
      - example-value
...
    # Project URL
    url: https://example.com/org/repo
...
    # Sync setting
    sync: true
    # Programming language
    language: python
    # Language version
    language_version: 3.12
    # Package manager used by the project
    package_manager: uv
    # Frameworks used by the project
    frameworks:
      - example-value
...
    # Provider used to discover this repository
    source_provider: example-value
...
    # Source namespace identifier (org/user/group)
    source_namespace: example-value
...
    # Provider-native repository identifier
    source_repo_id: example-value
...
    # Flat metadata tags for managed repo search and filtering
    tags:
      example: documentation
    # If true, reconcile mode must not remove this repository automatically
    protected: false
    # Optional instructions for subagents operating in this repo or path
    agent_instructions: Example text for documentation; replace before production use.
# Additional project dependencies not found in the paths or components lists. These include docker images, helm charts, or terraform modules.
dependencies:
  -
    # Friendly name for the path or project
    name: example-name
...
    # Short description of the path or project
    description: Example text for documentation; replace before production use.
    # Project kind
    kind: monorepo
    # Reference in the current project for the target project, used in dependencies
    ref: example
    # Project path
    path: example-value
...
    # Project branches
    branches:
      - example-value
...
    # Project URL
    url: https://example.com/org/repo
...
    # Sync setting
    sync: true
    # Programming language
    language: python
    # Language version
    language_version: 3.12
    # Package manager used by the project
    package_manager: uv
    # Frameworks used by the project
    frameworks:
      - example-value
...
    # Provider used to discover this repository
    source_provider: example-value
...
    # Source namespace identifier (org/user/group)
    source_namespace: example-value
...
    # Provider-native repository identifier
    source_repo_id: example-value
...
    # Flat metadata tags for managed repo search and filtering
    tags:
      example: documentation
    # If true, reconcile mode must not remove this repository automatically
    protected: false
    # Optional instructions for subagents operating in this repo or path
    agent_instructions: Example text for documentation; replace before production use.
# Additional project component paths that may be useful in other projects.
components:
  -
    # Friendly name for the path or project
    name: example-name
...
    # Short description of the path or project
    description: Example text for documentation; replace before production use.
    # Project kind
    kind: monorepo
    # Reference in the current project for the target project, used in dependencies
    ref: example
    # Project path
    path: example-value
...
    # Project branches
    branches:
      - example-value
...
    # Project URL
    url: https://example.com/org/repo
...
    # Sync setting
    sync: true
    # Programming language
    language: python
    # Language version
    language_version: 3.12
    # Package manager used by the project
    package_manager: uv
    # Frameworks used by the project
    frameworks:
      - example-value
...
    # Provider used to discover this repository
    source_provider: example-value
...
    # Source namespace identifier (org/user/group)
    source_namespace: example-value
...
    # Provider-native repository identifier
    source_repo_id: example-value
...
    # Flat metadata tags for managed repo search and filtering
    tags:
      example: documentation
    # If true, reconcile mode must not remove this repository automatically
    protected: false
    # Optional instructions for subagents operating in this repo or path
    agent_instructions: Example text for documentation; replace before production use.
# Workspaces are a collection of projects that are related to each other. They are used to group projects together for a specific purpose. These are manually defined by the user. The internal workspace name is reservice
workspace:
  # Human-readable description of this workspace
  description: Portfolio of git repos and local publish paths managed by metagit.
  # Optional instructions for agents working in this workspace
  agent_instructions: Validate after manifest edits (metagit config validate). Default sync is fetch-only;\n
...
  # Workspace projects
  projects:
    -
      # Workspace project name
      name: portfolio
      # Human-readable description of this workspace project
      description: Git-backed applications and services.
...
      # Optional instructions for agents working in this workspace project
      agent_instructions: Delegate single-repo work to subagents with effective_agent_instructions.\n
...
      # Repository list
      repos:
        -
          # Friendly name for the path or project
          name: example-service
...
          # Short description of the path or project
          description: Sample git-backed service.
...
          # Project URL
          url: https://github.com/example-org/example-service.git
...
          # Sync setting
          sync: true
          # Optional instructions for subagents operating in this repo or path
          agent_instructions: Run tests before opening PRs; keep tags updated in this manifest.\n
    -
      # Workspace project name
      name: local
      # Human-readable description of this workspace project
      description: Local-only paths for static sites (no git remotes).
...
      # Optional override of app-config workspace.dedupe for this project (currently supports enabled only)
      dedupe:
        # When set, overrides workspace.dedupe.enabled from metagit.config.yaml for sync and layout under this project only
        enabled: false
      # Optional instructions for agents working in this workspace project
      agent_instructions: Do not git clone or pull repos in this project; paths are symlinks to user dirs.\nDedupe is disabled for this project (overrides workspace.dedupe in metagit.config.yaml).\n
      # Repository list
      repos:
        -
          # Friendly name for the path or project
          name: example-site
...
          # Short description of the path or project
          description: Sample static site published from a local folder.
          # Project path
          path: ~/Sites/example-site
...
          # Sync setting
          sync: true
          # Project kind
          kind: website
          # Optional instructions for subagents operating in this repo or path
          agent_instructions: Document build and publish steps here; update path if the site moves.\n
`````

## File: examples/fuzzyfinder_custom_colors_demo.py
`````python
#!/usr/bin/env python
"""
Example demonstrating custom colors per list item in FuzzyFinder.
This shows different ways to assign colors to individual items, including:
1. FuzzyFinderTarget objects with built-in color/opacity properties
2. Custom color mapping via custom_colors configuration
3. Mixed approaches and fallback behavior
"""

import sys
from pathlib import Path

# Add the metagit package to the path
sys.path.insert(0, str(Path(__file__).parent.parent))

from metagit.core.utils.fuzzyfinder import (
    FuzzyFinder,
    FuzzyFinderConfig,
    FuzzyFinderTarget,
)


def test_fuzzyfindertarget_with_colors():
    """Test FuzzyFinderTarget objects with built-in color and opacity properties."""
    print("🎨 Test 1: FuzzyFinderTarget Objects with Built-in Colors")
    print("-" * 55)

    # Create FuzzyFinderTarget objects with individual colors and opacity
    items = [
        FuzzyFinderTarget(
            name="Critical Bug Fix",
            description="Urgent security vulnerability needs immediate attention",
            color="bold white bg:#cc0000",  # Red background for critical
            opacity=1.0,
        ),
        FuzzyFinderTarget(
            name="Feature Development",
            description="New user dashboard implementation",
            color="bold yellow",  # Yellow for high priority
            opacity=0.9,
        ),
        FuzzyFinderTarget(
            name="Code Refactoring",
            description="Clean up legacy authentication module",
            color="green",  # Green for normal priority
            opacity=0.8,
        ),
        FuzzyFinderTarget(
            name="Documentation Update",
            description="Update API documentation and examples",
            color="#0088cc",  # Blue hex color for docs
            opacity=0.7,
        ),
        FuzzyFinderTarget(
            name="Testing Suite",
            description="Add comprehensive unit tests for new features",
            color="cyan",  # Cyan for testing
            opacity=0.9,
        ),
        FuzzyFinderTarget(
            name="Performance Optimization",
            description="Improve database query performance",
            color="magenta bg:#001122",  # Custom background
            opacity=0.85,
        ),
    ]

    # Note: custom_colors is defined but will be overridden by FuzzyFinderTarget properties
    fallback_colors = {
        "Critical Bug Fix": "red",  # This won't be used - object has its own color
        "Feature Development": "blue",  # This won't be used either
    }

    config = FuzzyFinderConfig(
        items=items,
        display_field="name",
        prompt_text="🔍 Search tasks (object colors): ",
        max_results=10,
        custom_colors=fallback_colors,  # These are fallbacks, won't be used
        item_opacity=0.5,  # This is fallback opacity, objects have their own
        highlight_color="bold white bg:#4400aa",
    )

    finder = FuzzyFinder(config)
    print("Each FuzzyFinderTarget has its own color and opacity:")
    print("- 🔴 Critical Bug: Red background, full opacity")
    print("- 🟡 Feature Dev: Yellow, 90% opacity")
    print("- 🟢 Refactoring: Green, 80% opacity")
    print("- 🔵 Documentation: Blue hex, 70% opacity")
    print("- 🟦 Testing: Cyan, 90% opacity")
    print("- 🟣 Performance: Magenta with custom background, 85% opacity")
    print("\n💡 These colors come from the objects themselves, not custom_colors!")
    print()

    result = finder.run()
    if result:
        print(f"Selected: {result.name}")
        print(f"Description: {result.description}")
        print(f"Color: {result.color}")
        print(f"Opacity: {result.opacity}")
    else:
        print("No selection made.")
    print()


def test_mixed_object_types():
    """Test mixing FuzzyFinderTarget objects with regular objects."""
    print("🎨 Test 2: Mixed Object Types (FuzzyFinderTarget + Custom Objects)")
    print("-" * 65)

    # Mix FuzzyFinderTarget objects with regular objects
    class SimpleTask:
        def __init__(self, name, priority):
            self.name = name
            self.priority = priority

    items = [
        # FuzzyFinderTarget with built-in colors
        FuzzyFinderTarget(
            name="Security Audit",
            description="Comprehensive security review",
            color="bold red",
            opacity=1.0,
        ),
        FuzzyFinderTarget(
            name="UI Polish",
            description="Final touches on user interface",
            color="#ff6600",
            opacity=0.8,
        ),
        # Regular objects that will use custom_colors mapping
        SimpleTask("Database Migration", "high"),
        SimpleTask("Email Integration", "medium"),
        SimpleTask("Backup System", "low"),
    ]

    # Custom colors for regular objects (by name)
    color_mapping = {
        "Database Migration": "bold yellow bg:#332200",
        "Email Integration": "green",
        "Backup System": "#888888",
    }

    config = FuzzyFinderConfig(
        items=items,
        display_field="name",
        custom_colors=color_mapping,
        item_opacity=0.6,  # Fallback opacity for regular objects
        prompt_text="🔍 Mixed object types: ",
        max_results=10,
        highlight_color="bold white bg:#006600",
    )

    finder = FuzzyFinder(config)
    print("Mixing FuzzyFinderTarget objects with regular objects:")
    print("- FuzzyFinderTarget objects use their own color/opacity")
    print("- Regular objects use custom_colors mapping and fallback opacity")
    print()

    result = finder.run()
    if result:
        if isinstance(result, FuzzyFinderTarget):
            print(f"Selected FuzzyFinderTarget: {result.name}")
            print(f"Built-in color: {result.color}, opacity: {result.opacity}")
        else:
            print(f"Selected regular object: {result.name}")
            print("Uses custom_colors mapping and fallback opacity")
    else:
        print("No selection made.")
    print()


def test_priority_demonstration():
    """Demonstrate the priority system: FuzzyFinderTarget properties override custom_colors."""
    print("🎨 Test 3: Priority System Demonstration")
    print("-" * 43)

    # Create items where custom_colors would conflict with object properties
    items = [
        FuzzyFinderTarget(
            name="Override Test",
            description="This item should be GREEN despite custom_colors saying red",
            color="green",  # Object says GREEN
            opacity=0.9,
        ),
        FuzzyFinderTarget(
            name="Another Override",
            description="This should be BLUE despite custom_colors",
            color="blue",  # Object says BLUE
            opacity=0.7,
        ),
        FuzzyFinderTarget(
            name="No Color Set",
            description="This will fall back to custom_colors",
            # No color/opacity set - will use fallbacks
        ),
    ]

    # Custom colors that would conflict with object properties
    conflicting_colors = {
        "Override Test": "red",  # Object overrides this with GREEN
        "Another Override": "yellow",  # Object overrides this with BLUE
        "No Color Set": "purple",  # This will be used (no object color)
    }

    config = FuzzyFinderConfig(
        items=items,
        display_field="name",
        custom_colors=conflicting_colors,
        item_opacity=0.5,  # Fallback opacity
        prompt_text="🔍 Priority system test: ",
        max_results=10,
    )

    finder = FuzzyFinder(config)
    print("Priority system in action:")
    print("- 'Override Test': GREEN (object) not red (custom_colors)")
    print("- 'Another Override': BLUE (object) not yellow (custom_colors)")
    print("- 'No Color Set': PURPLE (falls back to custom_colors)")
    print("\n💡 FuzzyFinderTarget properties always take precedence!")
    print()

    result = finder.run()
    if result:
        print(f"Selected: {result.name}")
        print(f"Object color: {result.color or 'None (uses fallback)'}")
        print(f"Object opacity: {result.opacity or 'None (uses fallback)'}")
    else:
        print("No selection made.")
    print()


def test_string_items_with_colors():
    """Test custom colors with string items."""
    print("🎨 Test 4: String Items with Custom Colors (Legacy Method)")
    print("-" * 58)

    # Create items with different priorities/types
    items = [
        "critical_bug",
        "urgent_feature",
        "normal_task",
        "low_priority",
        "documentation",
        "testing",
        "refactoring",
        "security_fix",
    ]

    # Define custom colors for different item types
    color_map = {
        "critical_bug": "bold red",
        "urgent_feature": "bold yellow",
        "security_fix": "bold red bg:#440000",
        "normal_task": "green",
        "low_priority": "#888888",
        "documentation": "blue",
        "testing": "cyan",
        "refactoring": "magenta",
    }

    config = FuzzyFinderConfig(
        items=items,
        prompt_text="🔍 Search tasks (notice colors): ",
        max_results=10,
        custom_colors=color_map,
        item_opacity=0.9,
        highlight_color="bold white bg:#0066cc",
    )

    finder = FuzzyFinder(config)
    print("Each item type has its own color:")
    print("- 🔴 Critical/Security: Red")
    print("- 🟡 Urgent: Yellow")
    print("- 🟢 Normal: Green")
    print("- ⚪ Low Priority: Gray")
    print("- 🔵 Documentation: Blue")
    print("- 🟦 Testing: Cyan")
    print("- 🟣 Refactoring: Magenta")
    print()

    result = finder.run()
    print(f"Selected: {result}" if result else "No selection made.")
    print()


def test_object_items_with_colors():
    """Test custom colors with object items."""
    print("🎨 Test 5: Object Items with Custom Colors (Legacy Method)")
    print("-" * 56)

    # Create task objects
    class Task:
        def __init__(self, name, priority, category):
            self.name = name
            self.priority = priority
            self.category = category

        def __str__(self):
            return f"{self.name} ({self.priority})"

    tasks = [
        Task("Fix login bug", "critical", "bug"),
        Task("Add user dashboard", "high", "feature"),
        Task("Update documentation", "medium", "docs"),
        Task("Write unit tests", "medium", "testing"),
        Task("Refactor auth module", "low", "refactor"),
        Task("Security audit", "critical", "security"),
        Task("Performance optimization", "high", "performance"),
        Task("UI polish", "low", "ui"),
    ]

    # Color by priority
    priority_colors = {
        "critical": "bold white bg:#cc0000",
        "high": "bold yellow",
        "medium": "green",
        "low": "#999999",
    }

    config = FuzzyFinderConfig(
        items=tasks,
        display_field="name",
        color_field="priority",  # Use priority field for color mapping
        custom_colors=priority_colors,
        prompt_text="🔍 Search tasks by priority color: ",
        max_results=10,
        item_opacity=0.85,
        highlight_color="bold white bg:#4400aa",
    )

    finder = FuzzyFinder(config)
    print("Tasks colored by priority:")
    print("- 🔴 Critical: Red background")
    print("- 🟡 High: Yellow")
    print("- 🟢 Medium: Green")
    print("- ⚪ Low: Gray")
    print()

    result = finder.run()
    if result:
        print(f"Selected: {result.name} (Priority: {result.priority})")
    else:
        print("No selection made.")
    print()


def test_mixed_color_formats():
    """Test different color format specifications."""
    print("🎨 Test 6: Mixed Color Formats")
    print("-" * 31)

    items = [
        "hex_color",
        "named_color",
        "background_only",
        "text_and_bg",
        "rich_markup",
    ]

    # Demonstrate different color formats
    color_formats = {
        "hex_color": "#ff6600",  # Hex color
        "named_color": "cyan",  # Named color
        "background_only": "bg:#ffff00",  # Background only
        "text_and_bg": "blue bg:#ffffcc",  # Text and background
        "rich_markup": "bold magenta",  # Rich markup
    }

    config = FuzzyFinderConfig(
        items=items,
        prompt_text="🎨 Different color formats: ",
        custom_colors=color_formats,
        max_results=10,
        highlight_color="bold white bg:#006600",
    )

    finder = FuzzyFinder(config)
    print("Different color format examples:")
    print("- hex_color: Orange (#ff6600)")
    print("- named_color: Cyan")
    print("- background_only: Yellow background")
    print("- text_and_bg: Blue text on light yellow")
    print("- rich_markup: Bold magenta")
    print()

    result = finder.run()
    print(f"Selected: {result}" if result else "No selection made.")
    print()


def test_preview_with_colors():
    """Test custom colors with preview enabled."""
    print("🎨 Test 7: Custom Colors with Preview")
    print("-" * 36)

    class ColoredItem:
        def __init__(self, name, color_type, description):
            self.name = name
            self.color_type = color_type
            self.description = description

    items = [
        ColoredItem(
            "Primary Action",
            "primary",
            "Main action button - most important user action",
        ),
        ColoredItem(
            "Secondary Action", "secondary", "Secondary button - alternative action"
        ),
        ColoredItem(
            "Success Message",
            "success",
            "Positive feedback - operation completed successfully",
        ),
        ColoredItem(
            "Warning Alert",
            "warning",
            "Caution message - potential issues or important info",
        ),
        ColoredItem("Error State", "error", "Error feedback - something went wrong"),
        ColoredItem(
            "Info Notice", "info", "Neutral information - general notices or tips"
        ),
    ]

    ui_colors = {
        "primary": "bold white bg:#0066cc",
        "secondary": "white bg:#666666",
        "success": "bold white bg:#00aa00",
        "warning": "bold black bg:#ffaa00",
        "error": "bold white bg:#cc0000",
        "info": "white bg:#0088cc",
    }

    config = FuzzyFinderConfig(
        items=items,
        display_field="name",
        color_field="color_type",
        preview_field="description",
        preview_header="UI Element Details:",
        enable_preview=True,
        custom_colors=ui_colors,
        prompt_text="🎨 Search UI elements: ",
        max_results=10,
        item_opacity=0.9,
    )

    finder = FuzzyFinder(config)
    print("UI elements with semantic colors and preview:")
    print("Navigate with arrows to see descriptions →")
    print()

    result = finder.run()
    if result:
        print(f"Selected: {result.name} ({result.color_type})")
    else:
        print("No selection made.")


def test_fuzzyfindertarget_with_preview():
    """Test FuzzyFinderTarget objects with preview functionality."""
    print("🎨 Test 8: FuzzyFinderTarget with Preview and Custom Colors")
    print("-" * 60)

    # Create FuzzyFinderTarget objects for UI components with rich descriptions
    items = [
        FuzzyFinderTarget(
            name="Primary Button",
            description="Main call-to-action button used for primary user actions like 'Save', 'Submit', or 'Continue'. Should be prominent and easily discoverable.",
            color="bold white bg:#0066cc",
            opacity=1.0,
        ),
        FuzzyFinderTarget(
            name="Warning Alert",
            description="Alert component for cautionary messages. Used to warn users about potential issues or consequences before they take action.",
            color="bold black bg:#ffaa00",
            opacity=0.95,
        ),
        FuzzyFinderTarget(
            name="Success Message",
            description="Positive feedback component shown after successful operations. Confirms to users that their action was completed successfully.",
            color="bold white bg:#00aa00",
            opacity=0.9,
        ),
        FuzzyFinderTarget(
            name="Error State",
            description="Error feedback component displayed when operations fail. Should clearly communicate what went wrong and suggest next steps.",
            color="bold white bg:#cc0000",
            opacity=1.0,
        ),
        FuzzyFinderTarget(
            name="Loading Spinner",
            description="Progress indicator shown during asynchronous operations. Keeps users informed that the system is processing their request.",
            color="cyan",
            opacity=0.8,
        ),
    ]

    config = FuzzyFinderConfig(
        items=items,
        display_field="name",
        preview_field="description",
        preview_header="🎨 UI Component Details:",
        enable_preview=True,
        prompt_text="🔍 Search UI components: ",
        max_results=10,
        highlight_color="bold white bg:#4400aa",
    )

    finder = FuzzyFinder(config)
    print("FuzzyFinderTarget objects with preview and custom colors:")
    print("- Each component has its own semantic color and opacity")
    print("- Navigate with arrows to see detailed descriptions →")
    print("- Colors come directly from the objects, not configuration")
    print()

    result = finder.run()
    if result:
        print(f"Selected: {result.name}")
        print(f"Color: {result.color}")
        print(f"Opacity: {result.opacity}")
        print(f"Description: {result.description}")
    else:
        print("No selection made.")
    print()


def main():
    """Run all custom color demonstrations."""
    print("🌈 FuzzyFinder Custom Colors Demo - Updated with FuzzyFinderTarget")
    print("=" * 70)
    print()

    try:
        # New tests showcasing FuzzyFinderTarget objects
        test_fuzzyfindertarget_with_colors()
        test_mixed_object_types()
        test_priority_demonstration()
        test_fuzzyfindertarget_with_preview()

        # Legacy tests for custom_colors mapping
        test_string_items_with_colors()
        test_object_items_with_colors()
        test_mixed_color_formats()
        test_preview_with_colors()

        print("✅ All custom color tests completed!")
        print("\n🎨 FuzzyFinder Color System Features:")
        print("=" * 40)
        print("🆕 NEW - FuzzyFinderTarget Objects:")
        print("   ✅ Built-in color and opacity properties")
        print("   ✅ Object properties override custom_colors mapping")
        print("   ✅ Perfect for type-safe, object-oriented design")
        print("   ✅ Works seamlessly with preview mode")
        print()
        print("🔄 LEGACY - Custom Colors Mapping:")
        print("   ✅ Color mapping for string items")
        print("   ✅ Color mapping for object items with color_field")
        print("   ✅ Multiple color formats (hex, named, background, combined)")
        print("   ✅ Fallback system when FuzzyFinderTarget has no color")
        print()
        print("🎯 PRIORITY SYSTEM:")
        print("   1. FuzzyFinderTarget.color/opacity (highest priority)")
        print("   2. custom_colors mapping (fallback)")
        print("   3. config.item_opacity (fallback for opacity)")
        print()
        print("💡 RECOMMENDATION: Use FuzzyFinderTarget objects for new code!")

    except KeyboardInterrupt:
        print("\n👋 Demo interrupted by user.")
    except Exception as e:
        print(f"\n❌ Demo failed: {e}")
        import traceback

        traceback.print_exc()


if __name__ == "__main__":
    main()
`````

## File: skills/metagit-release-audit/SKILL.md
`````markdown
---
name: metagit-release-audit
description: Mandatory before calling work complete when the session changed repo files. Runs format, lint, tests, integration tests, context-aware pip-audit/bandit, and optional gitleaks via task qa:prepush. Use before push, release, or hand-off.
---

# Auditing Release Readiness

Use this skill **whenever** your session added or edited tracked files in this repository and you are about to hand off or say the task is done—not only “release” workflows. Read-only Q&A with no writes can skip it.

## Workflow

1. Run the pre-push quality gate.
2. Capture failing stage logs and iterate fixes.
3. Re-run until all required checks pass.
4. Return a short readiness summary.

## Commands

- `task qa:prepush`
- `task qa:prepush:loop -- 3` (optional bounded retry loop)

## Output Contract

Return:
- pass/fail status by stage (including `security_sync` / `security_audit` / `security_bandit` when triggered)
- unresolved blockers (if any)
- push readiness recommendation

Security in the gate is context-aware: lockfile changes run `uv sync --frozen --all-extras` + `pip-audit` + `bandit`; `src/` changes run `pip-audit` + `bandit`; docs-only diffs skip security. Use `task security:scan` for a full manual run.

## Safety

- Do not claim readiness unless checks are actually green.
`````

## File: src/metagit/cli/commands/init.py
`````python
#!/usr/bin/env python
"""
Init subcommand

Creates a validated `.metagit.yml` from bundled templates (copier-style prompts or
answers files) or from a minimal kind profile aligned with the current schema.
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import Optional, Tuple

import click
from git import Repo

from metagit.core.appconfig import AppConfig
from metagit.core.init.service import InitService
from metagit.core.project.models import ProjectKind
from metagit.core.utils.logging import UnifiedLogger


def _resolve_project_metadata(directory: Path) -> Tuple[str, Optional[str]]:
    """
    Resolve project name and optional remote URL from a directory.

    Works inside a Git repository or any ordinary folder.
    """
    name = directory.name
    url: Optional[str] = None
    try:
        git_repo = Repo(directory)
        name = Path(git_repo.working_dir).name
        if git_repo.remotes:
            remote_url = git_repo.remotes[0].url
            url = remote_url if remote_url else None
    except Exception:
        pass
    return name, url


def _kind_choice() -> list[str]:
    return [item.value for item in ProjectKind]


def resolve_target_dir(
    target: str,
    *,
    create: bool = False,
) -> Path:
    """
    Resolve and validate the directory to initialize.

    Args:
        target: Path string (relative, absolute, or ~).
        create: When true, create the directory tree if missing.

    Returns:
        Absolute resolved target path.
    """
    path = Path(target).expanduser().resolve()
    if path.is_dir():
        return path
    if path.exists():
        raise click.ClickException(f"Target path exists but is not a directory: {path}")
    if create:
        path.mkdir(parents=True, exist_ok=False)
        return path
    raise click.ClickException(
        f"Target directory does not exist: {path} (use --create to create it)"
    )


@click.command("init")
@click.argument(
    "target",
    required=False,
    default=".",
    type=click.Path(file_okay=False, dir_okay=True),
)
@click.option(
    "--target",
    "target_option",
    default=None,
    type=click.Path(file_okay=False, dir_okay=True),
    help="Target folder to initialize (overrides positional TARGET)",
)
@click.option(
    "--create",
    is_flag=True,
    help="Create the target directory if it does not exist",
)
@click.option(
    "--template",
    "-t",
    "template",
    default=None,
    help="Bundled init template id (see --list-templates)",
)
@click.option(
    "--list-templates",
    is_flag=True,
    help="List bundled init templates and exit",
)
@click.option(
    "--answers-file",
    type=click.Path(path_type=Path, exists=True, dir_okay=False),
    default=None,
    help="YAML/JSON file with template answers (copier-style)",
)
@click.option(
    "--no-prompt",
    is_flag=True,
    help="Do not prompt; use defaults and answers file only",
)
@click.option(
    "--kind",
    "-k",
    default="application",
    type=click.Choice(_kind_choice(), case_sensitive=False),
    help="Project kind (used when no bundled template exists for this kind)",
)
@click.option(
    "--name",
    "-n",
    default=None,
    help="Override manifest name",
)
@click.option(
    "--description",
    "-d",
    default=None,
    help="Override manifest description",
)
@click.option(
    "--url",
    "-u",
    default=None,
    help="Override manifest git URL",
)
@click.option(
    "--minimal",
    is_flag=True,
    help="Skip bundled templates; write minimal schema-backed manifest for --kind",
)
@click.option(
    "--force",
    "-f",
    is_flag=True,
    help="Overwrite existing .metagit.yml",
)
@click.option(
    "--skip-gitignore",
    "-s",
    is_flag=True,
    help="Skip updating .gitignore",
)
@click.option(
    "--dry-run",
    is_flag=True,
    help="Validate and render without writing files",
)
@click.pass_context
def init(
    ctx: click.Context,
    target: str,
    target_option: Optional[str],
    create: bool,
    template: Optional[str],
    list_templates: bool,
    answers_file: Optional[Path],
    no_prompt: bool,
    kind: str,
    name: Optional[str],
    description: Optional[str],
    url: Optional[str],
    minimal: bool,
    force: bool,
    skip_gitignore: bool,
    dry_run: bool,
) -> None:
    """
    Initialize metagit with a validated manifest and optional companion files.

    TARGET is the folder to write into (default: current directory). Use --target
    to pass a path when you prefer a flag over the positional argument.
    """
    logger: UnifiedLogger = ctx.obj["logger"]
    app_config: AppConfig = ctx.obj["config"]
    service = InitService()
    target_path = resolve_target_dir(
        target_option if target_option is not None else target,
        create=create,
    )

    if list_templates:
        rows = service.list_templates()
        if not rows:
            click.echo("No bundled init templates found.")
            return
        for row in rows:
            click.echo(f"{row.id}\t{row.label}\t(kind={row.kind})")
            click.echo(f"  {row.description.strip()}")
        return

    directory_name, git_url = _resolve_project_metadata(target_path)
    overrides: dict[str, str] = {}
    if name:
        overrides["name"] = name
    if description:
        overrides["description"] = description
    if url is not None:
        overrides["url"] = url

    template_id = service.resolve_template_id(template, kind if not minimal else None)
    manifest = service.registry.load_manifest(template_id)
    use_bundled = manifest is not None and not minimal

    agent_mode = bool(ctx.obj.get("agent_mode", False))
    if agent_mode:
        no_prompt = True

    if (target_path / ".metagit.yml").exists() and force and not dry_run:
        if agent_mode:
            raise click.UsageError(
                "Overwrite confirmation is disabled in agent mode; remove .metagit.yml first or use a new directory"
            )
        if not click.confirm("Overwrite existing .metagit.yml?"):
            ctx.abort()

    try:
        if use_bundled and manifest is not None:
            result = service.initialize(
                target_path,
                template_id=template_id,
                directory_name=directory_name,
                git_remote_url=git_url,
                answers_file=answers_file,
                overrides=overrides,
                no_prompt=no_prompt,
                force=force,
                dry_run=dry_run,
            )
            effective_kind = manifest.kind
        else:
            desc = description or f"{kind} project managed by metagit."
            result = service.initialize_minimal(
                target_path,
                kind=kind,
                name=name or directory_name,
                description=desc,
                url=url if url is not None else git_url,
                force=force,
                dry_run=dry_run,
            )
            effective_kind = kind
    except click.ClickException as exc:
        logger.error(str(exc))
        ctx.abort()

    if dry_run:
        logger.success("Dry run: manifest validated (no files written).")
        return

    if not skip_gitignore:
        _update_gitignore(
            os.path.join(target_path, ".gitignore"),
            app_config.workspace.path,
            logger,
        )
    else:
        logger.info("Skipping .gitignore file update")

    logger.success(f"Created {result.metagit_yml}")
    for extra in result.extra_files:
        logger.info(f"Created {extra}")

    _print_next_steps(
        logger,
        effective_kind=effective_kind,
        template_id=template_id if use_bundled else None,
    )


def _print_next_steps(
    logger: UnifiedLogger,
    *,
    effective_kind: str,
    template_id: Optional[str],
) -> None:
    logger.header("Metagit initialization complete!")
    logger.info("  metagit config validate")
    if template_id == "hermes-orchestrator":
        logger.info("  metagit project repo add --project portfolio --prompt")
        logger.info("  metagit project sync --project portfolio")
        logger.info("  metagit project sync --project local")
        logger.info("  metagit mcp serve --root .")
        return
    if effective_kind == "application":
        logger.info("  metagit detect repo --force   # optional discovery")
        logger.info("  metagit project sync --project local   # when using paths block")
        return
    if effective_kind == "umbrella":
        logger.info("  metagit project repo add --project default --prompt")
        logger.info("  metagit project sync")
        return
    logger.info("  metagit project repo add --prompt")


def _sanitize_workspace_path(workspace_path: str) -> str:
    """Sanitize workspace path for .gitignore."""
    if workspace_path.startswith("./"):
        sanitized = workspace_path[2:]
    else:
        sanitized = workspace_path

    return f"{sanitized}/" if not sanitized.endswith("/") else sanitized


def _update_gitignore(
    gitignore_path: str,
    workspace_path: str,
    logger: UnifiedLogger,
) -> None:
    """Update .gitignore file to include workspace path."""
    target_path = _sanitize_workspace_path(workspace_path)
    try:
        if Path(gitignore_path).exists():
            with open(gitignore_path, "r", encoding="utf-8") as handle:
                lines = handle.readlines()

            for line in lines:
                if line.strip() == target_path.strip():
                    logger.info(
                        f"Workspace path already defined in .gitignore: '{target_path}'"
                    )
                    return

            with open(gitignore_path, "a", encoding="utf-8") as handle:
                handle.write(f"\n# Metagit workspace\n{target_path}\n")
            logger.info(f"Added to existing .gitignore: {target_path}")

        else:
            with open(gitignore_path, "w", encoding="utf-8") as handle:
                handle.write(f"# Metagit workspace\n{target_path}\n")
            logger.info(f"Created .gitignore with: {target_path}")

    except OSError as exc:
        logger.warning(f"Failed to update .gitignore: {exc}")
        logger.info(f"Please manually add '{target_path}' to your .gitignore file.")
`````

## File: src/metagit/cli/commands/workspace.py
`````python
"""
Workspace subcommand
"""

import sys
from pathlib import Path

import click

from metagit.cli.commands.project_repo import repo_select
from metagit.cli.json_output import (
    emit_json,
    exit_on_catalog_mutation,
    exit_on_layout_mutation,
)
from metagit.core.appconfig import AppConfig
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectKind
from metagit.core.utils.click import call_click_command_with_ctx
from metagit.core.workspace.catalog_models import CatalogError
from metagit.core.workspace.catalog_service import WorkspaceCatalogService
from metagit.core.workspace.dedupe_resolver import resolve_dedupe_for_layout
from metagit.core.workspace.layout_service import WorkspaceLayoutService


def _catalog_ctx(ctx: click.Context) -> tuple[MetagitConfig, str, str]:
    local_config: MetagitConfig = ctx.obj["local_config"]
    config_path: str = ctx.obj["config_path"]
    app_config: AppConfig = ctx.obj["config"]
    workspace_root = str(Path(app_config.workspace.path).expanduser().resolve())
    return local_config, config_path, workspace_root


def _layout_ctx(ctx: click.Context) -> tuple[MetagitConfig, str, str, AppConfig]:
    local_config, config_path, workspace_root = _catalog_ctx(ctx)
    app_config: AppConfig = ctx.obj["config"]
    return local_config, config_path, workspace_root, app_config


@click.group(name="workspace", invoke_without_command=True)
@click.option(
    "--config",
    "config_path",
    default=".metagit.yml",
    help="Path to the metagit definition file",
)
@click.pass_context
def workspace(ctx: click.Context, config_path: str) -> None:
    """Workspace subcommands"""

    logger = ctx.obj["logger"]
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())
        return

    ctx.obj["config_path"] = config_path
    try:
        config_manager = MetagitConfigManager(config_path)
        local_config = config_manager.load_config()
        if isinstance(local_config, Exception):
            raise local_config
    except Exception as e:
        logger.error(f"Failed to load metagit definition file: {e}")
        sys.exit(1)
    ctx.obj["local_config"] = local_config


@workspace.command("list")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.option(
    "--no-index",
    is_flag=True,
    default=False,
    help="Omit per-repo disk status from workspace list JSON",
)
@click.pass_context
def workspace_list(ctx: click.Context, as_json: bool, no_index: bool) -> None:
    """List workspace manifest summary, projects, and repository index."""
    local_config, config_path, workspace_root = _catalog_ctx(ctx)
    service = WorkspaceCatalogService()
    result = service.list_workspace(
        local_config,
        config_path,
        workspace_root,
        include_index=not no_index,
    )
    if as_json:
        emit_json(result)
        return
    summary = (result.data or {}).get("summary", {})
    click.echo(f"Definition: {summary.get('definition_path', config_path)}")
    click.echo(f"Workspace root: {summary.get('workspace_root', workspace_root)}")
    click.echo(
        f"Projects: {summary.get('project_count', 0)} | Repos: {summary.get('repo_count', 0)}"
    )
    for project in (result.data or {}).get("projects", []):
        click.echo(f"  - {project.get('name')} ({project.get('repo_count', 0)} repos)")


@workspace.group("project")
@click.pass_context
def workspace_project(_ctx: click.Context) -> None:
    """Manage workspace projects in the manifest."""


@workspace_project.command("list")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def workspace_project_list(ctx: click.Context, as_json: bool) -> None:
    """List projects defined in the workspace manifest."""
    local_config, _, _ = _catalog_ctx(ctx)
    result = WorkspaceCatalogService().list_projects(local_config)
    if as_json:
        emit_json(result)
        return
    for project in (result.data or {}).get("projects", []):
        click.echo(f"{project.get('name')} ({project.get('repo_count', 0)} repos)")


@workspace_project.command("add")
@click.argument("name")
@click.option("--description", default=None, help="Project description")
@click.option(
    "--agent-instructions", default=None, help="Agent instructions for the project"
)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def workspace_project_add(
    ctx: click.Context,
    name: str,
    description: str | None,
    agent_instructions: str | None,
    as_json: bool,
) -> None:
    """Add a project to the workspace manifest."""
    local_config, config_path, _ = _catalog_ctx(ctx)
    result = WorkspaceCatalogService().add_project(
        local_config,
        config_path,
        name=name,
        description=description,
        agent_instructions=agent_instructions,
    )
    exit_on_catalog_mutation(result, as_json=as_json)


@workspace_project.command("remove")
@click.argument("name")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def workspace_project_remove(ctx: click.Context, name: str, as_json: bool) -> None:
    """Remove a project (and its repos) from the workspace manifest."""
    local_config, config_path, _ = _catalog_ctx(ctx)
    result = WorkspaceCatalogService().remove_project(
        local_config,
        config_path,
        name=name,
    )
    exit_on_catalog_mutation(result, as_json=as_json)


@workspace_project.command("rename")
@click.argument("from_name")
@click.argument("to_name")
@click.option(
    "--dry-run",
    is_flag=True,
    default=False,
    help="Show planned manifest and disk steps without applying",
)
@click.option(
    "--manifest-only",
    is_flag=True,
    default=False,
    help="Update .metagit.yml only; do not rename sync folders",
)
@click.option(
    "--no-update-sessions",
    is_flag=True,
    default=False,
    help="Do not migrate .metagit/sessions project files",
)
@click.option("--force", is_flag=True, default=False, help="Overwrite existing targets")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def workspace_project_rename(
    ctx: click.Context,
    from_name: str,
    to_name: str,
    dry_run: bool,
    manifest_only: bool,
    no_update_sessions: bool,
    force: bool,
    as_json: bool,
) -> None:
    """Rename a workspace project (manifest and sync folder when present)."""
    local_config, config_path, workspace_root, app_config = _layout_ctx(ctx)
    dedupe = resolve_dedupe_for_layout(
        app_config.workspace.dedupe,
        local_config,
        from_name,
    )
    result = WorkspaceLayoutService().rename_project(
        local_config,
        config_path,
        workspace_root,
        from_name=from_name,
        to_name=to_name,
        dedupe=dedupe,
        dry_run=dry_run,
        move_disk=not manifest_only,
        update_sessions=not no_update_sessions,
        force=force,
    )
    exit_on_layout_mutation(result, as_json=as_json)


@workspace.group("repo")
@click.pass_context
def workspace_repo(_ctx: click.Context) -> None:
    """Manage repository entries in the workspace manifest."""


@workspace_repo.command("list")
@click.option("--project", "-p", default=None, help="Limit to one project name")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def workspace_repo_list(
    ctx: click.Context,
    project: str | None,
    as_json: bool,
) -> None:
    """List repositories in the workspace manifest."""
    local_config, _, workspace_root = _catalog_ctx(ctx)
    result = WorkspaceCatalogService().list_repos(
        local_config,
        workspace_root,
        project_name=project,
    )
    if as_json:
        emit_json(result)
        return
    for row in (result.data or {}).get("repos", []):
        repo = row.get("repo", {})
        click.echo(
            f"{row.get('project_name')}/{repo.get('name')} "
            f"path={row.get('configured_path') or repo.get('path')} "
            f"status={row.get('status') or 'unknown'}"
        )


@workspace_repo.command("add")
@click.option("--project", "-p", required=True, help="Workspace project name")
@click.option("--name", "-n", required=True, help="Repository name")
@click.option("--description", default=None)
@click.option("--kind", type=click.Choice([k.value for k in ProjectKind]), default=None)
@click.option("--path", default=None, help="Relative path under workspace root")
@click.option("--url", default=None, help="Remote repository URL")
@click.option("--sync/--no-sync", default=None)
@click.option("--agent-instructions", default=None)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def workspace_repo_add(
    ctx: click.Context,
    project: str,
    name: str,
    description: str | None,
    kind: str | None,
    path: str | None,
    url: str | None,
    sync: bool | None,
    agent_instructions: str | None,
    as_json: bool,
) -> None:
    """Add a repository entry to a workspace project (manifest only)."""
    local_config, config_path, _ = _catalog_ctx(ctx)
    service = WorkspaceCatalogService()
    built = service.build_repo_from_fields(
        name=name,
        description=description,
        kind=kind,
        path=path,
        url=url,
        sync=sync,
        agent_instructions=agent_instructions,
    )
    if isinstance(built, CatalogError):
        raise click.ClickException(built.message)
    result = service.add_repo(
        local_config,
        config_path,
        project_name=project,
        repo=built,
    )
    exit_on_catalog_mutation(result, as_json=as_json)


@workspace_repo.command("remove")
@click.option("--project", "-p", required=True, help="Workspace project name")
@click.option("--name", "-n", required=True, help="Repository name")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def workspace_repo_remove(
    ctx: click.Context,
    project: str,
    name: str,
    as_json: bool,
) -> None:
    """Remove a repository entry from the workspace manifest (does not delete files)."""
    local_config, config_path, _ = _catalog_ctx(ctx)
    result = WorkspaceCatalogService().remove_repo(
        local_config,
        config_path,
        project_name=project,
        repo_name=name,
    )
    exit_on_catalog_mutation(result, as_json=as_json)


@workspace_repo.command("rename")
@click.option("--project", "-p", required=True, help="Workspace project name")
@click.argument("from_name")
@click.argument("to_name")
@click.option("--dry-run", is_flag=True, default=False)
@click.option(
    "--manifest-only",
    is_flag=True,
    default=False,
    help="Update manifest only; do not rename sync mount",
)
@click.option("--force", is_flag=True, default=False)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def workspace_repo_rename(
    ctx: click.Context,
    project: str,
    from_name: str,
    to_name: str,
    dry_run: bool,
    manifest_only: bool,
    force: bool,
    as_json: bool,
) -> None:
    """Rename a repository entry and its sync mount when present."""
    local_config, config_path, workspace_root, app_config = _layout_ctx(ctx)
    dedupe = resolve_dedupe_for_layout(
        app_config.workspace.dedupe,
        local_config,
        project,
    )
    result = WorkspaceLayoutService().rename_repo(
        local_config,
        config_path,
        workspace_root,
        project_name=project,
        from_name=from_name,
        to_name=to_name,
        dedupe=dedupe,
        dry_run=dry_run,
        move_disk=not manifest_only,
        force=force,
    )
    exit_on_layout_mutation(result, as_json=as_json)


@workspace_repo.command("move")
@click.option("--project", "-p", "from_project", required=True, help="Source project")
@click.option("--name", "-n", "repo_name", required=True, help="Repository name")
@click.option(
    "--to-project",
    required=True,
    help="Target workspace project",
)
@click.option("--dry-run", is_flag=True, default=False)
@click.option(
    "--manifest-only",
    is_flag=True,
    default=False,
    help="Update manifest only; do not move sync mount",
)
@click.option("--force", is_flag=True, default=False)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def workspace_repo_move(
    ctx: click.Context,
    from_project: str,
    repo_name: str,
    to_project: str,
    dry_run: bool,
    manifest_only: bool,
    force: bool,
    as_json: bool,
) -> None:
    """Move a repository entry to another workspace project."""
    local_config, config_path, workspace_root, app_config = _layout_ctx(ctx)
    dedupe = resolve_dedupe_for_layout(
        app_config.workspace.dedupe,
        local_config,
        to_project,
    )
    result = WorkspaceLayoutService().move_repo(
        local_config,
        config_path,
        workspace_root,
        repo_name=repo_name,
        from_project=from_project,
        to_project=to_project,
        dedupe=dedupe,
        dry_run=dry_run,
        move_disk=not manifest_only,
        force=force,
    )
    exit_on_layout_mutation(result, as_json=as_json)


@workspace.command("select")
@click.option(
    "--project",
    "-p",
    default=None,
    help="Project within workspace to select target paths from",
)
@click.pass_context
def workspace_select(ctx: click.Context, project: str = None) -> None:
    """Select project repo to work on"""
    app_config: AppConfig = ctx.obj["config"]
    if not project:
        project = app_config.workspace.default_project
        ctx.obj["project"] = project
    else:
        ctx.obj["project"] = project
    call_click_command_with_ctx(repo_select, ctx)
`````

## File: src/metagit/core/api/server.py
`````python
#!/usr/bin/env python
"""
Minimal local JSON HTTP API for managed workspace repository search.
"""

import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse

from metagit.core.api.catalog_handler import CatalogApiHandler
from metagit.core.api.layout_handler import LayoutApiHandler
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.project.search_service import ManagedRepoSearchService


def _parse_tag_filters_from_query(tag_values: list[str]) -> dict[str, str] | None:
    """Parse repeated `tag=key=value` style query pairs into a tag filter dict."""
    if not tag_values:
        return None
    parsed: dict[str, str] = {}
    for item in tag_values:
        if "=" not in item:
            continue
        key, _, value = item.partition("=")
        if key:
            parsed[key] = value
    return parsed or None


def _first(
    params: dict[str, list[str]], key: str, default: str | None = None
) -> str | None:
    """Return the first query value for a key, or default if missing or empty."""
    values = params.get(key)
    if not values:
        return default
    first = values[0]
    return first if first else default


def build_server(root: str, host: str, port: int) -> ThreadingHTTPServer:
    """Build a threading HTTP server rooted at a workspace directory."""
    root_resolved = str(Path(root).resolve())
    config_path = os.path.join(root_resolved, ".metagit.yml")
    service = ManagedRepoSearchService()
    catalog = CatalogApiHandler(
        workspace_root=root_resolved,
        config_path=config_path,
    )
    layout = LayoutApiHandler(
        definition_root=root_resolved,
        config_path=config_path,
    )

    class ReusableThreadingHTTPServer(ThreadingHTTPServer):
        allow_reuse_address = True

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, format: str, *args: Any) -> None:
            _ = (format, args)

        def do_GET(self) -> None:
            parsed = urlparse(self.path)
            params = parse_qs(parsed.query, keep_blank_values=True)
            if catalog.handle(
                "GET",
                parsed.path,
                parsed.query,
                b"",
                self._json,
            ):
                return
            manager = MetagitConfigManager(config_path)
            loaded = manager.load_config()
            if isinstance(loaded, Exception):
                self._json(
                    500,
                    {
                        "error": {
                            "kind": "config_error",
                            "message": str(loaded),
                        }
                    },
                )
                return
            config = loaded

            if parsed.path == "/v1/repos/search":
                limit_raw = _first(params, "limit", "10") or "10"
                try:
                    limit_val = int(limit_raw)
                except ValueError:
                    limit_val = 10
                limit_val = max(1, min(limit_val, 500))
                project_raw = _first(params, "project")
                project_filter = (
                    project_raw.strip()
                    if isinstance(project_raw, str) and project_raw.strip()
                    else None
                )
                result = service.search(
                    config=config,
                    workspace_root=root_resolved,
                    query=_first(params, "q", "") or "",
                    project=project_filter,
                    exact=(_first(params, "exact", "false") or "false").lower()
                    == "true",
                    synced_only=(
                        _first(params, "synced_only", "false") or "false"
                    ).lower()
                    == "true",
                    tags=_parse_tag_filters_from_query(params.get("tag", [])),
                    limit=limit_val,
                )
                self._json(200, result.model_dump(mode="json"))
                return

            if parsed.path == "/v1/repos/resolve":
                project_raw = _first(params, "project")
                project_filter = (
                    project_raw.strip()
                    if isinstance(project_raw, str) and project_raw.strip()
                    else None
                )
                resolved = service.resolve_one(
                    config=config,
                    workspace_root=root_resolved,
                    query=_first(params, "q", "") or "",
                    project=project_filter,
                    exact=(_first(params, "exact", "false") or "false").lower()
                    == "true",
                    synced_only=(
                        _first(params, "synced_only", "true") or "true"
                    ).lower()
                    == "true",
                    tags=_parse_tag_filters_from_query(params.get("tag", [])),
                )
                if resolved.match is not None:
                    code = 200
                elif (
                    resolved.error is not None
                    and resolved.error.kind == "ambiguous_match"
                ):
                    code = 409
                else:
                    code = 404
                self._json(code, resolved.model_dump(mode="json"))
                return

            self._json(
                404,
                {"error": {"kind": "not_found", "message": "Unknown endpoint"}},
            )

        def do_POST(self) -> None:
            parsed = urlparse(self.path)
            length = int(self.headers.get("Content-Length", "0") or "0")
            body = self.rfile.read(length) if length > 0 else b""
            if layout.handle("POST", parsed.path, parsed.query, body, self._json):
                return
            if catalog.handle("POST", parsed.path, parsed.query, body, self._json):
                return
            self._json(
                404,
                {"error": {"kind": "not_found", "message": "Unknown endpoint"}},
            )

        def do_DELETE(self) -> None:
            parsed = urlparse(self.path)
            if catalog.handle("DELETE", parsed.path, parsed.query, b"", self._json):
                return
            self._json(
                404,
                {"error": {"kind": "not_found", "message": "Unknown endpoint"}},
            )

        def _json(self, status: int, payload: dict[str, Any]) -> None:
            body = json.dumps(payload).encode("utf-8")
            self.send_response(status)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

    return ReusableThreadingHTTPServer((host, port), Handler)
`````

## File: src/metagit/core/appconfig/__init__.py
`````python
#!/usr/bin/env python

import json
from pathlib import Path
from typing import Union

import yaml as base_yaml

from metagit.core.appconfig.agent_mode import resolve_agent_mode
from metagit.core.appconfig.models import AppConfig

__all__ = [
    "AppConfig",
    "get_config",
    "load_config",
    "resolve_agent_mode",
    "save_config",
    "set_config",
]
from metagit.core.utils.logging import LoggerConfig, UnifiedLogger
from metagit.core.utils.yaml_class import yaml


def load_config(config_path: str) -> Union[AppConfig, Exception]:
    """
    Load and validate the YAML configuration file.
    """
    try:
        config_file = Path(config_path)
        if not config_file.exists():
            return FileNotFoundError(f"Configuration file {config_path} not found")

        with config_file.open("r") as file:
            config_data = yaml.safe_load(file)

        config = AppConfig(**config_data["config"])
        return AppConfig._override_from_environment(config)
    except Exception as e:
        return e


def save_config(config_path: str, config: AppConfig) -> Union[None, Exception]:
    """
    Save the AppConfig object to a YAML file.
    """
    try:
        config_dict = {"config": config.model_dump(exclude_none=True, mode="json")}
        with open(config_path, "w") as f:
            base_yaml.dump(
                config_dict,
                f,
                default_flow_style=False,
                sort_keys=False,
                indent=2,
                line_break=True,
            )
        return None
    except Exception as e:
        return e


def set_config(
    appconfig: AppConfig, name: str, value: str, logger=None
) -> Union[AppConfig, Exception]:
    """Set appconfig values"""
    if logger is None:
        logger = UnifiedLogger(
            LoggerConfig(
                log_level="INFO",
                use_rich_console=True,
                minimal_console=False,
                terse=False,
            )
        )
    try:
        config_path = name.split(".")
        current_level = appconfig
        for element in config_path[:-1]:
            if hasattr(current_level, element):
                current_level = getattr(current_level, element)
            else:
                return ValueError(f"Invalid key: {name}")

        last_element = config_path[-1]
        if hasattr(current_level, last_element):
            field_type = type(getattr(current_level, last_element))
            if isinstance(field_type, bool):
                if value.lower() in ["true", "1", "yes"]:
                    converted_value = True
                elif value.lower() in ["false", "0", "no"]:
                    converted_value = False
                else:
                    return TypeError(f"Invalid value for boolean: {value}")
            else:
                try:
                    converted_value = field_type(value)
                except (ValueError, TypeError):
                    return TypeError(
                        f"Invalid value type for '{name}'. Expected {field_type.__name__}."
                    )
            setattr(current_level, last_element, converted_value)
        else:
            return ValueError(f"Invalid key: {name}")

        return appconfig
    except Exception as e:
        return e


def get_config(
    appconfig: AppConfig, name="", show_keys=False, output="json", logger=None
) -> Union[dict, None, Exception]:
    """Retrieve appconfig values"""
    if logger is None:
        # Map LOG_LEVELS[3] (which is logging.INFO) to the string 'INFO'
        logger = UnifiedLogger(
            LoggerConfig(
                log_level="INFO",
                use_rich_console=True,
                minimal_console=False,
                terse=False,
            )
        )
    try:
        appconfig_dict = appconfig.model_dump(
            exclude_none=True, exclude_unset=True, mode="json"
        )
        output_value = {"config": appconfig_dict}
        config_path = name.split(".")
        if name != "":
            for element in config_path:
                element_value = output_value[element]
                if isinstance(element_value, AppConfig):
                    output_value = element_value.__dict__
                elif isinstance(element_value, dict):
                    output_value = element_value
                else:
                    output_value = element_value
                    break
        if show_keys and isinstance(output_value, dict):
            output_value = list(output_value.keys())
        elif show_keys and isinstance(output_value, list):
            output_result = []
            for output_name in output_value:
                output_result.append(output_name)
            output_value = output_result

        if output == "yml" or output == "yaml":
            base_yaml.Dumper.ignore_aliases = lambda *args: True  # noqa: ARG005
            logger.echo(
                base_yaml.dump(
                    output_value,
                    default_flow_style=False,
                    sort_keys=False,
                    indent=2,
                    line_break=True,
                ),
                console=True,
            )
        elif output == "json":
            logger.echo(json.dumps(output_value, indent=2), console=True)
        elif output == "dict":
            return output_value
        elif isinstance(output_value, list):
            for result_item in output_value:
                logger.echo(result_item, console=True)
        elif isinstance(output_value, dict):
            logger.echo(output_value.__str__(), console=True)
        else:
            for result_item in output_value:
                logger.echo(result_item, console=True)
        return None
    except Exception as e:
        return e
`````

## File: src/metagit/core/config/models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for .metagit.yml configuration file.

This module defines the data models used to parse and validate
the .metagit.yml configuration file structure.
"""

import os
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, List, Optional, Union

import yaml
from pydantic import (
    AliasChoices,
    BaseModel,
    Field,
    HttpUrl,
    field_serializer,
    field_validator,
)

from metagit.core.appconfig.models import AppConfig
from metagit.core.project.models import GitUrl, ProjectKind, ProjectPath
from metagit.core.config.documentation_models import (
    DocumentationSource,
    normalize_documentation_entries,
)
from metagit.core.config.graph_models import WorkspaceGraph
from metagit.core.workspace.models import Workspace, WorkspaceProject


class LicenseKind(str, Enum):
    """Enumeration of license kinds."""

    NONE = "None"
    MIT = "MIT"
    APACHE_2_0 = "Apache-2.0"
    GPL_3_0 = "GPL-3.0"
    BSD_3_CLAUSE = "BSD-3-Clause"
    PROPRIETARY = "proprietary"
    CUSTOM = "custom"
    UNKNOWN = "unknown"


class BranchStrategy(str, Enum):
    """Enumeration of branch strategies."""

    TRUNK = "trunk"
    GITFLOW = "gitflow"
    GITHUBFLOW = "githubflow"
    GITLABFLOW = "gitlabflow"
    FORK = "fork"
    NONE = "none"
    CUSTOM = "custom"
    UNKNOWN = "unknown"


class TaskerKind(str, Enum):
    """Enumeration of tasker kinds."""

    TASKFILE = "Taskfile"
    MAKEFILE = "Makefile"
    JEST = "Jest"
    NPM = "NPM"
    ATMOS = "Atmos"
    CUSTOM = "custom"
    NONE = "none"
    MISE_TASKS = "mise_tasks"


class ArtifactType(str, Enum):
    """Enumeration of artifact types."""

    DOCKER = "docker"
    GITHUB_RELEASE = "github_release"
    HELM_CHART = "helm_chart"
    NPM_PACKAGE = "npm_package"
    STATIC_WEBSITE = "static_website"
    PYTHON_PACKAGE = "python_package"
    NODE_PACKAGE = "node_package"
    RUBY_PACKAGE = "ruby_package"
    JAVA_PACKAGE = "java_package"
    C_PACKAGE = "c_package"
    CPP_PACKAGE = "cpp_package"
    CSHARP_PACKAGE = "csharp_package"
    GO_PACKAGE = "go_package"
    RUST_PACKAGE = "rust_package"
    PHP_PACKAGE = "php_package"
    DOTNET_PACKAGE = "dotnet_package"
    ELIXIR_PACKAGE = "elixir_package"
    HASKELL_PACKAGE = "haskell_package"
    CUSTOM = "custom"
    NONE = "none"
    UNKNOWN = "unknown"
    OTHER = "other"
    PLUGIN = "plugin"
    TEMPLATE = "template"
    CONFIG = "config"
    BINARY = "binary"
    ARCHIVE = "archive"


class VersionStrategy(str, Enum):
    """Enumeration of version strategies."""

    SEMVER = "semver"
    NONE = "none"
    CUSTOM = "custom"
    UNKNOWN = "unknown"
    OTHER = "other"


class SecretKind(str, Enum):
    """Enumeration of secret kinds."""

    REMOTE_JWT = "remote_jwt"
    REMOTE_API_KEY = "remote_api_key"
    GENERATED_STRING = "generated_string"
    CUSTOM = "custom"
    DYNAMIC = "dynamic"
    PRIVATE_KEY = "private_key"
    PUBLIC_KEY = "public_key"
    SECRET_KEY = "secret_key"
    API_KEY = "api_key"
    ACCESS_TOKEN = "access_token"
    REFRESH_TOKEN = "refresh_token"
    PASSWORD = "password"
    DATABASE_PASSWORD = "database_password"
    UNKNOWN = "unknown"
    OTHER = "other"


class VariableKind(str, Enum):
    """Enumeration of variable kinds."""

    STRING = "string"
    INTEGER = "integer"
    BOOLEAN = "boolean"
    CUSTOM = "custom"
    UNKNOWN = "unknown"
    OTHER = "other"


class CICDPlatform(str, Enum):
    """Enumeration of CI/CD platforms."""

    GITHUB = "GitHub"
    GITLAB = "GitLab"
    CIRCLECI = "CircleCI"
    JENKINS = "Jenkins"
    JX = "jx"
    TEKTON = "tekton"
    CUSTOM = "custom"
    NONE = "none"
    UNKNOWN = "unknown"
    OTHER = "other"


class DeploymentStrategy(str, Enum):
    """Enumeration of deployment strategies."""

    BLUE_GREEN = "blue/green"
    ROLLING = "rolling"
    MANUAL = "manual"
    GITOPS = "gitops"
    PIPELINE = "pipeline"
    CUSTOM = "custom"
    NONE = "none"
    UNKNOWN = "unknown"
    OTHER = "other"


class ProvisioningTool(str, Enum):
    """Enumeration of provisioning tools."""

    TERRAFORM = "Terraform"
    CLOUDFORMATION = "CloudFormation"
    CDKTF = "CDKTF"
    AWS_CDK = "AWS CDK"
    BICEP = "Bicep"
    CUSTOM = "custom"
    NONE = "none"
    UNKNOWN = "unknown"
    OTHER = "other"


class Hosting(str, Enum):
    """Enumeration of hosting options."""

    EC2 = "EC2"
    VMWARE = "VMware"
    ORACLE = "Oracle"
    KUBERNETES = "Kubernetes"
    VERCEL = "Vercel"
    ECS = "ECS"
    AWS_LAMBDA = "AWS Lambda"
    AWS_FARGATE = "AWS Fargate"
    AWS_EKS = "AWS EKS"
    AWS_ECS = "AWS ECS"
    AWS_ECS_FARGATE = "AWS ECS Fargate"
    AWS_ECS_FARGATE_SPOT = "AWS ECS Fargate Spot"
    AWS_ECS_FARGATE_SPOT_SPOT = "AWS ECS Fargate Spot Spot"
    ELASTIC_BEANSTALK = "Elastic Beanstalk"
    AZURE_APP_SERVICE = "Azure App Service"
    AZURE_FUNCTIONS = "Azure Functions"
    AZURE_CONTAINER_INSTANCES = "Azure Container Instances"
    AZURE_CONTAINER_APPS = "Azure Container Apps"
    AZURE_CONTAINER_APPS_ENVIRONMENT = "Azure Container Apps Environment"
    AZURE_CONTAINER_APPS_ENVIRONMENT_SERVICE = (
        "Azure Container Apps Environment Service"
    )
    AZURE_CONTAINER_APPS_ENVIRONMENT_SERVICE_SERVICE = (
        "Azure Container Apps Environment Service Service"
    )
    CUSTOM = "custom"
    NONE = "none"
    UNKNOWN = "unknown"


class LoggingProvider(str, Enum):
    """Enumeration of logging providers."""

    CONSOLE = "console"
    CLOUDWATCH = "cloudwatch"
    ELK = "elk"
    SENTRY = "sentry"
    CUSTOM = "custom"
    NONE = "none"
    UNKNOWN = "unknown"
    OTHER = "other"


class MonitoringProvider(str, Enum):
    """Enumeration of monitoring providers."""

    PROMETHEUS = "prometheus"
    DATADOG = "datadog"
    GRAFANA = "grafana"
    SENTRY = "sentry"
    CUSTOM = "custom"
    NONE = "none"
    UNKNOWN = "unknown"
    OTHER = "other"


class AlertingChannelType(str, Enum):
    """Enumeration of alerting channel types."""

    SLACK = "slack"
    TEAMS = "teams"
    EMAIL = "email"
    SMS = "sms"
    WEBHOOK = "webhook"
    CUSTOM = "custom"
    UNKNOWN = "unknown"
    OTHER = "other"


class ComponentKind(str, Enum):
    """Enumeration of component kinds."""

    ENTRY_POINT = "entry_point"


class DependencyKind(str, Enum):
    """Enumeration of dependency kinds."""

    DOCKER_IMAGE = "docker_image"
    REPOSITORY = "repository"
    UNKNOWN = "unknown"
    OTHER = "other"


class Maintainer(BaseModel):
    """Model for project maintainer information."""

    name: str = Field(..., description="Maintainer name")
    email: str = Field(..., description="Maintainer email")
    role: str = Field(..., description="Maintainer role")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class License(BaseModel):
    """Model for project license information."""

    kind: LicenseKind = Field(..., description="License type")
    file: str = Field(default="", description="License file path")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Tasker(BaseModel):
    """Model for task management tools."""

    kind: TaskerKind = Field(..., description="Tasker type")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class BranchNaming(BaseModel):
    """Model for branch naming patterns."""

    kind: BranchStrategy = Field(..., description="Branch strategy")
    pattern: str = Field(..., description="Branch naming pattern")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Branch(BaseModel):
    """Model for branch information."""

    name: str = Field(..., description="Branch name")
    environment: Optional[str] = Field(None, description="Environment for this branch")


class Artifact(BaseModel):
    """Model for generated artifacts."""

    type: ArtifactType = Field(..., description="Artifact type")
    definition: str = Field(..., description="Artifact definition")
    location: Union[HttpUrl, str] = Field(..., description="Artifact location")
    version_strategy: VersionStrategy = Field(..., description="Version strategy")

    @field_serializer("location")
    def serialize_location(self, location: Union[HttpUrl, str], _info: Any) -> str:
        """Serialize location to string."""
        return str(location)

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Secret(BaseModel):
    """Model for secret definitions."""

    name: str = Field(..., description="Secret name")
    kind: SecretKind = Field(..., description="Secret type")
    ref: str = Field(..., description="Secret reference")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Variable(BaseModel):
    """Model for variable definitions."""

    name: str = Field(..., description="Variable name")
    kind: VariableKind = Field(..., description="Variable type")
    ref: str = Field(..., description="Variable reference")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Pipeline(BaseModel):
    """Model for CI/CD pipeline."""

    name: str = Field(..., description="Pipeline name")
    ref: str = Field(..., description="Pipeline reference")
    variables: Optional[List[str]] = Field(None, description="Pipeline variables")

    @field_validator("variables", mode="before")
    def validate_variables(cls, v: Any) -> Any:
        """Validate variables field."""
        if v is None:
            return []
        return v

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class CICD(BaseModel):
    """Model for CI/CD configuration."""

    platform: CICDPlatform = Field(..., description="CI/CD platform")
    pipelines: List[Pipeline] = Field(..., description="List of pipelines")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Environment(BaseModel):
    """Model for deployment environment."""

    name: str = Field(..., description="Environment name")
    url: Optional[HttpUrl] = Field(None, description="Environment URL")

    @field_serializer("url")
    def serialize_url(self, url: Optional[HttpUrl], _info: Any) -> Optional[str]:
        """Serialize URL to string."""
        return str(url) if url else None

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Infrastructure(BaseModel):
    """Model for infrastructure configuration."""

    provisioning_tool: ProvisioningTool = Field(..., description="Provisioning tool")
    hosting: Hosting = Field(..., description="Hosting platform")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Deployment(BaseModel):
    """Model for deployment configuration."""

    strategy: DeploymentStrategy = Field(..., description="Deployment strategy")
    environments: Optional[List[Environment]] = Field(
        None, description="Deployment environments"
    )
    infrastructure: Optional[Infrastructure] = Field(
        None, description="Infrastructure configuration"
    )

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class AlertingChannel(BaseModel):
    """Model for alerting channel."""

    name: str = Field(..., description="Alerting channel name")
    type: AlertingChannelType = Field(..., description="Alerting channel type")
    url: Union[HttpUrl, str] = Field(..., description="Alerting channel URL")

    @field_serializer("url")
    def serialize_url(self, url: Union[HttpUrl, str], _info: Any) -> str:
        """Serialize URL to string."""
        return str(url)

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Dashboard(BaseModel):
    """Model for monitoring dashboard."""

    name: str = Field(..., description="Dashboard name")
    tool: str = Field(..., description="Dashboard tool")
    url: HttpUrl = Field(..., description="Dashboard URL")

    @field_serializer("url")
    def serialize_url(self, url: HttpUrl, _info: Any) -> str:
        """Serialize URL to string."""
        return str(url)

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Observability(BaseModel):
    """Model for observability configuration."""

    logging_provider: Optional[LoggingProvider] = Field(
        None, description="Logging provider"
    )
    monitoring_providers: Optional[List[MonitoringProvider]] = Field(
        None, description="Monitoring providers"
    )
    alerting_channels: Optional[List[AlertingChannel]] = Field(
        None, description="Alerting channels"
    )
    dashboards: Optional[List[Dashboard]] = Field(
        None, description="Monitoring dashboards"
    )

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Visibility(str, Enum):
    """Enumeration of repository visibility types."""

    PUBLIC = "public"
    PRIVATE = "private"
    INTERNAL = "internal"


class ProjectType(str, Enum):
    """Enumeration of project types."""

    APPLICATION = "application"
    LIBRARY = "library"
    MICROSERVICE = "microservice"
    CLI = "cli"
    IAC = "iac"
    CONFIG = "config"
    DATA_SCIENCE = "data-science"
    PLUGIN = "plugin"
    TEMPLATE = "template"
    DOCS = "docs"
    TEST = "test"
    OTHER = "other"


class ProjectDomain(str, Enum):
    """Enumeration of project domains."""

    WEB = "web"
    MOBILE = "mobile"
    DEVOPS = "devops"
    ML = "ml"
    DATABASE = "database"
    SECURITY = "security"
    FINANCE = "finance"
    GAMING = "gaming"
    IOT = "iot"
    AGENT = "agent"
    OTHER = "other"
    DOCUMENTATION = "documentation"
    TEST = "test"
    PLUGIN = "plugin"
    TEMPLATE = "template"
    CONFIG = "config"
    DATA_SCIENCE = "data-science"
    MICROSERVICE = "microservice"
    CLI = "cli"


class BuildTool(str, Enum):
    """Enumeration of build tools."""

    MAKE = "make"
    CMAKE = "cmake"
    BAZEL = "bazel"
    NONE = "none"


class LicenseType(str, Enum):
    """Enumeration of license types."""

    MIT = "MIT"
    APACHE_2_0 = "Apache-2.0"
    PROPRIETARY = "proprietary"


class Owner(BaseModel):
    """Model for repository owner information."""

    org: str = Field(..., description="Organization name")
    team: str = Field(..., description="Team name")
    contact: str = Field(..., description="Contact email")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Language(BaseModel):
    """Model for project language information."""

    primary: str = Field(..., description="Primary programming language")
    secondary: Optional[List[str]] = Field(
        None, description="Secondary programming languages"
    )

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Project(BaseModel):
    """Model for project information."""

    description: Optional[str] = Field(
        None, description="Human-readable description of this project"
    )
    agent_instructions: Optional[str] = Field(
        None,
        validation_alias=AliasChoices("agent_instructions", "agent_prompt"),
        description="Optional instructions for agents working on this project",
    )
    type: ProjectType = Field(..., description="Project type")
    domain: ProjectDomain = Field(..., description="Project domain")
    language: Language = Field(..., description="Language information")
    framework: Optional[List[str]] = Field(None, description="Frameworks used")
    package_managers: Optional[List[str]] = Field(
        None, description="Package managers used"
    )
    build_tool: Optional[BuildTool] = Field(None, description="Build tool used")
    deploy_targets: Optional[List[str]] = Field(None, description="Deployment targets")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class RepoMetadata(BaseModel):
    """Model for repository metadata."""

    tags: Optional[List[str]] = Field(None, description="Repository tags")
    created_at: Optional[datetime] = Field(None, description="Repository creation date")
    last_commit_at: Optional[datetime] = Field(None, description="Last commit date")
    default_branch: Optional[str] = Field(None, description="Default branch name")
    topics: Optional[List[str]] = Field(None, description="Repository topics")
    forked_from: Optional[Union[HttpUrl, str]] = Field(
        None, description="Forked from repository URL"
    )
    archived: Optional[bool] = Field(
        False, description="Whether repository is archived"
    )
    template: Optional[bool] = Field(
        False, description="Whether repository is a template"
    )
    has_ci: Optional[bool] = Field(False, description="Whether repository has CI/CD")
    has_tests: Optional[bool] = Field(False, description="Whether repository has tests")
    has_docs: Optional[bool] = Field(
        False, description="Whether repository has documentation"
    )
    has_docker: Optional[bool] = Field(
        False, description="Whether repository has Docker configuration"
    )
    has_iac: Optional[bool] = Field(
        False, description="Whether repository has Infrastructure as Code"
    )

    @field_serializer("forked_from")
    def serialize_forked_from(
        self, forked_from: Optional[Union[HttpUrl, str]], _info: Any
    ) -> Optional[str]:
        """Serialize forked_from to string."""
        return str(forked_from) if forked_from else None

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class CommitFrequency(str, Enum):
    """Enumeration of commit frequency types."""

    DAILY = "daily"
    WEEKLY = "weekly"
    MONTHLY = "monthly"
    UNKNOWN = "unknown"


class PullRequests(BaseModel):
    """Model for pull request metrics."""

    open: int = Field(..., description="Number of open pull requests")
    merged_last_30d: int = Field(
        ..., description="Number of pull requests merged in last 30 days"
    )

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Metrics(BaseModel):
    """Model for repository metrics."""

    stars: int = Field(..., description="Number of stars")
    forks: int = Field(..., description="Number of forks")
    open_issues: int = Field(..., description="Number of open issues")
    pull_requests: PullRequests = Field(..., description="Pull request metrics")
    contributors: int = Field(..., description="Number of contributors")
    commit_frequency: CommitFrequency = Field(..., description="Commit frequency")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


# New configuration models for AppConfig
class MetagitConfig(BaseModel):
    """Main model for .metagit.yml configuration file."""

    name: str = Field(..., description="Project name")
    description: Optional[str] = Field(
        default="No description", description="Project description"
    )
    agent_instructions: Optional[str] = Field(
        None,
        validation_alias=AliasChoices("agent_instructions", "agent_prompt"),
        description=(
            "Optional instructions for controller agents; applies with or without "
            "a workspace block"
        ),
    )
    url: Optional[Union[HttpUrl, GitUrl]] = Field(None, description="Project URL")
    kind: Optional[ProjectKind] = Field(
        default=ProjectKind.APPLICATION,
        description="Project kind. This is used to determine the type of project and the best way to manage it.",
    )
    documentation: Optional[List[DocumentationSource]] = Field(
        None,
        description=(
            "Documentation sources: bare strings (path or URL) or objects with "
            "kind, path/url, tags, and metadata for knowledge-graph ingestion"
        ),
    )
    graph: Optional[WorkspaceGraph] = Field(
        None,
        description=(
            "Manual cross-repo relationships and graph metadata for exports "
            "and GitNexus-style dependency maps"
        ),
    )
    license: Optional[License] = Field(None, description="License information")
    maintainers: Optional[List[Maintainer]] = Field(
        None, description="Project maintainers"
    )
    branch_strategy: Optional[BranchStrategy] = Field(
        default="unknown", description="Branch strategy used by the project."
    )
    taskers: Optional[List[Tasker]] = Field(
        None, description="Task management tools employed by the project."
    )
    branch_naming: Optional[List[BranchNaming]] = Field(
        None, description="Branch naming patterns used by the project."
    )
    artifacts: Optional[List[Artifact]] = Field(
        default_factory=lambda: [], description="Generated artifacts from the project."
    )
    secrets_management: Optional[List[str]] = Field(
        None, description="Secrets management tools employed by the project."
    )
    secrets: Optional[List[Secret]] = Field(None, description="Secret definitions")
    variables: Optional[List[Variable]] = Field(
        None, description="Variable definitions"
    )
    cicd: Optional[CICD] = Field(None, description="CI/CD configuration")
    deployment: Optional[Deployment] = Field(
        None, description="Deployment configuration"
    )
    observability: Optional[Observability] = Field(
        None, description="Observability configuration"
    )
    paths: Optional[List[ProjectPath]] = Field(
        default_factory=lambda: [],
        description="Important local project paths. In a monorepo, this would include any sub-projects typically found being built in the CICD pipelines.",
    )
    dependencies: Optional[List[ProjectPath]] = Field(
        default_factory=lambda: [],
        description="Additional project dependencies not found in the paths or components lists. These include docker images, helm charts, or terraform modules.",
    )
    components: Optional[List[ProjectPath]] = Field(
        None,
        description="Additional project component paths that may be useful in other projects.",
    )
    workspace: Optional[Workspace] = Field(
        default_factory=lambda: Workspace(
            projects=[
                WorkspaceProject(
                    name="default",
                    repos=[],
                )
            ],
        ),
        description="Workspaces are a collection of projects that are related to each other. They are used to group projects together for a specific purpose. These are manually defined by the user. The internal workspace name is reservice",
    )

    @field_validator("documentation", mode="before")
    @classmethod
    def _coerce_documentation(cls, value: object) -> object:
        """Accept strings or dicts in YAML documentation lists."""
        return normalize_documentation_entries(value)

    @field_serializer("url")
    def serialize_url(
        self, url: Optional[Union[HttpUrl, str]], _info: Any
    ) -> Optional[str]:
        """Serialize URL to string."""
        return str(url) if url else None

    def documentation_graph_nodes(self) -> list[dict[str, Any]]:
        """Export documentation entries for knowledge-graph ingestors."""
        if not self.documentation:
            return []
        return [entry.graph_node_payload() for entry in self.documentation]

    def graph_export_payload(self) -> dict[str, Any]:
        """Export manual graph relationships and metadata for external tools."""
        if self.graph is None:
            return {"relationships": [], "metadata": {}}
        relationships = []
        for rel in self.graph.relationships:
            relationships.append(
                {
                    "id": rel.id,
                    "from": rel.from_endpoint.model_dump(mode="json", by_alias=True),
                    "to": rel.to.model_dump(mode="json"),
                    "type": rel.type,
                    "label": rel.label,
                    "description": rel.description,
                    "tags": dict(rel.tags),
                    "metadata": dict(rel.metadata),
                }
            )
        return {
            "relationships": relationships,
            "metadata": dict(self.graph.metadata),
        }

    @property
    def local_workspace_project(self) -> WorkspaceProject:
        """Get the local workspace project configuration."""
        # Combine paths and dependencies into a single list of repos
        repos = []
        if self.paths:
            repos.extend(self.paths)
        if self.dependencies:
            repos.extend(self.dependencies)
        return WorkspaceProject(name="local", repos=repos)

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        validate_assignment = True
        extra = "forbid"


class TenantConfig(AppConfig):
    """Model for tenant configuration that inherits from AppConfig to include all Boundary settings."""

    # Tenant-specific fields (in addition to all AppConfig fields)
    enabled: bool = Field(default=False, description="Whether multi-tenancy is enabled")
    default_tenant: str = Field(default="default", description="Default tenant ID")
    tenant_header: str = Field(default="X-Tenant-ID", description="Tenant header name")
    tenant_required: bool = Field(
        default=False, description="Whether tenant is required"
    )
    allowed_tenants: List[str] = Field(
        default_factory=list, description="List of allowed tenant IDs"
    )

    @classmethod
    def _override_from_environment(cls, config: "TenantConfig") -> "TenantConfig":
        """
        Override configuration with environment variables, including tenant-specific ones.

        Args:
            config: TenantConfig to override

        Returns:
            Updated TenantConfig
        """
        # Call parent method first
        config = super()._override_from_environment(config)

        # Tenant-specific environment variables
        if os.getenv("METAGIT_TENANT_ENABLED"):
            config.enabled = os.getenv("METAGIT_TENANT_ENABLED").lower() == "true"
        if os.getenv("METAGIT_TENANT_DEFAULT"):
            config.default_tenant = os.getenv("METAGIT_TENANT_DEFAULT")
        if os.getenv("METAGIT_TENANT_HEADER"):
            config.tenant_header = os.getenv("METAGIT_TENANT_HEADER")
        if os.getenv("METAGIT_TENANT_REQUIRED"):
            config.tenant_required = (
                os.getenv("METAGIT_TENANT_REQUIRED").lower() == "true"
            )
        if os.getenv("METAGIT_TENANT_ALLOWED"):
            config.allowed_tenants = os.getenv("METAGIT_TENANT_ALLOWED").split(",")

        return config

    @classmethod
    def load(cls, config_path: str = None) -> Union["TenantConfig", Exception]:
        """
        Load TenantConfig from file.

        Args:
            config_path: Path to configuration file (optional)

        Returns:
            TenantConfig object or Exception
        """
        try:
            if not config_path:
                config_path = os.path.join(
                    Path.home(), ".config", "metagit", "config.yml"
                )

            config_file = Path(config_path)
            if not config_file.exists():
                return cls()

            with config_file.open("r") as f:
                config_data = yaml.safe_load(f)

            if "config" in config_data:
                config = cls(**config_data["config"])
            else:
                config = cls(**config_data)

            # Override with environment variables
            config = cls._override_from_environment(config)

            return config

        except Exception as e:
            return e

    class Config:
        """Pydantic configuration."""

        extra = "forbid"
`````

## File: src/metagit/core/detect/manager.py
`````python
#!/usr/bin/env python3

import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Union
import importlib
import pkgutil

import yaml
from git import InvalidGitRepositoryError, NoSuchPathError, Repo
from pydantic import Field

from metagit.core.config.models import (
    Branch,
    Language,
    MetagitConfig,
    Metrics,
    PullRequests,
    RepoMetadata,
)
from metagit.core.detect.models import (
    BranchInfo,
    BranchStrategy,
    CIConfigAnalysis,
    DetectionManagerConfig,
    GitBranchAnalysis,
    LanguageDetection,
    ProjectTypeDetection,
    Detector,
    DiscoveryResult,
    ProjectScanContext,
)
from metagit.core.record.models import MetagitRecord
from metagit.core.utils.common import normalize_git_url
from metagit.core.utils.files import (
    FileExtensionLookup,
    directory_details,
    directory_summary,
    list_git_files,
)
from metagit.core.utils.logging import LoggerConfig, LoggingModel, UnifiedLogger

import metagit.core.detect.detectors as detectors

# from metagit.core.detect.detectors.terraform import TerraformModuleDiscovery


class DetectionManager(MetagitRecord, LoggingModel):
    """
    Single entrypoint for performing detection analysis of a target git project or git project path.

    This class inherits from MetagitRecord and includes all RepositoryAnalysis functionality.
    Existing metagitconfig data is loaded first if a config file exists in the project.
    """

    # Detection-specific configuration
    detection_config: DetectionManagerConfig = Field(
        default_factory=DetectionManagerConfig, description="Analysis configuration"
    )

    # Internal tracking
    analysis_completed: bool = Field(
        default=False, description="Whether analysis has been completed"
    )

    @property
    def project_path(self) -> str:
        """Get the project path."""
        return self.path or ""

    @project_path.setter
    def project_path(self, value: str) -> None:
        """Set the project path."""
        self.path = value

    @classmethod
    def from_path(
        cls,
        path: str,
        logger: Optional[UnifiedLogger] = None,
        config: Optional[DetectionManagerConfig] = None,
    ) -> Union["DetectionManager", Exception]:
        """
        Create a DetectionManager from a local path.

        Args:
            path: Path to the git repository or project directory
            logger: Logger instance to use
            config: Detection configuration

        Returns:
            DetectionManager instance or Exception
        """
        logger = logger or UnifiedLogger(LoggerConfig()).get_logger()
        try:
            logger.debug(f"Creating DetectionManager from path: {path}")

            if not os.path.exists(path):
                return FileNotFoundError(f"Path does not exist: {path}")

            # Load existing metagitconfig if it exists
            existing_config = cls._load_existing_config(path)

            # Create base MetagitRecord data
            record_data = {
                "name": Path(path).name,
                "path": path,
                "detection_timestamp": datetime.now(timezone.utc),
                "detection_source": "local",
                "detection_version": "1.0.0",
            }

            # Merge with existing config if found
            if existing_config:
                record_data.update(existing_config.model_dump(exclude_none=True))

            # Create DetectionManager instance
            manager = cls(
                **record_data,
                detection_config=config or DetectionManagerConfig(),
            )
            manager.set_logger(logger)

            return manager

        except Exception as e:
            return e

    @classmethod
    def from_url(
        cls,
        url: str,
        temp_dir: Optional[str] = None,
        logger: Optional[UnifiedLogger] = None,
        config: Optional[DetectionManagerConfig] = None,
    ) -> Union["DetectionManager", Exception]:
        """
        Create a DetectionManager from a git URL (clones the repository).

        Args:
            url: Git repository URL
            temp_dir: Temporary directory for cloning
            logger: Logger instance to use
            config: Detection configuration

        Returns:
            DetectionManager instance or Exception
        """
        logger = logger or UnifiedLogger(LoggerConfig()).get_logger()
        try:
            normalized_url = normalize_git_url(url)
            logger.debug(f"Creating DetectionManager from URL: {normalized_url}")

            # Create temporary directory if not provided
            if temp_dir is None:
                temp_dir = tempfile.mkdtemp(prefix="metagit_")

            # Clone the repository
            try:
                _ = Repo.clone_from(normalized_url, temp_dir)
                logger.debug(f"Successfully cloned repository to: {temp_dir}")
            except Exception as e:
                return Exception(f"Failed to clone repository: {e}")

            # Create base MetagitRecord data
            record_data = {
                "name": Path(temp_dir).name,
                "path": temp_dir,
                "url": normalized_url,
                "is_git_repo": True,
                "is_cloned": True,
                "temp_dir": temp_dir,
                "detection_timestamp": datetime.now(timezone.utc),
                "detection_source": "remote",
                "detection_version": "1.0.0",
            }

            # Load existing metagitconfig if it exists in the cloned repo
            existing_config = cls._load_existing_config(temp_dir)
            if existing_config:
                record_data.update(existing_config.model_dump(exclude_none=True))

            # Create DetectionManager instance
            manager = cls(
                **record_data,
                detection_config=config or DetectionManagerConfig(),
            )
            manager.set_logger(logger)

            return manager

        except Exception as e:
            return e

    @staticmethod
    def _load_existing_config(path: str) -> Optional[MetagitConfig]:
        """Load existing metagitconfig if it exists in the project."""
        config_paths = [
            Path(path) / "metagit.config.yaml",
            Path(path) / "metagit.config.yml",
            Path(path) / ".metagit.yml",
            Path(path) / ".metagit.yaml",
        ]

        for config_path in config_paths:
            if config_path.exists():
                try:
                    with open(config_path, "r", encoding="utf-8") as f:
                        config_data = yaml.safe_load(f)
                    return MetagitConfig(**config_data)
                except Exception:
                    continue

        return None

    def run_all(self) -> Union[None, Exception]:
        """
        Run all enabled analysis methods.

        Returns:
            None if successful, Exception if failed
        """
        try:
            # Check if this is a git repository
            try:
                _ = Repo(self.path)
                self.is_git_repo = True
            except (InvalidGitRepositoryError, NoSuchPathError):
                self.is_git_repo = False

            self._extract_metadata()

            language_result = self._detect_languages()
            if isinstance(language_result, Exception):
                self.logger.warning(f"Language detection failed: {language_result}")
            else:
                self.language_detection = language_result

            # Run project type detection
            type_result = self._detect_project_type()
            if isinstance(type_result, Exception):
                self.logger.warning(f"Project type detection failed: {type_result}")
            else:
                self.project_type_detection = type_result

            # Run branch analysis if enabled
            if self.detection_config.branch_analysis_enabled and self.is_git_repo:
                try:
                    self.branch_analysis = GitBranchAnalysis.from_repo(
                        self.path, self.logger
                    )
                    if isinstance(self.branch_analysis, Exception):
                        self.logger.warning(
                            f"Branch analysis failed: {self.branch_analysis}"
                        )
                        self.branch_analysis = None
                except Exception as e:
                    self.logger.warning(f"Branch analysis failed: {e}")

            # Run CI/CD analysis if enabled
            if self.detection_config.ci_config_analysis_enabled:
                try:
                    self.ci_config_analysis = self._ci_config_analysis()
                    if isinstance(self.ci_config_analysis, Exception):
                        self.logger.warning(
                            f"CI/CD analysis failed: {self.ci_config_analysis}"
                        )
                        self.ci_config_analysis = None
                except Exception as e:
                    self.logger.warning(f"CI/CD analysis failed: {e}")

            # Run directory summary analysis if enabled
            if self.detection_config.directory_summary_enabled:
                try:
                    self.directory_summary = directory_summary(self.path)
                except Exception as e:
                    self.logger.warning(f"Directory summary analysis failed: {e}")

            # Run directory details analysis if enabled
            if self.detection_config.directory_details_enabled:
                try:
                    file_lookup = FileExtensionLookup()
                    self.directory_details = directory_details(self.path, file_lookup)
                except Exception as e:
                    self.logger.warning(f"Directory details analysis failed: {e}")

            # Analyze files
            self._analyze_files()

            # Detect metrics
            self._detect_metrics()

            # Update MetagitRecord fields
            self._update_metagit_record()

            self.analysis_completed = True
            self.logger.debug("All analysis methods completed successfully")
            return None

        except Exception as e:
            return e

    def run_specific(self, method_name: str) -> Union[None, Exception]:
        """
        Run a specific analysis method.

        Args:
            method_name: Name of the method to run

        Returns:
            None if successful, Exception if failed
        """
        try:
            self.logger.debug(f"Running specific analysis method: {method_name}")

            if method_name == "language_detection":
                result = self._detect_languages()
                if isinstance(result, Exception):
                    return result
                self.language_detection = result

            elif method_name == "project_type_detection":
                result = self._detect_project_type()
                if isinstance(result, Exception):
                    return result
                self.project_type_detection = result

            elif method_name == "branch_analysis":
                if not self.is_git_repo:
                    return Exception("Branch analysis requires a git repository")
                result = GitBranchAnalysis.from_repo(self.path, self.logger)
                if isinstance(result, Exception):
                    return result
                self.branch_analysis = result

            elif method_name == "ci_config_analysis":
                result = self._ci_config_analysis()
                if isinstance(result, Exception):
                    return result
                self.ci_config_analysis = result

            elif method_name == "directory_summary":
                result = directory_summary(self.path)
                self.directory_summary = result

            elif method_name == "directory_details":
                file_lookup = FileExtensionLookup()
                result = directory_details(self.path, file_lookup)
                self.directory_details = result

            else:
                return Exception(f"Unknown analysis method: {method_name}")

            # Update MetagitRecord fields
            self._update_metagit_record()

            self.logger.debug(f"Successfully ran analysis method: {method_name}")
            return None

        except Exception as e:
            return e

    def _extract_metadata(self) -> None:
        """Extract basic repository metadata."""
        try:
            # Extract name from path if not set
            if not self.name:
                self.name = Path(self.path).name

            # Try to extract description from README files
            readme_files = ["README.md", "README.txt", "README.rst", "README"]
            for readme_file in readme_files:
                readme_path = Path(self.path) / readme_file
                if readme_path.exists():
                    try:
                        with open(readme_path, "r", encoding="utf-8") as f:
                            content = f.read()
                            # Extract first line as description
                            lines = content.split("\n")
                            for line in lines:
                                line = line.strip()
                                if line and not line.startswith("#"):
                                    self.description = line[:200]  # Limit to 200 chars
                                    break
                    except Exception:
                        continue
                    break

        except Exception as e:
            self.logger.warning(f"Metadata extraction failed: {e}")

    def _detect_languages(self) -> Union[LanguageDetection, Exception]:
        """Detect programming languages in the repository."""
        try:
            # This is a simplified language detection
            # In a real implementation, you would use more sophisticated detection
            detected_languages = []
            frameworks = []
            package_managers = []
            build_tools = []

            # Check for common file extensions
            for root, dirs, files in os.walk(self.path):
                for file in files:
                    if file.endswith(".py"):
                        detected_languages.append("Python")
                    elif file.endswith(".js"):
                        detected_languages.append("JavaScript")
                    elif file.endswith(".ts"):
                        detected_languages.append("TypeScript")
                    elif file.endswith(".java"):
                        detected_languages.append("Java")
                    elif file.endswith(".go"):
                        detected_languages.append("Go")
                    elif file.endswith(".rs"):
                        detected_languages.append("Rust")
                    elif file.endswith(".cpp") or file.endswith(".cc"):
                        detected_languages.append("C++")
                    elif file.endswith(".c"):
                        detected_languages.append("C")

                # Check for framework and tool indicators
                if "requirements.txt" in files or "pyproject.toml" in files:
                    package_managers.append("pip")
                if "package.json" in files:
                    package_managers.append("npm")
                if "Cargo.toml" in files:
                    package_managers.append("cargo")
                if "go.mod" in files:
                    package_managers.append("go modules")
                if "pom.xml" in files:
                    package_managers.append("maven")
                if "build.gradle" in files:
                    package_managers.append("gradle")
            package_managers = list(set(package_managers))

            # Determine primary language (most common)
            if detected_languages:
                primary = max(set(detected_languages), key=detected_languages.count)
                secondary = list(set(detected_languages) - {primary})
            else:
                primary = "Unknown"
                secondary = []

            return LanguageDetection(
                primary=primary,
                secondary=secondary,
                frameworks=frameworks,
                package_managers=package_managers,
                build_tools=build_tools,
            )

        except Exception as e:
            return e

    def _detect_project_type(self) -> Union[ProjectTypeDetection, Exception]:
        """Detect project type and domain."""
        try:
            # This is a simplified project type detection
            # In a real implementation, you would use more sophisticated detection
            indicators = []
            project_type = "other"
            domain = "other"
            confidence = 0.5

            # Check for common project indicators
            if any(Path(self.path).glob("*.py")):
                indicators.append("Python files")
                if any(Path(self.path).glob("*.py")):
                    project_type = "application"
                    confidence = 0.7

            if any(Path(self.path).glob("package.json")):
                indicators.append("Node.js project")
                project_type = "application"
                confidence = 0.8

            if any(Path(self.path).glob("Dockerfile")):
                indicators.append("Docker configuration")
                project_type = "application"
                confidence = 0.6

            if any(Path(self.path).glob("*.md")):
                indicators.append("Documentation")
                domain = "documentation"

            return ProjectTypeDetection(
                type=project_type,
                domain=domain,
                confidence=confidence,
                indicators=indicators,
            )

        except Exception as e:
            return e

    def _analyze_files(self) -> None:
        """Analyze files in the repository."""
        try:
            # Check for various file types
            self.has_docker = any(Path(self.path).glob("Dockerfile*"))
            self.has_tests = any(
                Path(self.path).glob("**/test*") or Path(self.path).glob("**/*test*")
            )
            self.has_docs = any(Path(self.path).glob("**/*.md"))
            self.has_iac = any(
                Path(self.path).glob("**/*.tf") or Path(self.path).glob("**/*.yaml")
            )

            # Categorize detected files
            self.detected_files = {
                "docker": [str(f) for f in Path(self.path).glob("Dockerfile*")],
                "tests": [str(f) for f in Path(self.path).glob("**/test*")],
                "docs": [str(f) for f in Path(self.path).glob("**/*.md")],
                "config": [str(f) for f in Path(self.path).glob("**/*.yaml")],
            }

        except Exception as e:
            self.logger.warning(f"File analysis failed: {e}")

    def _detect_metrics(self) -> None:
        """Detect repository metrics."""
        try:
            if not self.is_git_repo:
                return

            repo = Repo(self.path)

            # Create metrics object
            self.metrics = Metrics(
                stars=0,  # Would be fetched from provider API
                forks=0,  # Would be fetched from provider API
                open_issues=0,  # Would be fetched from provider API
                pull_requests=PullRequests(open=0, merged_last_30d=0),
                contributors=len(repo.heads) if repo.heads else 0,
                commit_frequency="unknown",
            )

            # Create metadata object
            self.metadata = RepoMetadata(
                tags=[],
                created_at=None,
                last_commit_at=(
                    repo.head.commit.committed_datetime
                    if repo.head.is_valid()
                    else None
                ),
                default_branch=(
                    repo.active_branch.name if repo.head.is_valid() else None
                ),
                topics=[],
                forked_from=None,
                archived=False,
                template=False,
                has_ci=self.ci_config_analysis is not None,
                has_tests=self.has_tests,
                has_docs=self.has_docs,
                has_docker=self.has_docker,
                has_iac=self.has_iac,
            )

        except Exception as e:
            self.logger.warning(f"Metrics detection failed: {e}")

    def _update_metagit_record(self) -> None:
        """Update MetagitRecord fields with analysis results."""
        try:
            # Update language information
            if self.language_detection:
                self.language = Language(
                    primary=self.language_detection.primary,
                    secondary=self.language_detection.secondary,
                )

            # Update project type information
            if self.project_type_detection:
                self.domain = self.project_type_detection.domain

            # Update branch information
            if self.branch_analysis:
                # Convert BranchInfo to Branch objects
                self.branches = [
                    Branch(
                        name=branch.name,
                        environment=(
                            "production" if branch.name == "main" else "development"
                        ),
                    )
                    for branch in self.branch_analysis.branches
                ]

            # Update detection timestamp
            self.detection_timestamp = datetime.now(timezone.utc)

        except Exception as e:
            self.logger.warning(f"Failed to update MetagitRecord: {e}")

    def summary(self) -> Union[str, Exception]:
        """
        Generate a summary of the repository analysis.

        Returns:
            Summary string or Exception
        """
        try:
            lines = [f"Repository Analysis for: {self.name or self.path}"]
            lines.append(f"Path: {self.path}")
            if self.url:
                lines.append(f"URL: {self.url}")
            lines.append(f"Git repository: {self.is_git_repo}")
            lines.append(f"Cloned: {self.is_cloned}")

            # Language detection
            if self.language_detection:
                lines.append(f"Primary language: {self.language_detection.primary}")
                if self.language_detection.secondary:
                    lines.append(
                        f"Secondary languages: {', '.join(self.language_detection.secondary)}"
                    )
                if self.language_detection.frameworks:
                    lines.append(
                        f"Frameworks: {', '.join(self.language_detection.frameworks)}"
                    )

            # Project type detection
            if self.project_type_detection:
                lines.append(f"Project type: {self.project_type_detection.type}")
                lines.append(f"Domain: {self.project_type_detection.domain}")
                lines.append(f"Confidence: {self.project_type_detection.confidence}")

            # Branch analysis
            if self.branch_analysis:
                lines.append(f"Branch strategy: {self.branch_analysis.strategy_guess}")
                lines.append(
                    f"Number of branches: {len(self.branch_analysis.branches)}"
                )

            # CI/CD analysis
            if self.ci_config_analysis:
                lines.append(f"CI/CD tool: {self.ci_config_analysis.detected_tool}")

            # Directory analysis
            if self.directory_summary:
                lines.append(f"Total files: {self.directory_summary.num_files}")
                lines.append(f"File types: {len(self.directory_summary.file_types)}")

            if self.directory_details:
                lines.append(f"Detailed files: {self.directory_details.num_files}")
                lines.append(
                    f"File categories: {len(self.directory_details.file_types)}"
                )

            # File analysis
            lines.append(f"Has Docker: {self.has_docker}")
            lines.append(f"Has tests: {self.has_tests}")
            lines.append(f"Has docs: {self.has_docs}")
            lines.append(f"Has IaC: {self.has_iac}")

            # Metrics
            if self.metrics:
                lines.append(f"Total commits: {self.metrics.contributors}")
                lines.append(f"Commit frequency: {self.metrics.commit_frequency}")

            return "\n".join(lines)

        except Exception as e:
            return e

    def to_yaml(self) -> Union[str, Exception]:
        """
        Convert DetectionManager to YAML string.

        Returns:
            YAML string or Exception
        """
        try:
            data = self.model_dump(exclude_none=True, exclude_defaults=True)

            # Handle complex objects that can't be serialized directly
            def convert_objects(obj):
                if hasattr(obj, "model_dump"):
                    return obj.model_dump()
                elif isinstance(obj, datetime):
                    return obj.isoformat()
                elif isinstance(obj, Path):
                    return str(obj)
                return obj

            # Convert nested objects
            for key, value in data.items():
                if isinstance(value, dict):
                    data[key] = {k: convert_objects(v) for k, v in value.items()}
                elif isinstance(value, list):
                    data[key] = [convert_objects(v) for v in value]
                else:
                    data[key] = convert_objects(value)

            return yaml.safe_dump(data, indent=2, default_flow_style=False)

        except Exception as e:
            return e

    def to_json(self) -> Union[str, Exception]:
        """
        Convert DetectionManager to JSON string.

        Returns:
            JSON string or Exception
        """
        try:
            data = self.model_dump(exclude_none=True, exclude_defaults=True)

            # Handle complex objects that can't be serialized directly
            def convert_objects(obj):
                if hasattr(obj, "model_dump"):
                    return obj.model_dump()
                elif isinstance(obj, datetime):
                    return obj.isoformat()
                elif isinstance(obj, Path):
                    return str(obj)
                return obj

            # Convert nested objects
            for key, value in data.items():
                if isinstance(value, dict):
                    data[key] = {k: convert_objects(v) for k, v in value.items()}
                elif isinstance(value, list):
                    data[key] = [convert_objects(v) for v in value]
                else:
                    data[key] = convert_objects(value)

            return json.dumps(data, indent=2, default=str)

        except Exception as e:
            return e

    def _ci_config_analysis(
        self, repo_path: str = None
    ) -> Union[CIConfigAnalysis, Exception]:
        """
        Analyze CI/CD configuration in the repository.

        Args:
            repo_path: Path to the repository

        Returns:
            CIConfigAnalysis object or Exception
        """
        if not repo_path:
            repo_path = self.path
        if not Path(repo_path).is_dir():
            return Exception(f"Invalid repository path: {repo_path}")

        repo_path_obj = Path(repo_path)
        if not repo_path_obj.exists():
            return Exception(f"Repository path does not exist: {repo_path}")

        try:
            analysis = CIConfigAnalysis()

            # Check for common CI/CD configuration files
            ci_files = self.detection_config.data_ci_file_source

            for file_path, tool_name in ci_files.items():
                full_path = os.path.join(repo_path_obj, file_path)
                if full_path.exists():
                    analysis.detected_tool = tool_name
                    analysis.ci_config_path = str(full_path)

                    # Read configuration content
                    if full_path.is_dir():
                        for file in full_path.iterdir():
                            if file.is_file():
                                with open(file, "r", encoding="utf-8") as f:
                                    analysis.config_content = f.read()
                    else:
                        try:
                            with open(full_path, "r", encoding="utf-8") as f:
                                analysis.config_content = f.read()
                        except Exception as e:
                            self.logger.warning(
                                f"Could not read CI config file {full_path}: {e}"
                            )

                    self.logger.debug(f"Detected CI/CD tool: {tool_name}")
                    break

            # Count pipelines (basic heuristic)
            if analysis.config_content:
                # Simple pipeline counting based on common patterns
                pipeline_indicators = ["job:", "stage:", "pipeline:", "workflow:"]
                analysis.pipeline_count = sum(
                    1
                    for indicator in pipeline_indicators
                    if indicator in analysis.config_content
                )

            return analysis

        except Exception as e:
            self.logger.exception(f"CI/CD analysis failed: {e}")
            return e

    def _branch_analysis(
        self, repo_path: str = "."
    ) -> Union[GitBranchAnalysis, Exception]:
        """
        Analyze the git repository at the given path and return branch information and a strategy guess.
        Uses GitPython for all git operations.

        Args:
            repo_path: Path to the repository

        Returns:
            GitBranchAnalysis object or Exception

        Notes:
          - Should look to replace this with a more sophisticated analysis
          - Should replace GitPython with a more lightweight library
        """

        try:
            repo = Repo(repo_path)
        except (InvalidGitRepositoryError, NoSuchPathError) as e:
            self.logger.exception(f"Invalid git repository at '{repo_path}': {e}")
            return ValueError(f"Invalid git repository at '{repo_path}': {e}")

        # Get local branches
        local_branches = [
            BranchInfo(name=branch.name, is_remote=False)
            for branch in repo.branches
            if branch.name != "HEAD"  # Exclude HEAD branch
        ]

        # Get remote branches
        remote_branches = []
        for remote in repo.remotes:
            for ref in remote.refs:
                # Remove remote name prefix (e.g., 'origin/')
                branch_name = ref.name.split("/", 1)[1] if "/" in ref.name else ref.name
                # Exclude HEAD branch from remote branches
                if branch_name != "HEAD":
                    remote_branches.append(BranchInfo(name=branch_name, is_remote=True))

        # Combine and deduplicate branches (prefer local if name overlaps)
        all_branches_dict = {b.name: b for b in remote_branches}
        all_branches_dict.update({b.name: b for b in local_branches})
        all_branches = list(all_branches_dict.values())

        # Analyze branching strategy
        strategy_guess = self._analyze_branching_strategy(all_branches)

        return GitBranchAnalysis(branches=all_branches, strategy_guess=strategy_guess)

    def _analyze_branching_strategy(self, branches: List[BranchInfo]) -> BranchStrategy:
        """Analyze the branching strategy based on branch names and patterns.

        Args:
            branches: List of BranchInfo objects

        Returns:
            BranchStrategy enum value

        Notes:
          - Should look to replace this with a more sophisticated analysis
          - Should replace GitPython with a more lightweight library
          - Consider custom branch names via appconfig for analysis of additional strategies
        """

        if len(branches) == 0:
            return BranchStrategy.UNKNOWN

        # Only remote branches matter
        remote_branch_names = [b.name for b in branches if b.is_remote]
        # local_branch_names = [b.name for b in branches if not b.is_remote]

        # Check for Git Flow patterns
        if any(name in remote_branch_names for name in ["develop", "master", "main"]):
            if "develop" in remote_branch_names:
                return BranchStrategy.GIT_FLOW

        # Check for GitHub Flow patterns
        if "main" in remote_branch_names or "master" in remote_branch_names:
            if len(remote_branch_names) <= 2:  # main/master + feature branches
                return BranchStrategy.GITHUB_FLOW

        # Check for GitLab Flow patterns
        if any(name in remote_branch_names for name in ["staging", "production"]):
            return BranchStrategy.GITLAB_FLOW

        # Check for Trunk-Based Development
        if len(remote_branch_names) <= 1:
            return BranchStrategy.TRUNK_BASED_DEVELOPMENT

        # Check for Release Branching
        if any(name.startswith("release/") for name in remote_branch_names):
            return BranchStrategy.RELEASE_BRANCHING

        return BranchStrategy.UNKNOWN

    # def cleanup(self) -> None:
    #     """Clean up temporary files if this was a cloned repository."""
    #     if self.is_cloned and self.temp_dir and os.path.exists(self.temp_dir):
    #         try:
    #             shutil.rmtree(self.temp_dir)
    #             self.logger.debug(f"Cleaned up temporary directory: {self.temp_dir}")
    #         except Exception as e:
    #             self.logger.warning(f"Failed to clean up temporary directory: {e}")


# class ProjectDiscovery(Protocol):
#     def discover(self, metadata: ProjectMetadata) -> None:
#         """Mutates the metadata object by adding discovered dependencies"""
#         ...

# def run_discovery(root_dir: Path, logger: UnifiedLogger) -> ProjectMetadata:
#     """Create a map of files and folders in a repository for further analysis."""

#     try:
#         summary = directory_summary(root_dir, file_lookup=FileExtensionLookup())
#     except Exception as e:
#         logger.error(f"Error creating directory summary at {root_dir}: {e}")
#         return Exception(f"Error creating directory summary at {root_dir}: {e}")

#     try:
#         details = directory_details(target_path=root_dir, file_lookup=FileExtensionLookup())
#     except Exception as e:
#         logger.error(f"Error creating directory details at {root_dir}: {e}")
#         return Exception(f"Error creating directory details at {root_dir}: {e}")

#     metadata = ProjectMetadata(project_root=root_dir, directory_summary=summary, directory_details=details)

#     detectors = [TerraformModuleDiscovery()]  # Add more detectors here

#     for detector in detectors:
#         detector.discover(metadata)

#     return metadata


class ProjectDetection:
    """
    Class to manage project detection and analysis.
    This class is responsible for running various detection methods on a project.
    """

    def __init__(self, logger: Optional[UnifiedLogger] = None):
        # Dynamically register all detectors found in src/metagit/core/detect/detectors/

        self.detectors: List[Detector] = []
        self.logger = logger or UnifiedLogger(LoggerConfig()).get_logger()
        self._load_detectors()

    def _load_detectors(self) -> None:
        """
        Load all detector modules dynamically from the detectors package.
        This method uses pkgutil to find all modules in the detectors package and registers
        all classes that are subclasses of Detector.
        """

        for _, module_name, is_pkg in pkgutil.iter_modules(detectors.__path__):
            if not is_pkg:
                try:
                    module = importlib.import_module(
                        f"metagit.core.detect.detectors.{module_name}"
                    )
                    # Register all classes that are subclasses of Detector
                    for attr_name in dir(module):
                        attr = getattr(module, attr_name)
                        if isinstance(attr, Detector):
                            self.detectors.append(attr())
                except Exception as e:
                    if self.logger:
                        self.logger.warning(
                            f"Failed to load detector '{module_name}': {e}"
                        )

    def run(self, path: str) -> Union[List[DiscoveryResult], Exception]:
        """
        Run all registered detectors on the specified path.

        Args:
            path (str): The path to the project directory.

        Returns:
            Union[List[DiscoveryResult], Exception]: A list of discovery results if detection is successful, otherwise an exception.
        """
        try:
            path_files = list_git_files(path)
        except Exception as e:
            self.logger.error(f"Error enumerating files in {path}: {e}")
            return Exception(f"Error enumerating files in {path}: {e}")
        if not path_files:
            self.logger.error(f"No git files found in the specified path: {path}")
            return Exception(f"No git files found in the specified path: {path}")
        scan_context = ProjectScanContext(root_path=Path(path), all_files=path_files)
        results = []
        for detector in self.detectors:
            result = detector.run(scan_context)
            if isinstance(result, DiscoveryResult):
                results.append(result)
        if results:
            return results
        return Exception("No valid discovery results found.")

    def all_files(self, path: str) -> Union[List[Path], Exception]:
        """
        Get all files in the specified path.
        This method uses the list_git_files utility to enumerate all files in the project directory rapidly.

        Args:
            path (str): The path to the project directory.

        Returns:
            List[Path]: A list of all file paths in the project directory.
        """
        files = list_git_files(path)
        file_list = [f.as_posix() for f in files]
        try:
            return file_list
        except Exception as e:
            self.logger.error(f"Error enumerating files in {path}: {e}")
            return []
`````

## File: src/metagit/core/detect/models.py
`````python
#!/usr/bin/env python3
"""
Combined models for the detect module.

This module contains all the Pydantic models used in the detection system,
including language detection, project type detection, branch analysis, CI/CD analysis,
and detection manager configuration.
"""

import os
from enum import Enum
from typing import List, Optional, Any, Protocol, runtime_checkable
from pathlib import Path

from pydantic import BaseModel, Field

from metagit import DATA_PATH
from metagit.core.config.models import ProjectDomain, ProjectType
from metagit.core.utils.logging import LoggingModel


class LanguageDetection(BaseModel):
    """Model for language detection results."""

    primary: str = Field(default="Unknown", description="Primary programming language")
    secondary: List[str] = Field(
        default_factory=list, description="Secondary programming languages"
    )
    frameworks: List[str] = Field(
        default_factory=list, description="Detected frameworks"
    )
    package_managers: List[str] = Field(
        default_factory=list, description="Detected package managers"
    )
    build_tools: List[str] = Field(
        default_factory=list, description="Detected build tools"
    )

    class Config:
        use_enum_values = True
        extra = "forbid"


class ProjectTypeDetection(BaseModel):
    """Model for project type detection results."""

    type: ProjectType = Field(
        default=ProjectType.OTHER, description="Detected project type"
    )
    domain: ProjectDomain = Field(
        default=ProjectDomain.OTHER, description="Detected project domain"
    )
    confidence: float = Field(default=0.0, description="Confidence score (0.0 to 1.0)")
    indicators: List[str] = Field(
        default_factory=list, description="Indicators used for detection"
    )

    class Config:
        use_enum_values = True
        extra = "forbid"


class BranchInfo(BaseModel):
    """Model for branch information."""

    name: str = Field(..., description="Branch name")
    is_remote: bool = Field(
        default=False, description="Whether this is a remote branch"
    )


class BranchStrategy(str, Enum):
    """Model for branch strategy."""

    GIT_FLOW = "Git Flow"
    GITHUB_FLOW = "GitHub Flow"
    GITLAB_FLOW = "GitLab Flow"
    TRUNK_BASED_DEVELOPMENT = "Trunk-Based Development"
    RELEASE_BRANCHING = "Release Branching"
    UNKNOWN = "Unknown"
    OTHER = "Other"


class GitBranchAnalysis(LoggingModel):
    """Model for Git branch analysis results."""

    branches: List[BranchInfo] = Field(
        default_factory=list, description="List of branches"
    )
    strategy_guess: Optional[BranchStrategy] = Field(
        default=BranchStrategy.UNKNOWN, description="Detected branching strategy"
    )

    class Config:
        use_enum_values = True
        extra = "forbid"

    # @classmethod
    # def from_repo(
    #     cls, repo_path: str = ".", logger: Optional[UnifiedLogger] = None
    # ) -> Union["GitBranchAnalysis", Exception]:
    #     """
    #     Analyze the git repository at the given path and return branch information and a strategy guess.
    #     Uses GitPython for all git operations.
    #     """
    #     logger = logger or UnifiedLogger().get_logger()

    #     try:
    #         repo = Repo(repo_path)
    #     except (InvalidGitRepositoryError, NoSuchPathError) as e:
    #         logger.exception(f"Invalid git repository at '{repo_path}': {e}")
    #         return ValueError(f"Invalid git repository at '{repo_path}': {e}")

    #     # Get local branches
    #     local_branches = [
    #         BranchInfo(name=branch.name, is_remote=False)
    #         for branch in repo.branches
    #         if branch.name != "HEAD"  # Exclude HEAD branch
    #     ]
    #     logger.debug(f"Found {len(local_branches)} local branches")

    #     # Get remote branches
    #     remote_branches = []
    #     for remote in repo.remotes:
    #         for ref in remote.refs:
    #             # Remove remote name prefix (e.g., 'origin/')
    #             branch_name = ref.name.split("/", 1)[1] if "/" in ref.name else ref.name
    #             # Exclude HEAD branch from remote branches
    #             if branch_name != "HEAD":
    #                 remote_branches.append(BranchInfo(name=branch_name, is_remote=True))
    #     logger.debug(f"Found {len(remote_branches)} remote branches")

    #     # Combine and deduplicate branches (prefer local if name overlaps)
    #     all_branches_dict = {b.name: b for b in remote_branches}
    #     all_branches_dict.update({b.name: b for b in local_branches})
    #     all_branches = list(all_branches_dict.values())

    #     # Analyze branching strategy
    #     strategy_guess = cls._analyze_branching_strategy(all_branches, logger)

    #     return cls(branches=all_branches, strategy_guess=strategy_guess)

    # @staticmethod
    # def _analyze_branching_strategy(
    #     branches: List[BranchInfo], logger: UnifiedLogger
    # ) -> str:
    #     """Analyze the branching strategy based on branch names and patterns."""
    #     branch_names = [b.name for b in branches]
    #     local_branches = [b.name for b in branches if not b.is_remote]

    #     logger.debug(f"Analyzing branching strategy for branches: {branch_names}")

    #     # Check for Git Flow patterns
    #     if any(name in branch_names for name in ["develop", "master", "main"]):
    #         if "develop" in branch_names:
    #             return "Git Flow"

    #     # Check for GitHub Flow patterns
    #     if "main" in branch_names or "master" in branch_names:
    #         if len(local_branches) <= 2:  # main/master + feature branches
    #             return "GitHub Flow"

    #     # Check for GitLab Flow patterns
    #     if any(name in branch_names for name in ["staging", "production"]):
    #         return "GitLab Flow"

    #     # Check for Trunk-Based Development
    #     if len(local_branches) <= 1:
    #         return "Trunk-Based Development"

    #     # Check for Release Branching
    #     if any(name.startswith("release/") for name in branch_names):
    #         return "Release Branching"

    #     return "Unknown"


class CIConfigAnalysis(LoggingModel):
    """Model for CI/CD configuration analysis results."""

    detected_tool: Optional[str] = Field(None, description="Detected CI/CD tool")
    ci_config_path: Optional[str] = Field(
        None, description="Path to CI/CD configuration file"
    )
    config_content: Optional[str] = Field(
        None, description="Content of CI/CD configuration file"
    )
    pipeline_count: int = Field(default=0, description="Number of detected pipelines")
    triggers: Optional[List[str]] = Field(default=[], description="Detected triggers")

    # @classmethod
    # def from_repo(
    #     cls, repo_path: str = ".", logger: Optional[UnifiedLogger] = None
    # ) -> Union["CIConfigAnalysis", Exception]:
    #     """
    #     Analyze CI/CD configuration in the repository.

    #     Args:
    #         repo_path: Path to the repository
    #         logger: Logger instance

    #     Returns:
    #         CIConfigAnalysis object or Exception
    #     """
    #     logger = logger or UnifiedLogger().get_logger()

    #     try:
    #         repo_path_obj = Path(repo_path)
    #         analysis = cls()

    #         # Check for common CI/CD configuration files
    #         ci_files = {
    #             ".github/workflows/": "GitHub Actions",
    #             ".gitlab-ci.yml": "GitLab CI",
    #             ".circleci/config.yml": "CircleCI",
    #             "Jenkinsfile": "Jenkins",
    #             ".travis.yml": "Travis CI",
    #             "azure-pipelines.yml": "Azure DevOps",
    #             "bitbucket-pipelines.yml": "Bitbucket Pipelines",
    #         }

    #         for file_path, tool_name in ci_files.items():
    #             full_path = os.path.join(repo_path_obj, file_path)
    #             if full_path.exists():
    #                 analysis.detected_tool = tool_name
    #                 analysis.ci_config_path = str(full_path)

    #                 # Read configuration content
    #                 if full_path.is_dir():
    #                     for file in full_path.iterdir():
    #                         if file.is_file():
    #                             with open(file, "r", encoding="utf-8") as f:
    #                                 analysis.config_content = f.read()
    #                 else:
    #                     try:
    #                         with open(full_path, "r", encoding="utf-8") as f:
    #                             analysis.config_content = f.read()
    #                     except Exception as e:
    #                         logger.warning(
    #                             f"Could not read CI config file {full_path}: {e}"
    #                         )

    #                 logger.debug(f"Detected CI/CD tool: {tool_name}")
    #                 break

    #         # Count pipelines (basic heuristic)
    #         if analysis.config_content:
    #             # Simple pipeline counting based on common patterns
    #             pipeline_indicators = ["job:", "stage:", "pipeline:", "workflow:"]
    #             analysis.pipeline_count = sum(
    #                 1
    #                 for indicator in pipeline_indicators
    #                 if indicator in analysis.config_content
    #             )

    #         return analysis

    #     except Exception as e:
    #         logger.exception(f"CI/CD analysis failed: {e}")
    #         return e


class DetectionManagerConfig(BaseModel):
    """
    Configuration for DetectionManager specifying which analysis methods are enabled.
    """

    branch_analysis_enabled: bool = Field(
        default=True, description="Enable Git branch analysis"
    )
    ci_config_analysis_enabled: bool = Field(
        default=True, description="Enable CI/CD configuration analysis"
    )
    directory_summary_enabled: bool = Field(
        default=True, description="Enable directory summary analysis"
    )
    directory_details_enabled: bool = Field(
        default=True, description="Enable detailed directory analysis"
    )
    # Future analysis methods
    commit_analysis_enabled: bool = Field(
        default=False, description="Enable Git commit analysis"
    )
    tag_analysis_enabled: bool = Field(
        default=False, description="Enable Git tag analysis"
    )
    data_file_type_source: Optional[str] = Field(
        default=os.path.join(DATA_PATH, "file-types.json"),
        description="Source of data file types",
    )
    data_ci_file_source: Optional[str] = Field(
        default=os.path.join(DATA_PATH, "ci-files.json"),
        description="Source of data CI files",
    )
    data_cd_file_source: Optional[str] = Field(
        default=os.path.join(DATA_PATH, "cd-files.json"),
        description="Source of data CD files",
    )
    data_package_manager_source: Optional[str] = Field(
        default=os.path.join(DATA_PATH, "package-managers.json"),
        description="Source of data package managers",
    )

    @classmethod
    def all_enabled(cls) -> "DetectionManagerConfig":
        """Create a configuration with all analysis methods enabled."""
        return cls(
            branch_analysis_enabled=True,
            ci_config_analysis_enabled=True,
            directory_summary_enabled=True,
            directory_details_enabled=True,
            commit_analysis_enabled=True,
            tag_analysis_enabled=True,
        )

    @classmethod
    def minimal(cls) -> "DetectionManagerConfig":
        """Create a configuration with only essential analysis methods enabled."""
        return cls(
            branch_analysis_enabled=True,
            ci_config_analysis_enabled=True,
            directory_summary_enabled=False,
            directory_details_enabled=False,
            commit_analysis_enabled=False,
            tag_analysis_enabled=False,
        )

    def get_enabled_methods(self) -> list[str]:
        """Get a list of enabled analysis method names."""
        enabled = []
        if self.branch_analysis_enabled:
            enabled.append("branch_analysis")
        if self.ci_config_analysis_enabled:
            enabled.append("ci_config_analysis")
        if self.directory_summary_enabled:
            enabled.append("directory_summary")
        if self.directory_details_enabled:
            enabled.append("directory_details")
        if self.commit_analysis_enabled:
            enabled.append("commit_analysis")
        if self.tag_analysis_enabled:
            enabled.append("tag_analysis")
        return enabled


class DiscoveryResult(BaseModel):
    name: str
    description: Optional[str] = None
    tags: List[str] = []
    confidence: float = 1.0
    data: dict[str, Any] = {}  # detector-specific structured data


class ProjectScanContext(BaseModel):
    root_path: Path
    all_files: List[Path]


@runtime_checkable
class Detector(Protocol):
    name: str

    def should_run(self, ctx: ProjectScanContext) -> bool: ...

    def run(self, ctx: ProjectScanContext) -> Optional[DiscoveryResult]: ...
`````

## File: src/metagit/core/mcp/services/workspace_health.py
`````python
#!/usr/bin/env python
"""
Workspace integrity and maintenance health checks.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any, Optional

from metagit.core.appconfig.models import WorkspaceDedupeConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.workspace import workspace_dedupe
from metagit.core.mcp.gate import WorkspaceGate
from metagit.core.mcp.services.gitnexus_registry import GitNexusRegistryAdapter
from metagit.core.mcp.services.repo_git_stats import inspect_repo_state
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.workspace.health_models import (
    HealthRecommendation,
    RepoHealthRow,
    WorkspaceHealthResult,
)


class WorkspaceHealthService:
    """Validate workspace integrity and emit maintenance recommendations."""

    def __init__(
        self,
        index_service: Optional[WorkspaceIndexService] = None,
        registry: Optional[GitNexusRegistryAdapter] = None,
        gate: Optional[WorkspaceGate] = None,
    ) -> None:
        self._index = index_service or WorkspaceIndexService()
        self._registry = registry or GitNexusRegistryAdapter()
        self._gate = gate or WorkspaceGate()

    def check(
        self,
        config: MetagitConfig,
        workspace_root: str,
        *,
        check_git_status: bool = True,
        check_dependencies: bool = True,
        check_stale_branches: bool = True,
        check_gitnexus: bool = True,
        project_name: Optional[str] = None,
        branch_head_warning_days: float = 180.0,
        branch_head_critical_days: float = 365.0,
        integration_stale_days: float = 90.0,
        dedupe: WorkspaceDedupeConfig | None = None,
    ) -> WorkspaceHealthResult:
        """Run selected health checks across workspace repositories."""
        gate_status = self._gate.evaluate(root_path=workspace_root)
        rows = self._index.build_index(config=config, workspace_root=workspace_root)
        if project_name:
            rows = [row for row in rows if row["project_name"] == project_name]

        recommendations: list[HealthRecommendation] = []
        repo_rows: list[RepoHealthRow] = []
        gitnexus_map: dict[str, str] = {}
        if check_gitnexus:
            paths = [str(row["repo_path"]) for row in rows if row.get("exists")]
            gitnexus_map = self._registry.summarize_for_paths(repo_paths=paths)

        missing_count = 0
        dirty_count = 0
        behind_count = 0
        stale_gn_count = 0
        stale_head_warn_count = 0
        stale_head_critical_count = 0
        integration_stale_count = 0

        for row in rows:
            repo_path = str(row.get("repo_path", ""))
            inspect_git = (
                row.get("exists")
                and row.get("is_git_repo")
                and (check_git_status or check_stale_branches)
            )
            inspected = inspect_repo_state(repo_path=repo_path) if inspect_git else {}
            gn_status = gitnexus_map.get(repo_path) if check_gitnexus else None
            head_raw = inspected.get("head_commit_age_days")
            head_age_days = (
                float(head_raw) if isinstance(head_raw, (int, float)) else None
            )
            merge_raw = inspected.get("merge_base_age_days")
            merge_age_days = (
                float(merge_raw) if isinstance(merge_raw, (int, float)) else None
            )
            ahead_raw = inspected.get("ahead")
            behind_raw = inspected.get("behind")
            repo_rows.append(
                RepoHealthRow(
                    project_name=str(row.get("project_name", "")),
                    repo_name=str(row.get("repo_name", "")),
                    repo_path=repo_path,
                    status=str(row.get("status", "")),
                    exists=bool(row.get("exists")),
                    is_git_repo=bool(row.get("is_git_repo")),
                    branch=str(inspected.get("branch"))
                    if inspected.get("branch") is not None
                    else None,
                    dirty=bool(inspected.get("dirty"))
                    if check_git_status and inspected.get("dirty") is not None
                    else None,
                    ahead=int(ahead_raw)
                    if check_git_status and isinstance(ahead_raw, int)
                    else None,
                    behind=int(behind_raw)
                    if check_git_status and isinstance(behind_raw, int)
                    else None,
                    gitnexus_status=gn_status,
                    head_commit_age_days=head_age_days
                    if check_stale_branches
                    else None,
                    merge_base_age_days=merge_age_days
                    if check_stale_branches
                    else None,
                )
            )

            if check_dependencies and row.get("status") == "configured_missing":
                missing_count += 1
                recommendations.append(
                    HealthRecommendation(
                        severity="warning",
                        action="clone",
                        message="Configured repository path is missing on disk.",
                        project_name=row.get("project_name"),
                        repo_name=row.get("repo_name"),
                        repo_path=repo_path,
                    )
                )

            if check_stale_branches and inspected.get("ok"):
                warn_td = min(branch_head_warning_days, branch_head_critical_days)
                crit_td = max(branch_head_warning_days, branch_head_critical_days)
                if head_age_days is not None:
                    if head_age_days >= crit_td:
                        stale_head_critical_count += 1
                        recommendations.append(
                            HealthRecommendation(
                                severity="warning",
                                action="review_branch_age",
                                message=(
                                    "HEAD commit is stale "
                                    f"({head_age_days:.0f}d); merge or archive."
                                ),
                                project_name=row.get("project_name"),
                                repo_name=row.get("repo_name"),
                                repo_path=repo_path,
                            )
                        )
                    elif head_age_days >= warn_td:
                        stale_head_warn_count += 1
                        recommendations.append(
                            HealthRecommendation(
                                severity="info",
                                action="review_branch_age",
                                message=(
                                    "HEAD commit is aging "
                                    f"({head_age_days:.0f}d since last commit on HEAD)."
                                ),
                                project_name=row.get("project_name"),
                                repo_name=row.get("repo_name"),
                                repo_path=repo_path,
                            )
                        )
                if (
                    merge_age_days is not None
                    and merge_age_days >= integration_stale_days
                ):
                    integration_stale_count += 1
                    recommendations.append(
                        HealthRecommendation(
                            severity="warning",
                            action="reconcile_integration",
                            message=(
                                "Merge-base with default remote branch is old "
                                f"({merge_age_days:.0f}d); rebase or merge default."
                            ),
                            project_name=row.get("project_name"),
                            repo_name=row.get("repo_name"),
                            repo_path=repo_path,
                        )
                    )

            if check_git_status and inspected.get("ok"):
                if inspected.get("dirty"):
                    dirty_count += 1
                    recommendations.append(
                        HealthRecommendation(
                            severity="info",
                            action="review_changes",
                            message="Repository has uncommitted changes.",
                            project_name=row.get("project_name"),
                            repo_name=row.get("repo_name"),
                            repo_path=repo_path,
                        )
                    )
                behind = inspected.get("behind")
                if isinstance(behind, int) and behind > 0:
                    behind_count += 1
                    recommendations.append(
                        HealthRecommendation(
                            severity="warning",
                            action="sync",
                            message=f"Repository is {behind} commit(s) behind upstream.",
                            project_name=row.get("project_name"),
                            repo_name=row.get("repo_name"),
                            repo_path=repo_path,
                        )
                    )
                if inspected.get("branch") == "DETACHED":
                    recommendations.append(
                        HealthRecommendation(
                            severity="warning",
                            action="fix_branch",
                            message="Repository is in detached HEAD state.",
                            project_name=row.get("project_name"),
                            repo_name=row.get("repo_name"),
                            repo_path=repo_path,
                        )
                    )

            if check_gitnexus and row.get("exists") and row.get("is_git_repo"):
                if gn_status == "stale":
                    stale_gn_count += 1
                    recommendations.append(
                        HealthRecommendation(
                            severity="warning",
                            action="analyze",
                            message="GitNexus index is stale; run gitnexus analyze.",
                            project_name=row.get("project_name"),
                            repo_name=row.get("repo_name"),
                            repo_path=repo_path,
                        )
                    )
                elif gn_status == "missing":
                    recommendations.append(
                        HealthRecommendation(
                            severity="info",
                            action="analyze",
                            message="Repository is not indexed in GitNexus.",
                            project_name=row.get("project_name"),
                            repo_name=row.get("repo_name"),
                            repo_path=repo_path,
                        )
                    )

        if gate_status.state.value != "active":
            recommendations.insert(
                0,
                HealthRecommendation(
                    severity="critical",
                    action="fix_config",
                    message=gate_status.reason
                    or "Workspace configuration is not active.",
                ),
            )

        recommendations.extend(self._duplicate_url_warnings(rows=rows, dedupe=dedupe))
        recommendations.extend(self._broken_mount_warnings(rows=rows))
        if dedupe is not None and dedupe.enabled:
            recommendations.extend(
                self._orphan_canonical_warnings(
                    config=config,
                    workspace_root=workspace_root,
                    dedupe=dedupe,
                )
            )
        summary = {
            "repos_total": len(repo_rows),
            "repos_missing": missing_count,
            "repos_dirty": dirty_count,
            "repos_behind": behind_count,
            "repos_gitnexus_stale": stale_gn_count,
            "repos_branch_head_stale_warning": stale_head_warn_count,
            "repos_branch_head_stale_critical": stale_head_critical_count,
            "repos_integration_stale": integration_stale_count,
            "recommendations": len(recommendations),
        }
        critical = sum(1 for item in recommendations if item.severity == "critical")
        return WorkspaceHealthResult(
            ok=critical == 0,
            workspace_root=workspace_root,
            summary=summary,
            repos=repo_rows,
            recommendations=self._sort_recommendations(recommendations),
        )

    def _duplicate_url_warnings(
        self,
        rows: list[dict[str, Any]],
        dedupe: WorkspaceDedupeConfig | None = None,
    ) -> list[HealthRecommendation]:
        """Warn when multiple repos share the same configured URL."""
        by_url: dict[str, list[dict[str, Any]]] = {}
        for row in rows:
            url = row.get("url")
            if not url:
                continue
            by_url.setdefault(str(url), []).append(row)
        warnings: list[HealthRecommendation] = []
        for url, grouped in by_url.items():
            if len(grouped) < 2:
                continue
            names = ", ".join(
                f"{item['project_name']}/{item['repo_name']}" for item in grouped
            )
            action = "review_config"
            message = f"Multiple repos share URL {url}: {names}"
            if dedupe is not None and dedupe.enabled:
                action = "resync_canonical"
                message = (
                    f"{message}. Dedupe is enabled; run project sync to refresh mounts."
                )
            elif dedupe is not None and not dedupe.enabled:
                message = (
                    f"{message}. Consider enabling workspace.dedupe in app config."
                )
            warnings.append(
                HealthRecommendation(
                    severity="info",
                    action=action,
                    message=message,
                )
            )
        return warnings

    def _broken_mount_warnings(
        self, rows: list[dict[str, Any]]
    ) -> list[HealthRecommendation]:
        """Recommend repair when a configured repo path is a broken symlink."""
        warnings: list[HealthRecommendation] = []
        for row in rows:
            repo_path = Path(str(row.get("repo_path", "")))
            if not repo_path.is_symlink():
                continue
            if repo_path.exists():
                continue
            warnings.append(
                HealthRecommendation(
                    severity="warning",
                    action="repair_mount",
                    message=(
                        f"Broken symlink for {row['project_name']}/{row['repo_name']} "
                        f"at {repo_path}; run project sync to repair."
                    ),
                )
            )
        return warnings

    def _orphan_canonical_warnings(
        self,
        *,
        config: MetagitConfig,
        workspace_root: str,
        dedupe: WorkspaceDedupeConfig,
    ) -> list[HealthRecommendation]:
        """Warn about canonical directories not referenced in the manifest."""
        references = workspace_dedupe.list_canonical_references(
            config=config,
            workspace_path=Path(workspace_root).expanduser().resolve(),
            dedupe=dedupe,
        )
        orphans = workspace_dedupe.list_orphan_canonical_dirs(
            Path(workspace_root).expanduser().resolve(),
            dedupe,
            references,
        )
        warnings: list[HealthRecommendation] = []
        for orphan in orphans:
            warnings.append(
                HealthRecommendation(
                    severity="info",
                    action="prune_canonical",
                    message=(
                        f"Canonical directory has no manifest references: {orphan}. "
                        "Remove manually or run project repo prune after dropping entries."
                    ),
                )
            )
        return warnings

    def _sort_recommendations(
        self, recommendations: list[HealthRecommendation]
    ) -> list[HealthRecommendation]:
        """Sort recommendations by severity."""
        order = {"critical": 0, "warning": 1, "info": 2}
        return sorted(recommendations, key=lambda item: order.get(item.severity, 3))
`````

## File: src/metagit/core/mcp/resources.py
`````python
#!/usr/bin/env python
"""
Resource publishing for Metagit MCP runtime.
"""

from typing import Any, Optional

from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.ops_log import OperationsLogService
from metagit.core.mcp.services.session_store import SessionStore


class ResourcePublisher:
    """Serve MCP resources for workspace config and status views."""

    def __init__(self, ops_log: OperationsLogService) -> None:
        self._ops_log = ops_log

    def get_resource(
        self,
        uri: str,
        config: MetagitConfig | None = None,
        repos_status: list[dict[str, Any]] | None = None,
        workspace_root: Optional[str] = None,
        health_payload: Optional[dict[str, Any]] = None,
    ) -> dict[str, Any]:
        """Return a resource payload for known URIs."""
        if uri == "metagit://workspace/config":
            return {
                "uri": uri,
                "data": config.model_dump(exclude_none=True) if config else {},
            }
        if uri == "metagit://workspace/repos/status":
            return {"uri": uri, "data": repos_status or []}
        if uri == "metagit://workspace/ops-log":
            return {"uri": uri, "data": self._ops_log.list_entries()}
        if uri == "metagit://workspace/health":
            return {"uri": uri, "data": health_payload or {}}
        if uri == "metagit://workspace/context":
            if not workspace_root:
                return {"uri": uri, "data": {"active_project": None}}
            meta = SessionStore(workspace_root=workspace_root).get_workspace_meta()
            session = (
                SessionStore(workspace_root=workspace_root).get_project_session(
                    project_name=meta.active_project
                )
                if meta.active_project
                else None
            )
            return {
                "uri": uri,
                "data": {
                    "active_project": meta.active_project,
                    "last_switch_at": meta.last_switch_at,
                    "session": session.model_dump(mode="json") if session else None,
                },
            }
        return {"uri": uri, "error": "Unknown resource URI"}
`````

## File: src/metagit/core/project/models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for project configuration.
"""

import re
from enum import Enum
from typing import Any, List, Optional, Union

from pydantic import (
    AliasChoices,
    BaseModel,
    Field,
    HttpUrl,
    field_serializer,
    field_validator,
)
from pydantic_core import core_schema


class GitUrl(str):
    """Custom type for Git repository URLs."""

    GIT_URL_REGEX = re.compile(
        r"((git|ssh|http(s)?)|(git@[\w\.-]+))(:(//)?)([\w\.@\:/\-~]+)(\.git)?(/)?"
    )

    @classmethod
    def __get_pydantic_core_schema__(
        cls, source_type: Any, handler: Any
    ) -> core_schema.CoreSchema:
        return core_schema.json_or_python_schema(
            json_schema=core_schema.str_schema(),
            python_schema=core_schema.with_info_plain_validator_function(cls.validate),
            serialization=core_schema.plain_serializer_function_ser_schema(
                lambda x: str(x)
            ),
        )

    @classmethod
    def validate(cls, value: str, _: Any) -> "GitUrl":
        if not isinstance(value, str) or not cls.GIT_URL_REGEX.match(value):
            raise ValueError("Invalid Git repository URL format")
        return cls(value)


class ProjectKind(str, Enum):
    """Enumeration of project kinds."""

    MONOREPO = "monorepo"
    UMBRELLA = "umbrella"
    APPLICATION = "application"
    GITOPS = "gitops"
    INFRASTRUCTURE = "infrastructure"
    SERVICE = "service"
    LIBRARY = "library"
    WEBSITE = "website"
    OTHER = "other"
    DOCKER_IMAGE = "docker_image"
    REPOSITORY = "repository"
    CLI = "cli"


class ProjectPath(BaseModel):
    """Model for project path, dependency, component, or workspace project information."""

    name: str = Field(..., description="Friendly name for the path or project")
    description: Optional[str] = Field(
        None, description="Short description of the path or project"
    )
    kind: Optional[ProjectKind] = Field(None, description="Project kind")
    ref: Optional[str] = Field(
        None,
        description="Reference in the current project for the target project, used in dependencies",
    )
    path: Optional[str] = Field(None, description="Project path")
    branches: Optional[List[str]] = Field(None, description="Project branches")
    url: Optional[Union[HttpUrl, GitUrl]] = Field(None, description="Project URL")
    sync: Optional[bool] = Field(None, description="Sync setting")
    language: Optional[str] = Field(None, description="Programming language")
    language_version: Optional[Union[str, float, int]] = Field(
        None, description="Language version"
    )
    package_manager: Optional[str] = Field(
        None, description="Package manager used by the project"
    )
    frameworks: Optional[List[str]] = Field(
        None, description="Frameworks used by the project"
    )
    source_provider: Optional[str] = Field(
        None, description="Provider used to discover this repository"
    )
    source_namespace: Optional[str] = Field(
        None, description="Source namespace identifier (org/user/group)"
    )
    source_repo_id: Optional[str] = Field(
        None, description="Provider-native repository identifier"
    )
    tags: dict[str, str] = Field(
        default_factory=dict,
        description="Flat metadata tags for managed repo search and filtering",
    )
    protected: Optional[bool] = Field(
        False,
        description="If true, reconcile mode must not remove this repository automatically",
    )
    agent_instructions: Optional[str] = Field(
        None,
        validation_alias=AliasChoices("agent_instructions", "agent_prompt"),
        description=(
            "Optional instructions for subagents operating in this repo or path"
        ),
    )

    @field_validator("language_version", mode="before")
    def validate_language_version(cls, v: Any) -> Optional[str]:
        if v is None:
            return None
        return str(v)

    @field_serializer("url")
    def serialize_url(
        self, url: Optional[Union[HttpUrl, GitUrl]], _info: Any
    ) -> Optional[str]:
        """Serialize the URL to a string."""
        return str(url) if url else None

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"
`````

## File: src/metagit/core/skills/installer.py
`````python
#!/usr/bin/env python
"""
Installer utilities for bundled skills and MCP config updates.
"""

from __future__ import annotations

import json
import os
import re
import shutil
from pathlib import Path
from typing import Dict, List, Literal, Optional

from pydantic import BaseModel, Field

from metagit import DATA_PATH

InstallScope = Literal["project", "user"]
InstallMode = Literal["skills", "mcp"]

SUPPORTED_TARGETS = [
    "opencode",
    "hermes",
    "openclaw",
    "claude_code",
    "github_copilot",
]


class TargetPaths(BaseModel):
    """Paths for target deployment in each scope."""

    project_skills_path: str = Field(
        ..., description="Project-local skills destination"
    )
    user_skills_path: str = Field(..., description="User-global skills destination")
    project_mcp_path: str = Field(..., description="Project-local MCP config path")
    user_mcp_path: str = Field(..., description="User-global MCP config path")


class InstallResult(BaseModel):
    """Summary for a single target installation."""

    target: str
    mode: InstallMode
    scope: InstallScope
    applied: bool
    path: str
    details: str
    dry_run: bool = Field(
        default=False, description="True when no changes were written"
    )


TARGET_PATHS: Dict[str, TargetPaths] = {
    "opencode": TargetPaths(
        project_skills_path=".opencode/skills",
        user_skills_path="~/.config/opencode/skills",
        project_mcp_path=".opencode/mcp.json",
        user_mcp_path="~/.config/opencode/mcp.json",
    ),
    "hermes": TargetPaths(
        project_skills_path=".hermes/skills",
        user_skills_path="~/.config/hermes/skills",
        project_mcp_path=".hermes/mcp.json",
        user_mcp_path="~/.config/hermes/mcp.json",
    ),
    "openclaw": TargetPaths(
        project_skills_path=".openclaw/skills",
        user_skills_path="~/.config/openclaw/skills",
        project_mcp_path=".openclaw/mcp.json",
        user_mcp_path="~/.config/openclaw/mcp.json",
    ),
    "claude_code": TargetPaths(
        project_skills_path=".claude/skills",
        user_skills_path="~/.claude/skills",
        project_mcp_path=".claude/mcp.json",
        user_mcp_path="~/.claude/mcp.json",
    ),
    "github_copilot": TargetPaths(
        project_skills_path=".github/copilot/skills",
        user_skills_path="~/.config/github-copilot/skills",
        project_mcp_path=".github/copilot/mcp.json",
        user_mcp_path="~/.config/github-copilot/mcp.json",
    ),
}


def bundled_skills_root() -> Path:
    """Resolve bundled skill source path."""
    return Path(DATA_PATH) / "skills"


def list_bundled_skills() -> List[str]:
    """Return bundled skill names."""
    skills_root = bundled_skills_root()
    if not skills_root.exists():
        return []
    return sorted([item.name for item in skills_root.iterdir() if item.is_dir()])


def skill_markdown(skill_name: str) -> Optional[str]:
    """Load SKILL.md content for a bundled skill."""
    skill_file = bundled_skills_root() / skill_name / "SKILL.md"
    if not skill_file.exists():
        return None
    return skill_file.read_text(encoding="utf-8")


def resolve_skill_names(skill_names: Optional[List[str]]) -> List[str]:
    """Validate and resolve bundled skill names for install."""
    bundled = list_bundled_skills()
    if not skill_names:
        return bundled
    unknown = sorted({name for name in skill_names if name not in bundled})
    if unknown:
        available = ", ".join(bundled) if bundled else "(none)"
        raise ValueError(
            f"Unknown skill(s): {', '.join(unknown)}. Available: {available}"
        )
    return list(dict.fromkeys(skill_names))


def resolve_targets(
    mode: InstallMode,
    scope: InstallScope,
    enable_targets: List[str],
    disable_targets: List[str],
) -> List[str]:
    """Resolve install targets by explicit include/exclude or auto-detection."""
    disabled = set(disable_targets)
    if enable_targets:
        return [target for target in enable_targets if target not in disabled]
    detected = autodetect_targets(mode=mode, scope=scope)
    return [target for target in detected if target not in disabled]


def autodetect_targets(mode: InstallMode, scope: InstallScope) -> List[str]:
    """Detect target applications by existing config/directories."""
    resolved: List[str] = []
    for target in SUPPORTED_TARGETS:
        target_paths = TARGET_PATHS[target]
        if mode == "skills":
            candidate = _expand_target_path(
                target_paths.project_skills_path
                if scope == "project"
                else target_paths.user_skills_path
            )
        else:
            candidate = _expand_target_path(
                target_paths.project_mcp_path
                if scope == "project"
                else target_paths.user_mcp_path
            )
        if candidate.exists() or candidate.parent.exists():
            resolved.append(target)
    return resolved


def _install_details_label(
    installed_names: List[str],
    *,
    dry_run: bool,
) -> str:
    """Build a human-readable install summary line."""
    verb = "Would install" if dry_run else "Installed"
    if len(installed_names) == 1:
        return f"{verb} skill '{installed_names[0]}'"
    names = ", ".join(installed_names)
    return f"{verb} {len(installed_names)} skills: {names}"


def install_skills_for_targets(
    targets: List[str],
    scope: InstallScope,
    skill_names: Optional[List[str]] = None,
    *,
    dry_run: bool = False,
) -> List[InstallResult]:
    """Install bundled skills for selected targets."""
    source_root = bundled_skills_root()
    results: List[InstallResult] = []
    if not source_root.exists():
        return [
            InstallResult(
                target="all",
                mode="skills",
                scope=scope,
                applied=False,
                path=str(source_root),
                details="Bundled skills directory not found",
            )
        ]
    selected_skills = resolve_skill_names(skill_names)
    if not selected_skills:
        return [
            InstallResult(
                target="all",
                mode="skills",
                scope=scope,
                applied=False,
                path=str(source_root),
                details="No bundled skills available to install",
            )
        ]
    for target in targets:
        target_paths = TARGET_PATHS[target]
        destination = _expand_target_path(
            target_paths.project_skills_path
            if scope == "project"
            else target_paths.user_skills_path
        )
        if not dry_run:
            destination.mkdir(parents=True, exist_ok=True)
        installed_names: List[str] = []
        for skill_name in selected_skills:
            source_skill = source_root / skill_name
            if not source_skill.is_dir():
                continue
            dest_skill = destination / skill_name
            if dry_run:
                installed_names.append(skill_name)
                continue
            if dest_skill.exists():
                shutil.rmtree(dest_skill)
            shutil.copytree(source_skill, dest_skill)
            installed_names.append(skill_name)
        results.append(
            InstallResult(
                target=target,
                mode="skills",
                scope=scope,
                applied=bool(installed_names),
                path=str(destination),
                details=_install_details_label(installed_names, dry_run=dry_run),
                dry_run=dry_run,
            )
        )
    return results


def install_mcp_for_targets(
    targets: List[str],
    scope: InstallScope,
    server_name: str = "metagit",
) -> List[InstallResult]:
    """Install/update MCP server configuration for selected targets."""
    results: List[InstallResult] = []
    for target in targets:
        target_paths = TARGET_PATHS[target]
        config_path = _expand_target_path(
            target_paths.project_mcp_path
            if scope == "project"
            else target_paths.user_mcp_path
        )
        config_path.parent.mkdir(parents=True, exist_ok=True)
        config_data = _read_json_with_comments(config_path)
        if not isinstance(config_data, dict):
            config_data = {}
        mcp_servers = config_data.get("mcpServers")
        if not isinstance(mcp_servers, dict):
            mcp_servers = {}
        mcp_servers[server_name] = {
            "command": "uvx",
            "args": ["metagit-cli", "mcp", "serve"],
        }
        config_data["mcpServers"] = mcp_servers
        config_path.write_text(
            json.dumps(config_data, indent=2, sort_keys=False) + "\n",
            encoding="utf-8",
        )
        results.append(
            InstallResult(
                target=target,
                mode="mcp",
                scope=scope,
                applied=True,
                path=str(config_path),
                details=f"Updated server '{server_name}'",
            )
        )
    return results


def _read_json_with_comments(path: Path) -> Dict[str, object]:
    """Parse JSON or JSONC-style files."""
    if not path.exists():
        return {}
    content = path.read_text(encoding="utf-8").strip()
    if not content:
        return {}
    no_line_comments = re.sub(r"^\s*//.*$", "", content, flags=re.MULTILINE)
    no_block_comments = re.sub(r"/\*.*?\*/", "", no_line_comments, flags=re.DOTALL)
    return json.loads(no_block_comments)


def _expand_target_path(path_value: str) -> Path:
    expanded = Path(os.path.expanduser(path_value))
    return expanded if expanded.is_absolute() else Path.cwd() / expanded
`````

## File: src/metagit/core/utils/files.py
`````python
#! /usr/bin/env python3
"""
File reader tool for the detect flow
"""

import fnmatch
import json
import os
from pathlib import Path
from typing import Dict, List, Optional, Set

from pydantic import BaseModel
from git import Repo

from metagit import DATA_PATH


def directory_tree(paths: List[Path], show_files: bool = False) -> str:
    """
    Generate a tree diagram of directory structure from a list of paths.

    Args:
        paths: List of Path objects representing files and directories
        show_files: Whether to display individual files in each directory

    Returns:
        String representation of the directory tree
    """
    if not paths:
        return ""

    # Build a tree structure from the paths
    tree = {}

    # Normalize all paths
    normalized_paths = [path.resolve() for path in paths]

    # Build tree structure
    for path in normalized_paths:
        parts = path.parts
        current = tree
        for part in parts:
            if part not in current:
                current[part] = {}
            current = current[part]

    # Generate tree string representation
    def _build_tree(
        tree_dict: dict, prefix: str = "", is_last: bool = True
    ) -> List[str]:
        lines = []
        keys = sorted(tree_dict.keys())

        for i, key in enumerate(keys):
            is_last_item = i == len(keys) - 1
            connector = "└── " if is_last_item else "├── "
            lines.append(f"{prefix}{connector}{key}")

            if tree_dict[key]:  # Has children
                extension = "    " if is_last_item else "│   "
                child_lines = _build_tree(
                    tree_dict[key], prefix + extension, is_last_item
                )
                lines.extend(child_lines)

        return lines

    return "\n".join(_build_tree(tree))


def is_binary_file(file_path: str) -> bool:
    """
    Check if a file is binary by examining its content.

    Args:
        file_path: Path to the file to check

    Returns:
        True if the file is binary, False if it's text
    """
    try:
        with open(file_path, "rb") as f:
            # Read first 1024 bytes to check for binary content
            chunk = f.read(1024)
            if not chunk:
                return False

            # Check for null bytes (common in binary files)
            if b"\x00" in chunk:
                return True

            # Check if the chunk contains mostly printable ASCII characters
            text_chars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x7F)))
            return bool(chunk.translate(None, text_chars))

    except (IOError, OSError):
        # If we can't read the file, assume it's not binary
        return False


def get_file_size(file_path: str) -> int:
    """
    Get the size of a file in bytes.

    Args:
        file_path: Path to the file

    Returns:
        File size in bytes, 0 if file doesn't exist
    """
    try:
        return os.path.getsize(file_path)
    except (OSError, IOError):
        return 0


def list_files(directory_path: str) -> List[str]:
    """
    List all files in a directory recursively.

    Args:
        directory_path: Path to the directory

    Returns:
        List of file paths
    """
    try:
        files = []
        for root, _, filenames in os.walk(directory_path):
            for filename in filenames:
                files.append(os.path.join(root, filename))
        return files
    except (OSError, IOError):
        return []


def list_git_files(directory_path: str) -> List[Path]:
    """
    List all files in a Git repository.

    Args:
        directory_path: Path to the Git repository
    Returns:
        List of file paths in the repository
    """
    try:
        repo = Repo(directory_path)
    except Exception as e:
        return Exception(f"Not a valid Git repository: {e}")
    try:
        values = repo.git.ls_files(
            "--cached", "--others", "--exclude-standard"
        ).splitlines()
    except Exception as e:
        return Exception(f"Error listing files in Git repository: {e}")

    return [Path(v) for v in values]


def read_file_lines(file_path: str) -> List[str]:
    """
    Read all lines from a file.

    Args:
        file_path: Path to the file

    Returns:
        List of lines (without newline characters)
    """
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            return [line.rstrip("\n") for line in f]
    except (OSError, IOError):
        return []


def write_file_lines(file_path: str, lines: List[str]) -> bool:
    """
    Write lines to a file.

    Args:
        file_path: Path to the file
        lines: List of lines to write

    Returns:
        True if successful, False otherwise
    """
    try:
        with open(file_path, "w", encoding="utf-8") as f:
            for line in lines:
                f.write(line + "\n")
        return True
    except (OSError, IOError):
        return False


def copy_file(source_path: str, dest_path: str) -> bool:
    """
    Copy a file from source to destination.

    Args:
        source_path: Path to source file
        dest_path: Path to destination file

    Returns:
        True if successful, False otherwise
    """
    try:
        import shutil

        shutil.copy2(source_path, dest_path)
        return True
    except (OSError, IOError):
        return False


def remove_file(file_path: str) -> bool:
    """
    Remove a file.

    Args:
        file_path: Path to the file to remove

    Returns:
        True if successful, False otherwise
    """
    try:
        os.remove(file_path)
        return True
    except (OSError, IOError):
        return False


def make_dir(dir_path: str) -> bool:
    """
    Create a directory.

    Args:
        dir_path: Path to the directory to create

    Returns:
        True if successful, False otherwise
    """
    try:
        os.makedirs(dir_path, exist_ok=True)
        return True
    except (OSError, IOError):
        return False


def remove_dir(dir_path: str) -> bool:
    """
    Remove a directory and its contents.

    Args:
        dir_path: Path to the directory to remove

    Returns:
        True if successful, False otherwise
    """
    try:
        import shutil

        shutil.rmtree(dir_path)
        return True
    except (OSError, IOError):
        return False


class FileTypeInfo(BaseModel):
    kind: str
    type: str


class FileTypeWithPercent(BaseModel):
    kind: str
    percent: float


class DirectoryDetails(BaseModel):
    path: str
    num_files: int
    file_types: Dict[str, List[FileTypeWithPercent]]
    subpaths: List["DirectoryDetails"]


class FileExtensionLookup:
    def __init__(
        self, extension_data: str = os.path.join(DATA_PATH, "file-types.json")
    ):
        # Parse JSON data
        try:
            with open(extension_data, "r", encoding="utf-8") as f:
                data = json.loads(f.read())
        except json.JSONDecodeError as exc:
            raise ValueError(f"Invalid JSON data: {exc}") from exc

        # Create extension to info mapping for O(1) lookup
        self._lookup: Dict[str, FileTypeInfo] = {}

        # Handle the JSON structure which has data wrapped in "extensions" key
        if isinstance(data, dict) and "extensions" in data:
            items = data["extensions"]
        else:
            items = data

        for item in items:
            if isinstance(item, dict):
                kind = item.get("kind", "")
                file_type = item.get("type", "")
                extensions = item.get("extensions", [])

                # Store each extension with its corresponding info
                info = FileTypeInfo(kind=kind, type=file_type)
                for ext in extensions:
                    # Normalize extension (lowercase, ensure leading dot)
                    ext = ext.lower()
                    if not ext.startswith("."):
                        ext = f".{ext}"
                    self._lookup[ext] = info

    def get_file_info(self, filename: str) -> Optional[FileTypeInfo]:
        """
        Look up file type information based on file extension.

        Args:
            filename: File name or path to check

        Returns:
            FileTypeInfo tuple containing name and type, or None if not found
        """
        # Extract extension and normalize
        _, ext = os.path.splitext(filename)
        ext = ext.lower()

        return self._lookup.get(ext)


def parse_gitignore(ignore_file: Path) -> Set[str]:
    """
    Parse .gitignore files.

    Args:
        directory_path: Path to the current directory to check for .gitignore
        base_path: Base directory path for the analysis (root of the tree)

    Returns:
        Set of patterns to ignore (combined from all .gitignore files in the path)
    """
    ignore_patterns = set()

    if Path(ignore_file).exists():
        try:
            with open(ignore_file, "r", encoding="utf-8") as f:
                for line in f:
                    line = line.strip()
                    # Skip empty lines and comments
                    if line and not line.startswith("#"):
                        # Remove trailing slash from patterns
                        line = line.rstrip("/")
                        ignore_patterns.add(line)
        except Exception:
            pass

    return ignore_patterns


def should_ignore_path(path: Path, ignore_patterns: Set[str], base_path: Path) -> bool:
    """
    Check if a path should be ignored based on ignored patterns.

    Args:
        path: Path to check
        ignore_patterns: Set of patterns from .gitignore files
        base_path: Base directory path for relative pattern matching

    Returns:
        True if the path should be ignored, False otherwise
    """
    if not ignore_patterns:
        return False

    # Get relative path from base directory
    try:
        relative_path = path.relative_to(base_path)
    except ValueError:
        # Path is not relative to base, use the path name
        relative_path = Path(path.name)

    relative_str = str(relative_path)

    # Check each pattern
    for pattern in ignore_patterns:
        # Handle file patterns
        if (
            fnmatch.fnmatch(relative_str, pattern)
            or fnmatch.fnmatch(path.name, pattern)
            or fnmatch.fnmatch(relative_str, pattern)
        ):
            return True

    return False


def directory_details(
    target_path: str,
    file_lookup: FileExtensionLookup,
    ignore_patterns: Optional[Set[str]] = None,
    resolve_path: bool = False,
) -> DirectoryDetails:
    """
    Recursively walks a directory and builds detailed metadata structure using FileExtensionLookup.

    Args:
        target_path: Path to the target directory to analyze
        file_lookup: Single instance of FileExtensionLookup for file type information
        ignore_patterns: Set of patterns to ignore (applied to all subdirectories)

    Returns:
        DirectoryDetails: NamedTuple containing directory structure and detailed file statistics grouped by category
    """
    path = Path(target_path)
    ignore_file = os.path.join(path, ".gitignore")
    ignore_patterns = ignore_patterns or set()
    ignore_patterns = ignore_patterns.union(parse_gitignore(ignore_file))

    if not path.is_dir():
        raise ValueError(f"Path {target_path} is not a directory")

    # Initialize data structures
    file_type_counts: Dict[str, Dict[str, int]] = {
        "programming": {},
        "data": {},
        "markup": {},
        "prose": {},
    }
    subpaths: List[DirectoryDetails] = []
    num_files = 0

    # Process directory contents
    for item in path.iterdir():
        # Always ignore .git folders
        if item.name == ".git":
            continue
        # Check if item should be ignored based on ignore_patterns
        if should_ignore_path(item, ignore_patterns, Path(target_path)):
            continue
        if item.is_dir():
            # Recursively process subdirectory with the same ignore_patterns
            sub_metadata = directory_details(
                str(item), file_lookup, ignore_patterns, resolve_path
            )
            subpaths.append(sub_metadata)
        else:
            # Count file and get detailed type information
            num_files += 1
            file_info = file_lookup.get_file_info(item.name)
            if file_info:
                # Group by type category and count by kind
                category = file_info.type
                kind = file_info.kind
                if category in file_type_counts:
                    file_type_counts[category][kind] = (
                        file_type_counts[category].get(kind, 0) + 1
                    )

    # Convert counts to percentages based on total files in directory
    file_types_by_category: Dict[str, List[FileTypeWithPercent]] = {}

    if num_files > 0:  # Only calculate percentages if there are files
        for category, kinds in file_type_counts.items():
            if kinds:  # Only include categories that have files
                file_types_by_category[category] = [
                    FileTypeWithPercent(
                        kind=kind, percent=round((count / num_files) * 100, 1)
                    )
                    for kind, count in sorted(
                        kinds.items(), key=lambda x: x[1], reverse=True
                    )
                ]
    final_path = path.resolve() if resolve_path else path
    return DirectoryDetails(
        path=str(final_path),
        num_files=num_files,
        file_types=file_types_by_category,
        subpaths=subpaths,
    )


class FileType(BaseModel):
    type: str
    count: int


class DirectorySummary(BaseModel):
    path: str
    num_files: int
    file_types: List[FileType]
    subpaths: List["DirectorySummary"]


def directory_summary(
    target_path: str,
    ignore_patterns: Optional[Set[str]] = None,
    resolve_path: bool = False,
) -> DirectorySummary:
    """
    Recursively walks a directory and builds a metadata structure for a directory summary.
    This is a simplified version of directory_details that only returns the file types and counts.
    This will adhere to .gitignore files.

    Args:
        target_path: Path to the target directory to analyze
        ignore_patterns: Set of patterns to ignore (applied to all subdirectories)

    Returns:
        DirectorySummary: Pydantic model containing directory structure and file statistics
    """
    path = Path(target_path)
    if not path.is_dir():
        raise ValueError(f"Path {target_path} is not a directory")

    ignore_file = os.path.join(path, ".gitignore")

    ignore_patterns = ignore_patterns or set()
    ignore_patterns = ignore_patterns.union(parse_gitignore(ignore_file))

    # Initialize data structures
    file_types: Dict[str, int] = {}
    subpaths: List[DirectorySummary] = []
    num_files = 0

    # Process directory contents
    for item in path.iterdir():
        # Always ignore .git folders
        if item.name == ".git":
            continue
        # Check if item should be ignored based on ignore_patterns
        if should_ignore_path(item, ignore_patterns, Path(target_path)):
            continue
        if item.is_dir():
            # Recursively process subdirectory with the same ignore_patterns
            sub_metadata = directory_summary(str(item), ignore_patterns, resolve_path)
            subpaths.append(sub_metadata)
        else:
            # Count file and type
            num_files += 1

            file_ext = (
                item.suffix[1:] if item.suffix else item.name
            )  # Only the extension without the dot, or full name if no extension
            file_types[file_ext] = file_types.get(file_ext, 0) + 1

    # Convert file types to list of FileType models
    file_types_list = [
        FileType(type=ext, count=count) for ext, count in sorted(file_types.items())
    ]
    final_path = path.resolve() if resolve_path else path
    return DirectorySummary(
        path=str(final_path),
        num_files=num_files,
        file_types=file_types_list,
        subpaths=subpaths,
    )
`````

## File: src/metagit/core/web/config_handler.py
`````python
#!/usr/bin/env python
"""HTTP handlers for metagit and appconfig schema tree routes (v3 API)."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Callable, Literal
from urllib.parse import urlparse

from pydantic import ValidationError

from metagit.core.appconfig import load_config as load_appconfig
from metagit.core.appconfig import save_config as save_appconfig
from metagit.core.appconfig.models import AppConfig
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.web.config_preview import (
    PreviewStyle,
    read_disk_text,
    render_appconfig_yaml,
    render_metagit_yaml,
)
from metagit.core.web.models import (
    ConfigPatchRequest,
    ConfigPreviewRequest,
    ConfigPreviewResponse,
    ConfigTreeResponse,
)
from metagit.core.web.schema_tree import SchemaTreeService

JsonResponder = Callable[[int, dict[str, Any]], None]

ConfigTarget = Literal["metagit", "appconfig"]
ValidateTarget = Literal["metagit", "appconfig", "both"]


class ConfigWebHandler:
    """Route config tree and patch operations for the local web HTTP API."""

    def __init__(
        self,
        *,
        metagit_config_path: str,
        appconfig_path: str,
    ) -> None:
        self._metagit_config_path = str(Path(metagit_config_path).resolve())
        self._appconfig_path = str(Path(appconfig_path).resolve())
        self._schema = SchemaTreeService()

    def handle(
        self,
        method: str,
        path: str,
        query: str,
        body: bytes,
        respond: JsonResponder,
    ) -> bool:
        """Dispatch config routes; return True when handled."""
        parsed_path = urlparse(path).path

        if method == "GET" and parsed_path == "/v3/config/metagit/tree":
            self._respond_metagit_tree(respond, saved=False)
            return True

        if method == "GET" and parsed_path == "/v3/config/appconfig/tree":
            self._respond_appconfig_tree(respond, saved=False)
            return True

        if method == "PATCH" and parsed_path == "/v3/config/metagit":
            self._patch_metagit(body, respond)
            return True

        if method == "PATCH" and parsed_path == "/v3/config/appconfig":
            self._patch_appconfig(body, respond)
            return True

        if method == "POST" and parsed_path == "/v3/config/validate":
            self._validate_configs(body, respond)
            return True

        if method == "GET" and parsed_path == "/v3/config/metagit/preview":
            self._preview_metagit(query, b"", respond)
            return True

        if method == "GET" and parsed_path == "/v3/config/appconfig/preview":
            self._preview_appconfig(query, b"", respond)
            return True

        if method == "POST" and parsed_path == "/v3/config/metagit/preview":
            self._preview_metagit(query, body, respond)
            return True

        if method == "POST" and parsed_path == "/v3/config/appconfig/preview":
            self._preview_appconfig(query, body, respond)
            return True

        return False

    def _respond_metagit_tree(
        self,
        respond: JsonResponder,
        *,
        config: MetagitConfig | None = None,
        validation_errors: list[dict[str, str]] | None = None,
        saved: bool,
    ) -> None:
        loaded = config
        errors = list(validation_errors or [])
        if loaded is None:
            loaded_result = self._load_metagit(respond)
            if loaded_result is None:
                return
            loaded = loaded_result
        response = self._tree_response(
            target="metagit",
            config_path=self._metagit_config_path,
            config=loaded,
            model_class=MetagitConfig,
            validation_errors=errors,
            saved=saved,
            mask_secrets=False,
        )
        respond(200, response.model_dump(mode="json"))

    def _respond_appconfig_tree(
        self,
        respond: JsonResponder,
        *,
        config: AppConfig | None = None,
        validation_errors: list[dict[str, str]] | None = None,
        saved: bool,
    ) -> None:
        loaded = config
        errors = list(validation_errors or [])
        if loaded is None:
            loaded_result = self._load_appconfig(respond)
            if loaded_result is None:
                return
            loaded = loaded_result
        response = self._tree_response(
            target="appconfig",
            config_path=self._appconfig_path,
            config=loaded,
            model_class=AppConfig,
            validation_errors=errors,
            saved=saved,
            mask_secrets=True,
        )
        respond(200, response.model_dump(mode="json"))

    def _patch_metagit(self, body: bytes, respond: JsonResponder) -> None:
        loaded = self._load_metagit(respond)
        if loaded is None:
            return
        patch = self._parse_patch(body, respond)
        if patch is None:
            return
        updated, validation_errors = self._schema.apply_operations(
            loaded,
            MetagitConfig,
            patch.operations,
        )
        saved = False
        if patch.save:
            if validation_errors:
                response = self._tree_response(
                    target="metagit",
                    config_path=self._metagit_config_path,
                    config=updated,
                    model_class=MetagitConfig,
                    validation_errors=validation_errors,
                    saved=False,
                    mask_secrets=False,
                )
                respond(422, response.model_dump(mode="json"))
                return
            manager = MetagitConfigManager(self._metagit_config_path)
            save_result = manager.save_config(updated)
            if isinstance(save_result, Exception):
                respond(
                    500,
                    {
                        "ok": False,
                        "error": {"kind": "save_error", "message": str(save_result)},
                    },
                )
                return
            saved = True
        self._respond_metagit_tree(
            respond,
            config=updated,
            validation_errors=validation_errors,
            saved=saved,
        )

    def _patch_appconfig(self, body: bytes, respond: JsonResponder) -> None:
        loaded = self._load_appconfig(respond)
        if loaded is None:
            return
        patch = self._parse_patch(body, respond)
        if patch is None:
            return
        updated, validation_errors = self._schema.apply_operations(
            loaded,
            AppConfig,
            patch.operations,
        )
        saved = False
        if patch.save:
            if validation_errors:
                response = self._tree_response(
                    target="appconfig",
                    config_path=self._appconfig_path,
                    config=updated,
                    model_class=AppConfig,
                    validation_errors=validation_errors,
                    saved=False,
                    mask_secrets=True,
                )
                respond(422, response.model_dump(mode="json"))
                return
            save_result = save_appconfig(self._appconfig_path, updated)
            if isinstance(save_result, Exception):
                respond(
                    500,
                    {
                        "ok": False,
                        "error": {"kind": "save_error", "message": str(save_result)},
                    },
                )
                return
            saved = True
        self._respond_appconfig_tree(
            respond,
            config=updated,
            validation_errors=validation_errors,
            saved=saved,
        )

    def _validate_configs(self, body: bytes, respond: JsonResponder) -> None:
        payload = self._parse_body(body, respond, required=False) or {}
        target_raw = payload.get("target", "both")
        if target_raw not in {"metagit", "appconfig", "both"}:
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_target",
                        "message": "target must be metagit, appconfig, or both",
                    },
                },
            )
            return
        target: ValidateTarget = target_raw
        results: list[dict[str, Any]] = []
        targets: list[ConfigTarget] = (
            ["metagit", "appconfig"] if target == "both" else [target]
        )
        for item in targets:
            if item == "metagit":
                errors = self._validation_errors_for_metagit()
            else:
                errors = self._validation_errors_for_appconfig()
            results.append(
                {
                    "target": item,
                    "ok": len(errors) == 0,
                    "validation_errors": errors,
                }
            )
        respond(
            200,
            {
                "ok": all(entry["ok"] for entry in results),
                "results": results,
            },
        )

    def _validation_errors_for_metagit(self) -> list[dict[str, str]]:
        manager = MetagitConfigManager(self._metagit_config_path)
        loaded = manager.load_config()
        if isinstance(loaded, Exception):
            return [{"path": "", "message": str(loaded)}]
        try:
            MetagitConfig.model_validate(loaded.model_dump(mode="python"))
        except ValidationError as exc:
            return [
                {
                    "path": self._format_error_path(err.get("loc", ())),
                    "message": err.get("msg", "validation error"),
                }
                for err in exc.errors()
            ]
        return []

    def _validation_errors_for_appconfig(self) -> list[dict[str, str]]:
        loaded = load_appconfig(self._appconfig_path)
        if isinstance(loaded, Exception):
            return [{"path": "", "message": str(loaded)}]
        try:
            AppConfig.model_validate(loaded.model_dump(mode="python"))
        except ValidationError as exc:
            return [
                {
                    "path": self._format_error_path(err.get("loc", ())),
                    "message": err.get("msg", "validation error"),
                }
                for err in exc.errors()
            ]
        return []

    def _preview_metagit(
        self,
        query: str,
        body: bytes,
        respond: JsonResponder,
    ) -> None:
        request = self._parse_preview_request(query, body, respond)
        if request is None:
            return
        if request.style == "disk" and request.operations:
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_preview",
                        "message": "disk preview cannot include draft operations",
                    },
                },
            )
            return
        loaded = self._load_metagit(respond)
        if loaded is None:
            return
        config = loaded
        validation_errors: list[dict[str, str]] = []
        draft = bool(request.operations)
        if draft:
            config, validation_errors = self._schema.apply_operations(
                loaded,
                MetagitConfig,
                request.operations,
            )
        if request.style == "disk":
            yaml_text = read_disk_text(self._metagit_config_path)
        else:
            yaml_text = render_metagit_yaml(config, style=request.style)
        response = ConfigPreviewResponse(
            ok=len(validation_errors) == 0,
            target="metagit",
            config_path=self._metagit_config_path,
            style=request.style,
            yaml=yaml_text,
            draft=draft,
            validation_errors=validation_errors,
        )
        respond(200, response.model_dump(mode="json"))

    def _preview_appconfig(
        self,
        query: str,
        body: bytes,
        respond: JsonResponder,
    ) -> None:
        request = self._parse_preview_request(query, body, respond)
        if request is None:
            return
        if request.style == "disk" and request.operations:
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_preview",
                        "message": "disk preview cannot include draft operations",
                    },
                },
            )
            return
        loaded = self._load_appconfig(respond)
        if loaded is None:
            return
        config = loaded
        validation_errors: list[dict[str, str]] = []
        draft = bool(request.operations)
        if draft:
            config, validation_errors = self._schema.apply_operations(
                loaded,
                AppConfig,
                request.operations,
            )
        if request.style == "disk":
            yaml_text = read_disk_text(self._appconfig_path)
        else:
            yaml_text = render_appconfig_yaml(
                config,
                config_path=self._appconfig_path,
                style=request.style,
                mask_secrets=True,
            )
        response = ConfigPreviewResponse(
            ok=len(validation_errors) == 0,
            target="appconfig",
            config_path=self._appconfig_path,
            style=request.style,
            yaml=yaml_text,
            draft=draft,
            validation_errors=validation_errors,
        )
        respond(200, response.model_dump(mode="json"))

    def _parse_preview_request(
        self,
        query: str,
        body: bytes,
        respond: JsonResponder,
    ) -> ConfigPreviewRequest | None:
        from urllib.parse import parse_qs

        params = parse_qs(query, keep_blank_values=True)
        style_raw = (params.get("style") or ["normalized"])[0]
        if style_raw not in {"normalized", "minimal", "disk"}:
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_style",
                        "message": "style must be normalized, minimal, or disk",
                    },
                },
            )
            return None
        style: PreviewStyle = style_raw
        if not body:
            return ConfigPreviewRequest(style=style, operations=[])
        payload = self._parse_body(body, respond, required=False)
        if payload is None:
            return None
        try:
            parsed = ConfigPreviewRequest.model_validate(
                {"style": payload.get("style", style), **payload}
            )
        except ValidationError as exc:
            respond(
                400,
                {
                    "ok": False,
                    "error": {"kind": "invalid_body", "message": str(exc)},
                },
            )
            return None
        if parsed.style not in {"normalized", "minimal", "disk"}:
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_style",
                        "message": "style must be normalized, minimal, or disk",
                    },
                },
            )
            return None
        return parsed

    def _tree_response(
        self,
        *,
        target: ConfigTarget,
        config_path: str,
        config: MetagitConfig | AppConfig,
        model_class: type[MetagitConfig] | type[AppConfig],
        validation_errors: list[dict[str, str]],
        saved: bool,
        mask_secrets: bool,
    ) -> ConfigTreeResponse:
        tree = self._schema.build_tree(
            config,
            model_class,
            mask_secrets=mask_secrets,
        )
        return ConfigTreeResponse(
            ok=len(validation_errors) == 0,
            target=target,
            config_path=config_path,
            tree=tree,
            validation_errors=validation_errors,
            saved=saved,
        )

    def _load_metagit(self, respond: JsonResponder) -> MetagitConfig | None:
        manager = MetagitConfigManager(self._metagit_config_path)
        loaded = manager.load_config()
        if isinstance(loaded, Exception):
            respond(
                500,
                {
                    "ok": False,
                    "error": {"kind": "config_error", "message": str(loaded)},
                },
            )
            return None
        return loaded

    def _load_appconfig(self, respond: JsonResponder) -> AppConfig | None:
        loaded = load_appconfig(self._appconfig_path)
        if isinstance(loaded, Exception):
            respond(
                500,
                {
                    "ok": False,
                    "error": {"kind": "config_error", "message": str(loaded)},
                },
            )
            return None
        return loaded

    def _parse_patch(
        self,
        body: bytes,
        respond: JsonResponder,
    ) -> ConfigPatchRequest | None:
        payload = self._parse_body(body, respond, required=True)
        if payload is None:
            return None
        try:
            return ConfigPatchRequest.model_validate(payload)
        except ValidationError as exc:
            respond(
                400,
                {
                    "ok": False,
                    "error": {"kind": "invalid_body", "message": str(exc)},
                },
            )
            return None

    def _parse_body(
        self,
        body: bytes,
        respond: JsonResponder,
        *,
        required: bool,
    ) -> dict[str, Any] | None:
        if not body:
            if required:
                respond(
                    400,
                    {
                        "ok": False,
                        "error": {
                            "kind": "invalid_body",
                            "message": "JSON body required",
                        },
                    },
                )
                return None
            return {}
        try:
            parsed = json.loads(body.decode("utf-8"))
        except json.JSONDecodeError as exc:
            respond(
                400,
                {"ok": False, "error": {"kind": "invalid_json", "message": str(exc)}},
            )
            return None
        if not isinstance(parsed, dict):
            respond(
                400,
                {
                    "ok": False,
                    "error": {
                        "kind": "invalid_body",
                        "message": "expected JSON object",
                    },
                },
            )
            return None
        return parsed

    @staticmethod
    def _format_error_path(loc: tuple[Any, ...]) -> str:
        parts: list[str] = []
        for item in loc:
            if isinstance(item, int):
                parts.append(f"[{item}]")
            else:
                if parts:
                    parts.append(f".{item}")
                else:
                    parts.append(str(item))
        return "".join(parts)
`````

## File: src/metagit/core/web/models.py
`````python
#!/usr/bin/env python
"""Pydantic models for metagit web API."""

from enum import Enum
from typing import Any, Literal

from pydantic import BaseModel, Field


class ConfigOpKind(str, Enum):
    ENABLE = "enable"
    DISABLE = "disable"
    SET = "set"
    APPEND = "append"
    REMOVE = "remove"


class ConfigOperation(BaseModel):
    op: ConfigOpKind
    path: str
    value: Any | None = None


class ConfigPatchRequest(BaseModel):
    save: bool = False
    operations: list[ConfigOperation] = Field(default_factory=list)


class SchemaFieldNode(BaseModel):
    path: str
    key: str
    type: str
    type_label: str | None = None
    description: str | None = None
    required: bool = False
    enabled: bool = False
    editable: bool = True
    sensitive: bool = False
    default_value: Any | None = None
    value: Any | None = None
    enum_options: list[str] = Field(default_factory=list)
    item_count: int | None = None
    can_append: bool = False
    children: list["SchemaFieldNode"] = Field(default_factory=list)


class ConfigTreeResponse(BaseModel):
    ok: bool
    target: Literal["metagit", "appconfig"]
    config_path: str
    tree: SchemaFieldNode
    validation_errors: list[dict[str, str]] = Field(default_factory=list)
    saved: bool = False


class ConfigPreviewRequest(BaseModel):
    style: Literal["normalized", "minimal", "disk"] = "normalized"
    operations: list[ConfigOperation] = Field(default_factory=list)


class ConfigPreviewResponse(BaseModel):
    ok: bool
    target: Literal["metagit", "appconfig"]
    config_path: str
    style: Literal["normalized", "minimal", "disk"]
    yaml: str
    draft: bool = False
    validation_errors: list[dict[str, str]] = Field(default_factory=list)


class SyncJobRequest(BaseModel):
    repos: list[str] | None = None
    mode: Literal["fetch", "pull", "clone"] = "fetch"
    dry_run: bool = False
    allow_mutation: bool = True
    max_parallel: int = 4


class SyncJobStatus(BaseModel):
    job_id: str
    state: Literal["pending", "running", "completed", "failed"]
    summary: dict[str, Any] = Field(default_factory=dict)
    results: list[dict[str, Any]] = Field(default_factory=list)
    error: str | None = None
`````

## File: src/metagit/core/web/server.py
`````python
#!/usr/bin/env python
"""Local HTTP server for the metagit web UI."""

from __future__ import annotations

import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

from metagit.core.api.catalog_handler import CatalogApiHandler
from metagit.core.api.layout_handler import LayoutApiHandler
from metagit.core.appconfig import load_config as load_appconfig
from metagit.core.web.config_handler import ConfigWebHandler
from metagit.core.web.ops_handler import OpsWebHandler
from metagit.core.web.static_handler import StaticWebHandler


def _resolve_workspace_root(root: str, workspace_path: str) -> str:
    """Resolve workspace sync root from appconfig path (relative to manifest root)."""
    path = Path(workspace_path).expanduser()
    if not path.is_absolute():
        path = (Path(root) / path).resolve()
    else:
        path = path.resolve()
    return str(path)


def build_web_server(
    *,
    root: str,
    appconfig_path: str,
    host: str = "127.0.0.1",
    port: int = 8787,
) -> ThreadingHTTPServer:
    """Build a threading HTTP server for web UI routes."""
    root_resolved = str(Path(root).resolve())
    config_path = os.path.join(root_resolved, ".metagit.yml")
    appconfig_resolved = str(Path(appconfig_path).resolve())
    app_config = load_appconfig(appconfig_resolved)
    if isinstance(app_config, Exception):
        raise ValueError(f"Failed to load app config: {app_config}")
    if app_config.workspace is None:
        raise ValueError("app config missing workspace section")
    workspace_root = _resolve_workspace_root(
        root_resolved,
        str(app_config.workspace.path),
    )
    static_handler = StaticWebHandler()
    catalog_handler = CatalogApiHandler(
        workspace_root=workspace_root,
        config_path=config_path,
    )
    layout_handler = LayoutApiHandler(
        definition_root=root_resolved,
        config_path=config_path,
    )
    config_handler = ConfigWebHandler(
        metagit_config_path=config_path,
        appconfig_path=appconfig_resolved,
    )
    ops_handler = OpsWebHandler(
        root=root_resolved,
        config_path=config_path,
        appconfig_path=appconfig_resolved,
        workspace_root=workspace_root,
    )

    class ReusableThreadingHTTPServer(ThreadingHTTPServer):
        allow_reuse_address = True

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, format: str, *args: Any) -> None:
            _ = (format, args)

        def do_GET(self) -> None:
            self._dispatch("GET")

        def do_PATCH(self) -> None:
            self._dispatch("PATCH")

        def do_POST(self) -> None:
            self._dispatch("POST")

        def do_DELETE(self) -> None:
            self._dispatch("DELETE")

        def _dispatch(self, method: str) -> None:
            parsed = urlparse(self.path)
            events_job_id = ops_handler.sync_events_job_id(method, parsed.path)
            if events_job_id is not None:
                self.send_response(200)
                self.send_header("Content-Type", "text/event-stream; charset=utf-8")
                self.send_header("Cache-Control", "no-cache")
                self.send_header("Connection", "keep-alive")
                self.end_headers()
                ops_handler.stream_sync_events(events_job_id, self.wfile)
                return

            length = int(self.headers.get("Content-Length", "0") or "0")
            body = self.rfile.read(length) if length > 0 else b""

            if method == "GET":
                if static_handler.handle(method, parsed.path, self):
                    return
                if catalog_handler.handle(
                    method,
                    parsed.path,
                    parsed.query,
                    body,
                    self._json,
                ):
                    return
                if layout_handler.handle(
                    method,
                    parsed.path,
                    parsed.query,
                    body,
                    self._json,
                ):
                    return
                if config_handler.handle(
                    method,
                    parsed.path,
                    parsed.query,
                    body,
                    self._json,
                ):
                    return
                if ops_handler.handle(
                    method,
                    parsed.path,
                    parsed.query,
                    body,
                    self._json,
                ):
                    return
                if StaticWebHandler.is_api_path(parsed.path):
                    self._json(
                        404,
                        {"error": {"kind": "not_found", "message": "Unknown endpoint"}},
                    )
                    return
                return

            if layout_handler.handle(
                method,
                parsed.path,
                parsed.query,
                body,
                self._json,
            ):
                return
            if catalog_handler.handle(
                method,
                parsed.path,
                parsed.query,
                body,
                self._json,
            ):
                return
            if config_handler.handle(
                method,
                parsed.path,
                parsed.query,
                body,
                self._json,
            ):
                return
            if ops_handler.handle(
                method,
                parsed.path,
                parsed.query,
                body,
                self._json,
            ):
                return
            if StaticWebHandler.is_api_path(parsed.path):
                self._json(
                    404,
                    {"error": {"kind": "not_found", "message": "Unknown endpoint"}},
                )
                return
            self._json(
                404,
                {"error": {"kind": "not_found", "message": "Unknown endpoint"}},
            )

        def _json(self, status: int, payload: dict[str, Any]) -> None:
            body = json.dumps(payload).encode("utf-8")
            self.send_response(status)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

    return ReusableThreadingHTTPServer((host, port), Handler)
`````

## File: src/metagit/core/workspace/catalog_service.py
`````python
#!/usr/bin/env python
"""
List and mutate workspace projects and repositories in `.metagit.yml`.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any, Optional

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.project.models import ProjectKind, ProjectPath
from metagit.core.workspace.workspace_dedupe import find_duplicate_identities
from metagit.core.workspace.catalog_models import (
    CatalogError,
    CatalogMutationResult,
    CatalogResult,
    ProjectListEntry,
    RepoListEntry,
    WorkspaceSummary,
)
from metagit.core.workspace.models import Workspace, WorkspaceProject


class WorkspaceCatalogService:
    """CRUD-style catalog operations for workspace manifests."""

    def __init__(
        self,
        index_service: Optional[WorkspaceIndexService] = None,
    ) -> None:
        self._index = index_service or WorkspaceIndexService()

    def list_workspace(
        self,
        config: MetagitConfig,
        config_path: str,
        workspace_root: str,
        *,
        include_index: bool = True,
    ) -> CatalogResult:
        """Return workspace summary, projects, and optional index rows."""
        summary = self._workspace_summary(
            config=config,
            config_path=config_path,
            workspace_root=workspace_root,
        )
        projects = self.list_projects(config=config).data or {}
        payload: dict[str, Any] = {
            "summary": summary.model_dump(mode="json"),
            "projects": projects.get("projects", []),
        }
        if include_index:
            payload["repos_index"] = self._index.build_index(
                config=config,
                workspace_root=workspace_root,
            )
        return CatalogResult(ok=True, data=payload)

    def list_projects(self, config: MetagitConfig) -> CatalogResult:
        """List workspace projects defined in the manifest."""
        if not config.workspace:
            return CatalogResult(
                ok=True,
                data={"projects": [], "project_count": 0},
            )
        entries = [
            ProjectListEntry(
                name=project.name,
                description=project.description,
                agent_instructions=project.agent_instructions,
                dedupe_enabled=(
                    project.dedupe.enabled if project.dedupe is not None else None
                ),
                repo_count=len(project.repos),
            ).model_dump(mode="json")
            for project in config.workspace.projects
        ]
        return CatalogResult(
            ok=True,
            data={"projects": entries, "project_count": len(entries)},
        )

    def list_repos(
        self,
        config: MetagitConfig,
        workspace_root: str,
        *,
        project_name: Optional[str] = None,
        include_status: bool = True,
    ) -> CatalogResult:
        """List configured repositories, optionally scoped to one project."""
        if not config.workspace:
            return CatalogResult(ok=True, data={"repos": [], "repo_count": 0})
        index_rows: list[dict[str, Any]] = []
        if include_status:
            index_rows = self._index.build_index(
                config=config,
                workspace_root=workspace_root,
            )
        index_by_key = {
            (row["project_name"], row["repo_name"]): row for row in index_rows
        }
        repos: list[dict[str, Any]] = []
        for project in config.workspace.projects:
            if project_name and project.name != project_name:
                continue
            for repo in project.repos:
                row = index_by_key.get((project.name, repo.name), {})
                entry = RepoListEntry(
                    project_name=project.name,
                    repo=repo,
                    configured_path=repo.path,
                    repo_path=row.get("repo_path"),
                    exists=row.get("exists"),
                    status=row.get("status"),
                )
                repos.append(entry.model_dump(mode="json"))
        return CatalogResult(
            ok=True,
            data={"repos": repos, "repo_count": len(repos)},
        )

    def add_project(
        self,
        config: MetagitConfig,
        config_path: str,
        *,
        name: str,
        description: Optional[str] = None,
        agent_instructions: Optional[str] = None,
    ) -> CatalogMutationResult:
        """Add a workspace project (group) to the manifest."""
        trimmed = name.strip()
        if not trimmed:
            return self._mutation_error(
                entity="project",
                operation="add",
                kind="invalid_name",
                message="project name is required",
            )
        if not config.workspace:
            config.workspace = Workspace(projects=[])
        for project in config.workspace.projects:
            if project.name == trimmed:
                return self._mutation_error(
                    entity="project",
                    operation="add",
                    kind="already_exists",
                    message=f"project '{trimmed}' already exists",
                    project_name=trimmed,
                )
        config.workspace.projects.append(
            WorkspaceProject(
                name=trimmed,
                description=description,
                agent_instructions=agent_instructions,
                repos=[],
            )
        )
        save_err = self._save(config=config, config_path=config_path)
        if save_err:
            return self._mutation_error(
                entity="project",
                operation="add",
                kind="save_failed",
                message=str(save_err),
                project_name=trimmed,
            )
        return CatalogMutationResult(
            ok=True,
            entity="project",
            operation="add",
            project_name=trimmed,
            config_path=config_path,
        )

    def remove_project(
        self,
        config: MetagitConfig,
        config_path: str,
        *,
        name: str,
    ) -> CatalogMutationResult:
        """Remove a workspace project from the manifest (repos are removed with it)."""
        trimmed = name.strip()
        if not config.workspace:
            return self._mutation_error(
                entity="project",
                operation="remove",
                kind="not_found",
                message=f"project '{trimmed}' not found",
                project_name=trimmed,
            )
        before = len(config.workspace.projects)
        config.workspace.projects = [
            project for project in config.workspace.projects if project.name != trimmed
        ]
        if len(config.workspace.projects) == before:
            return self._mutation_error(
                entity="project",
                operation="remove",
                kind="not_found",
                message=f"project '{trimmed}' not found",
                project_name=trimmed,
            )
        save_err = self._save(config=config, config_path=config_path)
        if save_err:
            return self._mutation_error(
                entity="project",
                operation="remove",
                kind="save_failed",
                message=str(save_err),
                project_name=trimmed,
            )
        return CatalogMutationResult(
            ok=True,
            entity="project",
            operation="remove",
            project_name=trimmed,
            config_path=config_path,
        )

    def add_repo(
        self,
        config: MetagitConfig,
        config_path: str,
        *,
        project_name: str,
        repo: ProjectPath,
    ) -> CatalogMutationResult:
        """Add a repository entry under a workspace project."""
        project = self._find_project(config=config, project_name=project_name)
        if project is None:
            return self._mutation_error(
                entity="repo",
                operation="add",
                kind="project_not_found",
                message=f"project '{project_name}' not found",
                project_name=project_name,
            )
        for existing in project.repos:
            if existing.name == repo.name:
                return self._mutation_error(
                    entity="repo",
                    operation="add",
                    kind="already_exists",
                    message=(
                        f"repo '{repo.name}' already exists in project '{project_name}'"
                    ),
                    project_name=project_name,
                    repo_name=repo.name,
                )
        if repo.path is None and repo.url is None:
            return self._mutation_error(
                entity="repo",
                operation="add",
                kind="invalid_repo",
                message="repo requires at least path or url",
                project_name=project_name,
                repo_name=repo.name,
            )
        duplicates = find_duplicate_identities(config, repo)
        if duplicates:
            locations = ", ".join(f"{proj}/{name}" for proj, name in duplicates)
            return self._mutation_error(
                entity="repo",
                operation="add",
                kind="duplicate_identity",
                message=(
                    f"repo identity already registered as {locations}; "
                    "reuse that entry or enable workspace dedupe before adding again"
                ),
                project_name=project_name,
                repo_name=repo.name,
            )
        project.repos.append(repo)
        save_err = self._save(config=config, config_path=config_path)
        if save_err:
            return self._mutation_error(
                entity="repo",
                operation="add",
                kind="save_failed",
                message=str(save_err),
                project_name=project_name,
                repo_name=repo.name,
            )
        return CatalogMutationResult(
            ok=True,
            entity="repo",
            operation="add",
            project_name=project_name,
            repo_name=repo.name,
            config_path=config_path,
        )

    def remove_repo(
        self,
        config: MetagitConfig,
        config_path: str,
        *,
        project_name: str,
        repo_name: str,
    ) -> CatalogMutationResult:
        """Remove a repository entry from a workspace project (manifest only)."""
        project = self._find_project(config=config, project_name=project_name)
        if project is None:
            return self._mutation_error(
                entity="repo",
                operation="remove",
                kind="project_not_found",
                message=f"project '{project_name}' not found",
                project_name=project_name,
                repo_name=repo_name,
            )
        before = len(project.repos)
        project.repos = [item for item in project.repos if item.name != repo_name]
        if len(project.repos) == before:
            return self._mutation_error(
                entity="repo",
                operation="remove",
                kind="not_found",
                message=(f"repo '{repo_name}' not found in project '{project_name}'"),
                project_name=project_name,
                repo_name=repo_name,
            )
        save_err = self._save(config=config, config_path=config_path)
        if save_err:
            return self._mutation_error(
                entity="repo",
                operation="remove",
                kind="save_failed",
                message=str(save_err),
                project_name=project_name,
                repo_name=repo_name,
            )
        return CatalogMutationResult(
            ok=True,
            entity="repo",
            operation="remove",
            project_name=project_name,
            repo_name=repo_name,
            config_path=config_path,
        )

    def build_repo_from_fields(
        self,
        *,
        name: str,
        description: Optional[str] = None,
        kind: Optional[str] = None,
        path: Optional[str] = None,
        url: Optional[str] = None,
        sync: Optional[bool] = None,
        agent_instructions: Optional[str] = None,
        tags: Optional[dict[str, str]] = None,
    ) -> ProjectPath | CatalogError:
        """Construct a ProjectPath from API/MCP/CLI fields."""
        trimmed_name = name.strip()
        if not trimmed_name:
            return CatalogError(kind="invalid_name", message="repo name is required")
        kind_val: ProjectKind | None = None
        if kind:
            try:
                kind_val = ProjectKind(kind)
            except ValueError:
                return CatalogError(
                    kind="invalid_kind",
                    message=f"invalid project kind: {kind}",
                )
        return ProjectPath(
            name=trimmed_name,
            description=description,
            kind=kind_val,
            path=path,
            url=url,
            sync=sync,
            agent_instructions=agent_instructions,
            tags=tags or {},
        )

    def _workspace_summary(
        self,
        config: MetagitConfig,
        config_path: str,
        workspace_root: str,
    ) -> WorkspaceSummary:
        project_count = len(config.workspace.projects) if config.workspace else 0
        repo_count = 0
        if config.workspace:
            repo_count = sum(
                len(project.repos) for project in config.workspace.projects
            )
        return WorkspaceSummary(
            definition_path=str(Path(config_path).resolve()),
            workspace_root=str(Path(workspace_root).resolve()),
            file_name=config.name,
            file_description=config.description,
            file_agent_instructions=config.agent_instructions,
            workspace=config.workspace,
            project_count=project_count,
            repo_count=repo_count,
        )

    def _find_project(
        self, config: MetagitConfig, project_name: str
    ) -> Optional[WorkspaceProject]:
        if not config.workspace:
            return None
        for project in config.workspace.projects:
            if project.name == project_name:
                return project
        return None

    def _save(self, config: MetagitConfig, config_path: str) -> Optional[Exception]:
        manager = MetagitConfigManager(metagit_config=config)
        result = manager.save_config(config, Path(config_path))
        if isinstance(result, Exception):
            return result
        return None

    def _mutation_error(
        self,
        *,
        entity: str,
        operation: str,
        kind: str,
        message: str,
        project_name: str = "",
        repo_name: Optional[str] = None,
    ) -> CatalogMutationResult:
        return CatalogMutationResult(
            ok=False,
            error=CatalogError(kind=kind, message=message),
            entity="repo" if entity == "repo" else "project",
            operation="remove" if operation == "remove" else "add",
            project_name=project_name,
            repo_name=repo_name,
            config_path="",
        )
`````

## File: src/metagit/core/workspace/models.py
`````python
#!/usr/bin/env python
"""
Pydantic models for .metagit.yml workspace configuration.
"""

from typing import Any, List, Optional

from pydantic import AliasChoices, BaseModel, Field, field_validator

from metagit.core.project.models import ProjectPath


class ProjectDedupeOverride(BaseModel):
    """Per-project override of app-config ``workspace.dedupe``."""

    enabled: Optional[bool] = Field(
        default=None,
        description=(
            "When set, overrides workspace.dedupe.enabled from metagit.config.yaml "
            "for sync and layout under this project only"
        ),
    )

    class Config:
        """Pydantic configuration."""

        extra = "forbid"


class WorkspaceProject(BaseModel):
    """Model for workspace project."""

    name: str = Field(..., description="Workspace project name")
    description: Optional[str] = Field(
        None, description="Human-readable description of this workspace project"
    )
    agent_instructions: Optional[str] = Field(
        None,
        validation_alias=AliasChoices("agent_instructions", "agent_prompt"),
        description="Optional instructions for agents working in this workspace project",
    )
    dedupe: Optional[ProjectDedupeOverride] = Field(
        default=None,
        description=(
            "Optional override of app-config workspace.dedupe for this project "
            "(currently supports enabled only)"
        ),
    )
    repos: List[ProjectPath] = Field(..., description="Repository list")

    @field_validator("repos", mode="before")
    def validate_repos(cls, v: Any) -> Any:
        """Handle YAML anchors and complex repo structures."""
        if isinstance(v, list):
            # Flatten any nested lists that might come from YAML anchors
            flattened: List[Any] = []
            for item in v:
                if isinstance(item, list):
                    flattened.extend(item)
                else:
                    flattened.append(item)
            return flattened
        return v

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
        extra = "forbid"


class Workspace(BaseModel):
    """Model for workspace configuration."""

    description: Optional[str] = Field(
        None, description="Human-readable description of this workspace"
    )
    agent_instructions: Optional[str] = Field(
        None,
        validation_alias=AliasChoices("agent_instructions", "agent_prompt"),
        description="Optional instructions for agents working in this workspace",
    )
    projects: List[WorkspaceProject] = Field(..., description="Workspace projects")

    class Config:
        """Pydantic configuration."""

        use_enum_values = True
`````

## File: src/metagit/data/skills/metagit-release-audit/SKILL.md
`````markdown
---
name: metagit-release-audit
description: Mandatory before calling work complete when the session changed repo files. Runs format, lint, tests, integration tests, context-aware pip-audit/bandit, and optional gitleaks via task qa:prepush. Use before push, release, or hand-off.
---

# Auditing Release Readiness

Use this skill **whenever** your session added or edited tracked files in this repository and you are about to hand off or say the task is done—not only "release" workflows. Read-only Q&A with no writes can skip it.

## Workflow

1. Run the pre-push quality gate.
2. Capture failing stage logs and iterate fixes.
3. Re-run until all required checks pass.
4. Return a short readiness summary.

## Commands

- `task qa:prepush`
- `task qa:prepush:loop -- 3` (optional bounded retry loop)

## Output Contract

Return:
- pass/fail status by stage (including `security_sync` / `security_audit` / `security_bandit` when triggered)
- unresolved blockers (if any)
- push readiness recommendation

Security in the gate is context-aware: lockfile changes run `uv sync --frozen --all-extras` + `pip-audit` + `bandit`; `src/` changes run `pip-audit` + `bandit`; docs-only diffs skip security. Use `task security:scan` for a full manual run.

## Safety

- Do not claim readiness unless checks are actually green.
`````

## File: src/metagit/data/metagit.config.yaml
`````yaml
config:
  description: "Configuration for metagit CLI"
  agent_mode: false
  # Reserved for future use
  api_url: ""
  # Reserved for future use
  api_version: ""
  # Reserved for future use
  api_key: ""
  # Reserved for future use
  cicd_file_data: data/cicd-files.json
  file_type_data: data/file-types.json
  package_manager_data: data/package-managers.json
  default_profile: default

  llm:
    provider: "openrouter"
    provider_model: "gpt-4o-mini"
    embedder: "ollama"
    embedder_model: "nomic-embed-text"
    api_key: ""

  workspace:
    # Default workspace path, should be added to .gitignore
    path: ./.metagit
    default_project: default
    ui_ignore_hidden: true
    dedupe:
      enabled: false
      canonical_dir: _canonical

  profiles:
    # Profiles are used to define multiple configurations for different organizations
    - name: default
    # Boundaries are used to define the boundaries of the organization
    # They are used to determine which repositories are part of the organization
    # and which are not. This is important for auto-detection of internal and external repositories.
    # Any artifact, repository, or registry that is not part of the defined boundaries is considered external.
      boundaries:
      - name: github
        values: []
      - name: jfrog
        values: []
      - name: gitlab
        values: []
      - name: bitbucket
        values: []
      - name: azure_devops
        values: []
      - name: dockerhub
        values: []
      - name: domain
        values:
          - localhost
          - "127.0.0.1"
          - "0.0.0.0"
          - 192.168.*
          - 10.0.*
          - 172.16.*
`````

## File: tests/core/mcp/services/test_workspace_search.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.services.workspace_search
"""

from pathlib import Path
from unittest.mock import patch

from metagit.core.mcp.services.workspace_search import WorkspaceSearchService


def test_workspace_search_returns_scoped_hits(tmp_path: Path) -> None:
    repo_path = tmp_path / "repo-1"
    repo_path.mkdir()
    tf_file = repo_path / "main.tf"
    tf_file.write_text(
        "\n".join(
            [
                'module "network" {',
                '  source = "git::ssh://example/network"',
                "}",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    service = WorkspaceSearchService()

    results = service.search(
        query="module",
        repo_paths=[str(repo_path)],
        preset="terraform",
        max_results=10,
    )

    assert len(results) >= 1
    assert results[0]["file_path"].endswith("main.tf")


def test_filter_repo_paths_supports_project_repo_selector() -> None:
    service = WorkspaceSearchService()
    rows = [
        {
            "project_name": "alpha",
            "repo_name": "repo-one",
            "repo_path": "/tmp/alpha/repo-one",
            "exists": True,
        },
        {
            "project_name": "beta",
            "repo_name": "repo-two",
            "repo_path": "/tmp/beta/repo-two",
            "exists": True,
        },
    ]
    paths = service.filter_repo_paths(rows, repos=["alpha/repo-one"])
    assert paths == ["/tmp/alpha/repo-one"]


@patch("metagit.core.mcp.services.workspace_search.shutil.which", return_value=None)
def test_workspace_search_terraform_preset_fallback_without_rg(
    _mock_which: object,
    tmp_path: Path,
) -> None:
    repo_path = tmp_path / "repo-1"
    repo_path.mkdir()
    tf_file = repo_path / "main.tf"
    tf_file.write_text('module "x" {}\n', encoding="utf-8")
    service = WorkspaceSearchService()
    results = service.search(
        query="module",
        repo_paths=[str(repo_path)],
        preset="terraform",
        max_results=10,
    )
    assert len(results) >= 1


def test_workspace_search_includes_context_when_rg_available(tmp_path: Path) -> None:
    import shutil

    if not shutil.which("rg"):
        return
    repo_path = tmp_path / "repo-1"
    repo_path.mkdir()
    sample = repo_path / "sample.txt"
    sample.write_text("alpha\nbeta line\ngamma\n", encoding="utf-8")
    service = WorkspaceSearchService()

    results = service.search(
        query="beta",
        repo_paths=[str(repo_path)],
        context_lines=1,
        max_results=5,
    )

    assert len(results) == 1
    assert results[0]["line"] == "beta line"
    context = results[0]["context_before"] + results[0]["context_after"]
    assert any(line in {"alpha", "gamma"} for line in context)


def test_discover_files_returns_categorized_entries(tmp_path: Path) -> None:
    repo_path = tmp_path / "repo-1"
    repo_path.mkdir()
    (repo_path / "config.yml").write_text("key: value\n", encoding="utf-8")
    (repo_path / "run.sh").write_text("echo hi\n", encoding="utf-8")
    service = WorkspaceSearchService()

    payload = service.discover_files(
        repo_paths=[str(repo_path)],
        intent="config",
        max_results=20,
        categorize=True,
    )

    assert payload["total"] >= 1
    assert "categories" in payload
`````

## File: tests/core/mcp/test_runtime.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.runtime
"""

import json
from pathlib import Path

from metagit.core.mcp.runtime import MetagitMcpRuntime


def test_initialize_request_returns_capabilities(tmp_path: Path) -> None:
    runtime = MetagitMcpRuntime(root=str(tmp_path))

    response = runtime._handle_request(
        {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
    )

    assert response is not None
    assert response["result"]["serverInfo"]["name"] == "metagit-mcp"
    assert "tools" in response["result"]["capabilities"]


def test_tools_list_returns_inactive_tools_without_config(tmp_path: Path) -> None:
    runtime = MetagitMcpRuntime(root=str(tmp_path))

    response = runtime._handle_request(
        {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
    )

    assert response is not None
    names = [item["name"] for item in response["result"]["tools"]]
    assert "metagit_workspace_status" in names
    assert "metagit_bootstrap_config_plan_only" in names
    assert "metagit_workspace_index" not in names
    workspace_status_tool = next(
        item for item in response["result"]["tools"] if item["name"] == "metagit_workspace_status"
    )
    assert workspace_status_tool["inputSchema"]["type"] == "object"


def test_tools_call_workspace_status_returns_text_payload(tmp_path: Path) -> None:
    runtime = MetagitMcpRuntime(root=str(tmp_path))

    response = runtime._handle_request(
        {
            "jsonrpc": "2.0",
            "id": 3,
            "method": "tools/call",
            "params": {"name": "metagit_workspace_status", "arguments": {}},
        }
    )

    assert response is not None
    payload = json.loads(response["result"]["content"][0]["text"])
    assert payload["state"] == "inactive_missing_config"


def test_resources_read_ops_log_returns_json_content(tmp_path: Path) -> None:
    runtime = MetagitMcpRuntime(root=str(tmp_path))

    response = runtime._handle_request(
        {
            "jsonrpc": "2.0",
            "id": 4,
            "method": "resources/read",
            "params": {"uri": "metagit://workspace/ops-log"},
        }
    )

    assert response is not None
    assert response["result"]["contents"][0]["mimeType"] == "application/json"


def test_tools_call_invalid_arguments_returns_mcp_invalid_params(tmp_path: Path) -> None:
    runtime = MetagitMcpRuntime(root=str(tmp_path))

    response = runtime._handle_request(
        {
            "jsonrpc": "2.0",
            "id": 5,
            "method": "tools/call",
            "params": {"name": "metagit_workspace_search", "arguments": {}},
        }
    )

    assert response is not None
    assert response["error"]["code"] == -32602
    assert response["error"]["data"]["kind"] == "invalid_arguments"


def test_tools_call_workspace_semantic_search_requires_query(tmp_path: Path) -> None:
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: default",
                "      repos: []",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    runtime = MetagitMcpRuntime(root=str(tmp_path))
    response = runtime._handle_request(
        {
            "jsonrpc": "2.0",
            "id": 12,
            "method": "tools/call",
            "params": {
                "name": "metagit_workspace_semantic_search",
                "arguments": {"query": "  "},
            },
        }
    )

    assert response is not None
    assert response["error"]["code"] == -32602
    assert response["error"]["data"]["kind"] == "invalid_arguments"


def test_initialize_can_enable_sampling_capability(tmp_path: Path) -> None:
    runtime = MetagitMcpRuntime(root=str(tmp_path))

    response = runtime._handle_request(
        {
            "jsonrpc": "2.0",
            "id": 6,
            "method": "initialize",
            "params": {"capabilities": {"sampling": {}}},
        }
    )

    assert response is not None
    assert runtime._sampling_supported is True


def test_bootstrap_uses_sampling_when_client_supports_it(tmp_path: Path) -> None:
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: sampled-workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: default",
                "      repos: []",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    runtime = MetagitMcpRuntime(root=str(tmp_path))
    runtime._sampling_supported = True
    runtime._request_client_sampling = lambda context: {  # type: ignore[method-assign]
        "content": {
            "type": "text",
            "text": "\n".join(
                [
                    "name: sampled",
                    "kind: application",
                    "workspace:",
                    "  projects:",
                    "    - name: default",
                    "      repos: []",
                ]
            )
            + "\n",
        }
    }

    response = runtime._handle_request(
        {
            "jsonrpc": "2.0",
            "id": 7,
            "method": "tools/call",
            "params": {"name": "metagit_bootstrap_config", "arguments": {}},
        }
    )

    assert response is not None
    payload = json.loads(response["result"]["content"][0]["text"])
    assert payload["mode"] == "sampled"


def test_tools_list_includes_repo_search_for_active_workspace(tmp_path: Path) -> None:
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: platform",
                "      repos: []",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    runtime = MetagitMcpRuntime(root=str(tmp_path))
    response = runtime._handle_request(
        {"jsonrpc": "2.0", "id": 10, "method": "tools/list", "params": {}}
    )

    assert response is not None
    names = [item["name"] for item in response["result"]["tools"]]
    assert "metagit_repo_search" in names


def test_tools_call_repo_search_returns_matches(tmp_path: Path) -> None:
    repo_dir = tmp_path / "platform" / "abacus-app"
    repo_dir.mkdir(parents=True)
    (repo_dir / ".git").mkdir()
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: platform",
                "      repos:",
                "        - name: abacus-app",
                "          path: platform/abacus-app",
                "          sync: true",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    runtime = MetagitMcpRuntime(root=str(tmp_path))
    response = runtime._handle_request(
        {
            "jsonrpc": "2.0",
            "id": 11,
            "method": "tools/call",
            "params": {"name": "metagit_repo_search", "arguments": {"query": "abacus"}},
        }
    )

    assert response is not None
    payload = json.loads(response["result"]["content"][0]["text"])
    assert payload["matches"][0]["repo_name"] == "abacus-app"


def test_tools_list_includes_project_context_tools(tmp_path: Path) -> None:
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: alpha",
                "      repos: []",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    runtime = MetagitMcpRuntime(root=str(tmp_path))
    response = runtime._handle_request(
        {"jsonrpc": "2.0", "id": 20, "method": "tools/list", "params": {}}
    )

    assert response is not None
    names = [item["name"] for item in response["result"]["tools"]]
    assert "metagit_project_context_switch" in names
    assert "metagit_workspace_state_snapshot" in names


def test_tools_call_project_context_switch_unknown_project(tmp_path: Path) -> None:
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: alpha",
                "      repos: []",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    runtime = MetagitMcpRuntime(root=str(tmp_path))
    response = runtime._handle_request(
        {
            "jsonrpc": "2.0",
            "id": 21,
            "method": "tools/call",
            "params": {
                "name": "metagit_project_context_switch",
                "arguments": {"project_name": "missing"},
            },
        }
    )

    assert response is not None
    payload = json.loads(response["result"]["content"][0]["text"])
    assert payload["ok"] is False
    assert payload["error"] == "project_not_found"


def test_tools_call_cross_project_dependencies(tmp_path: Path) -> None:
    (tmp_path / "alpha" / "api").mkdir(parents=True)
    (tmp_path / ".metagit.yml").write_text(
        "\n".join(
            [
                "name: workspace",
                "kind: application",
                "workspace:",
                "  projects:",
                "    - name: alpha",
                "      repos:",
                "        - name: api",
                "          path: alpha/api",
                "          sync: true",
                "    - name: beta",
                "      repos:",
                "        - name: worker",
                "          path: beta/worker",
                "          sync: true",
                "          tags:",
                "            depends_on: alpha",
            ]
        )
        + "\n",
        encoding="utf-8",
    )
    runtime = MetagitMcpRuntime(root=str(tmp_path))
    response = runtime._handle_request(
        {
            "jsonrpc": "2.0",
            "id": 30,
            "method": "tools/call",
            "params": {
                "name": "metagit_cross_project_dependencies",
                "arguments": {
                    "source_project": "alpha",
                    "dependency_types": ["declared"],
                    "depth": 2,
                },
            },
        }
    )

    assert response is not None
    payload = json.loads(response["result"]["content"][0]["text"])
    assert payload["ok"] is True
    assert payload["source_project"] == "alpha"
`````

## File: tests/core/mcp/test_tool_registry.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.mcp.tool_registry
"""

from metagit.core.mcp.models import McpActivationState, WorkspaceStatus
from metagit.core.mcp.tool_registry import ToolRegistry


def test_inactive_registry_exposes_only_safe_tools() -> None:
    registry = ToolRegistry()
    status = WorkspaceStatus(
        state=McpActivationState.INACTIVE_MISSING_CONFIG,
        root_path=None,
        reason="missing",
    )

    tools = registry.list_tools(status=status)

    assert tools == [
        "metagit_workspace_status",
        "metagit_bootstrap_config_plan_only",
    ]


def test_active_registry_exposes_full_toolset() -> None:
    registry = ToolRegistry()
    status = WorkspaceStatus(
        state=McpActivationState.ACTIVE,
        root_path="/tmp/workspace",
        reason=None,
    )

    tools = registry.list_tools(status=status)

    assert "metagit_workspace_status" in tools
    assert "metagit_workspace_index" in tools
    assert "metagit_workspace_search" in tools
    assert "metagit_repo_search" in tools
    assert "metagit_upstream_hints" in tools
    assert "metagit_repo_sync" in tools
    assert "metagit_workspace_sync" in tools
    assert "metagit_project_context_switch" in tools
    assert "metagit_workspace_state_snapshot" in tools
    assert "metagit_workspace_state_restore" in tools
    assert "metagit_session_update" in tools
    assert "metagit_cross_project_dependencies" in tools
    assert "metagit_workspace_health_check" in tools
    assert "metagit_workspace_semantic_search" in tools
    assert "metagit_workspace_discover" in tools
    assert "metagit_project_template_apply" in tools
    assert "metagit_bootstrap_config_plan_only" not in tools
`````

## File: tests/core/web/test_config_handler.py
`````python
#!/usr/bin/env python
"""HTTP tests for web config tree routes (v3 API)."""

import json
import threading
import urllib.error
import urllib.request
from pathlib import Path

from metagit.core.web.server import build_web_server


def _start_server(
  tmp_path: Path,
  *,
  appconfig_name: str = "metagit.config.yaml",
) -> tuple[threading.Thread, str]:
  (tmp_path / ".metagit.yml").write_text(
    "\n".join(
      [
        "name: workspace",
        "kind: application",
      ]
    )
    + "\n",
    encoding="utf-8",
  )
  (tmp_path / appconfig_name).write_text(
    "\n".join(
      [
        "config:",
        "  workspace:",
        "    path: ./sync",
      ]
    )
    + "\n",
    encoding="utf-8",
  )
  server = build_web_server(
    root=str(tmp_path),
    appconfig_path=str(tmp_path / appconfig_name),
    host="127.0.0.1",
    port=0,
  )
  thread = threading.Thread(target=server.serve_forever, daemon=True)
  thread.start()
  port = server.server_address[1]
  base = f"http://127.0.0.1:{port}"
  return thread, base


def _patch_json(base: str, target: str, body: dict) -> tuple[int, dict]:
  patch_body = json.dumps(body).encode("utf-8")
  patch_req = urllib.request.Request(
    f"{base}/v3/config/{target}",
    data=patch_body,
    method="PATCH",
    headers={"Content-Type": "application/json"},
  )
  try:
    with urllib.request.urlopen(patch_req, timeout=5) as resp:
      return resp.status, json.loads(resp.read().decode("utf-8"))
  except urllib.error.HTTPError as exc:
    raw = exc.read().decode("utf-8")
    return exc.code, json.loads(raw) if raw else {}


def test_get_metagit_config_tree(tmp_path: Path) -> None:
  thread, base = _start_server(tmp_path)
  try:
    payload = json.loads(
      urllib.request.urlopen(
        f"{base}/v3/config/metagit/tree",
        timeout=5,
      ).read().decode("utf-8")
    )
    assert payload["ok"] is True
    assert payload["target"] == "metagit"
    assert payload["saved"] is False
    name_node = next(
      child for child in payload["tree"]["children"] if child["key"] == "name"
    )
    assert name_node["value"] == "workspace"
  finally:
    thread.join(timeout=0.1)


def test_patch_metagit_set_name_without_save(tmp_path: Path) -> None:
  thread, base = _start_server(tmp_path)
  try:
    patch_body = json.dumps(
      {
        "save": False,
        "operations": [{"op": "set", "path": "name", "value": "renamed"}],
      }
    ).encode("utf-8")
    patch_req = urllib.request.Request(
      f"{base}/v3/config/metagit",
      data=patch_body,
      method="PATCH",
      headers={"Content-Type": "application/json"},
    )
    patched = json.loads(
      urllib.request.urlopen(patch_req, timeout=5).read().decode("utf-8")
    )
    assert patched["saved"] is False
    name_node = next(
      child for child in patched["tree"]["children"] if child["key"] == "name"
    )
    assert name_node["value"] == "renamed"

    on_disk = (tmp_path / ".metagit.yml").read_text(encoding="utf-8")
    assert "name: workspace" in on_disk
  finally:
    thread.join(timeout=0.1)


def test_patch_metagit_set_name_with_save(tmp_path: Path) -> None:
  thread, base = _start_server(tmp_path)
  try:
    patch_body = json.dumps(
      {
        "save": True,
        "operations": [{"op": "set", "path": "name", "value": "saved-name"}],
      }
    ).encode("utf-8")
    patch_req = urllib.request.Request(
      f"{base}/v3/config/metagit",
      data=patch_body,
      method="PATCH",
      headers={"Content-Type": "application/json"},
    )
    patched = json.loads(
      urllib.request.urlopen(patch_req, timeout=5).read().decode("utf-8")
    )
    assert patched["saved"] is True
    name_node = next(
      child for child in patched["tree"]["children"] if child["key"] == "name"
    )
    assert name_node["value"] == "saved-name"

    on_disk = (tmp_path / ".metagit.yml").read_text(encoding="utf-8")
    assert "name: saved-name" in on_disk
  finally:
    thread.join(timeout=0.1)


def test_patch_metagit_save_true_invalid_op_returns_422_and_does_not_write(
  tmp_path: Path,
) -> None:
  thread, base = _start_server(tmp_path)
  try:
    status, patched = _patch_json(
      base,
      "metagit",
      {
        "save": True,
        "operations": [{"op": "set", "path": "kind", "value": "not-a-valid-kind"}],
      },
    )
    assert status == 422
    assert patched["ok"] is False
    assert patched["saved"] is False
    assert len(patched["validation_errors"]) > 0

    kind_node = next(
      child for child in patched["tree"]["children"] if child["key"] == "kind"
    )
    assert kind_node["value"] == "application"

    on_disk = (tmp_path / ".metagit.yml").read_text(encoding="utf-8")
    assert "kind: application" in on_disk
  finally:
    thread.join(timeout=0.1)


def test_get_metagit_preview_normalized(tmp_path: Path) -> None:
  thread, base = _start_server(tmp_path)
  try:
    payload = json.loads(
      urllib.request.urlopen(
        f"{base}/v3/config/metagit/preview?style=normalized",
        timeout=5,
      ).read().decode("utf-8")
    )
    assert payload["ok"] is True
    assert payload["target"] == "metagit"
    assert "name: workspace" in payload["yaml"]
    assert payload["draft"] is False
  finally:
    thread.join(timeout=0.1)


def test_post_metagit_preview_draft_operations(tmp_path: Path) -> None:
  thread, base = _start_server(tmp_path)
  try:
    body = json.dumps(
      {
        "style": "normalized",
        "operations": [{"op": "set", "path": "name", "value": "draft-name"}],
      }
    ).encode("utf-8")
    req = urllib.request.Request(
      f"{base}/v3/config/metagit/preview",
      data=body,
      method="POST",
      headers={"Content-Type": "application/json"},
    )
    payload = json.loads(urllib.request.urlopen(req, timeout=5).read().decode("utf-8"))
    assert payload["draft"] is True
    assert "name: draft-name" in payload["yaml"]
    on_disk = (tmp_path / ".metagit.yml").read_text(encoding="utf-8")
    assert "name: workspace" in on_disk
  finally:
    thread.join(timeout=0.1)
`````

## File: tests/test_config_models.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.config.models
"""

from datetime import datetime

import pytest
from pydantic import ValidationError

from metagit.core.config import models
from metagit.core.project.models import ProjectPath
from metagit.core.workspace.models import ProjectDedupeOverride


def test_license_kind_enum():
    assert models.LicenseKind.MIT == "MIT"
    assert models.LicenseKind.NONE == "None"


def test_branch_strategy_enum():
    assert models.BranchStrategy.TRUNK == "trunk"
    assert models.BranchStrategy.GITFLOW == "gitflow"


def test_license_model():
    lic = models.License(kind=models.LicenseKind.MIT, file="LICENSE")
    assert lic.kind == models.LicenseKind.MIT
    assert lic.file == "LICENSE"
    with pytest.raises(ValidationError):
        models.License(kind="INVALID", file="LICENSE")


def test_branch_naming_model():
    bn = models.BranchNaming(kind=models.BranchStrategy.TRUNK, pattern="main/*")
    assert bn.kind == models.BranchStrategy.TRUNK
    assert bn.pattern == "main/*"


def test_artifact_model():
    art = models.Artifact(
        type=models.ArtifactType.DOCKER,
        definition="Dockerfile",
        location="http://example.com/image",
        version_strategy=models.VersionStrategy.SEMVER,
    )
    assert art.type == models.ArtifactType.DOCKER
    assert art.location == "http://example.com/image"
    assert art.version_strategy == models.VersionStrategy.SEMVER


def test_secret_model():
    sec = models.Secret(
        name="API_KEY", kind=models.SecretKind.REMOTE_API_KEY, ref="env:API_KEY"
    )
    assert sec.name == "API_KEY"
    assert sec.kind == models.SecretKind.REMOTE_API_KEY
    assert sec.ref == "env:API_KEY"


def test_variable_model():
    var = models.Variable(
        name="DEBUG", kind=models.VariableKind.BOOLEAN, ref="env:DEBUG"
    )
    assert var.name == "DEBUG"
    assert var.kind == models.VariableKind.BOOLEAN
    assert var.ref == "env:DEBUG"


def test_pipeline_and_cicd():
    pipe = models.Pipeline(
        name="build", ref=".github/workflows/build.yml", variables=["DEBUG"]
    )
    cicd = models.CICD(platform=models.CICDPlatform.GITHUB, pipelines=[pipe])
    assert cicd.platform == models.CICDPlatform.GITHUB
    assert cicd.pipelines[0].name == "build"


def test_environment_and_deployment():
    env = models.Environment(name="prod", url="http://prod.example.com")
    infra = models.Infrastructure(
        provisioning_tool=models.ProvisioningTool.TERRAFORM, hosting=models.Hosting.EC2
    )
    dep = models.Deployment(
        strategy=models.DeploymentStrategy.ROLLING,
        environments=[env],
        infrastructure=infra,
    )
    assert dep.strategy == models.DeploymentStrategy.ROLLING
    assert dep.environments[0].name == "prod"
    assert dep.infrastructure.hosting == models.Hosting.EC2


def test_observability():
    alert = models.AlertingChannel(
        name="slack", type=models.AlertingChannelType.SLACK, url="http://slack.com"
    )
    dash = models.Dashboard(name="main", tool="grafana", url="http://grafana.com")
    obs = models.Observability(
        logging_provider=models.LoggingProvider.CONSOLE,
        monitoring_providers=[models.MonitoringProvider.PROMETHEUS],
        alerting_channels=[alert],
        dashboards=[dash],
    )
    assert obs.logging_provider == models.LoggingProvider.CONSOLE
    assert obs.monitoring_providers[0] == models.MonitoringProvider.PROMETHEUS
    assert obs.alerting_channels[0].type == models.AlertingChannelType.SLACK
    assert obs.dashboards[0].tool == "grafana"


def test_project_and_metadata():
    lang = models.Language(primary="python", secondary=["js"])
    proj = models.Project(
        description="Service catalog API",
        agent_instructions="Prefer small PRs and integration tests.",
        type=models.ProjectType.APPLICATION,
        domain=models.ProjectDomain.WEB,
        language=lang,
        framework=["pytest"],
        package_managers=["pip"],
        build_tool=models.BuildTool.MAKE,
        deploy_targets=["prod"],
    )
    assert proj.description == "Service catalog API"
    assert proj.agent_instructions == "Prefer small PRs and integration tests."
    assert proj.type == models.ProjectType.APPLICATION
    assert proj.language.primary == "python"
    meta = models.RepoMetadata(tags=["tag1"], created_at=datetime.now())
    assert "tag1" in meta.tags


def test_metrics_and_pull_requests():
    pr = models.PullRequests(open=2, merged_last_30d=5)
    metrics = models.Metrics(
        stars=10,
        forks=2,
        open_issues=1,
        pull_requests=pr,
        contributors=3,
        commit_frequency=models.CommitFrequency.DAILY,
    )
    assert metrics.stars == 10
    assert metrics.pull_requests.open == 2


def test_metagit_config_minimal():
    cfg = models.MetagitConfig(name="proj")
    assert cfg.name == "proj"
    assert cfg.agent_instructions is None
    # Test serialization
    assert isinstance(cfg.serialize_url(None, None), type(None))


def test_workspace_description_and_agent_instructions():
    """Workspace and workspace projects accept optional description and agent_instructions."""
    repo = ProjectPath(
        name="example",
        path="/tmp/example",
        agent_instructions="Stay in src/api.",
    )
    ws = models.Workspace(
        description="Multi-repo analytics workspace",
        agent_instructions="Keep migrations reversible.",
        projects=[
            models.WorkspaceProject(
                name="core",
                description="Core services",
                agent_instructions="Use typed APIs.",
                repos=[repo],
            )
        ],
    )
    assert ws.description == "Multi-repo analytics workspace"
    assert ws.agent_instructions == "Keep migrations reversible."
    assert ws.projects[0].description == "Core services"
    assert ws.projects[0].agent_instructions == "Use typed APIs."
    assert repo.agent_instructions == "Stay in src/api."


def test_workspace_project_dedupe_override() -> None:
    """Workspace projects accept optional dedupe.enabled override."""
    project = models.WorkspaceProject(
        name="local",
        dedupe=ProjectDedupeOverride(enabled=False),
        repos=[ProjectPath(name="site", path="~/Sites/site")],
    )
    assert project.dedupe is not None
    assert project.dedupe.enabled is False


class TestMetagitConfig:
    """Test MetagitConfig class."""

    def test_metagit_config_basic(self):
        """Test basic MetagitConfig creation."""
        config = models.MetagitConfig(
            name="test-project",
            description="A test project",
            agent_instructions="Follow the contributor guide.",
            kind=models.ProjectKind.APPLICATION,
        )

        assert config.name == "test-project"
        assert config.description == "A test project"
        assert config.agent_instructions == "Follow the contributor guide."
        assert config.kind == models.ProjectKind.APPLICATION

    def test_metagit_config_with_optional_fields(self):
        """Test MetagitConfig with optional fields."""
        config = models.MetagitConfig(
            name="test-project",
            description="A test project",
            kind=models.ProjectKind.APPLICATION,
            branch_strategy=models.BranchStrategy.TRUNK,
            license={"kind": models.LicenseKind.MIT, "file": "LICENSE"},
        )

        assert config.name == "test-project"
        assert config.branch_strategy == models.BranchStrategy.TRUNK
        assert config.license.kind == models.LicenseKind.MIT
        assert config.license.file == "LICENSE"

    def test_metagit_config_validation_error(self):
        """Test MetagitConfig validation error."""
        with pytest.raises(ValidationError):
            models.MetagitConfig()  # Missing required name field

    def test_metagit_config_does_not_have_detection_attributes(self):
        """Test that MetagitConfig does not have detection-specific attributes."""
        config = models.MetagitConfig(name="test-project")

        # These attributes should not exist on MetagitConfig
        assert not hasattr(config, "branches")
        assert not hasattr(config, "metrics")
        assert not hasattr(config, "metadata")
        assert not hasattr(config, "detection_timestamp")
        assert not hasattr(config, "detection_source")
        assert not hasattr(config, "detection_version")
`````

## File: web/src/components/FieldEditor.tsx
`````typescript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import type { ConfigOperation, SchemaFieldNode } from '../api/client'
import {
  configTreeQueryKey,
  fetchConfigTree,
  patchConfigTree,
  type ConfigTarget,
} from '../pages/configQueries'
import styles from './FieldEditor.module.css'

interface FieldEditorProps {
  target: ConfigTarget
  node: SchemaFieldNode | null
  pendingOps: ConfigOperation[]
  onPendingChange: (ops: ConfigOperation[]) => void
}

function scalarTypes(): Set<string> {
  return new Set(['string', 'integer', 'number', 'boolean', 'enum'])
}

function formatValidationErrors(
  errors: Array<Record<string, string>>,
): Array<{ path: string; message: string }> {
  return errors.map((entry) => ({
    path: entry.path ?? '',
    message: entry.message ?? 'Validation error',
  }))
}

function isMaskedSensitiveValue(node: SchemaFieldNode): boolean {
  return (
    node.sensitive === true &&
    typeof node.value === 'string' &&
    node.value.startsWith('***')
  )
}

function shouldSkipSensitiveSet(node: SchemaFieldNode, draft: string): boolean {
  if (!node.sensitive) {
    return false
  }
  return draft.trim() === ''
}

function normalizeDraftValue(node: SchemaFieldNode): unknown {
  if (isMaskedSensitiveValue(node)) {
    return ''
  }
  if (node.value !== undefined && node.value !== null) {
    return node.value
  }
  if (node.default_value !== undefined && node.default_value !== null) {
    return node.default_value
  }
  if (node.type === 'boolean') {
    return false
  }
  if (node.type === 'integer' || node.type === 'number') {
    return 0
  }
  return ''
}

function parseDraftValue(node: SchemaFieldNode, raw: string): unknown {
  if (node.type === 'boolean') {
    return raw === 'true'
  }
  if (node.type === 'integer') {
    return Number.parseInt(raw, 10)
  }
  if (node.type === 'number') {
    return Number.parseFloat(raw)
  }
  return raw
}

export default function FieldEditor({
  target,
  node,
  pendingOps,
  onPendingChange,
}: FieldEditorProps) {
  const queryClient = useQueryClient()
  const queryKey = configTreeQueryKey(target)
  const { data } = useQuery({
    queryKey,
    queryFn: () => fetchConfigTree(target),
  })

  const [draft, setDraft] = useState<string>('')
  const [dirty, setDirty] = useState(false)

  useEffect(() => {
    if (!node) {
      setDraft('')
      setDirty(false)
      return
    }
    const value = normalizeDraftValue(node)
    setDraft(node.type === 'boolean' ? String(value) : String(value ?? ''))
    setDirty(false)
  }, [node])

  const validationErrors = useMemo(
    () => formatValidationErrors(data?.validation_errors ?? []),
    [data?.validation_errors],
  )

  const applyMutation = useMutation({
    mutationFn: (payload: { ops: ConfigOperation[]; save: boolean }) =>
      patchConfigTree(target, payload.ops, payload.save),
    onSuccess: (response, variables) => {
      queryClient.setQueryData(queryKey, response)
      if (variables.save) {
        onPendingChange([])
      } else if (variables.ops.length > 0) {
        const merged = [...pendingOps]
        for (const op of variables.ops) {
          const index = merged.findIndex((item) => item.path === op.path)
          if (index >= 0) {
            merged[index] = op
          } else {
            merged.push(op)
          }
        }
        onPendingChange(merged)
      }
      setDirty(false)
    },
  })

  const enumOptions = useMemo(() => {
    if (!node) {
      return []
    }
    const options = new Set<string>(node.enum_options ?? [])
    if (node.value != null) {
      options.add(String(node.value))
    }
    if (node.default_value != null) {
      options.add(String(node.default_value))
    }
    return [...options]
  }, [node])

  if (!node) {
    return (
      <div className={styles.panel}>
        <p className={styles.empty}>Select a field in the tree to edit.</p>
      </div>
    )
  }

  const isScalar = scalarTypes().has(node.type)
  const isComplex = node.type === 'object' || node.type === 'array'
  const isArray = node.type === 'array'
  const typeLabel = node.type_label ?? node.type
  const editable = node.editable !== false && node.enabled !== false && !isComplex && !isArray

  const queueSetOp = (save: boolean) => {
    if (!node.path || !isScalar) {
      return
    }
    if (shouldSkipSensitiveSet(node, draft)) {
      if (save && pendingOps.length > 0) {
        applyMutation.mutate({ ops: pendingOps, save: true })
      }
      return
    }
    const value = parseDraftValue(node, draft)
    const op: ConfigOperation = { op: 'set', path: node.path, value }
    const nextPending = [
      ...pendingOps.filter((item) => item.path !== node.path),
      op,
    ]
    if (save) {
      applyMutation.mutate({ ops: nextPending, save: true })
      return
    }
    onPendingChange(nextPending)
    applyMutation.mutate({ ops: [op], save: false })
  }

  const saveAllPending = () => {
    if (pendingOps.length === 0 && !dirty) {
      return
    }
    const ops = [...pendingOps]
    if (dirty && node?.path && isScalar && !shouldSkipSensitiveSet(node, draft)) {
      const value = parseDraftValue(node, draft)
      const setOp: ConfigOperation = { op: 'set', path: node.path, value }
      const index = ops.findIndex((item) => item.path === node.path)
      if (index >= 0) {
        ops[index] = setOp
      } else {
        ops.push(setOp)
      }
    }
    applyMutation.mutate({ ops, save: true })
  }

  const revert = () => {
    onPendingChange([])
    setDirty(false)
    void queryClient.invalidateQueries({ queryKey })
  }

  return (
    <div className={styles.panel}>
      <header className={styles.header}>
        <h3 className={styles.title}>{node.key}</h3>
        <p className={styles.path}>{node.path || '(root)'}</p>
        <div className={styles.meta}>
          <span className={styles.badge}>{typeLabel}</span>
          {node.required ? <span className={styles.badge}>required</span> : null}
          {node.sensitive ? <span className={styles.badge}>sensitive</span> : null}
        </div>
      </header>

      {node.description ? (
        <p className={styles.description}>{node.description}</p>
      ) : null}

      {isArray && node.enabled ? (
        <p className={styles.hint}>
          List of {typeLabel}. Use <strong>+</strong> in the schema tree to add items
          and <strong>×</strong> on each row to remove. Currently{' '}
          {node.item_count ?? 0} item{(node.item_count ?? 0) === 1 ? '' : 's'}.
        </p>
      ) : null}

      {node.type === 'object' ? (
        <p className={styles.hint}>Edit via tree — expand nested fields in the schema tree.</p>
      ) : null}

      {editable && node.type === 'boolean' ? (
        <div className={styles.field}>
          <label className={styles.label} htmlFor="field-boolean">
            Value
          </label>
          <select
            id="field-boolean"
            className={styles.select}
            value={draft}
            disabled={applyMutation.isPending}
            onChange={(event) => {
              setDraft(event.target.value)
              setDirty(true)
            }}
          >
            <option value="true">true</option>
            <option value="false">false</option>
          </select>
        </div>
      ) : null}

      {editable && node.type === 'enum' ? (
        <div className={styles.field}>
          <label className={styles.label} htmlFor="field-enum">
            Value
          </label>
          <select
            id="field-enum"
            className={styles.select}
            value={draft}
            disabled={applyMutation.isPending}
            onChange={(event) => {
              setDraft(event.target.value)
              setDirty(true)
            }}
          >
            {enumOptions.map((option) => (
              <option key={option} value={option}>
                {option}
              </option>
            ))}
          </select>
        </div>
      ) : null}

      {editable &&
      (node.type === 'string' ||
        node.type === 'integer' ||
        node.type === 'number') ? (
        <div className={styles.field}>
          <label className={styles.label} htmlFor="field-scalar">
            Value
          </label>
          <input
            id="field-scalar"
            className={styles.input}
            type={node.type === 'string' || node.sensitive ? 'text' : 'number'}
            value={draft}
            placeholder={node.sensitive ? '••••••••' : undefined}
            disabled={applyMutation.isPending}
            onChange={(event) => {
              setDraft(event.target.value)
              setDirty(true)
            }}
          />
        </div>
      ) : null}

      {!editable && !isComplex && !isArray ? (
        <p className={styles.hint}>
          This field is not editable (disabled or read-only).
        </p>
      ) : null}

      {validationErrors.length > 0 ? (
        <ul className={styles.errors} aria-live="polite">
          {validationErrors.map((entry) => (
            <li key={`${entry.path}:${entry.message}`}>
              <strong>{entry.path || 'config'}:</strong> {entry.message}
            </li>
          ))}
        </ul>
      ) : null}

      {isScalar && editable ? (
        <div className={styles.actions}>
          <button
            type="button"
            className={`${styles.button} ${styles.buttonPrimary}`}
            disabled={applyMutation.isPending || (!dirty && pendingOps.length === 0)}
            onClick={() => queueSetOp(false)}
          >
            Apply
          </button>
          <button
            type="button"
            className={styles.button}
            disabled={applyMutation.isPending || (!dirty && pendingOps.length === 0)}
            onClick={() => queueSetOp(true)}
          >
            Save field
          </button>
          <button
            type="button"
            className={`${styles.button} ${styles.buttonPrimary}`}
            disabled={applyMutation.isPending}
            onClick={saveAllPending}
          >
            Save to disk
          </button>
          <button
            type="button"
            className={styles.button}
            disabled={applyMutation.isPending}
            onClick={revert}
          >
            Revert
          </button>
        </div>
      ) : null}

      {pendingOps.length > 0 ? (
        <p className={styles.status}>
          {pendingOps.length} pending change{pendingOps.length === 1 ? '' : 's'} not
          saved to disk
        </p>
      ) : null}

      {data?.saved ? (
        <p className={styles.status}>Last write saved to {data.config_path}</p>
      ) : null}
    </div>
  )
}
`````

## File: web/src/pages/WorkspacePage.tsx
`````typescript
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import GraphDiagram from '../components/GraphDiagram'
import OpsPanel from '../components/OpsPanel'
import RepoTable from '../components/RepoTable'
import SyncDialog from '../components/SyncDialog'
import { fetchWorkspaceGraph, graphQueryKey } from './graphQueries'
import {
  fetchWorkspace,
  workspaceQueryKey,
  type StatusFilter,
} from './workspaceQueries'
import styles from './WorkspacePage.module.css'

type WorkspaceView = 'repos' | 'graph'

interface SyncTarget {
  repos: string[]
  title: string
}

export default function WorkspacePage() {
  const [view, setView] = useState<WorkspaceView>('repos')
  const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
  const [search, setSearch] = useState('')
  const [includeInferred, setIncludeInferred] = useState(true)
  const [includeStructure, setIncludeStructure] = useState(true)
  const [syncTarget, setSyncTarget] = useState<SyncTarget | null>(null)

  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: workspaceQueryKey,
    queryFn: fetchWorkspace,
  })

  const {
    data: graphData,
    isLoading: graphLoading,
    isError: graphError,
    error: graphErr,
    refetch: refetchGraph,
  } = useQuery({
    queryKey: graphQueryKey(includeInferred, includeStructure),
    queryFn: () => fetchWorkspaceGraph(includeInferred, includeStructure),
    enabled: view === 'graph',
  })

  const reposIndex = data?.repos_index ?? []
  const projects = data?.projects ?? []

  const stats = useMemo(() => {
    const synced = reposIndex.filter((row) => row.status === 'synced').length
    const missing = reposIndex.filter(
      (row) => row.status === 'configured_missing',
    ).length
    return {
      projects: projects.length,
      repos: reposIndex.length,
      synced,
      missing,
    }
  }, [projects.length, reposIndex])

  const definitionPath =
    typeof data?.summary?.definition_path === 'string'
      ? data.summary.definition_path
      : null

  return (
    <section className={styles.page}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>Workspace</h2>
          {definitionPath ? (
            <p className={styles.subtitle}>{definitionPath}</p>
          ) : null}
        </div>
      </header>

      <div className={styles.chips} aria-label="Workspace summary">
        <span className={styles.chip}>
          <strong>{stats.projects}</strong> projects
        </span>
        <span className={styles.chip}>
          <strong>{stats.repos}</strong> repos
        </span>
        <span className={styles.chip}>
          <strong>{stats.synced}</strong> synced
        </span>
        <span className={styles.chip}>
          <strong>{stats.missing}</strong> missing
        </span>
      </div>

      <div className={styles.toolbar}>
        <div className={styles.tabs} role="tablist" aria-label="Workspace view">
          {(
            [
              ['repos', 'Repositories'],
              ['graph', 'Graph'],
            ] as const
          ).map(([value, label]) => (
            <button
              key={value}
              type="button"
              role="tab"
              aria-selected={view === value}
              className={view === value ? `${styles.tab} ${styles.tabActive}` : styles.tab}
              onClick={() => setView(value)}
            >
              {label}
            </button>
          ))}
        </div>
        {view === 'repos' ? (
          <>
            <div
              className={styles.tabs}
              role="tablist"
              aria-label="Repository status filter"
            >
              {(
                [
                  ['all', 'All'],
                  ['synced', 'Synced'],
                  ['missing', 'Missing'],
                ] as const
              ).map(([value, label]) => (
                <button
                  key={value}
                  type="button"
                  role="tab"
                  aria-selected={statusFilter === value}
                  className={
                    statusFilter === value
                      ? `${styles.tab} ${styles.tabActive}`
                      : styles.tab
                  }
                  onClick={() => setStatusFilter(value)}
                >
                  {label}
                </button>
              ))}
            </div>
            <input
              type="search"
              className={styles.search}
              placeholder="Search repositories…"
              value={search}
              onChange={(event) => setSearch(event.target.value)}
              aria-label="Search repositories"
            />
          </>
        ) : (
          <div className={styles.graphFilters}>
            <label className={styles.checkLabel}>
              <input
                type="checkbox"
                checked={includeInferred}
                onChange={(event) => setIncludeInferred(event.target.checked)}
              />
              Inferred dependencies
            </label>
            <label className={styles.checkLabel}>
              <input
                type="checkbox"
                checked={includeStructure}
                onChange={(event) => setIncludeStructure(event.target.checked)}
              />
              Project → repo structure
            </label>
          </div>
        )}
      </div>

      {isLoading ? <p className={styles.loading}>Loading workspace…</p> : null}
      {isError ? (
        <p className={styles.error}>
          {error instanceof Error ? error.message : 'Failed to load workspace.'}
        </p>
      ) : null}

      {!isLoading && !isError && data ? (
        <div className={styles.layout}>
          {view === 'repos' ? (
            <RepoTable
              projects={projects}
              reposIndex={reposIndex}
              statusFilter={statusFilter}
              search={search}
              onSync={(repos, title) => setSyncTarget({ repos, title })}
            />
          ) : (
            <section className={styles.graphPanel}>
              {graphLoading ? (
                <p className={styles.loading}>Loading relationship graph…</p>
              ) : null}
              {graphError ? (
                <p className={styles.error}>
                  {graphErr instanceof Error
                    ? graphErr.message
                    : 'Failed to load graph.'}
                </p>
              ) : null}
              {graphData ? (
                <GraphDiagram
                  nodes={graphData.nodes}
                  edges={graphData.edges}
                  manualEdgeCount={graphData.manual_edge_count}
                  inferredEdgeCount={graphData.inferred_edge_count}
                  structureEdgeCount={graphData.structure_edge_count}
                />
              ) : null}
            </section>
          )}
          <OpsPanel
            projects={projects}
            onWorkspaceRefresh={() => {
              void refetch()
              if (view === 'graph') {
                void refetchGraph()
              }
            }}
          />
        </div>
      ) : null}

      <SyncDialog
        open={syncTarget !== null}
        title={syncTarget?.title ?? ''}
        repos={syncTarget?.repos ?? []}
        onClose={() => setSyncTarget(null)}
      />
    </section>
  )
}
`````

## File: .cursorrules
`````
---
name: agents
description: Always-loaded project anchor. Read this first. Contains project identity, non-negotiables, commands, and pointer to ROUTER.md for full context.
last_updated: 2026-05-15
---

# [Project Name]

## What This Is
<!-- One sentence. What does this project do?
     Length: 1 sentence maximum.
     Not a tagline — a factual description of what the software does.
     Example: "A REST API for managing inventory across multiple warehouse locations." -->

## Non-Negotiables
<!-- Hard rules the agent must never violate. Not preferences — rules.
     These are the things that, if broken, cause real damage to the codebase.
     Length: 3-7 items maximum. More than 7 means the list has not been prioritised.
     Example:
     - Never write database queries outside of the repository layer
     - Never commit secrets or API keys
     - Always handle errors explicitly — no silent failures -->

## Commands
<!-- The exact commands needed to work on this project.
     Include: run dev server, run tests, run linter, build.
     Use the actual commands from this codebase — not placeholders.
     Example:
     - Dev: `npm run dev`
     - Test: `npm test`
     - Lint: `npm run lint`
     - Build: `npm run build` -->

## After Every Task
After completing any task: update `.mex/ROUTER.md` project state and any `.mex/` files that are now out of date. If no pattern existed for the task you just completed, create one in `.mex/patterns/`.

**Before you tell the user the work is done** — if this conversation introduced or edited project files under this repo — run **`task qa:prepush`** from the repository root (`scripts/prepush-gate.py` via `scripts/prepush-gate.zsh`). Fix failures and re-run until green. Only skip when the task was strictly read-only (no writes) or the user explicitly waived the QA gate in this thread.

## Commands (minimum)
- QA gate before hand-off / “work complete”: `task qa:prepush` (format, lint, tests, integration tests, context-aware pip-audit/bandit; optional `gitleaks` when installed)
- Full unit tests only: `task test`

## Navigation
At the start of every session, read `.mex/ROUTER.md` before doing anything else.
For full project context, patterns, and task guidance — everything is there.
`````

## File: .metagit.yml
`````yaml
documentation:
  # Strings are allowed and will be treated as a markdown file in the root of the project
  - README.md
  - CHANGELOG.md
  - CONTRIBUTING.md
  - CODE_OF_CONDUCT.md
  - SECURITY.md
  # URLs are allowed and will be treated as a web page.
  - https://metagit-ai.github.io/metagit-cli/
  # All of these are duplicates of the strings above, but they are here to demonstrate 
  # the different types of documentation that can be used.
  - kind: markdown
    path: README.md
  - kind: markdown
    path: CHANGELOG.md
  - kind: markdown
    path: CONTRIBUTING.md
  - kind: markdown
    path: CODE_OF_CONDUCT.md
  - kind: markdown
    path: SECURITY.md
  - kind: web
    url: https://metagit-ai.github.io/metagit-cli/
  # Explicitly defined documentation types.
  - kind: confluence
    url: https://confluence.example.com/display/METAGIT/Metagit+Documentation
    tags:
      - playbook
      - tutorial
  - kind: sharepoint
    url: https://sharepoint.example.com/display/METAGIT/Metagit+DevOps
    tags:
      - devops
  - kind: web
    url: https://hub.docker.com/_/python
    tags:
      - docker
      - python

artifacts:
  - definition: pyproject.toml
    location: dist/
    type: python_package
    version_strategy: semver
  - definition: Dockerfile
    location: .
    type: docker
    version_strategy: semver

branch_strategy: trunk

cicd:
  pipelines:
    - name: Lint and Test
      ref: .github/workflows/lint-and-test.yaml
      variables: []
    - name: Docs
      ref: .github/workflows/docs.yaml
      variables: []
    - name: PR and Push
      ref: .github/workflows/pr-and-push.yaml
      variables: []
    - name: Publish CLI
      ref: .github/workflows/publish-cli.yaml
      variables: []
  platform: GitHub
dependencies:
  - description:
      Ubuntu is a Linux distribution based on Debian and composed mostly
      of free and open-source software.
    kind: docker_image
    name: ubuntu_latest
    ref: ./.github/workflows/lint-and-test.yaml
  - kind: docker_image
    name: python:3.12-slim
    ref: ./Dockerfile
    url: https://hub.docker.com/_/python
description:
  Metagit is situational awareness for developers and agents. It can make
  a sprawling multi-repo project feel more like a monorepo or be used as a standalone
  tool to provide concise information on the project's used languages/frameworks,
  artifacts, upstream dependencies, and more.
license:
  file: LICENSE.md
  kind: BSD-3-Clause
maintainers:
  - email: zloeber@gmail.com
    name: Zachary Loeber
    role: Contributor
name: metagit-cli
observability: {}
  # dashboards:
  #   - name: OpenSearch Dashboards
  #     tool: OpenSearch
  #     url: http://localhost:5601/
  # logging_provider: elk
  # monitoring_providers:
  #   - grafana
paths:
  - description: The main project path
    kind: application
    language: python
    language_version: "3.13"
    name: metagit
    package_manager: uv
    frameworks:
      - CrewAI
      - Click
    path: ./metagit
taskers:
  - kind: Taskfile
workspace:
  projects: []
    # - name: default
    #   description: Default project for repositories that don't fit into other projects. Includes most IaC deployments.
`````

## File: .github/workflows/release.yaml
`````yaml
name: Publish to PyPI, TestPyPI, and GitHub

on:
  push:
  workflow_dispatch:
  
env:
  PROJECT_NAME: metagit-cli

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  build:
    name: Build distribution
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Set up Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.13"

      - name: Set up uv
        uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

      - name: Install dependencies
        run: |
          uv sync --frozen

      - name: Build package
        run: |
          uv build
      
      - name: Store the distribution packages
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: python-package-distributions
          path: dist/


  publish-to-testpypi:
    name: Publish to PyPI (TEST)
    needs:
    - build
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/')
    environment:
      name: testpypi
      url: https://test.pypi.org/p/${{ env.PROJECT_NAME }}
    permissions:
      id-token: write
    steps:
    - name: Download all the dists
      uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
      with:
        name: python-package-distributions
        path: dist/
    - name: Publish
      uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
      with:
        repository-url: https://test.pypi.org/legacy/

  publish-to-pypi:
    name: Publish to PyPI (PROD)
    runs-on: ubuntu-latest
    # only publish to PyPI on tag pushes
    if: startsWith(github.ref, 'refs/tags/')
    needs: 
      - build
      - publish-to-testpypi
    permissions:
      id-token: write
    environment:
      name: production
      url: https://pypi.org/p/${{ env.PROJECT_NAME }}
    steps:
      - name: Download all the dists
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: python-package-distributions
          path: dist/

      - name: Publish
        uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0

  create-release:
    name: Create GitHub Release
    permissions:
      contents: write
    runs-on: ubuntu-latest
    needs: publish-to-pypi
    if: github.ref_type == 'tag'
    steps:
      - name: Create Release
        uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
        with:
          files: dist/*
          make_latest: true
          generate_release_notes: true
`````

## File: .mex/patterns/metagit-web-api.md
`````markdown
# Metagit local web API (`metagit web serve`)

## Models

- Pydantic shapes for the localhost web UI live in `src/metagit/core/web/models.py`.
- Extend that module for new request/response types; keep CLI and HTTP handlers thin and delegate validation to these models.

## Server wiring

- `build_web_server(root, appconfig_path, host, port)` in `src/metagit/core/web/server.py` — `ThreadingHTTPServer` + `BaseHTTPRequestHandler`, same JSON helper pattern as `metagit.core.api.server`.
- Static assets: `StaticWebHandler` (`src/metagit/core/web/static_handler.py`) serves `DATA_PATH/web/`; SPA fallback for non-API GET paths.
- GET dispatch order: static (non-API) → v2 catalog → v2 layout → v3 config → v3 ops → 404 JSON for unknown `/v*`.
- POST/DELETE/PATCH: layout, catalog, config, ops (no static).
- Config routes live in `ConfigWebHandler` (`src/metagit/core/web/config_handler.py`): GET/PATCH `/v3/config/{metagit|appconfig}[/tree]`, POST `/v3/config/validate`.
- Ops routes live in `OpsWebHandler` (`src/metagit/core/web/ops_handler.py`): POST `/v3/ops/health`, `/v3/ops/prune/preview`, `/v3/ops/prune`, `/v3/ops/sync`; GET `/v3/ops/sync/{job_id}`; GET `/v3/ops/sync/{job_id}/events` (SSE). Module-level `SyncJobStore` tracks async sync jobs.

## When adding routes later

- Extend `ConfigWebHandler` / `OpsWebHandler` or add sibling handlers; register in `server.py` `Handler._dispatch` (SSE: match path before JSON dispatch).
- Run `task qa:prepush` before hand-off.
`````

## File: docs/reference/metagit-config.md
`````markdown
# Metagit configuration exemplar

The file [metagit-config.full-example.yml](metagit-config.full-example.yml) is a **generated,
non-production** sample of `.metagit.yml` with representative values and Pydantic field
descriptions as comments.

## Regenerate

From the repository root:

```bash
task generate:schema
```

That runs `metagit config schema`, `metagit appconfig schema`, and `metagit config example`.

To emit only the YAML exemplar:

```bash
metagit config example --output docs/reference/metagit-config.full-example.yml
```

Overrides merged from `src/metagit/data/config-example-overrides.yml` keep the workspace and
Hermes-oriented examples readable.

## Machine-readable schema

JSON Schema for editors and CI:

- [schemas/metagit_config.schema.json](../../schemas/metagit_config.schema.json)
- [schemas/metagit_appconfig.schema.json](../../schemas/metagit_appconfig.schema.json)

## Validate your manifest

```bash
metagit config validate --config-path .metagit.yml
```

## Schema-backed editing (CLI)

The same operation model as the web Config Studio is available on the CLI:

| Command | Purpose |
|---------|---------|
| `metagit config tree` | Browse fields, types, and paths |
| `metagit config preview` | Apply draft ops and print YAML (no save) |
| `metagit config patch --save` | Apply ops and write `.metagit.yml` when valid |
| `metagit appconfig tree` | App config field tree |
| `metagit appconfig preview` | Draft preview (secrets redacted) |
| `metagit appconfig patch --save` | Apply ops to `metagit.config.yaml` |

Operations: `enable`, `disable`, `set`, `append`, `remove`. Paths use dot/bracket notation
(e.g. `workspace.projects[0].name`, `documentation[0].path`).

```bash
# Single field
metagit config patch --op set --path name --value my-workspace --save

# Batch from JSON (same shape as web PATCH body)
metagit config patch --file ops.json --save
```

`ops.json` may be `{"operations": [...]}` or a bare array of operation objects.

Do not deploy the generated exemplar verbatim; copy sections you need and replace placeholders.

## Documentation sources

The top-level `documentation` list accepts **bare strings** or **objects**:

- A string without `http(s)://` is treated as `kind: markdown` with `path` set to that string.
- A URL string becomes `kind: web`.
- Objects support `kind`, `path`, `url`, `title`, `description`, `tags` (list or map), and `metadata` (map) for knowledge-graph ingestion.

Use `MetagitConfig.documentation_graph_nodes()` (or export from your tooling) to emit normalized node payloads.

## Manual graph relationships

The optional top-level `graph` block declares cross-repo edges that are not inferred from imports or URLs:

```yaml
graph:
  metadata:
    source: manual
  relationships:
    - id: platform-api-uses-infra-tf
      from:
        project: platform
        repo: api
      to:
        project: infra
        repo: terraform-modules
      type: depends_on
      label: API stack depends on shared modules
      tags:
        layer: platform
```

These edges are merged into cross-project dependency maps (`type: manual`) and available via `MetagitConfig.graph_export_payload()` for GitNexus-style exports. Request dependency type `manual` when calling `metagit_cross_project_dependencies` to focus on manifest-declared edges.

### Export to GitNexus (Cypher)

Export manual relationships (and optional structure/documentation nodes) as Cypher for `gitnexus_cypher` MCP tool calls:

```bash
metagit config graph export -c .metagit.yml --format json
metagit config graph export -c .metagit.yml --format cypher --output workspace-graph.cypher
metagit config graph export -c .metagit.yml --format tool-calls --manual-only
```

The exporter creates overlay tables `MetagitEntity` and `MetagitLink` (run `schema_statements` once per target GitNexus index), then `MERGE`/`CREATE` workspace nodes and manual edges. MCP: `metagit_export_workspace_graph_cypher`.

Pass `--gitnexus-repo <name>` when the umbrella workspace is indexed under a different GitNexus repo name than the manifest `name` field.
`````

## File: docs/reference/metagit-web.md
`````markdown
# Metagit Web

Bundled SPA + local HTTP handler that ships with the **`metagit-cli`** Python package.

## Purpose

Metagit Web is a developer-focused browser UI for workspace awareness and maintenance
while you work in a `.metagit.yml` umbrella. It sits next to `metagit` CLI commands and
the local HTTP handlers (for example `/v2` workspace catalog/layout and `/v3` config
and ops endpoints). Use it when you prefer point-and-click inspection and edits over
printing JSON in the terminal.

## `metagit web serve`

From a directory containing a valid `.metagit.yml` (or pointing at one with `--root`):

```bash
metagit web serve [OPTIONS]
```

| Flag | Default | Meaning |
|------|---------|---------|
| `--root` | `.` | Workspace directory that contains `.metagit.yml`. Resolved to an absolute path for logging and API handlers. |
| `--appconfig` | *(inherit)* | Overrides the usual Metagit app-config file path (`metagit` passes this from CLI context); required if `ctx.obj` is missing — run via installed `metagit` CLI, or pass `--appconfig` explicitly. |
| `--host` | `127.0.0.1` | Bind address for the bundled HTTP server. |
| `--port` | `8787` | TCP port for the bundled HTTP server (OS may probe if busy). |
| `--open` / `--no-open` | `no-open` | When `--open` is set, the default browser opens the UI URL once the server starts. |
| `--status-once` | *(off)* | Bind once, print a single `web_state=ready host=… port=… url=…` line, then exit immediately (agents and scripts use this for startup checks without leaving a process running). |

The server serves:

- Static assets packaged under `src/metagit/data/web/` from the SPA build.
- API routes delegated to workspace config, catalog, ops, and related handlers (`/v2`, `/v3`, etc.). See `.mex/patterns/metagit-web-api.md` in this repo for handler/model patterns.

Typical foreground run:

```bash
cd /path/to/workspace
metagit web serve
```

Browse to the printed URL (default root is `/`).

## UI tour

### Config Studio

**Config Studio** is the paired Metagit and application configuration editors accessed from the top navigation:

- **Metagit config** (`/config/metagit`): schema-backed tree navigation and field edits for `.metagit.yml` semantics (PATCH flows against `/v3` config APIs).
- **App config** (`/config/appconfig`): same interaction model against application configuration persisted via Metagit's app-config path.

These screens share TanStack Query data loading and theme styling with the shell.

Each config editor includes a **YAML preview** panel with three render modes:

| Mode | Meaning |
|------|---------|
| **Normalized** | Full config from the validated Pydantic model (same serializer as `metagit config show --normalized`). |
| **Minimal** | Non-default fields only (`exclude_defaults`), useful for seeing what differs from schema defaults. |
| **On disk** | Raw file contents as stored on disk (no draft overlay). |

When you **Apply** edits without saving, pending operations are merged into the preview and a **Draft** badge appears. Validation errors from draft operations surface above the YAML block. App-config previews redact sensitive tokens (`***` + last four characters).

API: `POST /v3/config/metagit/preview` and `POST /v3/config/appconfig/preview` with `{ "style": "normalized", "operations": [...] }`.

CLI parity (same operation model):

```bash
metagit config tree
metagit config preview --file ops.json
metagit config patch --file ops.json --save
metagit appconfig patch --op set --path workspace.dedupe.enabled --value false --save
```

See [metagit-config.md](metagit-config.md#schema-backed-editing-cli) for operation shapes and path examples.

### Workspace Console

The **Workspace Console** is **Workspace** in the chrome (`/workspace`): catalog-level context (projects/repos index, search/filter) plus the **workspace operations** side panel (health/prune/sync style actions routed through `/v3/ops`). This is meant for situational awareness and lightweight maintenance; destructive actions remain gated as in the CLI and API.

Use the **Repositories | Graph** toggle on the workspace toolbar:

- **Repositories** — filterable table of projects and repos (synced / missing) with per-repo sync actions.
- **Graph** — SVG diagram of workspace relationships: manual edges from `.metagit.yml` `graph.relationships`, optional inferred cross-project dependencies, and project → repo structure edges. Checkboxes control inferred and structure layers.

Graph data is loaded from `GET /v3/ops/graph`:

| Query param | Default | Meaning |
|-------------|---------|---------|
| `include_inferred` | `true` | Include edges inferred from cross-project dependency analysis. |
| `include_structure` | `true` | Include project → repo containment edges. |

Response shape: `{ ok, nodes[], edges[], manual_edge_count, inferred_edge_count, structure_edge_count }`. Each node has `id`, `label`, `kind` (`project` \| `repo`). Each edge has `from_id`, `to_id`, `type`, optional `label`, and `source` (`manual` \| `inferred` \| `structure`).

## Frontend development workflow

Prerequisites under `web/`: npm (Node toolchain). Prefer the Task targets from the repo root.

### Hot reload (`task web:dev`)

```bash
task web:dev
```

Runs `npm run dev` (Vite) with a dev-server proxy:

- Paths under `/v2` and `/v3` proxy to **`http://127.0.0.1:8787`** (`web/vite.config.ts`).

Therefore the usual workflow is:

1. In one shell: **`metagit web serve`** (or `--port`/`--host` overrides if you customize Vite targets).
2. In another shell: **`task web:dev`** and open Vite's origin (shown in npm output).

The browser talks to Vite while API calls traverse the proxy to Python.

### Production-like bundle (`task web:build`)

```bash
task web:build
```

This runs `npm ci || npm install` under `web/`, then `npm run build`, emitting into
`src/metagit/data/web/` (`emptyOutDir: true`). Commit those generated assets whenever
shipping UI fixes so `metagit web serve` picks them up without a local Node install.

Continuous integration hooks in `task qa:prepush`/`scripts/prepush-gate.py` focus on Python
tests and lint; they do **not** run **`task web:build`**. If you alter source under `web/`,
run **`task web:build`** manually before tagging or merging UI changes unless your team
delegates builds to CI elsewhere.

## Security

- **`--host` defaults to localhost** (`127.0.0.1`): the bundled server expects local-only use.
- **No authentication layer in Web v1** — anyone who can reach the TCP port can call the APIs the UI exposes. Do not expose an open `--host`/`--port` endpoint on shared networks until an auth model lands.
- Treat `metagit web serve` like any other localhost admin utility: firewall exposure is your responsibility once you bind `0.0.0.0` or comparable.
`````

## File: docs/development.md
`````markdown
# Metagit Development Guide

Upon making changes run the following to validate everything before submitting a PR

```bash
task format lint:fix test
```

## MCP Development Notes

- Use `metagit mcp serve` to start the MCP stdio runtime.
- Use `--root <path>` to test workspace gating against a specific folder.
- Use `--status-once` for quick diagnostics without starting the message loop.
- MCP gating states:
  - `inactive_missing_config` when `.metagit.yml` is not present
  - `inactive_invalid_config` when `.metagit.yml` fails validation
  - `active` when `.metagit.yml` loads successfully

## Provider Source Sync

Use source sync to discover repositories from GitHub/GitLab and plan/apply workspace updates:

- Discover-only: `metagit project source sync --provider github --org <org> --mode discover`
- Additive apply: `metagit project source sync --provider github --org <org> --mode additive --apply`
- Reconcile apply: `metagit project source sync --provider gitlab --group <group> --mode reconcile --apply --yes`

## Semantic Release Tags

`release-please` runs on merges to `main` and uses conventional commit prefixes to prepare/version releases.

- It opens/updates a release PR with computed next version + changelog.
- When that PR is merged, it creates the semantic tag (`vX.Y.Z`) and GitHub release.

- `fix:` -> patch release (`X.Y.Z+1`) **default for most updates**
- `feat:` -> minor release (`X.Y+1.0`)
- `type(scope)!:` or `BREAKING CHANGE:` -> major release (`X+1.0.0`)

### Commit Prefix Guidance

Use patch semantics first (`fix:`) unless schema/config compatibility is intentionally broken.

- Use `fix:` for normal maintenance and safe behavior changes.
- Use `feat:` only for additive, backward-compatible functionality.
- Use `!` / `BREAKING CHANGE` when changing `.metagit.yml` or app config schema in a non-backward-compatible way.
`````

## File: skills/metagit-cli/SKILL.md
`````markdown
---
name: metagit-cli
description: CLI-only shortcuts for metagit agents — workspace catalog, discovery, prompts, sync, layout, and config. Use instead of MCP or HTTP API when operating from a shell or agent_mode session.
---

# Metagit CLI (agent shortcuts)

Use this skill when an agent should drive metagit **only through the `metagit` command**. Do not call MCP tools or `metagit api` from workflows covered here unless the user explicitly asks.

Set non-interactive defaults when automating:

```bash
export METAGIT_AGENT_MODE=true
```

Global flags (most commands):

- `-c path/to/metagit.config.yaml` — app config (default `metagit.config.yaml`)
- Workspace manifest: `--definition` / `-c` on catalog commands (default `.metagit.yml`)

---

## Manifest editing fast map (`.metagit.yml`)

Use this table first when changing a workspace manifest from the CLI. Prefer **catalog commands** for projects/repos; use **`config patch`** for everything else in the schema (documentation, graph, dedupe overrides, nested fields).

| Task | Command |
|------|---------|
| **Inspect** manifest on disk | `metagit config show -c .metagit.yml` |
| **Inspect** normalized model | `metagit config show -c .metagit.yml --normalized` |
| **Inspect** as JSON (agents) | `metagit config show -c .metagit.yml --json` |
| **Browse** fields / paths | `metagit config tree -c .metagit.yml` or `… --json` |
| **Validate** after edits | `metagit config validate -c .metagit.yml` |
| **Dry-run** schema change | `metagit config preview -c .metagit.yml --file ops.json` |
| **Apply** schema change | `metagit config patch -c .metagit.yml --file ops.json --save` |
| **Set one field** | `metagit config patch -c .metagit.yml --op set --path <path> --value <v> --save` |
| **Enable** optional block | `metagit config patch … --op enable --path <path> --save` |
| **Add list item** | `metagit config patch … --op append --path <list.path> --save` then `set` on `[index].field` |
| **Remove list item** | `metagit config patch … --op remove --path <list.path>[index] --save` |

### Catalog shortcuts (projects & repos)

| Task | Command |
|------|---------|
| List projects | `metagit workspace project list -c .metagit.yml --json` |
| List repos (all / one project) | `metagit workspace repo list -c .metagit.yml --json` / `… --project <p> --json` |
| Add project | `metagit workspace project add -c .metagit.yml --name <p> --json` |
| Remove project | `metagit workspace project remove -c .metagit.yml --name <p> --json` |
| Rename project (dry-run) | `metagit workspace project rename -c .metagit.yml --name <old> --new-name <new> --dry-run --json` |
| Add repo | `metagit workspace repo add -c .metagit.yml --project <p> --name <r> --url <url> --json` |
| Remove repo (manifest only) | `metagit workspace repo remove -c .metagit.yml --project <p> --name <r> --json` |
| Rename / move repo (dry-run) | `metagit workspace repo rename …` / `metagit workspace repo move … --dry-run --json` |
| Search before adding | `metagit search "<name>" -c .metagit.yml --json` |

Active project context (optional): `metagit project repo add --name <r> --url <url>` after `metagit project select`.

### Schema patch examples (`ops.json`)

Paths use dot/bracket notation (same as web Config Studio). `--value` accepts JSON for objects.

```json
{
  "operations": [
    { "op": "set", "path": "name", "value": "my-workspace" },
    { "op": "enable", "path": "documentation" },
    { "op": "append", "path": "documentation" },
    { "op": "set", "path": "documentation[0]", "value": { "kind": "markdown", "path": "AGENTS.md" } },
    { "op": "enable", "path": "graph" },
    { "op": "append", "path": "graph.relationships" },
    {
      "op": "set",
      "path": "graph.relationships[0]",
      "value": {
        "id": "api-depends-infra",
        "from": { "project": "platform", "repo": "api" },
        "to": { "project": "infra", "repo": "terraform" },
        "type": "depends_on"
      }
    },
    { "op": "set", "path": "workspace.projects[0].dedupe.enabled", "value": false }
  ]
}
```

```bash
metagit config preview -c .metagit.yml --file ops.json
metagit config patch -c .metagit.yml --file ops.json --save
metagit config validate -c .metagit.yml
```

### App config vs manifest

| File | Scope | Edit via |
|------|-------|----------|
| `.metagit.yml` | Workspace manifest (projects, repos, docs, graph) | `metagit config …` + `metagit workspace …` |
| `metagit.config.yaml` | Tooling (paths, dedupe default, providers, profiles) | `metagit appconfig …` |

```bash
metagit appconfig show --format minimal-yaml
metagit appconfig patch --op set --path workspace.dedupe.enabled --value false --save
metagit config providers --show
```

### After every manifest edit

1. `metagit config validate -c .metagit.yml`
2. `metagit workspace list -c .metagit.yml --json` (sanity-check catalog)
3. If repos changed on disk: `metagit project sync` or `metagit project sync --hydrate`

### Export manual graph to GitNexus (Cypher)

| Task | Command |
|------|---------|
| Full export bundle (JSON) | `metagit config graph export -c .metagit.yml --json` |
| Raw Cypher script | `metagit config graph export -c .metagit.yml --format cypher -o graph.cypher` |
| MCP tool_calls only | `metagit config graph export -c .metagit.yml --format tool-calls` |
| Manual edges only | `metagit config graph export -c .metagit.yml --manual-only --format tool-calls` |
| MCP from agent | `metagit_export_workspace_graph_cypher` |

Ingest workflow: run `schema_statements` once via `gitnexus_cypher`, then each statement in `tool_calls` (or pipe `--format cypher`). Overlay tables: `MetagitEntity`, `MetagitLink`.

---

## Prompt commands (all kinds)

List built-in prompt kinds:

```bash
metagit prompt list
metagit prompt list --json
```

Emit prompts (`--text-only` for paste into agent context; `--json` for structured output; `--no-instructions` to omit manifest layers):

| Scope | Command | Default kind |
|-------|---------|--------------|
| Workspace | `metagit prompt workspace -c <definition> -k <kind>` | `instructions` |
| Project | `metagit prompt project -p <project> -c <definition> -k <kind>` | `instructions` |
| Repo | `metagit prompt repo -p <project> -n <repo> -c <definition> -k <kind>` | `instructions` |

### Prompt kinds by scope

| Kind | Workspace | Project | Repo | Purpose |
|------|:---------:|:-------:|:----:|---------|
| `instructions` | yes | yes | yes | Composed `agent_instructions` from manifest layers |
| `session-start` | yes | — | — | Session bootstrap checklist |
| `catalog-edit` | yes | yes | — | Search-before-create; catalog registration |
| `health-preflight` | yes | yes | — | Pre-work workspace/repo status pass |
| `sync-safe` | yes | yes | yes | Guarded sync rules |
| `subagent-handoff` | — | yes | yes | Delegate single-repo work |
| `layout-change` | yes | yes | yes | Rename/move dry-run workflow |
| `repo-enrich` | — | — | yes | **Discover + merge** workspace repo entry |

### Prompt shortcuts (copy-paste)

```bash
# Session bootstrap
metagit prompt workspace -k session-start --text-only -c .metagit.yml

# Composed instructions at each level
metagit prompt workspace -k instructions --text-only -c .metagit.yml
metagit prompt project -p default -k instructions --text-only -c .metagit.yml
metagit prompt repo -p default -n my-api -k instructions --text-only -c .metagit.yml

# Repo catalog enrichment (detect + merge manifest entry)
metagit prompt repo -p default -n my-api -k repo-enrich --text-only -c .metagit.yml

# Catalog registration discipline
metagit prompt workspace -k catalog-edit --text-only -c .metagit.yml

# Safe sync reminder
metagit prompt repo -p default -n my-api -k sync-safe --text-only -c .metagit.yml

# Subagent handoff
metagit prompt repo -p default -n my-api -k subagent-handoff --text-only -c .metagit.yml
```

---

## Repo enrich workflow (`repo-enrich`)

Run the prompt, then execute its steps:

```bash
metagit prompt repo -p <project> -n <repo> -k repo-enrich --text-only -c .metagit.yml
```

Typical discovery chain on the checkout:

```bash
cd "$(metagit search '<repo>' -c .metagit.yml --path-only)"
metagit detect repository -p . -o json
metagit detect repo -p . -o yaml
metagit detect repo_map -p . -o json
```

Provider metadata (dry-run):

```bash
metagit project source sync --provider github --org <org> --mode discover --no-apply
```

After merging fields into `workspace.projects[].repos[]`:

```bash
metagit config validate -c .metagit.yml
metagit workspace repo list --project <project> --json
```

---

## Workspace and catalog

Per-project dedupe override in `.metagit.yml` (overrides `workspace.dedupe.enabled` in `metagit.config.yaml` for that project only):

```yaml
workspace:
  projects:
    - name: local
      dedupe:
        enabled: false
      repos: []
```

```bash
metagit appconfig show --format json
metagit config info -c .metagit.yml
metagit config show -c .metagit.yml
metagit config validate -c .metagit.yml

metagit workspace list -c .metagit.yml --json
metagit workspace project list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --project <name> --json

metagit workspace project add --name <name> --json
metagit workspace repo add --project <name> --name <repo> --url <url> --json
metagit workspace project remove --name <name> --json
metagit workspace repo remove --project <name> --name <repo> --json
```

Search managed repos (always before creating entries):

```bash
metagit search "<query>" -c .metagit.yml --json
metagit search "<query>" -c .metagit.yml --path-only
metagit search "<query>" -c .metagit.yml --tag tier=1 --project <name>
```

---

## Project operations

```bash
metagit project list --config .metagit.yml --all --json
metagit project add --name <name> --json
metagit project remove --name <name> --json
metagit project rename --name <old> --new-name <new> --dry-run --json
metagit project select
metagit project sync
metagit project sync --hydrate   # symlink mounts → full directory copies (per-file progress)

metagit project repo list --json
metagit project repo add --project <name> --name <repo> --url <url>
metagit project repo remove --name <repo> --json
metagit project repo rename --name <old> --new-name <new> --dry-run --json
metagit project repo move --name <repo> --to-project <other> --dry-run --json
metagit project repo prune --project <name> --dry-run

metagit project source sync --provider github --org <org> --mode discover --no-apply
metagit project source sync --provider github --org <org> --mode additive --apply
```

Layout (manifest + disk; always dry-run first):

```bash
metagit workspace project rename --name <old> --new-name <new> --dry-run --json
metagit workspace repo rename --project <p> --name <old> --new-name <new> --dry-run --json
metagit workspace repo move --project <p> --name <repo> --to-project <other> --dry-run --json
```

---

## Discovery and local metadata

```bash
metagit detect project -p <path> -o yaml
metagit detect repo -p <path> -o yaml
metagit detect repo_map -p <path> -o json
metagit detect repository -p <path> -o json
metagit detect repository -p <path> -o metagit
# --save only with operator approval (blocked in agent_mode)
```

Bootstrap new trees:

```bash
metagit init --kind application
metagit init --kind umbrella --template hermes-orchestrator
```

---

## Selection and scope

```bash
metagit workspace select --project <name>
metagit project select
metagit project repo select
```

---

## Config and appconfig (reference)

See **Manifest editing fast map** above for day-to-day manifest work. Additional commands:

```bash
metagit config info -c .metagit.yml
metagit config example
metagit config schema
metagit appconfig validate
metagit appconfig get --name config.workspace.path
metagit appconfig tree --json
metagit appconfig preview --file ops.json
metagit appconfig patch --file ops.json --save
metagit config providers --show
```

---

## Records, skills, version

```bash
metagit record search "<query>"
metagit skills list
metagit skills show metagit-cli
metagit skills install --skill metagit-cli
metagit version
metagit info
```

---

## Agent habits

1. **Search before create** — `metagit search` then catalog add.
2. **Validate after manifest edits** — `metagit config validate`.
3. **Emit prompts instead of rewriting playbooks** — `metagit prompt … --text-only`.
4. **Enrich stale repo entries** — `metagit prompt repo … -k repo-enrich` then detect + merge.
5. **Dry-run layout** — always `--dry-run --json` before apply.
6. **Prefer `METAGIT_AGENT_MODE=true`** in CI and agent loops to skip fuzzy finder and confirm dialogs.

## Related bundled skills

Use topic skills when you need deeper playbooks (some mention MCP): `metagit-projects`, `metagit-workspace-scope`, `metagit-workspace-sync`, `metagit-config-refresh`. This skill is the **CLI-only** index and prompt reference.
`````

## File: src/metagit/cli/commands/appconfig.py
`````python
"""
Appconfig subcommand
"""

import json
import os
import sys
from typing import Union

import click
import yaml as base_yaml
from pydantic import ValidationError

from metagit import DATA_PATH, DEFAULT_CONFIG, __version__
from metagit.cli.config_patch_ops import (
    emit_patch_result,
    emit_preview_result,
    emit_tree_result,
    resolve_operations,
)
from metagit.cli.json_output import emit_json
from metagit.core.appconfig import get_config, load_config, save_config, set_config
from metagit.core.appconfig.display import render_appconfig_show
from metagit.core.appconfig.models import AppConfig
from metagit.core.config.patch_service import ConfigPatchService
from metagit.core.utils.logging import LoggerConfig, UnifiedLogger


@click.group(name="appconfig", invoke_without_command=True)
@click.pass_context
def appconfig(ctx: click.Context) -> None:
    """Application configuration subcommands"""
    # If no subcommand is provided, show help
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())
        return


@appconfig.command("info")
@click.pass_context
def appconfig_info(ctx: click.Context) -> None:
    """
    Information about the application configuration.
    """
    logger = ctx.obj.get("logger") or UnifiedLogger(LoggerConfig())
    logger.config_element(name="version", value=__version__, console=True)
    logger.config_element(
        name="config_path", value=ctx.obj["config_path"], console=True
    )


@appconfig.command("show")
@click.option(
    "--format",
    "-f",
    "output_format",
    type=click.Choice(["yaml", "json", "minimal-yaml"], case_sensitive=False),
    default="yaml",
    show_default=True,
    help="Output format (yaml=full active config, json=agents, minimal-yaml=non-default only)",
)
@click.pass_context
def appconfig_show(ctx: click.Context, output_format: str) -> None:
    """Show the full active application configuration."""
    logger = ctx.obj["logger"]
    try:
        config: AppConfig = ctx.obj["config"]
        config_path: str = ctx.obj["config_path"]
        minimal = output_format == "minimal-yaml"
        if output_format == "json":
            from metagit.core.appconfig.display import build_appconfig_payload

            emit_json(
                build_appconfig_payload(
                    config,
                    config_path=config_path,
                    minimal=minimal,
                )
            )
            return
        rendered = render_appconfig_show(
            config,
            config_path=config_path,
            output_format="yaml",
            minimal=minimal,
        )
        click.echo(rendered, nl=False)
    except Exception as e:
        if logger:
            logger.error(f"Failed to show appconfig: {e}")
        else:
            click.echo(f"An error occurred: {e}", err=True)
        ctx.abort()


@appconfig.command("validate")
@click.option("--config-path", help="Path to the configuration file", default=None)
@click.pass_context
def appconfig_validate(
    ctx: click.Context, config_path: Union[str, None] = None
) -> None:
    """Validate a configuration file"""
    logger = ctx.obj["logger"]
    try:
        if not config_path:
            config_path = os.path.join(DATA_PATH, "metagit.config.yaml")
        logger.echo(f"Validating configuration file: {config_path}")

        # Step 1: Load YAML
        with open(config_path) as f:
            from metagit.core.utils.yaml_class import load as yaml_load

            config_data = yaml_load(f)
            if isinstance(config_data, Exception):
                raise config_data
        # Step 2: Validate structure with Pydantic model
        try:
            _ = AppConfig(**config_data["config"])
        except ValidationError as ve:
            logger.error(f"Model validation failed: {ve}")
            sys.exit(1)
        logger.echo("Configuration is valid!")
    except Exception as e:
        logger.error(f"Failed to load or validate config: {e}")
        sys.exit(1)


@appconfig.command(
    "get",
    help="""
Get a value from the application configuration.\n
Example - show all keys in the providers section:\n
  metagit appconfig get --name config.providers --show-keys\n
Example - show all values in the providers section:\n
  metagit appconfig get --name config.providers
""",
)
@click.option("--name", default="", help="Appconfig element to target")
@click.option(
    "--show-keys",
    is_flag=True,
    default=False,
    help="If the element is a dictionary, show all key names. If it is a list, show all name attributes",
)
@click.option("--output", default="json", help="Output format (json/yaml)")
@click.pass_context
def appconfig_get(ctx: click.Context, name: str, show_keys: bool, output: str) -> None:
    """Display appconfig value"""
    try:
        config = ctx.obj["config"]
        result = get_config(
            appconfig=config, name=name, show_keys=show_keys, output=output
        )
        if isinstance(result, Exception):
            raise result
    except Exception as e:
        logger = ctx.obj.get("logger")
        if logger:
            logger.error(f"Failed to get appconfig value: {e}")
        else:
            click.echo(f"An error occurred: {e}", err=True)
        ctx.abort()


@appconfig.command("create")
@click.option(
    "--config-path",
    help="Path to save configuration file (default: ~/.config/metagit/config.yml).",
    default=os.path.join(os.getcwd(), "metagit.config.yaml"),
)
@click.pass_context
def appconfig_create(ctx: click.Context, config_path: str = None) -> None:
    """Create default application config"""
    logger = ctx.obj.get("logger")
    default_config = load_config(DEFAULT_CONFIG)
    if not os.path.exists(config_path):
        try:
            output = base_yaml.dump(
                {
                    "config": default_config.model_dump(
                        exclude_none=True, exclude_defaults=False, mode="json"
                    )
                },
                default_flow_style=False,
                sort_keys=False,
                indent=2,
                line_break=True,
            )
            with open(config_path, "w") as f:
                f.write(output)
            logger.success(f"Configuration file {config_path} created")
        except Exception as e:
            logger.error(f"Failed to create default appconfig: {e}")
            ctx.abort()
    else:
        logger.warning(f"Configuration file {config_path} already exists!")


@appconfig.command("set")
@click.option("--name", help="Appconfig element to target")
@click.option("--value", help="Value to set")
@click.pass_context
def appconfig_set(ctx: click.Context, name: str, value: str) -> None:
    """Set a value in the application configuration."""
    logger = ctx.obj.get("logger") or UnifiedLogger(LoggerConfig())
    try:
        config = ctx.obj["config"]
        config_path = ctx.obj["config_path"]

        updated_config = set_config(appconfig=config, name=name, value=value)
        if isinstance(updated_config, Exception):
            raise updated_config

        save_result = save_config(config_path=config_path, config=updated_config)
        if isinstance(save_result, Exception):
            raise save_result

        logger.success(f"Updated '{name}' to '{value}' in {config_path}")

    except Exception as e:
        if logger:
            logger.error(f"Failed to set appconfig value: {e}")
        else:
            click.echo(f"An error occurred: {e}", err=True)
        ctx.abort()


@appconfig.command("tree")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def appconfig_tree(ctx: click.Context, as_json: bool) -> None:
    """Show schema-backed field tree for metagit.config.yaml."""
    logger = ctx.obj["logger"]
    config_path = ctx.obj["config_path"]
    result = ConfigPatchService().build_tree("appconfig", config_path)
    if isinstance(result, Exception):
        logger.error(f"Failed to build appconfig tree: {result}")
        ctx.abort()
    emit_tree_result(result, as_json=as_json)


@appconfig.command("preview")
@click.option(
    "--style",
    type=click.Choice(["normalized", "minimal", "disk"], case_sensitive=False),
    default="normalized",
    show_default=True,
    help="YAML preview style",
)
@click.option(
    "--file",
    "operations_file",
    type=click.Path(exists=True, dir_okay=False),
    default=None,
    help="JSON file with operations array or {operations, save}",
)
@click.option(
    "--op",
    type=click.Choice(["enable", "disable", "set", "append", "remove"]),
    default=None,
    help="Single operation kind (use with --path)",
)
@click.option("--path", default=None, help="Field path for a single operation")
@click.option("--value", default=None, help="Value for set (JSON or scalar)")
@click.option(
    "--output",
    "output_path",
    default=None,
    help="Write preview YAML to this path instead of stdout",
)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def appconfig_preview(
    ctx: click.Context,
    style: str,
    operations_file: str | None,
    op: str | None,
    path: str | None,
    value: str | None,
    output_path: str | None,
    as_json: bool,
) -> None:
    """Preview app config after draft operations (secrets redacted in output)."""
    logger = ctx.obj["logger"]
    config_path = ctx.obj["config_path"]
    operations = (
        resolve_operations(
            operations_file=operations_file,
            op=op,
            path=path,
            value=value,
        )
        if operations_file or op
        else []
    )
    result = ConfigPatchService().preview(
        "appconfig",
        config_path,
        operations,
        style=style,
    )
    if isinstance(result, Exception):
        logger.error(f"Failed to preview appconfig: {result}")
        ctx.abort()
    emit_preview_result(
        result,
        as_json=as_json,
        logger=logger,
        output_path=output_path,
    )


@appconfig.command("patch")
@click.option(
    "--file",
    "operations_file",
    type=click.Path(exists=True, dir_okay=False),
    default=None,
    help="JSON file with operations array or {operations, save}",
)
@click.option(
    "--op",
    type=click.Choice(["enable", "disable", "set", "append", "remove"]),
    default=None,
    help="Single operation kind (use with --path)",
)
@click.option("--path", default=None, help="Field path for a single operation")
@click.option("--value", default=None, help="Value for set (JSON or scalar)")
@click.option(
    "--save",
    is_flag=True,
    default=False,
    help="Write changes to disk when validation passes",
)
@click.option(
    "--tree",
    "include_tree",
    is_flag=True,
    default=False,
    help="Include updated schema tree in JSON output",
)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def appconfig_patch(
    ctx: click.Context,
    operations_file: str | None,
    op: str | None,
    path: str | None,
    value: str | None,
    save: bool,
    include_tree: bool,
    as_json: bool,
) -> None:
    """
    Apply schema operations to metagit.config.yaml (enable/disable/set/append/remove).

    Paths use AppConfig field names (e.g. workspace.dedupe.enabled), not the config: wrapper.
    """
    logger = ctx.obj["logger"]
    config_path = ctx.obj["config_path"]
    operations = resolve_operations(
        operations_file=operations_file,
        op=op,
        path=path,
        value=value,
    )
    result = ConfigPatchService().patch(
        "appconfig",
        config_path,
        operations,
        save=save,
        include_tree=include_tree or as_json,
        mask_secrets=True,
    )
    if isinstance(result, Exception):
        logger.error(f"Failed to patch appconfig: {result}")
        ctx.abort()
    emit_patch_result(result, as_json=as_json, logger=logger)


@appconfig.command("schema")
@click.option(
    "--output-path",
    help="Path to output the JSON schema file",
    default="metagit_appconfig.schema.json",
)
@click.pass_context
def appconfig_schema(ctx: click.Context, output_path: str) -> None:
    """
    Generate a JSON schema for the AppConfig class and write it to a file.
    """
    logger = ctx.obj["logger"]
    try:
        schema = AppConfig.model_json_schema()
        with open(output_path, "w", encoding="utf-8") as f:
            json.dump(schema, f, indent=2)
        logger.success(f"JSON schema written to {output_path}")
    except Exception as e:
        logger.error(f"Failed to generate JSON schema: {e}")
        ctx.abort()
`````

## File: src/metagit/cli/commands/detect.py
`````python
"""
Detect subcommand
"""

import json
import os

import click
import yaml
from metagit.core.detect.manager import (
    DetectionManager,
    DetectionManagerConfig,
    ProjectDetection,
)
from metagit.core.providers import registry
from metagit.core.providers.github import GitHubProvider
from metagit.core.providers.gitlab import GitLabProvider
from metagit.core.utils.files import (
    FileExtensionLookup,
    directory_details,
    directory_summary,
    list_git_files,
)


@click.group(name="detect", invoke_without_command=True, help="Detection subcommands")
@click.pass_context
def detect(ctx: click.Context) -> None:
    """Detection subcommands"""
    # If no subcommand is provided, show help
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())
        return


@detect.command("project")
@click.option(
    "--path",
    "-p",
    default="./",
    show_default=True,
    help="Path to the git repository to analyze.",
)
@click.option(
    "--output",
    "-o",
    default="yaml",
    show_default=True,
    help="Output format (yaml, json, summary).",
)
@click.pass_context
def detect_project(ctx: click.Context, path: str, output: str) -> None:
    """Perform project detection and analysis."""
    logger = ctx.obj["logger"]
    try:
        path_files = list_git_files(path)
        if not path_files:
            logger.error(f"No git files found in the specified path: {path}")
            ctx.abort()

    except Exception as e:
        logger.error(f"Error enumerating files in {path}: {e}")
        ctx.abort()

    detection = ProjectDetection(logger=logger)
    try:
        results = detection.run(path)
    except Exception as e:
        logger.error(f"Error during project detection: {e}")
        ctx.abort()

    detections = []
    for result in results:
        detections.append(result.model_dump(exclude_none=True, exclude_defaults=True))

    if not detections:
        logger.warning("No project detections found.")
        return

    if output == "summary":
        summary = {
            "project_path": path,
            "project_detections": [d["name"] for d in detections],
            "total_detections": len(detections),
        }
        click.echo(json.dumps(summary, indent=2))
        return

    # .model_dump(exclude_none=True, exclude_defaults=True)
    full_result = {
        "project_path": path,
        "project_detections": detections,
        "total_detections": len(detections),
        "all_files": detection.all_files(path),
    }

    if output == "yaml":
        yaml_output = yaml.safe_dump(
            full_result, default_flow_style=False, sort_keys=False, indent=2
        )
        click.echo(yaml_output)
    elif output == "json":
        json_output = json.dumps(full_result, indent=2)
        click.echo(json_output)
    else:
        click.echo(detections)


@detect.command("repo_map")
@click.option(
    "--path",
    "-p",
    default="./",
    show_default=True,
    help="Path to the git repository to analyze.",
)
@click.option(
    "--output",
    "-o",
    default="yaml",
    show_default=True,
    help="Output format (yaml, json, summary).",
)
@click.pass_context
def detect_repo_map(ctx: click.Context, path: str, output: str) -> None:
    """Create a map of files and folders in a repository for further analysis."""
    logger = ctx.obj["logger"]
    try:
        summary = directory_summary(path)
    except Exception as e:
        logger.error(f"Error creating directory summary at {path}: {e}")
        ctx.abort()

    try:
        details = directory_details(target_path=path, file_lookup=FileExtensionLookup())
    except Exception as e:
        logger.error(f"Error creating directory details at {path}: {e}")
        ctx.abort()

    result = {
        "summary": summary.model_dump(mode="json"),
        "details": details.model_dump(mode="json"),
    }
    if output == "yaml":
        yaml_output = yaml.safe_dump(
            result, default_flow_style=False, sort_keys=False, indent=2
        )
        click.echo(yaml_output)
    elif output == "json":
        json_output = json.dumps(result, indent=2)
        click.echo(json_output)
    else:
        click.echo(result)


@detect.command("repo")
@click.option(
    "--path",
    "-p",
    default="./",
    show_default=True,
    help="Path to the git repository to analyze.",
)
@click.option(
    "--output",
    "-o",
    default="yaml",
    show_default=True,
    help="Output format (yaml, json, summary).",
)
@click.pass_context
def detect_repo(ctx: click.Context, path: str, output: str) -> None:
    """Detect the codebase."""
    logger = ctx.obj["logger"]
    try:
        # Create DetectionManager with all analyses enabled
        config = DetectionManagerConfig.all_enabled()
        project = DetectionManager.from_path(path, logger, config)
        if isinstance(project, Exception):
            raise project

        run_result = project.run_all()
        if isinstance(run_result, Exception):
            raise run_result

        if output == "yaml":
            yaml_output = project.to_yaml()
            if isinstance(yaml_output, Exception):
                raise yaml_output
            click.echo(yaml_output)
        elif output == "json":
            json_output = project.to_json()
            if isinstance(json_output, Exception):
                raise json_output
            click.echo(json_output)
        else:
            summary_output = project.summary()
            if isinstance(summary_output, Exception):
                raise summary_output
            click.echo(summary_output)
    except Exception as e:
        logger.error(f"Error analyzing project at {path}: {e}")
        ctx.abort()


@detect.command("repository")
@click.option(
    "--path",
    "-p",
    help="Path to local repository to analyze.",
)
@click.option(
    "--url",
    help="URL of remote git repository to clone and analyze.",
)
@click.option(
    "--output",
    "-o",
    default="summary",
    show_default=True,
    type=click.Choice(
        [
            "summary",
            "yaml",
            "json",
            "record",
            "metagit",
            "metagitconfig",
            "summary",
            "all",
        ]
    ),
    help="Output format. Defaults to 'summary'",
)
@click.option(
    "--save",
    "-s",
    is_flag=True,
    default=False,
    help="Save the generated configuration to .metagit.yml in the repository path.",
)
@click.option(
    "--temp-dir",
    help="Temporary directory for cloning remote repositories.",
)
@click.option(
    "--github-token",
    envvar="GITHUB_TOKEN",
    help="GitHub API token for fetching repository metrics (overrides AppConfig).",
)
@click.option(
    "--gitlab-token",
    envvar="GITLAB_TOKEN",
    help="GitLab API token for fetching repository metrics (overrides AppConfig).",
)
@click.option(
    "--github-url",
    envvar="GITHUB_URL",
    help="GitHub API base URL (for GitHub Enterprise, overrides AppConfig).",
)
@click.option(
    "--gitlab-url",
    envvar="GITLAB_URL",
    help="GitLab API base URL (for self-hosted GitLab, overrides AppConfig).",
)
@click.option(
    "--use-app-config",
    is_flag=True,
    default=True,
    help="Use AppConfig for provider configuration (default: True).",
)
@click.option(
    "--config-path",
    default=".metagit.yml",
    help="Path to the MetagitConfig file to save.",
)
@click.pass_context
def detect_repository(
    ctx: click.Context,
    path: str,
    url: str,
    output: str,
    save: bool,
    temp_dir: str,
    github_token: str,
    gitlab_token: str,
    github_url: str,
    gitlab_url: str,
    use_app_config: bool,
    config_path: str,
) -> None:
    """Comprehensive repository analysis and MetagitConfig generation using DetectionManager."""
    logger = ctx.obj["logger"]
    app_config = ctx.obj["config"]
    try:
        # Configure providers
        if use_app_config:
            # Try to load AppConfig and configure providers
            try:
                # app_config = AppConfig.load()
                registry.configure_from_app_config(app_config)
                logger.debug("Configured providers from AppConfig")
            except Exception as e:
                logger.warning(f"Failed to load AppConfig: {e}")
                # Fall back to environment variables
                registry.configure_from_environment()
                logger.debug("Configured providers from environment variables")
        else:
            # Use environment variables only
            registry.configure_from_environment()
            logger.debug("Configured providers from environment variables")

        # Override with CLI options if provided
        if github_token or gitlab_token or github_url or gitlab_url:
            # Clear existing providers and configure with CLI options
            registry.clear()

            if github_token:
                github_provider = GitHubProvider(
                    api_token=github_token,
                    base_url=github_url or "https://api.github.com",
                )
                registry.register(github_provider)
                logger.debug("GitHub provider configured from CLI options")

            if gitlab_token:
                gitlab_provider = GitLabProvider(
                    api_token=gitlab_token,
                    base_url=gitlab_url or "https://gitlab.com/api/v4",
                )
                registry.register(gitlab_provider)
                logger.debug("GitLab provider configured from CLI options")

        # Log configured providers
        providers = registry.get_all_providers()
        if providers:
            provider_names = [p.get_name() for p in providers]
            logger.debug(f"Configured providers: {', '.join(provider_names)}")
        else:
            logger.debug("No providers configured - will use git-based metrics")

        if not path and not url:
            # Default to current directory if no path or URL provided
            path = os.getcwd()
            logger.debug(f"No path or URL provided, using current directory: {path}")

        if path and url:
            logger.error("Please provide either --path or --url, not both.")
            ctx.abort()

        # if not output and not save:
        #     output = "summary"

        # Create DetectionManager with all analyses enabled
        config = DetectionManagerConfig.all_enabled()

        if path:
            logger.debug(f"Analyzing local repository at: {path}")
            detection_manager = DetectionManager.from_path(path, logger, config)
        elif url:
            logger.debug(f"Cloning and analyzing remote repository: {url}")
            detection_manager = DetectionManager.from_url(url, temp_dir, logger, config)

        if isinstance(detection_manager, Exception):
            raise detection_manager

        # Run all analyses
        run_result = detection_manager.run_all()
        if isinstance(run_result, Exception):
            raise run_result

        result = None

        if output == "all":
            # Output all detection data including MetagitRecord fields
            try:
                result = detection_manager.model_dump(
                    exclude_none=True, exclude_defaults=True, mode="json"
                )
                if isinstance(result, Exception):
                    raise result
                result = yaml.safe_dump(
                    result, default_flow_style=False, sort_keys=False, indent=2
                )
                if isinstance(result, Exception):
                    raise result
            except Exception as e:
                logger.error(f"Error dumping detection data: {e}")
                ctx.abort()
        elif output in ["record"]:
            result = detection_manager.to_yaml()
            if isinstance(result, Exception):
                raise result
        elif output in ["metagit", "metagitconfig"]:
            # Convert to MetagitConfig (remove detection-specific fields)
            config_data = detection_manager.model_dump(
                exclude={
                    "detection_config",
                    "branch_analysis",
                    "ci_config_analysis",
                    "directory_summary",
                    "directory_details",
                    "repository_analysis",
                    "logger",
                    "_analysis_completed",
                    "detection_timestamp",
                    "detection_source",
                    "detection_version",
                    "tenant_id",
                }
            )
            result = yaml.safe_dump(
                config_data, default_flow_style=False, sort_keys=False, indent=2
            )
        elif output == "summary":
            result = detection_manager.summary()
            if isinstance(result, Exception):
                raise result
        elif output == "yaml":
            result = yaml.dump(
                detection_manager.model_dump(exclude_none=True, exclude_defaults=True),
                default_flow_style=False,
                sort_keys=False,
                indent=2,
            )
        elif output == "json":
            result = json.dumps(
                detection_manager.model_dump(exclude_none=True, exclude_defaults=True),
                indent=2,
            )

        if not save:
            click.echo(result)
        else:
            if ctx.obj.get("agent_mode"):
                raise click.UsageError(
                    "Interactive save confirmation is disabled in agent mode; "
                    "remove the existing config file first or omit --save"
                )
            if os.path.exists(config_path) and not click.confirm(
                f"Configuration file at '{config_path}' already exists. Do you want to overwrite it?"
            ):
                click.echo("Save operation aborted.")
                if hasattr(detection_manager, "cleanup"):
                    detection_manager.cleanup()
                return

            # Save as MetagitConfig (not DetectionManager)
            config_data = detection_manager.model_dump(
                exclude={
                    "detection_config",
                    "branch_analysis",
                    "ci_config_analysis",
                    "directory_summary",
                    "directory_details",
                    "repository_analysis",
                    "logger",
                    "_analysis_completed",
                    "detection_timestamp",
                    "detection_source",
                    "detection_version",
                    "tenant_id",
                }
            )

            with open(config_path, "w") as f:
                yaml.dump(
                    config_data,
                    f,
                    default_flow_style=False,
                    sort_keys=False,
                    indent=2,
                )
            logger.info(f"✅ MetagitConfig saved to: {config_path}")

        # Clean up if this was a cloned repository
        if hasattr(detection_manager, "cleanup"):
            detection_manager.cleanup()

    except Exception as e:
        logger.error(f"Error during repository analysis: {e}")
        ctx.abort()
`````

## File: src/metagit/core/mcp/services/workspace_search.py
`````python
#!/usr/bin/env python
"""
Workspace-scoped search service using ripgrep with a bounded fallback scanner.
"""

import json
import shutil
import subprocess
from pathlib import Path
from typing import Any, Optional


class WorkspaceSearchService:
    """Search configured repositories with bounded, text-based matching."""

    _preset_terms: dict[str, list[str]] = {
        "terraform": ["terraform", "module", "variable", "tf"],
        "docker": ["docker", "from", "image", "container"],
        "infra": ["infra", "network", "cluster", "provision"],
        "ci": ["workflow", "pipeline", "actions", "runner"],
    }

    _intent_globs: dict[str, list[str]] = {
        "config": ["**/*.yml", "**/*.yaml", "**/*.toml", "**/.env.example"],
        "scripts": ["**/*.sh", "**/*.py", "**/scripts/**"],
        "ci": ["**/.github/**", "**/.gitlab-ci.yml", "**/Jenkinsfile"],
        "docker": ["**/Dockerfile", "**/docker-compose*.yml", "**/*.dockerfile"],
        "terraform": ["**/*.tf", "**/*.tfvars"],
    }

    _default_exclude: list[str] = [
        "**/.git/**",
        "**/node_modules/**",
        "**/__pycache__/**",
        "**/.venv/**",
        "**/dist/**",
        "**/build/**",
    ]

    _generated_exclude: list[str] = [
        "**/vendor/**",
        "**/generated/**",
        "**/*.min.js",
        "**/*.min.css",
        "**/package-lock.json",
        "**/yarn.lock",
        "**/pnpm-lock.yaml",
    ]

    _category_rules: list[tuple[str, list[str]]] = [
        ("config", ["**/*.yml", "**/*.yaml", "**/*.toml", "**/.env.example"]),
        ("scripts", ["**/*.sh", "**/*.py", "**/scripts/**"]),
        ("ci", ["**/.github/**", "**/.gitlab-ci.yml", "**/Jenkinsfile"]),
        ("docker", ["**/Dockerfile", "**/docker-compose*.yml"]),
        ("terraform", ["**/*.tf", "**/*.tfvars"]),
        ("docs", ["**/*.md", "**/docs/**"]),
    ]

    def search(
        self,
        query: str,
        repo_paths: list[str],
        preset: Optional[str] = None,
        max_results: int = 25,
        paths: Optional[list[str]] = None,
        exclude: Optional[list[str]] = None,
        context_lines: int = 0,
        include_paths: bool = False,
        intent: Optional[str] = None,
    ) -> list[dict[str, Any]]:
        """Search across scoped repository paths and return bounded hits."""
        if not repo_paths:
            return []
        glob_paths = list(paths or [])
        if intent and intent in self._intent_globs:
            glob_paths = list(dict.fromkeys(glob_paths + self._intent_globs[intent]))
        if preset and preset in self._intent_globs:
            glob_paths = list(dict.fromkeys(glob_paths + self._intent_globs[preset]))
        exclude_globs = list(dict.fromkeys((exclude or []) + self._default_exclude))
        search_query = self._compose_query(query=query, preset=preset)
        results: list[dict[str, Any]] = []
        for repo_path in repo_paths:
            root = Path(repo_path).expanduser().resolve()
            if not root.exists() or not root.is_dir():
                continue
            remaining = max_results - len(results)
            if remaining < 1:
                break
            hits: list[dict[str, Any]] = []
            if shutil.which("rg"):
                hits = self._search_with_rg(
                    root=root,
                    query=search_query,
                    glob_paths=glob_paths,
                    exclude_globs=exclude_globs,
                    context_lines=context_lines,
                    include_paths=include_paths,
                    max_results=remaining,
                )
                if not hits and (preset is not None or intent is not None):
                    hits = self._search_fallback(
                        root=root,
                        query=query,
                        preset=preset,
                        max_results=remaining,
                    )
            else:
                hits = self._search_fallback(
                    root=root,
                    query=query,
                    preset=preset,
                    max_results=remaining,
                )
            results.extend(hits)
            if len(results) >= max_results:
                return results[:max_results]
        return results[:max_results]

    def filter_repo_paths(
        self,
        repo_rows: list[dict[str, Any]],
        repos: Optional[list[str]] = None,
    ) -> list[str]:
        """Resolve repo path list from index rows and optional repo selectors."""
        if not repos or repos == ["all"]:
            return [str(row["repo_path"]) for row in repo_rows if row.get("exists")]
        selectors = {item.strip() for item in repos if item.strip()}
        selected: list[str] = []
        for row in repo_rows:
            repo_path = str(row.get("repo_path", ""))
            repo_name = str(row.get("repo_name", ""))
            project_name = str(row.get("project_name", ""))
            keys = {repo_path, repo_name, project_name, f"{project_name}/{repo_name}"}
            if selectors.intersection(keys):
                if row.get("exists"):
                    selected.append(repo_path)
        return selected

    def _compose_query(self, query: str, preset: Optional[str]) -> str:
        """Build ripgrep query text from user query and optional preset terms."""
        terms = self._terms(query=query, preset=preset)
        if not terms:
            return query
        if len(terms) == 1:
            return terms[0]
        return "|".join(terms)

    def _search_with_rg(
        self,
        root: Path,
        query: str,
        glob_paths: list[str],
        exclude_globs: list[str],
        context_lines: int,
        include_paths: bool,
        max_results: int,
    ) -> list[dict[str, Any]]:
        """Run ripgrep and parse JSON output into hit records."""
        if max_results < 1:
            return []
        cmd = [
            "rg",
            "--json",
            "--max-count",
            str(max_results),
            "--no-messages",
        ]
        if include_paths:
            cmd.append("--files-with-matches")
        if context_lines > 0 and not include_paths:
            cmd.extend(["-C", str(context_lines)])
        for pattern in exclude_globs:
            cmd.extend(["--glob", f"!{pattern}"])
        for pattern in glob_paths:
            cmd.extend(["--glob", pattern])
        if query.strip():
            cmd.append(query)
        cmd.append(str(root))

        try:
            completed = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                check=False,
            )
        except OSError:
            return []

        if completed.returncode not in {0, 1}:
            return []

        hits: list[dict[str, Any]] = []
        context_before: list[str] = []
        context_after: list[str] = []
        for line in completed.stdout.splitlines():
            if len(hits) >= max_results:
                break
            try:
                payload = json.loads(line)
            except json.JSONDecodeError:
                continue
            message_type = payload.get("type")
            data = payload.get("data", {})
            if message_type == "context":
                context_text = str(data.get("lines", {}).get("text", "")).rstrip("\n")
                if data.get("line_number") is None:
                    context_before.append(context_text)
                else:
                    context_after.append(context_text)
                continue
            if message_type != "match":
                continue
            path_text = str(data.get("path", {}).get("text", ""))
            line_number = int(data.get("line_number", 0))
            line_text = str(data.get("lines", {}).get("text", "")).rstrip("\n")
            hit = {
                "repo_path": str(root),
                "file_path": path_text,
                "line_number": line_number,
                "line": line_text,
                "context_before": list(context_before),
                "context_after": list(context_after),
                "match_kind": "path" if include_paths else "content",
            }
            hits.append(hit)
            context_before = []
            context_after = []
        return hits

    def _search_fallback(
        self,
        root: Path,
        query: str,
        preset: Optional[str],
        max_results: int,
    ) -> list[dict[str, Any]]:
        """Fallback scanner when ripgrep is unavailable."""
        raw_terms = [term for term in self._terms(query=query, preset=preset) if term]
        terms = [term.lower() for term in raw_terms]
        if not terms:
            terms = [t.lower() for t in query.split() if t]
        if not terms:
            return []
        results: list[dict[str, Any]] = []
        for file_path in root.rglob("*"):
            if len(results) >= max_results:
                return results
            if not file_path.is_file():
                continue
            if file_path.stat().st_size > 1_000_000:
                continue
            if self._is_ignored(file_path=file_path):
                continue
            try:
                lines = file_path.read_text(encoding="utf-8").splitlines()
            except (UnicodeDecodeError, OSError):
                continue
            for idx, line in enumerate(lines, start=1):
                lower_line = line.lower()
                if any(term in lower_line for term in terms):
                    results.append(
                        {
                            "repo_path": str(root),
                            "file_path": str(file_path),
                            "line_number": idx,
                            "line": line.strip(),
                            "context_before": [],
                            "context_after": [],
                            "match_kind": "content",
                        }
                    )
                    if len(results) >= max_results:
                        return results
        return results

    def discover_files(
        self,
        repo_paths: list[str],
        *,
        intent: Optional[str] = None,
        pattern: Optional[str] = None,
        exclude_generated: bool = True,
        max_results: int = 200,
        categorize: bool = True,
    ) -> dict[str, Any]:
        """Discover files by intent or glob pattern across repositories."""
        glob_paths: list[str] = []
        if pattern:
            glob_paths.append(pattern)
        if intent and intent in self._intent_globs:
            glob_paths.extend(self._intent_globs[intent])
        if not glob_paths:
            glob_paths = ["**/*"]
        exclude_globs = list(self._default_exclude)
        if exclude_generated:
            exclude_globs.extend(self._generated_exclude)

        files: list[dict[str, str]] = []
        for repo_path in repo_paths:
            root = Path(repo_path).expanduser().resolve()
            if not root.exists() or not root.is_dir():
                continue
            discovered = self._list_files_with_rg(
                root=root,
                glob_paths=glob_paths,
                exclude_globs=exclude_globs,
                max_results=max_results - len(files),
            )
            for file_path in discovered:
                files.append({"repo_path": str(root), "file_path": file_path})
            if len(files) >= max_results:
                break

        payload: dict[str, Any] = {
            "total": len(files[:max_results]),
            "files": files[:max_results],
        }
        if categorize:
            payload["categories"] = self._categorize_files(files=files[:max_results])
        return payload

    def _list_files_with_rg(
        self,
        root: Path,
        glob_paths: list[str],
        exclude_globs: list[str],
        max_results: int,
    ) -> list[str]:
        """List files in a repo root using ripgrep or directory walk."""
        if max_results < 1:
            return []
        if shutil.which("rg"):
            cmd = ["rg", "--files", "--no-messages"]
            for pattern in exclude_globs:
                cmd.extend(["--glob", f"!{pattern}"])
            for pattern in glob_paths:
                cmd.extend(["--glob", pattern])
            cmd.append(str(root))
            try:
                completed = subprocess.run(
                    cmd,
                    capture_output=True,
                    text=True,
                    check=False,
                )
            except OSError:
                completed = None
            if completed and completed.returncode in {0, 1}:
                lines = [
                    line.strip()
                    for line in completed.stdout.splitlines()
                    if line.strip()
                ]
                return lines[:max_results]
        return self._list_files_fallback(
            root=root,
            glob_paths=glob_paths,
            exclude_globs=exclude_globs,
            max_results=max_results,
        )

    def _list_files_fallback(
        self,
        root: Path,
        glob_paths: list[str],
        exclude_globs: list[str],
        max_results: int,
    ) -> list[str]:
        """Fallback file listing without ripgrep."""
        _ = exclude_globs
        results: list[str] = []
        for glob_pattern in glob_paths:
            for file_path in root.glob(glob_pattern):
                if len(results) >= max_results:
                    return results
                if not file_path.is_file():
                    continue
                if self._is_ignored(file_path=file_path):
                    continue
                results.append(str(file_path))
        return results[:max_results]

    def _categorize_files(
        self, files: list[dict[str, str]]
    ) -> dict[str, list[dict[str, str]]]:
        """Group discovered files into coarse categories."""
        categories: dict[str, list[dict[str, str]]] = {}
        for item in files:
            category = self._category_for_path(file_path=item.get("file_path", ""))
            categories.setdefault(category, []).append(item)
        return categories

    def _category_for_path(self, file_path: str) -> str:
        """Assign a category label for a file path."""
        lower = file_path.lower()
        if "/.github/" in lower or ".gitlab-ci" in lower or "jenkinsfile" in lower:
            return "ci"
        if "dockerfile" in lower or "docker-compose" in lower:
            return "docker"
        if lower.endswith((".tf", ".tfvars", ".tf.json")):
            return "terraform"
        if lower.endswith((".md", ".rst")) or "/docs/" in lower:
            return "docs"
        if lower.endswith((".sh", ".py", ".rb", ".js", ".ts")) or "/scripts/" in lower:
            return "scripts"
        if lower.endswith((".yml", ".yaml", ".toml", ".ini", ".cfg", ".env.example")):
            return "config"
        return "other"

    def _terms(self, query: str, preset: Optional[str]) -> list[str]:
        query_terms = [term for term in query.split() if term]
        if preset and preset in self._preset_terms:
            return list(dict.fromkeys(query_terms + self._preset_terms[preset]))
        return query_terms

    def _is_ignored(self, file_path: Path) -> bool:
        name = file_path.name
        return name.startswith(".") or name.endswith((".png", ".jpg", ".jpeg", ".gif"))
`````

## File: src/metagit/data/skills/metagit-cli/SKILL.md
`````markdown
---
name: metagit-cli
description: CLI-only shortcuts for metagit agents — workspace catalog, discovery, prompts, sync, layout, and config. Use instead of MCP or HTTP API when operating from a shell or agent_mode session.
---

# Metagit CLI (agent shortcuts)

Use this skill when an agent should drive metagit **only through the `metagit` command**. Do not call MCP tools or `metagit api` from workflows covered here unless the user explicitly asks.

Set non-interactive defaults when automating:

```bash
export METAGIT_AGENT_MODE=true
```

Global flags (most commands):

- `-c path/to/metagit.config.yaml` — app config (default `metagit.config.yaml`)
- Workspace manifest: `--definition` / `-c` on catalog commands (default `.metagit.yml`)

---

## Manifest editing fast map (`.metagit.yml`)

Use this table first when changing a workspace manifest from the CLI. Prefer **catalog commands** for projects/repos; use **`config patch`** for everything else in the schema (documentation, graph, dedupe overrides, nested fields).

| Task | Command |
|------|---------|
| **Inspect** manifest on disk | `metagit config show -c .metagit.yml` |
| **Inspect** normalized model | `metagit config show -c .metagit.yml --normalized` |
| **Inspect** as JSON (agents) | `metagit config show -c .metagit.yml --json` |
| **Browse** fields / paths | `metagit config tree -c .metagit.yml` or `… --json` |
| **Validate** after edits | `metagit config validate -c .metagit.yml` |
| **Dry-run** schema change | `metagit config preview -c .metagit.yml --file ops.json` |
| **Apply** schema change | `metagit config patch -c .metagit.yml --file ops.json --save` |
| **Set one field** | `metagit config patch -c .metagit.yml --op set --path <path> --value <v> --save` |
| **Enable** optional block | `metagit config patch … --op enable --path <path> --save` |
| **Add list item** | `metagit config patch … --op append --path <list.path> --save` then `set` on `[index].field` |
| **Remove list item** | `metagit config patch … --op remove --path <list.path>[index] --save` |

### Catalog shortcuts (projects & repos)

| Task | Command |
|------|---------|
| List projects | `metagit workspace project list -c .metagit.yml --json` |
| List repos (all / one project) | `metagit workspace repo list -c .metagit.yml --json` / `… --project <p> --json` |
| Add project | `metagit workspace project add -c .metagit.yml --name <p> --json` |
| Remove project | `metagit workspace project remove -c .metagit.yml --name <p> --json` |
| Rename project (dry-run) | `metagit workspace project rename -c .metagit.yml --name <old> --new-name <new> --dry-run --json` |
| Add repo | `metagit workspace repo add -c .metagit.yml --project <p> --name <r> --url <url> --json` |
| Remove repo (manifest only) | `metagit workspace repo remove -c .metagit.yml --project <p> --name <r> --json` |
| Rename / move repo (dry-run) | `metagit workspace repo rename …` / `metagit workspace repo move … --dry-run --json` |
| Search before adding | `metagit search "<name>" -c .metagit.yml --json` |

Active project context (optional): `metagit project repo add --name <r> --url <url>` after `metagit project select`.

### Schema patch examples (`ops.json`)

Paths use dot/bracket notation (same as web Config Studio). `--value` accepts JSON for objects.

```json
{
  "operations": [
    { "op": "set", "path": "name", "value": "my-workspace" },
    { "op": "enable", "path": "documentation" },
    { "op": "append", "path": "documentation" },
    { "op": "set", "path": "documentation[0]", "value": { "kind": "markdown", "path": "AGENTS.md" } },
    { "op": "enable", "path": "graph" },
    { "op": "append", "path": "graph.relationships" },
    {
      "op": "set",
      "path": "graph.relationships[0]",
      "value": {
        "id": "api-depends-infra",
        "from": { "project": "platform", "repo": "api" },
        "to": { "project": "infra", "repo": "terraform" },
        "type": "depends_on"
      }
    },
    { "op": "set", "path": "workspace.projects[0].dedupe.enabled", "value": false }
  ]
}
```

```bash
metagit config preview -c .metagit.yml --file ops.json
metagit config patch -c .metagit.yml --file ops.json --save
metagit config validate -c .metagit.yml
```

### App config vs manifest

| File | Scope | Edit via |
|------|-------|----------|
| `.metagit.yml` | Workspace manifest (projects, repos, docs, graph) | `metagit config …` + `metagit workspace …` |
| `metagit.config.yaml` | Tooling (paths, dedupe default, providers, profiles) | `metagit appconfig …` |

```bash
metagit appconfig show --format minimal-yaml
metagit appconfig patch --op set --path workspace.dedupe.enabled --value false --save
metagit config providers --show
```

### After every manifest edit

1. `metagit config validate -c .metagit.yml`
2. `metagit workspace list -c .metagit.yml --json` (sanity-check catalog)
3. If repos changed on disk: `metagit project sync` or `metagit project sync --hydrate`

### Export manual graph to GitNexus (Cypher)

| Task | Command |
|------|---------|
| Full export bundle (JSON) | `metagit config graph export -c .metagit.yml --json` |
| Raw Cypher script | `metagit config graph export -c .metagit.yml --format cypher -o graph.cypher` |
| MCP tool_calls only | `metagit config graph export -c .metagit.yml --format tool-calls` |
| Manual edges only | `metagit config graph export -c .metagit.yml --manual-only --format tool-calls` |
| MCP from agent | `metagit_export_workspace_graph_cypher` |

Ingest workflow: run `schema_statements` once via `gitnexus_cypher`, then each statement in `tool_calls` (or pipe `--format cypher`). Overlay tables: `MetagitEntity`, `MetagitLink`.

---

## Prompt commands (all kinds)

List built-in prompt kinds:

```bash
metagit prompt list
metagit prompt list --json
```

Emit prompts (`--text-only` for paste into agent context; `--json` for structured output; `--no-instructions` to omit manifest layers):

| Scope | Command | Default kind |
|-------|---------|--------------|
| Workspace | `metagit prompt workspace -c <definition> -k <kind>` | `instructions` |
| Project | `metagit prompt project -p <project> -c <definition> -k <kind>` | `instructions` |
| Repo | `metagit prompt repo -p <project> -n <repo> -c <definition> -k <kind>` | `instructions` |

### Prompt kinds by scope

| Kind | Workspace | Project | Repo | Purpose |
|------|:---------:|:-------:|:----:|---------|
| `instructions` | yes | yes | yes | Composed `agent_instructions` from manifest layers |
| `session-start` | yes | — | — | Session bootstrap checklist |
| `catalog-edit` | yes | yes | — | Search-before-create; catalog registration |
| `health-preflight` | yes | yes | — | Pre-work workspace/repo status pass |
| `sync-safe` | yes | yes | yes | Guarded sync rules |
| `subagent-handoff` | — | yes | yes | Delegate single-repo work |
| `layout-change` | yes | yes | yes | Rename/move dry-run workflow |
| `repo-enrich` | — | — | yes | **Discover + merge** workspace repo entry |

### Prompt shortcuts (copy-paste)

```bash
# Session bootstrap
metagit prompt workspace -k session-start --text-only -c .metagit.yml

# Composed instructions at each level
metagit prompt workspace -k instructions --text-only -c .metagit.yml
metagit prompt project -p default -k instructions --text-only -c .metagit.yml
metagit prompt repo -p default -n my-api -k instructions --text-only -c .metagit.yml

# Repo catalog enrichment (detect + merge manifest entry)
metagit prompt repo -p default -n my-api -k repo-enrich --text-only -c .metagit.yml

# Catalog registration discipline
metagit prompt workspace -k catalog-edit --text-only -c .metagit.yml

# Safe sync reminder
metagit prompt repo -p default -n my-api -k sync-safe --text-only -c .metagit.yml

# Subagent handoff
metagit prompt repo -p default -n my-api -k subagent-handoff --text-only -c .metagit.yml
```

---

## Repo enrich workflow (`repo-enrich`)

Run the prompt, then execute its steps:

```bash
metagit prompt repo -p <project> -n <repo> -k repo-enrich --text-only -c .metagit.yml
```

Typical discovery chain on the checkout:

```bash
cd "$(metagit search '<repo>' -c .metagit.yml --path-only)"
metagit detect repository -p . -o json
metagit detect repo -p . -o yaml
metagit detect repo_map -p . -o json
```

Provider metadata (dry-run):

```bash
metagit project source sync --provider github --org <org> --mode discover --no-apply
```

After merging fields into `workspace.projects[].repos[]`:

```bash
metagit config validate -c .metagit.yml
metagit workspace repo list --project <project> --json
```

---

## Workspace and catalog

Per-project dedupe override in `.metagit.yml` (overrides `workspace.dedupe.enabled` in `metagit.config.yaml` for that project only):

```yaml
workspace:
  projects:
    - name: local
      dedupe:
        enabled: false
      repos: []
```

```bash
metagit appconfig show --format json
metagit config info -c .metagit.yml
metagit config show -c .metagit.yml
metagit config validate -c .metagit.yml

metagit workspace list -c .metagit.yml --json
metagit workspace project list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --json
metagit workspace repo list -c .metagit.yml --project <name> --json

metagit workspace project add --name <name> --json
metagit workspace repo add --project <name> --name <repo> --url <url> --json
metagit workspace project remove --name <name> --json
metagit workspace repo remove --project <name> --name <repo> --json
```

Search managed repos (always before creating entries):

```bash
metagit search "<query>" -c .metagit.yml --json
metagit search "<query>" -c .metagit.yml --path-only
metagit search "<query>" -c .metagit.yml --tag tier=1 --project <name>
```

---

## Project operations

```bash
metagit project list --config .metagit.yml --all --json
metagit project add --name <name> --json
metagit project remove --name <name> --json
metagit project rename --name <old> --new-name <new> --dry-run --json
metagit project select
metagit project sync
metagit project sync --hydrate   # symlink mounts → full directory copies (per-file progress)

metagit project repo list --json
metagit project repo add --project <name> --name <repo> --url <url>
metagit project repo remove --name <repo> --json
metagit project repo rename --name <old> --new-name <new> --dry-run --json
metagit project repo move --name <repo> --to-project <other> --dry-run --json
metagit project repo prune --project <name> --dry-run

metagit project source sync --provider github --org <org> --mode discover --no-apply
metagit project source sync --provider github --org <org> --mode additive --apply
```

Layout (manifest + disk; always dry-run first):

```bash
metagit workspace project rename --name <old> --new-name <new> --dry-run --json
metagit workspace repo rename --project <p> --name <old> --new-name <new> --dry-run --json
metagit workspace repo move --project <p> --name <repo> --to-project <other> --dry-run --json
```

---

## Discovery and local metadata

```bash
metagit detect project -p <path> -o yaml
metagit detect repo -p <path> -o yaml
metagit detect repo_map -p <path> -o json
metagit detect repository -p <path> -o json
metagit detect repository -p <path> -o metagit
# --save only with operator approval (blocked in agent_mode)
```

Bootstrap new trees:

```bash
metagit init --kind application
metagit init --kind umbrella --template hermes-orchestrator
```

---

## Selection and scope

```bash
metagit workspace select --project <name>
metagit project select
metagit project repo select
```

---

## Config and appconfig (reference)

See **Manifest editing fast map** above for day-to-day manifest work. Additional commands:

```bash
metagit config info -c .metagit.yml
metagit config example
metagit config schema
metagit appconfig validate
metagit appconfig get --name config.workspace.path
metagit appconfig tree --json
metagit appconfig preview --file ops.json
metagit appconfig patch --file ops.json --save
metagit config providers --show
```

---

## Records, skills, version

```bash
metagit record search "<query>"
metagit skills list
metagit skills show metagit-cli
metagit skills install --skill metagit-cli
metagit version
metagit info
```

---

## Agent habits

1. **Search before create** — `metagit search` then catalog add.
2. **Validate after manifest edits** — `metagit config validate`.
3. **Emit prompts instead of rewriting playbooks** — `metagit prompt … --text-only`.
4. **Enrich stale repo entries** — `metagit prompt repo … -k repo-enrich` then detect + merge.
5. **Dry-run layout** — always `--dry-run --json` before apply.
6. **Prefer `METAGIT_AGENT_MODE=true`** in CI and agent loops to skip fuzzy finder and confirm dialogs.

## Related bundled skills

Use topic skills when you need deeper playbooks (some mention MCP): `metagit-projects`, `metagit-workspace-scope`, `metagit-workspace-sync`, `metagit-config-refresh`. This skill is the **CLI-only** index and prompt reference.
`````

## File: src/metagit/data/skills/README.md
`````markdown
# Bundled Metagit skills

Installed via `metagit skills install`. All names use the `metagit-` prefix.

- `metagit-cli` — CLI-only agent shortcuts (prompts, catalog, detect, sync; no MCP/API)
- `metagit-projects` — workspace project lifecycle
- `metagit-workspace-scope` — scope discovery
- `metagit-control-center` — multi-repo control center
- `metagit-workspace-sync` — guarded sync
- `metagit-config-refresh` / `metagit-bootstrap` — config refresh and bootstrap
- `metagit-gating` — MCP gate checks
- `metagit-upstream-scan` / `metagit-upstream-triage` — upstream discovery
- `metagit-repo-impact` / `metagit-multi-repo` — impact and implementation
- `metagit-gitnexus` — GitNexus analysis
- `metagit-release-audit` — release readiness

See [docs/skills.md](../../../../docs/skills.md) for install instructions.
`````

## File: tests/core/web/test_schema_tree.py
`````python
#!/usr/bin/env python
"""Unit tests for SchemaTreeService."""

from metagit.core.appconfig.models import AppConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.web.models import ConfigOpKind, ConfigOperation
from metagit.core.web.schema_tree import SchemaTreeService


def test_build_metagit_tree_marks_present_fields() -> None:
    service = SchemaTreeService()
    config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
    root = service.build_tree(config, MetagitConfig)

    name_node = service.find_node(root, "name")
    kind_node = service.find_node(root, "kind")

    assert name_node is not None
    assert name_node.enabled is True
    assert kind_node is not None
    assert kind_node.enabled is True
    assert kind_node.type == "enum"
    assert "application" in kind_node.enum_options


def test_enum_field_exposes_all_options() -> None:
    service = SchemaTreeService()
    config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
    root = service.build_tree(config, MetagitConfig)
    kind_node = service.find_node(root, "kind")

    assert kind_node is not None
    assert len(kind_node.enum_options) >= 3


def test_disable_optional_field_removes_key() -> None:
    service = SchemaTreeService()
    config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
    updated, errors = service.apply_operations(
        config,
        MetagitConfig,
        [ConfigOperation(op=ConfigOpKind.DISABLE, path="description")],
    )

    assert errors == []
    assert "description" not in updated.model_dump(exclude_none=True)


def test_enable_optional_field_adds_default() -> None:
    service = SchemaTreeService()
    config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
    disabled, _ = service.apply_operations(
        config,
        MetagitConfig,
        [ConfigOperation(op=ConfigOpKind.DISABLE, path="description")],
    )
    enabled, errors = service.apply_operations(
        disabled,
        MetagitConfig,
        [ConfigOperation(op=ConfigOpKind.ENABLE, path="description")],
    )

    assert errors == []
    assert enabled.description == "No description"
    assert "description" in enabled.model_dump(exclude_none=True)


def test_set_field_updates_value() -> None:
    service = SchemaTreeService()
    config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
    updated, errors = service.apply_operations(
        config,
        MetagitConfig,
        [ConfigOperation(op=ConfigOpKind.SET, path="name", value="new-name")],
    )

    assert errors == []
    assert updated.name == "new-name"


def test_appconfig_sensitive_field_masked_in_tree() -> None:
    service = SchemaTreeService()
    raw = {
        "workspace": {"path": "./sync"},
        "providers": {
            "github": {"enabled": True, "api_token": "ghp_abcdefghijklmnop"},
        },
    }
    config = AppConfig(**raw)
    root = service.build_tree(config, AppConfig, mask_secrets=True)
    token_node = service.find_node(root, "providers.github.api_token")

    assert token_node is not None
    assert token_node.sensitive is True
    assert token_node.value == "***mnop"


def test_apply_operations_returns_original_instance_on_validation_error() -> None:
    service = SchemaTreeService()
    config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
    updated, errors = service.apply_operations(
        config,
        MetagitConfig,
        [ConfigOperation(op=ConfigOpKind.SET, path="kind", value="not-a-valid-kind")],
    )

    assert errors != []
    assert updated is config
    assert updated.name == "demo"
    assert updated.kind == "application"


def test_sensitive_token_unchanged_after_masked_set() -> None:
    service = SchemaTreeService()
    raw = {
        "workspace": {"path": "./sync"},
        "providers": {
            "github": {"enabled": True, "api_token": "ghp_abcdefghijklmnop"},
        },
    }
    config = AppConfig(**raw)
    updated, errors = service.apply_operations(
        config,
        AppConfig,
        [
            ConfigOperation(
                op=ConfigOpKind.SET,
                path="providers.github.api_token",
                value="***mnop",
            )
        ],
    )

    assert errors == []
    assert updated.providers.github.api_token == "ghp_abcdefghijklmnop"


def test_type_label_shows_model_name() -> None:
    service = SchemaTreeService()
    config = MetagitConfig.model_validate(
        {
            "name": "demo",
            "kind": "application",
            "workspace": {"projects": []},
        }
    )
    root = service.build_tree(config, MetagitConfig)
    workspace_node = service.find_node(root, "workspace")

    assert workspace_node is not None
    assert workspace_node.type == "object"
    assert workspace_node.type_label == "Workspace"


def test_enable_optional_list_defaults_empty() -> None:
    service = SchemaTreeService()
    config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
    updated, errors = service.apply_operations(
        config,
        MetagitConfig,
        [ConfigOperation(op=ConfigOpKind.ENABLE, path="artifacts")],
    )

    assert errors == []
    assert updated.artifacts == []


def test_append_and_remove_list_items() -> None:
    service = SchemaTreeService()
    config = MetagitConfig.model_validate({"name": "demo", "kind": "application"})
    enabled, _ = service.apply_operations(
        config,
        MetagitConfig,
        [ConfigOperation(op=ConfigOpKind.ENABLE, path="artifacts")],
    )
    appended, errors = service.apply_operations(
        enabled,
        MetagitConfig,
        [ConfigOperation(op=ConfigOpKind.APPEND, path="artifacts")],
    )
    assert errors == []
    assert len(appended.artifacts) == 1

    root = service.build_tree(appended, MetagitConfig)
    artifacts_node = service.find_node(root, "artifacts")
    assert artifacts_node is not None
    assert artifacts_node.type_label == "Artifact[]"
    assert artifacts_node.can_append is True
    assert artifacts_node.item_count == 1

    removed, errors = service.apply_operations(
        appended,
        MetagitConfig,
        [ConfigOperation(op=ConfigOpKind.REMOVE, path="artifacts[0]")],
    )
    assert errors == []
    assert removed.artifacts == []
`````

## File: mise.toml
`````toml
[env]
_.source = '.envrc'

[settings]
python.uv_venv_auto = true

[tools]
uv = "latest"
python = "3.12"
task = "latest"
npm = "latest"
gh = "latest"
gitleaks = "latest"
glow = "latest"
rg = "latest"
node = "latest"
"npm:repomix" = "latest"
`````

## File: .cursor/rules/project-level.mdc
`````
---
description: Metagit
globs: *
alwaysApply: true
---

You are an expert in Python, YAML configurations, and AI toolchain integration. Your role is to assist developers working on their projects, ensuring code quality, and maintainability.

## Code Style and Structure

- Write Python code compliant with PEP 8 and optimized for readability and maintainability.
- Use type hints for all function parameters and return types.
- Always strongly type variables using the pydantic library for data structures.
- Always use Protocol definitions for interface definitions.
- Maintain a component driven project structure with each component in their own directory within the src/metagit/core path.
- Avoid duplication by using clear, modular code organization.
- All file paths should be constructed using os.path.join() instead of string concatenation.
- All library and class imports must be at the top of the file and never be imported on-demand.
- Remove all unused imports from each python file after editing them.
- Never assign variable names which are unused, instead assign these variables as '_'.
- Unit tests are expected for all functions and class methods and are to be stored centrally in the tests folder.
- Combine if statements instead of nesting them where possible.
- Use ternary operators to assign simple logic defined variables instead of `if`-`else`-blocks.
- Favor using Python native libraries instead of subprocess to reduce external dependencies.
- Use 2 spaces for indentation.
- Private class members should be prefixed with an underscore (`_`).
- Use `isinstance()` for type comparisons.

## Naming Conventions

- Use snake_case for variables, functions, and filenames.
- Use PascalCase for class names.
- Prefix environment variables with provider name (e.g., `OLLAMA_`, `OPENAI_`).
- Use descriptive names for configuration files (e.g., `agents.yaml`, `tasks.yaml`).

## Environment and Configuration

- Use `python-dotenv` to manage environment variables.
- Maintain `.env.example` as a template for environment setup.
- Structure YAML files clearly and validate on load:
  - Use `yaml.safe_load` for security.
  - Include clear error messages for missing or invalid keys.

## Syntax and Formatting

- New Python files should always include `#!/usr/bin/env python` as the very first line.
- Format code with tools like Black and lint with Flake8.
- Follow Pythonic idioms and best practices.
- Use early returns in functions to simplify logic.
- Write clear docstrings for all classes and functions.

## Error Handling and Validation

- Validate environment variables at startup.
- Use try-except blocks with meaningful error messages.
- Never create bare exception statements.
- Be as explicit as possible when handling exceptions.
- Log errors appropriately using the UnifiedLogger module.
- Ensure secure loading of configuration files.
- All functions and methods that produce exceptions should return a union of the expected result type and Exception and be handled appropriately when called.

## Regarding Dependencies

- Avoid introducing new external dependencies unless absolutely necessary.
- If a new dependency is required, please state the reason.

## Security

- Never hardcode sensitive data; always use environment variables.
- Keep API keys and sensitive data in `.env` (gitignored).
- Sanitize all inputs passed to external services.

## Documentation

- Maintain clear and comprehensive README.md:
  - Installation and setup instructions.
  - Environment configuration examples.
  - YAML file examples and structure.
- Document code with clear inline comments.
- Keep CHANGELOG.md updated with all changes.

## Project Structure

- Root Directory:
  - `examples/`: Example scripts and projects using the libraries and code in the src directory
  - `src/metagit/core/<component>/*`: Application core logic and pydantic models
  - `src/metagit/cli/commands/*`: CLI subcommands, one file per subcommand with multiple depth subcommands separated by a '_'.
  - `docs/`: Documentation as markdown
  - `tests/`: Unit tests

## Command-Line Tools

### GitHub
- Use the `gh` command-line to interact with GitHub.

### Markdown
- Use the `glow` command-line to present markdown content.

### JSON
- Use the `jq` command to read and extract information from JSON files.

### RipGrep
- The `rg` (ripgrep) command is available for fast searches in text files.

### Clipboard
- Pipe content into `pbcopy` to copy it into the clipboard. Example: `echo "hello" | pbcopy`.
- Pipe from `pbpaste` to get the contents of the clipboard. Example: `pbpaste > fromclipboard.txt`.

### Python
- Unless instructed otherwise, always use the `uv` Python environment and package manager for Python.
  - `uv run ...` for running a python script.
  - `uvx ...` for running program directly from a PyPI package.
  - `uv ... ...` for managing environments, installing packages, etc...

### JavaScript / TypeScript
- Unless instructed otherwise, always use `deno` to run .js or .ts scripts.
- Use `npx` for running commands directly from npm packages.

## Task closeout

- After changes to this codebase, before implying the task is finished, run **`task qa:prepush`** from the repository root (`scripts/prepush-gate.zsh`). It runs format, lint, unit tests, integration tests, and context-aware security (`pip-audit` / `bandit` when `src/` or lockfiles changed). Fix failures and re-run until it passes unless the conversation was read-only (no edits) or the user explicitly waived the gate.

## Documentation Sources
- If working with a new library or tool, consider looking for its documentation from its website, GitHub project, or the relevant llms.txt.
  - It is always better to have accurate, up-to-date documentation at your disposal, rather than relying on your pre-trained knowledge.
- You can search the following directories for llms.txt collections for many projects:
  - https://llmstxt.site/
  - https://directory.llmstxt.cloud/
- If you find a relevant llms.txt file, follow the links until you have access to the complete documentation.
`````

## File: .mex/context/conventions.md
`````markdown
---
name: conventions
description: How code is written in this project — naming, structure, patterns, and style. Load when writing new code or reviewing existing code.
triggers:
  - "convention"
  - "pattern"
  - "naming"
  - "style"
  - "how should I"
  - "what's the right way"
edges:
  - target: context/architecture.md
    condition: when a convention depends on understanding the system structure
  - target: context/stack.md
    condition: when selecting libraries/tools for a change
  - target: patterns/INDEX.md
    condition: when implementing task-specific workflows and verify/debug sequences
last_updated: 2026-05-15
---

# Conventions

## Naming
- Python files/modules/functions/variables use **snake_case**; classes use **PascalCase**.
- CLI subcommand modules are one file per command under `src/metagit/cli/commands/` (e.g., `project.py`, `mcp.py`).
- Private class members and internals are prefixed with `_` (consistent with core services/runtime patterns).
- Config file names are explicit (`.metagit.yml`, `metagit.config.yaml`, schema files under `schemas/`).

## Structure
- CLI entrypoint stays in `src/metagit/cli/main.py`; reusable logic belongs in `src/metagit/core/*`, not command functions.
- Each core concern is grouped by component directory (`config`, `detect`, `record`, `mcp`, `utils`, etc.) with focused manager/service classes.
- Tests live in centralized `tests/` and mirror module responsibility by filename (`test_*`), not colocated beside source files.
- MCP runtime flow is split into thin runtime dispatch + dedicated service classes (`workspace_index`, `workspace_search`, `upstream_hints`, `repo_ops`).

## Patterns
Prefer explicit exception-return handling in manager/service methods (consistent with existing union return style):
```python
result = manager.load_config()
if isinstance(result, Exception):
    return result
return result
```

Use state-based gating before exposing tool actions in MCP/runtime logic:
```python
allowed = set(registry.list_tools(status=status))
if tool_name not in allowed:
    raise InvalidToolArgumentsError(...)
```

Use bounded operations for search/sync style tasks:
```python
hits = search_service.search(query=query, repo_paths=paths, max_results=25)
if mode in {"pull", "clone"} and not allow_mutation:
    return {"ok": False, "error": "..."}
```

## Verify Checklist
Before presenting any code:
- [ ] New/changed CLI behavior is covered by command-level tests under `tests/cli` or integration tests when flow crosses modules.
- [ ] Core logic changes are covered by targeted unit tests under `tests/` for the touched service/manager/runtime area.
- [ ] `.metagit.yml`/config model interactions still pass validation paths (no silent schema drift).
- [ ] Lint/format checks pass via project commands (`task lint`, and format if needed).
- [ ] Mutating operations remain explicitly guarded (especially MCP sync/tool paths).
- [ ] Run `task skills:sync generate:schema` for session closeout sync/schema regeneration.
- [ ] **`task qa:prepush`** from repo root has passed — required before declaring work complete whenever this session modified project files; do not summarize success without evidence. Omit only if the session was read-only or the user explicitly waived the gate.

## Commit Semantics
- Default to `fix:` commit prefixes (patch intent) for normal maintenance and safe behavior updates.
- Use `feat:` only for additive backward-compatible capabilities.
- Use `type(scope)!:` or `BREAKING CHANGE:` only when changes intentionally break schema/config compatibility.
`````

## File: docs/cli_reference.md
`````markdown
# CLI Reference

This page contains the auto-generated documentation for the `metagit` command-line interface.

::: mkdocs-click
    :module: metagit.cli.main
    :command: cli
    :prog_name: metagit 

## MCP Command Notes

- Start MCP stdio runtime:
  - `metagit mcp serve`
- Start against a specific workspace root:
  - `metagit mcp serve --root /path/to/workspace`
- Print status snapshot and exit:
  - `metagit mcp serve --status-once`
- When the workspace gate is **active**, the tool **`metagit_repo_search`** searches only repos listed under `workspace.projects[].repos` in `.metagit.yml` (tags, sync status, resolved paths). Optional filters: `status[]`, `has_url`, `sync_enabled`, `sort` (`score`|`project`|`name`). Use query `*` for filter-only listing.
- **`metagit_workspace_search`** uses ripgrep when available (`repos`, `paths`, `exclude`, `context_lines`, `intent`, `include_paths`). Falls back to a bounded scanner if `rg` is not installed.
- **`metagit_workspace_sync`** batch-syncs repos (`repos: ["all"]` or selectors), with `only_if` (`any`|`missing`|`dirty`|`behind_origin`), `max_parallel`, and `dry_run`.
- **`metagit_cross_project_dependencies`** maps relationships from a `source_project` using `dependency_types` (`declared`, `imports`, `shared_config`, `url_match`, `ref`), `depth`, and per-repo GitNexus `graph_status` (`indexed`|`stale`|`missing`).
- **`metagit_workspace_health_check`** returns per-repo git/GitNexus signals, optional staleness metrics (`head_commit_age_days`, `merge_base_age_days` when `check_stale_branches`), tunable thresholds (`branch_head_warning_days`, `branch_head_critical_days`, `integration_stale_days`), and prioritized recommendations (`sync`, `analyze`, `clone`, `fix_config`, `review_branch_age`, `reconcile_integration`, …).
- **`metagit_workspace_semantic_search`** runs `npx gitnexus query -r <registry>` per selected repo (`query` required; optional `repos`, `task_context`, `goal`, `limit_per_repo`, `timeout_seconds`) for GitNexus-ranked processes.
- **`metagit_workspace_discover`** lists files by `intent` or `pattern` with optional `categorize` grouping (requires `intent` or `pattern`).
- **`metagit_project_template_apply`** previews or applies bundled templates from `src/metagit/data/templates/` (`dry_run` default; `confirm_apply` required for writes).
- **Resources (active gate):** `metagit://workspace/health`, `metagit://workspace/context` (active project session from `.metagit/sessions/`).
- **Project context (active gate):**
  - `metagit_project_context_switch` — set active workspace project; return repo branch/dirty summary, safe env exports (`METAGIT_*`), and restored session fields from `.metagit/sessions/<project>.json`.
  - `metagit_session_update` — persist `agent_notes`, `recent_repos`, and non-secret `env_overrides` before switching away.
  - `metagit_workspace_state_snapshot` — write a git-state manifest to `.metagit/snapshots/<id>.json` (not a file copy).
  - `metagit_workspace_state_restore` — reload snapshot metadata and optionally re-run context switch; does **not** reset git branches or uncommitted changes.

## Workspace configuration

Under `workspace.projects[].repos`, each repository entry may include a flat string-to-string `tags` map (for example `tier: "1"`). These tags are carried into the workspace index and into `metagit search` / `metagit find` for filtering.

## Managed repository search

- `metagit search QUERY` — list managed repositories from the workspace definition that match the query (name, URL substring, tag keys/values, project name). Only repos declared under `workspace.projects[].repos` are considered. Supports `--status` (repeatable) and `--sort score|project|name`.
- `metagit find QUERY` — alias for `metagit search`.
- `--definition PATH` — `.metagit.yml` to load (default: `.metagit.yml` in the current directory). The workspace root for resolving `path:` entries is the parent directory of that file.
- `--json` — print search results as JSON (matches include `match_reasons` and scores).
- `--path-only` — resolve to exactly one local directory (fails if there is no match or more than one match).
- `--tag key=value` — repeat to require matching tag values (all given pairs must match).
- `--project`, `--exact`, `--synced-only`, and `--limit` narrow or rank results further.

## Local JSON API (`metagit api`)

- `metagit api serve` — bind a `ThreadingHTTPServer` on `--host` / `--port` (default `127.0.0.1:7878`) under `--root` (directory containing `.metagit.yml`).
- `metagit api serve --status-once` — allocate a port (use `--port 0` for ephemeral), print `api_state=ready host=… port=…`, and exit (for tests and automation).
- `GET /v1/repos/search?q=…` — same managed-repo search as the CLI; optional query params: `project`, `exact=true|false`, `synced_only=true|false`, `limit`, repeat `tag=key=value`.
- `GET /v1/repos/resolve?q=…` — single-match resolution; HTTP `404` when not found, `409` when ambiguous (body includes `ManagedRepoResolveResult` JSON).
`````

## File: skills/README.md
`````markdown
# Metagit skills

Metagit ships agent **skills** (`skills/metagit-*/SKILL.md`): short playbooks for when and how to use the CLI and MCP. All bundled skills use a `metagit-` prefix.

## Install the CLI

```bash
uv tool install metagit-cli
uv tool install -U metagit-cli   # upgrade
metagit version
```

> PyPI package name: **`metagit-cli`** (not `metagit`).

## Install skills

```bash
metagit skills list
metagit skills show metagit-projects
metagit skills install
metagit skills install --scope user --target openclaw --target hermes
metagit skills install --skill metagit-projects --target openclaw
metagit skills install --skill metagit-projects --target openclaw --dry-run
```

Use `--skill` (repeatable) to install one or more bundled skills instead of the full set. Omit `--skill` to install every bundled skill. Add `--dry-run` to preview targets and paths without writing files.

Optional MCP registration:

```bash
metagit mcp install --scope user --target openclaw --target hermes
```

Other targets: `opencode`, `claude_code`, `github_copilot`.

## Skill catalog

| Skill | Use when |
|-------|----------|
| `metagit-cli` | CLI-only agent workflows: all `metagit prompt` kinds, catalog, detect, sync, layout (no MCP/API) |
| `metagit-projects` | Starting work; check for existing projects/repos before new folders |
| `metagit-workspace-scope` | Session start; workspace and sync context |
| `metagit-control-center` | Ongoing multi-repo coordination |
| `metagit-workspace-sync` | Guarded fetch/pull/clone |
| `metagit-config-refresh` | `.metagit.yml` missing or stale |
| `metagit-bootstrap` | Generate or refine config with discovery/MCP |
| `metagit-gating` | MCP workspace gate status |
| `metagit-upstream-scan` | Search other managed repos for causes |
| `metagit-upstream-triage` | Rank upstream blockers |
| `metagit-repo-impact` | Plan cross-repo changes |
| `metagit-multi-repo` | Implement across several repos |
| `metagit-gitnexus` | GitNexus index and graph workflows |
| `metagit-release-audit` | Pre-push / release readiness |

For workspace vs project vs repo definitions, see [Terminology](terminology.md).

## Source development

```bash
task skills:validate
task skills:sync    # mirrors into .cursor/skills/
```

Update both `skills/` and `src/metagit/data/skills/` when changing bundled skills.
`````

## File: src/metagit/cli/commands/project.py
`````python
"""
Project subcommand
"""

import sys
from pathlib import Path

import click
import yaml

from metagit.cli.commands.project_repo import repo, repo_select
from metagit.cli.json_output import (
    emit_json,
    exit_on_catalog_mutation,
    exit_on_layout_mutation,
)
from metagit.cli.commands.project_source import source
from metagit.core.appconfig import AppConfig
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.project.manager import ProjectManager, project_manager_from_app
from metagit.core.utils.click import call_click_command_with_ctx
from metagit.core.utils.logging import UnifiedLogger
from metagit.core.workspace.catalog_service import WorkspaceCatalogService
from metagit.core.workspace.dedupe_resolver import resolve_dedupe_for_layout
from metagit.core.workspace.layout_service import WorkspaceLayoutService
from metagit.core.workspace.models import WorkspaceProject


@click.group(name="project", invoke_without_command=True)
@click.option(
    "--config", "-c", default=".metagit.yml", help="Path to the metagit definition file"
)
@click.option(
    "--project",
    "-p",
    default=None,
    help="Project within workspace to operate on",
)
@click.pass_context
def project(ctx: click.Context, config: str, project: str = None) -> None:
    """Project subcommands"""
    logger = ctx.obj["logger"]
    # If no subcommand is provided, show help
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())
        return
    app_config: AppConfig = ctx.obj["config"]
    if not project:
        project: str = app_config.workspace.default_project
    ctx.obj["project"] = project
    ctx.obj["config_path"] = config
    try:
        config_manager: MetagitConfigManager = MetagitConfigManager(config)
        local_config: MetagitConfig = config_manager.load_config()
        ctx.obj["local_config"] = local_config
        if isinstance(local_config, Exception):
            raise local_config
    except Exception as e:
        logger.error(f"Failed to load metagit definition file: {e}")
        sys.exit(1)


# Add repo group to project group
project.add_command(repo)
project.add_command(source)


@project.command("list")
@click.option(
    "--all",
    "list_all",
    is_flag=True,
    default=False,
    help="List all workspace projects (catalog view) instead of one project YAML",
)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def project_list(ctx: click.Context, list_all: bool, as_json: bool) -> None:
    """List project configuration (YAML, JSON, or all projects)."""
    logger: UnifiedLogger = ctx.obj["logger"]
    project: str = ctx.obj["project"]
    local_config: MetagitConfig = ctx.obj["local_config"]

    if list_all:
        service = WorkspaceCatalogService()
        if as_json:
            emit_json(service.list_projects(local_config))
            return
        result = service.list_projects(local_config)
        for entry in (result.data or {}).get("projects", []):
            logger.echo(f"{entry.get('name')} ({entry.get('repo_count', 0)} repos)")
        return

    try:
        # Handle special "local" project case
        if project == "local":
            # Check if there's an existing project named "local" in workspace
            if local_config.workspace:
                workspace_project: WorkspaceProject = next(
                    (p for p in local_config.workspace.projects if p.name == project),
                    None,
                )
                if workspace_project:
                    # Use existing "local" project
                    project_dict = workspace_project.model_dump(exclude_none=True)
                else:
                    # Use computed local_workspace_project
                    workspace_project = local_config.local_workspace_project
                    project_dict = workspace_project.model_dump(exclude_none=True)
            else:
                # No workspace config, use computed local_workspace_project
                workspace_project = local_config.local_workspace_project
                project_dict = workspace_project.model_dump(exclude_none=True)
        else:
            # Handle regular project names
            if not local_config.workspace:
                logger.error("No workspace configuration found")
                ctx.abort()

            workspace_project: WorkspaceProject = next(
                (p for p in local_config.workspace.projects if p.name == project), None
            )

            if not workspace_project:
                logger.error(
                    f"Project '{project}' not found in workspace configuration"
                )
                ctx.abort()

            project_dict = workspace_project.model_dump(exclude_none=True)

        if as_json:
            emit_json(project_dict)
            return
        yaml_output = yaml.dump(project_dict, default_flow_style=False, sort_keys=False)
        logger.echo(yaml_output)

    except Exception as e:
        logger.error(f"Failed to list project: {e}")
        ctx.abort()


@project.command("add")
@click.argument("name")
@click.option("--description", default=None)
@click.option("--agent-instructions", default=None)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def project_add(
    ctx: click.Context,
    name: str,
    description: str | None,
    agent_instructions: str | None,
    as_json: bool,
) -> None:
    """Add a workspace project to the manifest."""
    local_config: MetagitConfig = ctx.obj["local_config"]
    config_path: str = ctx.obj["config_path"]
    result = WorkspaceCatalogService().add_project(
        local_config,
        config_path,
        name=name,
        description=description,
        agent_instructions=agent_instructions,
    )
    exit_on_catalog_mutation(result, as_json=as_json)


@project.command("remove")
@click.argument("name")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def project_remove(ctx: click.Context, name: str, as_json: bool) -> None:
    """Remove a workspace project from the manifest."""
    local_config: MetagitConfig = ctx.obj["local_config"]
    config_path: str = ctx.obj["config_path"]
    result = WorkspaceCatalogService().remove_project(
        local_config,
        config_path,
        name=name,
    )
    exit_on_catalog_mutation(result, as_json=as_json)


@project.command("rename")
@click.argument("from_name")
@click.argument("to_name")
@click.option("--dry-run", is_flag=True, default=False)
@click.option("--manifest-only", is_flag=True, default=False)
@click.option("--force", is_flag=True, default=False)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def project_rename(
    ctx: click.Context,
    from_name: str,
    to_name: str,
    dry_run: bool,
    manifest_only: bool,
    force: bool,
    as_json: bool,
) -> None:
    """Rename a workspace project (alias for workspace project rename)."""
    local_config: MetagitConfig = ctx.obj["local_config"]
    config_path: str = ctx.obj["config_path"]
    app_config: AppConfig = ctx.obj["config"]
    workspace_root = str(Path(app_config.workspace.path).expanduser().resolve())
    dedupe = resolve_dedupe_for_layout(
        app_config.workspace.dedupe,
        local_config,
        from_name,
    )
    result = WorkspaceLayoutService().rename_project(
        local_config,
        config_path,
        workspace_root,
        from_name=from_name,
        to_name=to_name,
        dedupe=dedupe,
        dry_run=dry_run,
        move_disk=not manifest_only,
        force=force,
    )
    exit_on_layout_mutation(result, as_json=as_json)


@project.command("select")
@click.pass_context
def project_select(ctx: click.Context) -> None:
    """Shortcut: Uses 'project repo select' to select workspace project repo to work on"""
    # Call the repo_select function
    call_click_command_with_ctx(repo_select, ctx)


@project.command("sync")
@click.option(
    "--hydrate",
    is_flag=True,
    default=False,
    help="After sync, replace symlink mounts with full directory copies",
)
@click.pass_context
def project_sync(ctx: click.Context, hydrate: bool) -> None:
    """Sync project within workspace"""
    logger = ctx.obj["logger"]
    app_config: AppConfig = ctx.obj["config"]
    project: str = ctx.obj["project"]
    local_config: MetagitConfig = ctx.obj["local_config"]
    project_manager: ProjectManager = project_manager_from_app(
        app_config,
        logger,
        metagit_config=local_config,
        project_name=project,
    )

    try:
        # Handle special "local" project case
        if project == "local":
            # Check if there's an existing project named "local" in workspace
            if local_config.workspace:
                workspace_project: WorkspaceProject = next(
                    (p for p in local_config.workspace.projects if p.name == project),
                    None,
                )
                if workspace_project:
                    # Use existing "local" project
                    pass
                else:
                    # Use computed local_workspace_project
                    workspace_project = local_config.local_workspace_project
            else:
                # No workspace config, use computed local_workspace_project
                workspace_project = local_config.local_workspace_project
        else:
            # Handle regular project names
            if not local_config.workspace:
                logger.error("No workspace configuration found")
                ctx.abort()

            workspace_project: WorkspaceProject = next(
                (p for p in local_config.workspace.projects if p.name == project), None
            )

            if not workspace_project:
                logger.error(
                    f"Project '{project}' not found in workspace configuration"
                )
                ctx.abort()

        sync_result: bool = project_manager.sync(workspace_project, hydrate=hydrate)
        if sync_result:
            logger.success(f"Project {project} synced successfully")
            exit(0)
        else:
            logger.error(f"Failed to sync project {project}")
            ctx.abort()

    except Exception as e:
        logger.error(f"Failed to sync project: {e}")
        ctx.abort()
`````

## File: src/metagit/core/mcp/services/workspace_index.py
`````python
#!/usr/bin/env python
"""
Workspace repository indexing service.
"""

from pathlib import Path
from typing import Any

from metagit.core.config.models import MetagitConfig
from metagit.core.utils.common import is_git_repository
from metagit.core.workspace import workspace_dedupe


class WorkspaceIndexService:
    """Build normalized repository status rows from workspace configuration."""

    def build_index(
        self, config: MetagitConfig, workspace_root: str
    ) -> list[dict[str, Any]]:
        """Return repository index rows for all configured workspace repos."""
        rows: list[dict[str, Any]] = []
        if not config.workspace:
            return rows

        for project in config.workspace.projects:
            for repo in project.repos:
                resolved_path = self._resolve_repo_path(
                    workspace_root=workspace_root,
                    project_name=project.name,
                    configured_path=repo.path,
                    repo_name=repo.name,
                )
                mount = Path(resolved_path)
                exists = mount.is_dir() or (
                    mount.is_symlink() and mount.resolve().is_dir()
                )
                is_git_repo = (
                    bool(is_git_repository(resolved_path)) if exists else False
                )
                status = "synced" if exists and is_git_repo else "configured_missing"
                rows.append(
                    {
                        "project_name": project.name,
                        "repo_name": repo.name,
                        "configured_path": repo.path,
                        "repo_path": resolved_path,
                        "exists": exists,
                        "is_git_repo": is_git_repo,
                        "status": status,
                        "url": str(repo.url) if repo.url else None,
                        "sync": repo.sync if repo.sync is not None else False,
                        "tags": dict(repo.tags),
                    }
                )
        return rows

    def _resolve_repo_path(
        self,
        workspace_root: str,
        project_name: str,
        configured_path: str | None,
        repo_name: str,
    ) -> str:
        """Resolve repository mount path (matches project sync layout)."""
        if configured_path:
            path = Path(configured_path).expanduser()
            if path.is_absolute():
                return str(path.resolve())
            return str((Path(workspace_root) / path).resolve())
        return str(
            workspace_dedupe.project_mount_path(
                Path(workspace_root),
                project_name,
                repo_name,
            )
        )
`````

## File: src/metagit/core/web/schema_tree.py
`````python
#!/usr/bin/env python
"""Build and mutate Pydantic config schema trees for the web UI."""

from __future__ import annotations

import re
from enum import Enum
from typing import Any, Union, get_args, get_origin

from pydantic import BaseModel, ValidationError
from pydantic.fields import FieldInfo

from metagit.core.config.example_generator import ConfigExampleGenerator
from metagit.core.web.models import ConfigOpKind, ConfigOperation, SchemaFieldNode

_PATH_SEGMENT_RE = re.compile(r"([^.\[\]]+)|\[(\d+|\*)\]")


class SchemaTreeService:
    """Walk Pydantic models into editable schema trees and apply config operations."""

    SENSITIVE_KEYS = frozenset({"api_token", "token", "password", "secret"})

    def __init__(self) -> None:
        self._example_generator = ConfigExampleGenerator()

    def build_tree(
        self,
        model_instance: BaseModel,
        model_class: type[BaseModel],
        *,
        mask_secrets: bool = False,
    ) -> SchemaFieldNode:
        """Build schema tree rooted at synthetic node key='root', path=''."""
        dump = model_instance.model_dump(exclude_none=True, mode="python")
        return SchemaFieldNode(
            path="",
            key="root",
            type="object",
            type_label=model_class.__name__,
            enabled=True,
            editable=False,
            children=self._build_children(
                model_instance,
                model_class,
                dump,
                parent_path="",
                mask_secrets=mask_secrets,
            ),
        )

    def find_node(self, root: SchemaFieldNode, path: str) -> SchemaFieldNode | None:
        """Find node by dot/bracket path like name, workspace.projects[0].name."""
        if not path:
            return root
        segments = self._parse_path(path)
        return self._find_by_segments(root, segments)

    def apply_operations(
        self,
        instance: BaseModel,
        model_class: type[BaseModel],
        ops: list[ConfigOperation],
    ) -> tuple[BaseModel, list[dict[str, str]]]:
        """Apply enable/disable/set ops; return (updated_instance, validation_errors)."""
        data = instance.model_dump(mode="python")
        for operation in ops:
            if operation.op == ConfigOpKind.DISABLE:
                self._disable_at_path(data, model_class, operation.path)
            elif operation.op == ConfigOpKind.ENABLE:
                self._enable_at_path(data, model_class, operation.path)
            elif operation.op == ConfigOpKind.SET:
                self._set_at_path(data, operation.path, operation.value)
            elif operation.op == ConfigOpKind.APPEND:
                self._append_at_path(data, model_class, operation.path)
            elif operation.op == ConfigOpKind.REMOVE:
                self._remove_at_path(data, model_class, operation.path)
        return self._validate(model_class, data, original=instance)

    def _build_children(
        self,
        model_instance: BaseModel,
        model_class: type[BaseModel],
        dump: dict[str, Any] | list[Any] | Any,
        *,
        parent_path: str,
        mask_secrets: bool,
    ) -> list[SchemaFieldNode]:
        children: list[SchemaFieldNode] = []
        for field_name, field_info in model_class.model_fields.items():
            child_path = self._join_path(parent_path, field_name)
            value = getattr(model_instance, field_name, None)
            field_dump = dump.get(field_name) if isinstance(dump, dict) else None
            enabled = self._field_enabled(field_info, value, field_dump)
            annotation = self._unwrap_optional(field_info.annotation)
            node_type = self._type_name(annotation)
            type_label = self._type_label(annotation)
            sensitive = self._is_sensitive(field_name)
            display_value = self._display_value(
                value,
                sensitive=sensitive,
                mask_secrets=mask_secrets,
            )
            default_value = self._default_for_field(field_info, annotation)
            enum_options = self._enum_options(annotation) if node_type == "enum" else []
            list_meta = self._list_node_meta(annotation, field_dump, enabled)
            node = SchemaFieldNode(
                path=child_path,
                key=field_name,
                type=node_type,
                type_label=type_label,
                description=field_info.description,
                required=field_info.is_required(),
                enabled=enabled,
                editable=enabled,
                sensitive=sensitive,
                default_value=default_value,
                value=display_value if node_type not in {"object", "array"} else None,
                enum_options=enum_options,
                item_count=list_meta.get("item_count"),
                can_append=list_meta.get("can_append", False),
                children=[],
            )
            node.children = self._build_field_children(
                value,
                annotation,
                field_dump,
                parent_path=child_path,
                mask_secrets=mask_secrets,
                enabled=enabled,
            )
            children.append(node)
        return children

    def _build_field_children(
        self,
        value: Any,
        annotation: Any,
        field_dump: Any,
        *,
        parent_path: str,
        mask_secrets: bool,
        enabled: bool,
    ) -> list[SchemaFieldNode]:
        if not enabled:
            return []
        origin = get_origin(annotation)
        if isinstance(annotation, type) and issubclass(annotation, BaseModel):
            if not isinstance(value, BaseModel):
                return []
            nested_dump = (
                field_dump
                if isinstance(field_dump, dict)
                else value.model_dump(
                    exclude_none=True,
                    mode="python",
                )
            )
            return self._build_children(
                value,
                annotation,
                nested_dump,
                parent_path=parent_path,
                mask_secrets=mask_secrets,
            )
        if origin is list:
            args = get_args(annotation)
            item_annotation = args[0] if args else Any
            item_unwrapped = self._unwrap_optional(item_annotation)
            is_model = isinstance(item_unwrapped, type) and issubclass(
                item_unwrapped,
                BaseModel,
            )
            items = value if isinstance(value, list) else []
            if not is_model:
                if not items:
                    return []
                scalar_label = self._type_label(item_unwrapped)
                return [
                    SchemaFieldNode(
                        path=self._join_path(parent_path, f"[{index}]"),
                        key=f"[{index}]",
                        type=self._type_name(item_unwrapped),
                        type_label=scalar_label,
                        enabled=True,
                        editable=True,
                        value=item,
                        children=[],
                    )
                    for index, item in enumerate(items)
                ]
            item_type = item_unwrapped
            items = value if isinstance(value, list) else []
            if items:
                children: list[SchemaFieldNode] = []
                item_label = self._type_label(item_type)
                for index, item in enumerate(items):
                    item_dump = (
                        field_dump[index]
                        if isinstance(field_dump, list) and index < len(field_dump)
                        else item.model_dump(exclude_none=True, mode="python")
                    )
                    item_path = self._join_path(parent_path, f"[{index}]")
                    children.append(
                        SchemaFieldNode(
                            path=item_path,
                            key=f"[{index}]",
                            type="object",
                            type_label=item_label,
                            enabled=True,
                            editable=True,
                            children=self._build_children(
                                item,
                                item_type,
                                item_dump,
                                parent_path=item_path,
                                mask_secrets=mask_secrets,
                            ),
                        )
                    )
                return children
            return []
        return []

    def _field_enabled(
        self,
        field_info: FieldInfo,
        value: Any,
        field_dump: Any,
    ) -> bool:
        if field_dump is not None:
            return True
        if field_info.is_required() and value is not None:
            return True
        return False

    def _disable_at_path(
        self,
        data: dict[str, Any],
        model_class: type[BaseModel],
        path: str,
    ) -> None:
        segments = self._parse_path(path)
        parent, model_at_parent, leaf = self._navigate_parent(
            data,
            model_class,
            segments,
        )
        if isinstance(leaf, int):
            if isinstance(parent, list):
                parent.pop(leaf)
            return
        field_info = model_at_parent.model_fields[str(leaf)]
        if self._accepts_none(field_info.annotation):
            parent[str(leaf)] = None
        else:
            parent.pop(str(leaf), None)

    def _enable_at_path(
        self,
        data: dict[str, Any],
        model_class: type[BaseModel],
        path: str,
    ) -> None:
        segments = self._parse_path(path)
        parent, model_at_parent, leaf = self._navigate_parent(
            data,
            model_class,
            segments,
        )
        field_info = model_at_parent.model_fields[str(leaf)]
        annotation = self._unwrap_optional(field_info.annotation)
        parent[str(leaf)] = self._default_for_field(field_info, annotation)

    def _append_at_path(
        self,
        data: dict[str, Any],
        model_class: type[BaseModel],
        path: str,
    ) -> None:
        segments = self._parse_path(path)
        parent, model_at_parent, leaf = self._navigate_parent(
            data,
            model_class,
            segments,
        )
        field_name = str(leaf)
        field_info = model_at_parent.model_fields[field_name]
        annotation = self._unwrap_optional(field_info.annotation)
        if get_origin(annotation) is not list:
            raise KeyError(f"{path} is not a list field")
        current = parent.get(field_name)
        if current is None:
            current = []
            parent[field_name] = current
        current.append(self._default_list_item(annotation))

    def _remove_at_path(
        self,
        data: dict[str, Any],
        model_class: type[BaseModel],
        path: str,
    ) -> None:
        segments = self._parse_path(path)
        if not segments or not isinstance(segments[-1], int):
            raise KeyError(f"{path} must end with a list index")
        parent, _, leaf = self._navigate_parent(data, model_class, segments)
        if not isinstance(parent, list) or not isinstance(leaf, int):
            raise KeyError(f"{path} is not a list item")
        parent.pop(leaf)

    def _set_at_path(self, data: dict[str, Any], path: str, value: Any) -> None:
        segments = self._parse_path(path)
        parent: Any = data
        index = 0
        while index < len(segments) - 1:
            segment = segments[index]
            next_segment = segments[index + 1]
            if isinstance(segment, int):
                if not isinstance(parent, list):
                    raise KeyError(path)
                parent = parent[segment]
                index += 1
                continue
            if isinstance(next_segment, int):
                if segment not in parent:
                    parent[segment] = []
                bucket = parent[segment]
                if not isinstance(bucket, list) or next_segment >= len(bucket):
                    raise KeyError(path)
                if index == len(segments) - 2:
                    parent = bucket
                    break
                parent = bucket[next_segment]
                index += 2
                continue
            if segment not in parent:
                parent[segment] = {}
            parent = parent[segment]
            index += 1
        leaf = segments[-1]
        if isinstance(leaf, str) and self._is_sensitive(leaf):
            if isinstance(value, str) and (value.startswith("***") or value == ""):
                return
        if isinstance(leaf, int):
            parent[leaf] = value
        else:
            parent[leaf] = value

    def _navigate_parent(
        self,
        data: dict[str, Any],
        model_class: type[BaseModel],
        segments: list[str | int],
    ) -> tuple[Any, type[BaseModel], str | int]:
        """Return parent container, model class at leaf field, and leaf key/index."""
        parent: Any = data
        current_class: type[BaseModel] = model_class
        index = 0
        while index < len(segments) - 1:
            segment = segments[index]
            if isinstance(segment, int):
                if not isinstance(parent, list):
                    raise KeyError("invalid list index in path")
                parent = parent[segment]
                index += 1
                continue
            field_info = current_class.model_fields[segment]
            annotation = self._unwrap_optional(field_info.annotation)
            next_segment = segments[index + 1]
            if isinstance(next_segment, int) and get_origin(annotation) is list:
                if index == len(segments) - 2:
                    parent = parent[segment]
                    current_class = self._list_item_type(annotation)
                    index += 1
                else:
                    parent = parent[segment][next_segment]
                    current_class = self._list_item_type(annotation)
                    index += 2
                continue
            parent = parent[segment]
            if isinstance(annotation, type) and issubclass(annotation, BaseModel):
                current_class = annotation
            index += 1
        leaf = segments[-1]
        if isinstance(leaf, int):
            return parent, current_class, leaf
        return parent, current_class, leaf

    def _validate(
        self,
        model_class: type[BaseModel],
        data: dict[str, Any],
        *,
        original: BaseModel,
    ) -> tuple[BaseModel, list[dict[str, str]]]:
        try:
            return model_class.model_validate(data), []
        except ValidationError as exc:
            errors = [
                {
                    "path": self._format_error_path(err.get("loc", ())),
                    "message": err.get("msg", "validation error"),
                }
                for err in exc.errors()
            ]
            return original, errors

    def _format_error_path(self, loc: tuple[Any, ...]) -> str:
        parts: list[str] = []
        for item in loc:
            if isinstance(item, int):
                parts.append(f"[{item}]")
            else:
                if parts:
                    parts.append(f".{item}")
                else:
                    parts.append(str(item))
        return "".join(parts)

    def _find_by_segments(
        self,
        node: SchemaFieldNode,
        segments: list[str | int],
    ) -> SchemaFieldNode | None:
        if not segments:
            return node
        head, *tail = segments
        for child in node.children:
            if child.key == head or child.key == f"[{head}]":
                return self._find_by_segments(child, tail)
        return None

    def _parse_path(self, path: str) -> list[str | int]:
        segments: list[str | int] = []
        for match in _PATH_SEGMENT_RE.finditer(path):
            name, index = match.groups()
            if name:
                segments.append(name)
            elif index == "*":
                segments.append("*")
            else:
                segments.append(int(index))
        return segments

    def _join_path(self, parent: str, segment: str) -> str:
        if parent:
            if segment.startswith("["):
                return f"{parent}{segment}"
            return f"{parent}.{segment}"
        return segment.lstrip(".")

    def _unwrap_optional(self, annotation: Any) -> Any:
        origin = get_origin(annotation)
        if origin is Union:
            args = [arg for arg in get_args(annotation) if arg is not type(None)]
            if len(args) == 1:
                return args[0]
        return annotation

    def _list_item_type(self, annotation: Any) -> type[BaseModel]:
        annotation = self._unwrap_optional(annotation)
        args = get_args(annotation)
        item = args[0] if args else Any
        if isinstance(item, type) and issubclass(item, BaseModel):
            return item
        raise TypeError("list item is not a BaseModel")

    def _enum_options(self, annotation: Any) -> list[str]:
        annotation = self._unwrap_optional(annotation)
        if isinstance(annotation, type) and issubclass(annotation, Enum):
            return [str(member.value) for member in annotation]
        return []

    def _type_name(self, annotation: Any) -> str:
        if annotation is None:
            return "unknown"
        annotation = self._unwrap_optional(annotation)
        origin = get_origin(annotation)
        if origin is list:
            return "array"
        if isinstance(annotation, type):
            if issubclass(annotation, Enum):
                return "enum"
            if issubclass(annotation, bool):
                return "boolean"
            if issubclass(annotation, int):
                return "integer"
            if issubclass(annotation, float):
                return "number"
            if issubclass(annotation, str):
                return "string"
            if issubclass(annotation, BaseModel):
                return "object"
        return "unknown"

    def _type_label(self, annotation: Any) -> str:
        if annotation is None:
            return "unknown"
        annotation = self._unwrap_optional(annotation)
        origin = get_origin(annotation)
        if origin is list:
            args = get_args(annotation)
            inner = args[0] if args else Any
            inner_label = self._type_label(inner)
            return f"{inner_label}[]"
        if isinstance(annotation, type):
            if issubclass(annotation, Enum):
                return annotation.__name__
            if issubclass(annotation, BaseModel):
                return annotation.__name__
            if issubclass(annotation, bool):
                return "boolean"
            if issubclass(annotation, int):
                return "integer"
            if issubclass(annotation, float):
                return "number"
            if issubclass(annotation, str):
                return "string"
        return "unknown"

    def _list_node_meta(
        self,
        annotation: Any,
        field_dump: Any,
        enabled: bool,
    ) -> dict[str, Any]:
        annotation = self._unwrap_optional(annotation)
        if get_origin(annotation) is not list:
            return {}
        count = len(field_dump) if isinstance(field_dump, list) else 0
        return {"item_count": count, "can_append": enabled}

    def _default_list_item(self, annotation: Any) -> Any:
        annotation = self._unwrap_optional(annotation)
        args = get_args(annotation) if get_origin(annotation) is list else (annotation,)
        item = args[0] if args else Any
        item = self._unwrap_optional(item)
        if isinstance(item, type) and issubclass(item, BaseModel):
            return self._default_model_dict(item)
        if isinstance(item, type) and issubclass(item, Enum):
            return next(iter(item)).value
        if item is bool:
            return False
        if item is int:
            return 0
        if item is float:
            return 0.0
        if item is str:
            return ""
        return None

    def _is_sensitive(self, key: str) -> bool:
        return key in self.SENSITIVE_KEYS or key.endswith("_token")

    def _display_value(
        self,
        value: Any,
        *,
        sensitive: bool,
        mask_secrets: bool,
    ) -> Any:
        if not mask_secrets or not sensitive or not isinstance(value, str):
            return value
        if len(value) > 4:
            return f"***{value[-4:]}"
        return "***"

    def _default_for_field(self, field_info: FieldInfo, annotation: Any) -> Any:
        if not field_info.is_required():
            default = field_info.get_default(call_default_factory=True)
            if default is not None:
                return default
        annotation = self._unwrap_optional(annotation)
        if get_origin(annotation) is list:
            return []
        if isinstance(annotation, type) and issubclass(annotation, Enum):
            return next(iter(annotation))
        if annotation is bool:
            return False
        if annotation is int:
            return 0
        if annotation is float:
            return 0.0
        if annotation is str:
            return ""
        if isinstance(annotation, type) and issubclass(annotation, BaseModel):
            return self._default_model_dict(annotation)
        return None

    def _default_model_dict(self, model_class: type[BaseModel]) -> dict[str, Any]:
        """Build a valid sample dict for a nested model."""
        return self._example_generator._sample_model(model_class)

    def _accepts_none(self, annotation: Any) -> bool:
        origin = get_origin(annotation)
        if origin is Union:
            return type(None) in get_args(annotation)
        return False
`````

## File: tests/test_appconfig_models.py
`````python
#!/usr/bin/env python
"""
Unit tests for metagit.core.appconfig.models
"""
import os

import yaml

from metagit.core.appconfig.models import (
    LLM,
    AppConfig,
    Boundary,
    GitHubProvider,
    GitLabProvider,
    Profile,
    Providers,
    WorkspaceConfig,
)


def test_boundary_model():
    b = Boundary(name="internal", values=["foo", "bar"])
    assert b.name == "internal"
    assert b.values == ["foo", "bar"]


def test_profiles_model():
    p = Profile()
    assert p.name == "default"
    assert isinstance(p.boundaries, list)


def test_workspace_model():
    w = WorkspaceConfig()
    assert w.path == "./.metagit"
    assert w.default_project == "default"
    assert w.dedupe.enabled is False
    assert w.ui_show_preview is True
    assert w.ui_ignore_hidden is True


def test_llm_model():
    llm = LLM()
    assert llm.provider == "openrouter"
    assert llm.api_key == ""


def test_github_provider_model():
    gh = GitHubProvider()
    assert gh.base_url.startswith("https://api.github")
    assert not gh.enabled


def test_gitlab_provider_model():
    gl = GitLabProvider()
    assert gl.base_url.startswith("https://gitlab")
    assert not gl.enabled


def test_providers_model():
    p = Providers()
    assert isinstance(p.github, GitHubProvider)
    assert isinstance(p.gitlab, GitLabProvider)


def test_appconfig_defaults():
    cfg = AppConfig()
    assert cfg.agent_mode is False
    assert cfg.llm.provider == "openrouter"
    assert isinstance(cfg.providers, Providers)
    assert cfg.workspace.dedupe.enabled is False
    assert cfg.workspace.ui_ignore_hidden is True


def test_appconfig_load_and_save(tmp_path):
    # Create a config file
    config_path = os.path.join(tmp_path, "testconfig.yaml")
    data = {"config": AppConfig().model_dump(mode="json")}
    print(f"config_path: {config_path}")

    with open(config_path, "w") as f:
        yaml.dump(data, f)

    # Load config
    cfg = AppConfig.load(str(config_path))
    if isinstance(cfg, Exception):
        # Print debug info on failure
        print(f"Config load failed: {cfg}")
        with open(config_path, "r") as f:
            print(f"Written YAML content: {f.read()}")
        # Try to load the YAML directly to see what's in it
        with open(config_path, "r") as f:
            loaded_data = yaml.safe_load(f)
            print(f"Direct YAML load result: {loaded_data}")
    assert isinstance(cfg, AppConfig)
    # Save config
    save_path = tmp_path / "saved.yaml"
    result = cfg.save(str(save_path))
    assert result is True
    # Load saved config
    loaded = AppConfig.load(str(save_path))
    assert isinstance(loaded, AppConfig)


def test_appconfig_load_file_not_found(tmp_path):
    result = AppConfig.load(str(tmp_path / "nope.yaml"))
    assert isinstance(result, AppConfig)


def test_appconfig_load_ignores_legacy_version_key(tmp_path):
    """Older metagit.config.yaml files stored a frozen CLI version; ignore it on load."""
    config_path = os.path.join(tmp_path, "legacy.yaml")
    with open(config_path, "w", encoding="utf-8") as handle:
        yaml.dump(
            {"config": {"version": "0.1.7", "description": "legacy"}},
            handle,
        )
    cfg = AppConfig.load(config_path)
    assert isinstance(cfg, AppConfig)
    assert cfg.description == "legacy"
`````

## File: web/src/api/client.ts
`````typescript
export type ConfigOpKind = 'enable' | 'disable' | 'set' | 'append' | 'remove'

export interface ConfigOperation {
  op: ConfigOpKind
  path: string
  value?: unknown
}

export interface SchemaFieldNode {
  path: string
  key: string
  type: string
  type_label?: string | null
  description?: string | null
  required?: boolean
  enabled?: boolean
  editable?: boolean
  sensitive?: boolean
  default_value?: unknown
  value?: unknown
  enum_options?: string[]
  item_count?: number | null
  can_append?: boolean
  children?: SchemaFieldNode[]
}

export interface ConfigTreeResponse {
  ok: boolean
  target: 'metagit' | 'appconfig'
  config_path: string
  tree: SchemaFieldNode
  validation_errors: Array<Record<string, string>>
  saved?: boolean
}

export type ConfigPreviewStyle = 'normalized' | 'minimal' | 'disk'

export interface ConfigPreviewResponse {
  ok: boolean
  target: 'metagit' | 'appconfig'
  config_path: string
  style: ConfigPreviewStyle
  yaml: string
  draft?: boolean
  validation_errors: Array<{ path?: string; message?: string }>
}

export interface SyncJobRequest {
  repos?: string[] | null
  mode?: 'fetch' | 'pull' | 'clone'
  dry_run?: boolean
  allow_mutation?: boolean
  max_parallel?: number
}

export interface SyncJobStatus {
  job_id: string
  state: 'pending' | 'running' | 'completed' | 'failed'
  summary: Record<string, unknown>
  results: Array<Record<string, unknown>>
  error?: string | null
}

export class ApiError extends Error {
  status: number
  body: unknown

  constructor(status: number, message: string, body?: unknown) {
    super(message)
    this.name = 'ApiError'
    this.status = status
    this.body = body
  }
}

async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(path, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      ...init?.headers,
    },
  })

  const text = await response.text()
  let data: unknown = null
  if (text) {
    try {
      data = JSON.parse(text) as unknown
    } catch {
      data = text
    }
  }

  if (!response.ok) {
    const message =
      typeof data === 'object' &&
      data !== null &&
      'message' in data &&
      typeof (data as { message: unknown }).message === 'string'
        ? (data as { message: string }).message
        : response.statusText
    throw new ApiError(response.status, message, data)
  }

  return data as T
}

export function getMetagitConfigTree(): Promise<ConfigTreeResponse> {
  return requestJson<ConfigTreeResponse>('/v3/config/metagit/tree')
}

export function getAppconfigTree(): Promise<ConfigTreeResponse> {
  return requestJson<ConfigTreeResponse>('/v3/config/appconfig/tree')
}

export function patchMetagitConfig(
  ops: ConfigOperation[],
  save: boolean,
): Promise<ConfigTreeResponse> {
  return requestJson<ConfigTreeResponse>('/v3/config/metagit', {
    method: 'PATCH',
    body: JSON.stringify({ operations: ops, save }),
  })
}

export function patchAppconfig(
  ops: ConfigOperation[],
  save: boolean,
): Promise<ConfigTreeResponse> {
  return requestJson<ConfigTreeResponse>('/v3/config/appconfig', {
    method: 'PATCH',
    body: JSON.stringify({ operations: ops, save }),
  })
}

export function postConfigPreview(
  target: 'metagit' | 'appconfig',
  style: ConfigPreviewStyle,
  operations: ConfigOperation[],
): Promise<ConfigPreviewResponse> {
  return requestJson<ConfigPreviewResponse>(`/v3/config/${target}/preview`, {
    method: 'POST',
    body: JSON.stringify({ style, operations }),
  })
}

export interface CatalogEnvelope<T> {
  ok: boolean
  error?: { kind: string; message: string } | null
  data?: T
}

export interface WorkspaceProjectEntry {
  name: string
  description?: string | null
  agent_instructions?: string | null
  dedupe_enabled?: boolean | null
  repo_count: number
}

export interface WorkspaceRepoIndexRow {
  project_name: string
  repo_name: string
  configured_path: string | null
  repo_path: string
  exists: boolean
  is_git_repo: boolean
  status: 'synced' | 'configured_missing' | string
  url?: string | null
  sync?: boolean
  tags?: Record<string, string>
}

export interface WorkspaceData {
  summary: Record<string, unknown>
  projects: WorkspaceProjectEntry[]
  repos_index: WorkspaceRepoIndexRow[]
}

export interface HealthRecommendation {
  severity: 'info' | 'warning' | 'critical'
  action: string
  message: string
  project_name?: string | null
  repo_name?: string | null
  repo_path?: string | null
}

export interface RepoHealthRow {
  project_name: string
  repo_name: string
  repo_path: string
  status: string
  exists: boolean
  is_git_repo: boolean
  branch?: string | null
  dirty?: boolean | null
}

export interface WorkspaceHealthResult {
  ok: boolean
  workspace_root: string
  summary: Record<string, number>
  repos: RepoHealthRow[]
  recommendations: HealthRecommendation[]
}

export interface PruneCandidate {
  path: string
  name: string
}

export function getWorkspace(): Promise<CatalogEnvelope<WorkspaceData>> {
  return requestJson<CatalogEnvelope<WorkspaceData>>('/v2/workspace')
}

export interface GraphViewNode {
  id: string
  label: string
  kind: 'project' | 'repo'
  project_name?: string | null
  repo_name?: string | null
}

export interface GraphViewEdge {
  id: string
  from_id: string
  to_id: string
  type: string
  label?: string | null
  source: 'manual' | 'inferred' | 'structure'
}

export interface WorkspaceGraphView {
  ok: boolean
  nodes: GraphViewNode[]
  edges: GraphViewEdge[]
  manual_edge_count: number
  inferred_edge_count: number
  structure_edge_count: number
}

export function getWorkspaceGraph(options?: {
  includeInferred?: boolean
  includeStructure?: boolean
}): Promise<WorkspaceGraphView> {
  const params = new URLSearchParams()
  if (options?.includeInferred === false) {
    params.set('include_inferred', 'false')
  }
  if (options?.includeStructure === false) {
    params.set('include_structure', 'false')
  }
  const query = params.toString()
  const path = query ? `/v3/ops/graph?${query}` : '/v3/ops/graph'
  return requestJson<WorkspaceGraphView>(path)
}

export function postHealth(
  body: Record<string, unknown> = {},
): Promise<WorkspaceHealthResult> {
  return requestJson<WorkspaceHealthResult>('/v3/ops/health', {
    method: 'POST',
    body: JSON.stringify(body),
  })
}

export function postSync(body: SyncJobRequest): Promise<Record<string, unknown>> {
  return requestJson<Record<string, unknown>>('/v3/ops/sync', {
    method: 'POST',
    body: JSON.stringify(body),
  })
}

export function getSyncJob(id: string): Promise<SyncJobStatus> {
  return requestJson<SyncJobStatus>(`/v3/ops/sync/${id}`)
}

export interface PrunePreviewResponse {
  ok: boolean
  candidates: PruneCandidate[]
}

export interface PruneExecuteResponse {
  ok: boolean
  dry_run: boolean
  force: boolean
  removed: string[]
  paths?: string[]
  errors?: Array<{ path: string; message: string }>
}

export function postPrunePreview(body: {
  project: string
  include_hidden?: boolean
}): Promise<PrunePreviewResponse> {
  return requestJson<PrunePreviewResponse>('/v3/ops/prune/preview', {
    method: 'POST',
    body: JSON.stringify(body),
  })
}

export function postPrune(body: {
  project: string
  paths: string[]
  dry_run?: boolean
  force?: boolean
}): Promise<PruneExecuteResponse> {
  return requestJson<PruneExecuteResponse>('/v3/ops/prune', {
    method: 'POST',
    body: JSON.stringify(body),
  })
}
`````

## File: .gitignore
`````
# Python-generated files
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv
venv

.env
__pycache__
.ruff*
.DS_Store
unit_test_results.txt
.pytest_cache
.mypy_cache
build/
*.egg-info/
__pycache__/
*.pyc

secret_results.json
# Metagit workspace
.metagit/
src/metagit/_version.py
site

# Crush contextual files
.crush
.gitnexus
graphify-*
docs/superpowers
web/node_modules
`````

## File: metagit.config.yaml
`````yaml
config:
  description: Configuration for metagit CLI
  agent_mode: false
  editor: code
  api_url: ''
  api_version: ''
  api_key: ''
  cicd_file_data: data/cicd-files.json
  file_type_data: data/file-types.json
  package_manager_data: data/package-managers.json
  llm:
    enabled: false
    provider: openrouter
    provider_model: gpt-4o-mini
    embedder: ollama
    embedder_model: nomic-embed-text
    api_key: ''
  workspace:
    path: ./.metagit
    default_project: default
    dedupe:
      enabled: false
      canonical_dir: _canonical
    ui_show_preview: true
    ui_menu_length: 20
    ui_ignore_hidden: true
  profiles:
  - name: default
    boundaries:
    - name: github
      values: []
    - name: jfrog
      values: []
    - name: gitlab
      values: []
    - name: bitbucket
      values: []
    - name: azure_devops
      values: []
    - name: dockerhub
      values: []
    - name: domain
      values:
      - localhost
      - 127.0.0.1
      - 0.0.0.0
      - 192.168.*
      - 10.0.*
      - 172.16.*
  providers:
    github:
      enabled: true
      api_token: ''
      base_url: https://api.github.com
    gitlab:
      enabled: false
      api_token: ''
      base_url: https://gitlab.com/api/v4
`````

## File: .mex/patterns/INDEX.md
`````markdown
# Pattern Index

Lookup table for all pattern files in this directory. Check here before starting any task — if a pattern exists, follow it.

<!-- This file is populated during setup (Pass 2) and updated whenever patterns are added.
     Each row maps a pattern file (or section) to its trigger — when should the agent load it?

     Format — simple (one task per file):
     | [filename.md](filename.md) | One-line description of when to use this pattern |

     Format — anchored (multi-section file, one row per task):
     | [filename.md#task-first-task](filename.md#task-first-task) | When doing the first task |
     | [filename.md#task-second-task](filename.md#task-second-task) | When doing the second task |

     Example (from a Flask API project):
     | [add-api-client.md](add-api-client.md) | Adding a new external service integration |
     | [debug-pipeline.md](debug-pipeline.md) | Diagnosing failures in the request pipeline |
     | [crud-operations.md#task-add-endpoint](crud-operations.md#task-add-endpoint) | Adding a new API route with validation |
     | [crud-operations.md#task-add-model](crud-operations.md#task-add-model) | Adding a new database model |

     Keep this table sorted alphabetically. One row per task (not per file).
     If you create a new pattern, add it here. If you delete one, remove it. -->

| Pattern | Use when |
|---------|----------|
| [add-cli-command.md](add-cli-command.md) | Adding or extending a Click CLI command while keeping core logic in `src/metagit/core/*` |
| [add-managed-repo-search.md](add-managed-repo-search.md) | Extending or debugging managed-only repo search (CLI, MCP `metagit_repo_search`, local JSON API) |
| [add-mcp-tool.md](add-mcp-tool.md) | Adding/changing MCP tools, schemas, dispatch behavior, and runtime tests |
| [mcp-project-context.md](mcp-project-context.md) | Project context switch, session store, or workspace snapshot MCP tools |
| [mcp-cross-project-dependencies.md](mcp-cross-project-dependencies.md) | Cross-project dependency graph MCP tool and collectors |
| [metagit-web-api.md](metagit-web-api.md) | Adding or changing Pydantic models and routes for `metagit web serve` |
| [bootstrap-metagit-config.md](bootstrap-metagit-config.md) | Creating, validating, or repairing `.metagit.yml` for workspace and MCP flows |
| [debug-mcp-runtime.md](debug-mcp-runtime.md) | Diagnosing MCP runtime protocol, framing, gating, and tool/resource failures |
| [debug-workspace-discovery.md](debug-workspace-discovery.md) | Diagnosing empty/incorrect workspace index, search hits, or upstream hint ranking |
| [run-graphify-analysis.md](run-graphify-analysis.md) | Running `graphify` on the repo or a focused subtree and turning the result into usable graph/report outputs |
| [update-release-workflow.md](update-release-workflow.md) | Replacing or repairing GitHub release automation and tag-driven publish flow |
`````

## File: schemas/metagit_config.schema.json
`````json
{
  "$defs": {
    "AlertingChannel": {
      "additionalProperties": false,
      "description": "Model for alerting channel.",
      "properties": {
        "name": {
          "description": "Alerting channel name",
          "title": "Name",
          "type": "string"
        },
        "type": {
          "$ref": "#/$defs/AlertingChannelType",
          "description": "Alerting channel type"
        },
        "url": {
          "anyOf": [
            {
              "format": "uri",
              "maxLength": 2083,
              "minLength": 1,
              "type": "string"
            },
            {
              "type": "string"
            }
          ],
          "description": "Alerting channel URL",
          "title": "Url"
        }
      },
      "required": [
        "name",
        "type",
        "url"
      ],
      "title": "AlertingChannel",
      "type": "object"
    },
    "AlertingChannelType": {
      "description": "Enumeration of alerting channel types.",
      "enum": [
        "slack",
        "teams",
        "email",
        "sms",
        "webhook",
        "custom",
        "unknown",
        "other"
      ],
      "title": "AlertingChannelType",
      "type": "string"
    },
    "Artifact": {
      "additionalProperties": false,
      "description": "Model for generated artifacts.",
      "properties": {
        "type": {
          "$ref": "#/$defs/ArtifactType",
          "description": "Artifact type"
        },
        "definition": {
          "description": "Artifact definition",
          "title": "Definition",
          "type": "string"
        },
        "location": {
          "anyOf": [
            {
              "format": "uri",
              "maxLength": 2083,
              "minLength": 1,
              "type": "string"
            },
            {
              "type": "string"
            }
          ],
          "description": "Artifact location",
          "title": "Location"
        },
        "version_strategy": {
          "$ref": "#/$defs/VersionStrategy",
          "description": "Version strategy"
        }
      },
      "required": [
        "type",
        "definition",
        "location",
        "version_strategy"
      ],
      "title": "Artifact",
      "type": "object"
    },
    "ArtifactType": {
      "description": "Enumeration of artifact types.",
      "enum": [
        "docker",
        "github_release",
        "helm_chart",
        "npm_package",
        "static_website",
        "python_package",
        "node_package",
        "ruby_package",
        "java_package",
        "c_package",
        "cpp_package",
        "csharp_package",
        "go_package",
        "rust_package",
        "php_package",
        "dotnet_package",
        "elixir_package",
        "haskell_package",
        "custom",
        "none",
        "unknown",
        "other",
        "plugin",
        "template",
        "config",
        "binary",
        "archive"
      ],
      "title": "ArtifactType",
      "type": "string"
    },
    "BranchNaming": {
      "additionalProperties": false,
      "description": "Model for branch naming patterns.",
      "properties": {
        "kind": {
          "$ref": "#/$defs/BranchStrategy",
          "description": "Branch strategy"
        },
        "pattern": {
          "description": "Branch naming pattern",
          "title": "Pattern",
          "type": "string"
        }
      },
      "required": [
        "kind",
        "pattern"
      ],
      "title": "BranchNaming",
      "type": "object"
    },
    "BranchStrategy": {
      "description": "Enumeration of branch strategies.",
      "enum": [
        "trunk",
        "gitflow",
        "githubflow",
        "gitlabflow",
        "fork",
        "none",
        "custom",
        "unknown"
      ],
      "title": "BranchStrategy",
      "type": "string"
    },
    "CICD": {
      "additionalProperties": false,
      "description": "Model for CI/CD configuration.",
      "properties": {
        "platform": {
          "$ref": "#/$defs/CICDPlatform",
          "description": "CI/CD platform"
        },
        "pipelines": {
          "description": "List of pipelines",
          "items": {
            "$ref": "#/$defs/Pipeline"
          },
          "title": "Pipelines",
          "type": "array"
        }
      },
      "required": [
        "platform",
        "pipelines"
      ],
      "title": "CICD",
      "type": "object"
    },
    "CICDPlatform": {
      "description": "Enumeration of CI/CD platforms.",
      "enum": [
        "GitHub",
        "GitLab",
        "CircleCI",
        "Jenkins",
        "jx",
        "tekton",
        "custom",
        "none",
        "unknown",
        "other"
      ],
      "title": "CICDPlatform",
      "type": "string"
    },
    "Dashboard": {
      "additionalProperties": false,
      "description": "Model for monitoring dashboard.",
      "properties": {
        "name": {
          "description": "Dashboard name",
          "title": "Name",
          "type": "string"
        },
        "tool": {
          "description": "Dashboard tool",
          "title": "Tool",
          "type": "string"
        },
        "url": {
          "description": "Dashboard URL",
          "format": "uri",
          "maxLength": 2083,
          "minLength": 1,
          "title": "Url",
          "type": "string"
        }
      },
      "required": [
        "name",
        "tool",
        "url"
      ],
      "title": "Dashboard",
      "type": "object"
    },
    "Deployment": {
      "additionalProperties": false,
      "description": "Model for deployment configuration.",
      "properties": {
        "strategy": {
          "$ref": "#/$defs/DeploymentStrategy",
          "description": "Deployment strategy"
        },
        "environments": {
          "anyOf": [
            {
              "items": {
                "$ref": "#/$defs/Environment"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Deployment environments",
          "title": "Environments"
        },
        "infrastructure": {
          "anyOf": [
            {
              "$ref": "#/$defs/Infrastructure"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Infrastructure configuration"
        }
      },
      "required": [
        "strategy"
      ],
      "title": "Deployment",
      "type": "object"
    },
    "DeploymentStrategy": {
      "description": "Enumeration of deployment strategies.",
      "enum": [
        "blue/green",
        "rolling",
        "manual",
        "gitops",
        "pipeline",
        "custom",
        "none",
        "unknown",
        "other"
      ],
      "title": "DeploymentStrategy",
      "type": "string"
    },
    "DocumentationSource": {
      "additionalProperties": false,
      "description": "One documentation source for agents and knowledge-graph ingestion.",
      "properties": {
        "kind": {
          "description": "Source type (markdown, web, confluence, sharepoint, wiki, api, other)",
          "title": "Kind",
          "type": "string"
        },
        "path": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Repo-relative or absolute path to a documentation file",
          "title": "Path"
        },
        "url": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Remote documentation URL",
          "title": "Url"
        },
        "title": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Human-readable title",
          "title": "Title"
        },
        "description": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Short summary for indexing and graph nodes",
          "title": "Description"
        },
        "tags": {
          "additionalProperties": {
            "type": "string"
          },
          "description": "Flat metadata tags for filtering and graph edges",
          "title": "Tags",
          "type": "object"
        },
        "metadata": {
          "additionalProperties": true,
          "description": "Extensible key-value payload for downstream graph ingestors",
          "title": "Metadata",
          "type": "object"
        }
      },
      "required": [
        "kind"
      ],
      "title": "DocumentationSource",
      "type": "object"
    },
    "Environment": {
      "additionalProperties": false,
      "description": "Model for deployment environment.",
      "properties": {
        "name": {
          "description": "Environment name",
          "title": "Name",
          "type": "string"
        },
        "url": {
          "anyOf": [
            {
              "format": "uri",
              "maxLength": 2083,
              "minLength": 1,
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Environment URL",
          "title": "Url"
        }
      },
      "required": [
        "name"
      ],
      "title": "Environment",
      "type": "object"
    },
    "GraphEndpoint": {
      "additionalProperties": false,
      "description": "Endpoint for a manual cross-repo relationship.",
      "properties": {
        "project": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Workspace project name",
          "title": "Project"
        },
        "repo": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Repository name under the project",
          "title": "Repo"
        },
        "path": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Optional file or directory path within the repo",
          "title": "Path"
        }
      },
      "title": "GraphEndpoint",
      "type": "object"
    },
    "GraphRelationship": {
      "additionalProperties": false,
      "description": "Manually declared edge between workspace projects or repos.",
      "properties": {
        "id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Stable identifier for exports and graph merges",
          "title": "Id"
        },
        "from": {
          "$ref": "#/$defs/GraphEndpoint",
          "description": "Relationship source"
        },
        "to": {
          "$ref": "#/$defs/GraphEndpoint",
          "description": "Relationship target"
        },
        "type": {
          "default": "depends_on",
          "description": "Relationship type (depends_on, documents, consumes, owns, related, \u2026)",
          "title": "Type",
          "type": "string"
        },
        "label": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Short label for graph UIs",
          "title": "Label"
        },
        "description": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Longer explanation for agents and exports",
          "title": "Description"
        },
        "tags": {
          "additionalProperties": {
            "type": "string"
          },
          "description": "Flat tags for filtering graph exports",
          "title": "Tags",
          "type": "object"
        },
        "metadata": {
          "additionalProperties": true,
          "description": "Extensible payload for GitNexus or other graph ingestors",
          "title": "Metadata",
          "type": "object"
        }
      },
      "required": [
        "from",
        "to"
      ],
      "title": "GraphRelationship",
      "type": "object"
    },
    "Hosting": {
      "description": "Enumeration of hosting options.",
      "enum": [
        "EC2",
        "VMware",
        "Oracle",
        "Kubernetes",
        "Vercel",
        "ECS",
        "AWS Lambda",
        "AWS Fargate",
        "AWS EKS",
        "AWS ECS",
        "AWS ECS Fargate",
        "AWS ECS Fargate Spot",
        "AWS ECS Fargate Spot Spot",
        "Elastic Beanstalk",
        "Azure App Service",
        "Azure Functions",
        "Azure Container Instances",
        "Azure Container Apps",
        "Azure Container Apps Environment",
        "Azure Container Apps Environment Service",
        "Azure Container Apps Environment Service Service",
        "custom",
        "none",
        "unknown"
      ],
      "title": "Hosting",
      "type": "string"
    },
    "Infrastructure": {
      "additionalProperties": false,
      "description": "Model for infrastructure configuration.",
      "properties": {
        "provisioning_tool": {
          "$ref": "#/$defs/ProvisioningTool",
          "description": "Provisioning tool"
        },
        "hosting": {
          "$ref": "#/$defs/Hosting",
          "description": "Hosting platform"
        }
      },
      "required": [
        "provisioning_tool",
        "hosting"
      ],
      "title": "Infrastructure",
      "type": "object"
    },
    "License": {
      "additionalProperties": false,
      "description": "Model for project license information.",
      "properties": {
        "kind": {
          "$ref": "#/$defs/LicenseKind",
          "description": "License type"
        },
        "file": {
          "default": "",
          "description": "License file path",
          "title": "File",
          "type": "string"
        }
      },
      "required": [
        "kind"
      ],
      "title": "License",
      "type": "object"
    },
    "LicenseKind": {
      "description": "Enumeration of license kinds.",
      "enum": [
        "None",
        "MIT",
        "Apache-2.0",
        "GPL-3.0",
        "BSD-3-Clause",
        "proprietary",
        "custom",
        "unknown"
      ],
      "title": "LicenseKind",
      "type": "string"
    },
    "LoggingProvider": {
      "description": "Enumeration of logging providers.",
      "enum": [
        "console",
        "cloudwatch",
        "elk",
        "sentry",
        "custom",
        "none",
        "unknown",
        "other"
      ],
      "title": "LoggingProvider",
      "type": "string"
    },
    "Maintainer": {
      "additionalProperties": false,
      "description": "Model for project maintainer information.",
      "properties": {
        "name": {
          "description": "Maintainer name",
          "title": "Name",
          "type": "string"
        },
        "email": {
          "description": "Maintainer email",
          "title": "Email",
          "type": "string"
        },
        "role": {
          "description": "Maintainer role",
          "title": "Role",
          "type": "string"
        }
      },
      "required": [
        "name",
        "email",
        "role"
      ],
      "title": "Maintainer",
      "type": "object"
    },
    "MonitoringProvider": {
      "description": "Enumeration of monitoring providers.",
      "enum": [
        "prometheus",
        "datadog",
        "grafana",
        "sentry",
        "custom",
        "none",
        "unknown",
        "other"
      ],
      "title": "MonitoringProvider",
      "type": "string"
    },
    "Observability": {
      "additionalProperties": false,
      "description": "Model for observability configuration.",
      "properties": {
        "logging_provider": {
          "anyOf": [
            {
              "$ref": "#/$defs/LoggingProvider"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Logging provider"
        },
        "monitoring_providers": {
          "anyOf": [
            {
              "items": {
                "$ref": "#/$defs/MonitoringProvider"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Monitoring providers",
          "title": "Monitoring Providers"
        },
        "alerting_channels": {
          "anyOf": [
            {
              "items": {
                "$ref": "#/$defs/AlertingChannel"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Alerting channels",
          "title": "Alerting Channels"
        },
        "dashboards": {
          "anyOf": [
            {
              "items": {
                "$ref": "#/$defs/Dashboard"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Monitoring dashboards",
          "title": "Dashboards"
        }
      },
      "title": "Observability",
      "type": "object"
    },
    "Pipeline": {
      "additionalProperties": false,
      "description": "Model for CI/CD pipeline.",
      "properties": {
        "name": {
          "description": "Pipeline name",
          "title": "Name",
          "type": "string"
        },
        "ref": {
          "description": "Pipeline reference",
          "title": "Ref",
          "type": "string"
        },
        "variables": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Pipeline variables",
          "title": "Variables"
        }
      },
      "required": [
        "name",
        "ref"
      ],
      "title": "Pipeline",
      "type": "object"
    },
    "ProjectDedupeOverride": {
      "additionalProperties": false,
      "description": "Per-project override of app-config ``workspace.dedupe``.",
      "properties": {
        "enabled": {
          "anyOf": [
            {
              "type": "boolean"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "When set, overrides workspace.dedupe.enabled from metagit.config.yaml for sync and layout under this project only",
          "title": "Enabled"
        }
      },
      "title": "ProjectDedupeOverride",
      "type": "object"
    },
    "ProjectKind": {
      "description": "Enumeration of project kinds.",
      "enum": [
        "monorepo",
        "umbrella",
        "application",
        "gitops",
        "infrastructure",
        "service",
        "library",
        "website",
        "other",
        "docker_image",
        "repository",
        "cli"
      ],
      "title": "ProjectKind",
      "type": "string"
    },
    "ProjectPath": {
      "additionalProperties": false,
      "description": "Model for project path, dependency, component, or workspace project information.",
      "properties": {
        "name": {
          "description": "Friendly name for the path or project",
          "title": "Name",
          "type": "string"
        },
        "description": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Short description of the path or project",
          "title": "Description"
        },
        "kind": {
          "anyOf": [
            {
              "$ref": "#/$defs/ProjectKind"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Project kind"
        },
        "ref": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Reference in the current project for the target project, used in dependencies",
          "title": "Ref"
        },
        "path": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Project path",
          "title": "Path"
        },
        "branches": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Project branches",
          "title": "Branches"
        },
        "url": {
          "anyOf": [
            {
              "format": "uri",
              "maxLength": 2083,
              "minLength": 1,
              "type": "string"
            },
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Project URL",
          "title": "Url"
        },
        "sync": {
          "anyOf": [
            {
              "type": "boolean"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Sync setting",
          "title": "Sync"
        },
        "language": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Programming language",
          "title": "Language"
        },
        "language_version": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "number"
            },
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Language version",
          "title": "Language Version"
        },
        "package_manager": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Package manager used by the project",
          "title": "Package Manager"
        },
        "frameworks": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Frameworks used by the project",
          "title": "Frameworks"
        },
        "source_provider": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Provider used to discover this repository",
          "title": "Source Provider"
        },
        "source_namespace": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Source namespace identifier (org/user/group)",
          "title": "Source Namespace"
        },
        "source_repo_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Provider-native repository identifier",
          "title": "Source Repo Id"
        },
        "tags": {
          "additionalProperties": {
            "type": "string"
          },
          "description": "Flat metadata tags for managed repo search and filtering",
          "title": "Tags",
          "type": "object"
        },
        "protected": {
          "anyOf": [
            {
              "type": "boolean"
            },
            {
              "type": "null"
            }
          ],
          "default": false,
          "description": "If true, reconcile mode must not remove this repository automatically",
          "title": "Protected"
        },
        "agent_instructions": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Optional instructions for subagents operating in this repo or path",
          "title": "Agent Instructions"
        }
      },
      "required": [
        "name"
      ],
      "title": "ProjectPath",
      "type": "object"
    },
    "ProvisioningTool": {
      "description": "Enumeration of provisioning tools.",
      "enum": [
        "Terraform",
        "CloudFormation",
        "CDKTF",
        "AWS CDK",
        "Bicep",
        "custom",
        "none",
        "unknown",
        "other"
      ],
      "title": "ProvisioningTool",
      "type": "string"
    },
    "Secret": {
      "additionalProperties": false,
      "description": "Model for secret definitions.",
      "properties": {
        "name": {
          "description": "Secret name",
          "title": "Name",
          "type": "string"
        },
        "kind": {
          "$ref": "#/$defs/SecretKind",
          "description": "Secret type"
        },
        "ref": {
          "description": "Secret reference",
          "title": "Ref",
          "type": "string"
        }
      },
      "required": [
        "name",
        "kind",
        "ref"
      ],
      "title": "Secret",
      "type": "object"
    },
    "SecretKind": {
      "description": "Enumeration of secret kinds.",
      "enum": [
        "remote_jwt",
        "remote_api_key",
        "generated_string",
        "custom",
        "dynamic",
        "private_key",
        "public_key",
        "secret_key",
        "api_key",
        "access_token",
        "refresh_token",
        "password",
        "database_password",
        "unknown",
        "other"
      ],
      "title": "SecretKind",
      "type": "string"
    },
    "Tasker": {
      "additionalProperties": false,
      "description": "Model for task management tools.",
      "properties": {
        "kind": {
          "$ref": "#/$defs/TaskerKind",
          "description": "Tasker type"
        }
      },
      "required": [
        "kind"
      ],
      "title": "Tasker",
      "type": "object"
    },
    "TaskerKind": {
      "description": "Enumeration of tasker kinds.",
      "enum": [
        "Taskfile",
        "Makefile",
        "Jest",
        "NPM",
        "Atmos",
        "custom",
        "none",
        "mise_tasks"
      ],
      "title": "TaskerKind",
      "type": "string"
    },
    "Variable": {
      "additionalProperties": false,
      "description": "Model for variable definitions.",
      "properties": {
        "name": {
          "description": "Variable name",
          "title": "Name",
          "type": "string"
        },
        "kind": {
          "$ref": "#/$defs/VariableKind",
          "description": "Variable type"
        },
        "ref": {
          "description": "Variable reference",
          "title": "Ref",
          "type": "string"
        }
      },
      "required": [
        "name",
        "kind",
        "ref"
      ],
      "title": "Variable",
      "type": "object"
    },
    "VariableKind": {
      "description": "Enumeration of variable kinds.",
      "enum": [
        "string",
        "integer",
        "boolean",
        "custom",
        "unknown",
        "other"
      ],
      "title": "VariableKind",
      "type": "string"
    },
    "VersionStrategy": {
      "description": "Enumeration of version strategies.",
      "enum": [
        "semver",
        "none",
        "custom",
        "unknown",
        "other"
      ],
      "title": "VersionStrategy",
      "type": "string"
    },
    "Workspace": {
      "description": "Model for workspace configuration.",
      "properties": {
        "description": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Human-readable description of this workspace",
          "title": "Description"
        },
        "agent_instructions": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Optional instructions for agents working in this workspace",
          "title": "Agent Instructions"
        },
        "projects": {
          "description": "Workspace projects",
          "items": {
            "$ref": "#/$defs/WorkspaceProject"
          },
          "title": "Projects",
          "type": "array"
        }
      },
      "required": [
        "projects"
      ],
      "title": "Workspace",
      "type": "object"
    },
    "WorkspaceGraph": {
      "additionalProperties": false,
      "description": "Top-level manual graph data on a .metagit.yml manifest.",
      "properties": {
        "relationships": {
          "description": "Manually entered cross-repo or cross-project edges",
          "items": {
            "$ref": "#/$defs/GraphRelationship"
          },
          "title": "Relationships",
          "type": "array"
        },
        "metadata": {
          "additionalProperties": true,
          "description": "Graph-level metadata for export pipelines",
          "title": "Metadata",
          "type": "object"
        }
      },
      "title": "WorkspaceGraph",
      "type": "object"
    },
    "WorkspaceProject": {
      "additionalProperties": false,
      "description": "Model for workspace project.",
      "properties": {
        "name": {
          "description": "Workspace project name",
          "title": "Name",
          "type": "string"
        },
        "description": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Human-readable description of this workspace project",
          "title": "Description"
        },
        "agent_instructions": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Optional instructions for agents working in this workspace project",
          "title": "Agent Instructions"
        },
        "dedupe": {
          "anyOf": [
            {
              "$ref": "#/$defs/ProjectDedupeOverride"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Optional override of app-config workspace.dedupe for this project (currently supports enabled only)"
        },
        "repos": {
          "description": "Repository list",
          "items": {
            "$ref": "#/$defs/ProjectPath"
          },
          "title": "Repos",
          "type": "array"
        }
      },
      "required": [
        "name",
        "repos"
      ],
      "title": "WorkspaceProject",
      "type": "object"
    }
  },
  "additionalProperties": false,
  "description": "Main model for .metagit.yml configuration file.",
  "properties": {
    "name": {
      "description": "Project name",
      "title": "Name",
      "type": "string"
    },
    "description": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": "No description",
      "description": "Project description",
      "title": "Description"
    },
    "agent_instructions": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional instructions for controller agents; applies with or without a workspace block",
      "title": "Agent Instructions"
    },
    "url": {
      "anyOf": [
        {
          "format": "uri",
          "maxLength": 2083,
          "minLength": 1,
          "type": "string"
        },
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Project URL",
      "title": "Url"
    },
    "kind": {
      "anyOf": [
        {
          "$ref": "#/$defs/ProjectKind"
        },
        {
          "type": "null"
        }
      ],
      "default": "application",
      "description": "Project kind. This is used to determine the type of project and the best way to manage it."
    },
    "documentation": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/DocumentationSource"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Documentation sources: bare strings (path or URL) or objects with kind, path/url, tags, and metadata for knowledge-graph ingestion",
      "title": "Documentation"
    },
    "graph": {
      "anyOf": [
        {
          "$ref": "#/$defs/WorkspaceGraph"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Manual cross-repo relationships and graph metadata for exports and GitNexus-style dependency maps"
    },
    "license": {
      "anyOf": [
        {
          "$ref": "#/$defs/License"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "License information"
    },
    "maintainers": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/Maintainer"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Project maintainers",
      "title": "Maintainers"
    },
    "branch_strategy": {
      "anyOf": [
        {
          "$ref": "#/$defs/BranchStrategy"
        },
        {
          "type": "null"
        }
      ],
      "default": "unknown",
      "description": "Branch strategy used by the project."
    },
    "taskers": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/Tasker"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Task management tools employed by the project.",
      "title": "Taskers"
    },
    "branch_naming": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/BranchNaming"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Branch naming patterns used by the project.",
      "title": "Branch Naming"
    },
    "artifacts": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/Artifact"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "description": "Generated artifacts from the project.",
      "title": "Artifacts"
    },
    "secrets_management": {
      "anyOf": [
        {
          "items": {
            "type": "string"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Secrets management tools employed by the project.",
      "title": "Secrets Management"
    },
    "secrets": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/Secret"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Secret definitions",
      "title": "Secrets"
    },
    "variables": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/Variable"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Variable definitions",
      "title": "Variables"
    },
    "cicd": {
      "anyOf": [
        {
          "$ref": "#/$defs/CICD"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "CI/CD configuration"
    },
    "deployment": {
      "anyOf": [
        {
          "$ref": "#/$defs/Deployment"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Deployment configuration"
    },
    "observability": {
      "anyOf": [
        {
          "$ref": "#/$defs/Observability"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Observability configuration"
    },
    "paths": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/ProjectPath"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "description": "Important local project paths. In a monorepo, this would include any sub-projects typically found being built in the CICD pipelines.",
      "title": "Paths"
    },
    "dependencies": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/ProjectPath"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "description": "Additional project dependencies not found in the paths or components lists. These include docker images, helm charts, or terraform modules.",
      "title": "Dependencies"
    },
    "components": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/ProjectPath"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Additional project component paths that may be useful in other projects.",
      "title": "Components"
    },
    "workspace": {
      "anyOf": [
        {
          "$ref": "#/$defs/Workspace"
        },
        {
          "type": "null"
        }
      ],
      "description": "Workspaces are a collection of projects that are related to each other. They are used to group projects together for a specific purpose. These are manually defined by the user. The internal workspace name is reservice"
    }
  },
  "required": [
    "name"
  ],
  "title": "MetagitConfig",
  "type": "object"
}
`````

## File: src/metagit/cli/commands/project_repo.py
`````python
#!/usr/bin/env python
"""
Project repo subcommand
"""

from pathlib import Path
from typing import Optional

import click

from metagit.cli.json_output import (
    emit_json,
    exit_on_catalog_mutation,
    exit_on_layout_mutation,
)
from metagit.core.appconfig import AppConfig
from metagit.core.config.models import MetagitConfig
from metagit.core.project.manager import project_manager_from_app
from metagit.core.project.models import ProjectKind, ProjectPath
from metagit.core.utils.common import open_editor
from metagit.core.workspace.catalog_models import CatalogMutationResult
from metagit.core.workspace.catalog_service import WorkspaceCatalogService
from metagit.core.workspace.layout_service import WorkspaceLayoutService
from metagit.core.workspace import workspace_dedupe
from metagit.core.workspace.dedupe_resolver import (
    resolve_dedupe_for_layout,
    resolve_effective_dedupe_for_project,
)
from metagit.core.utils.logging import UnifiedLogger


@click.group(name="repo")
@click.pass_context
def repo(ctx: click.Context) -> None:
    """Repository subcommands"""
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())
        return


@repo.command("select")
@click.pass_context
def repo_select(ctx: click.Context) -> None:
    """Select project repo to work on"""
    logger = ctx.obj["logger"]
    local_config: MetagitConfig = ctx.obj["local_config"]
    project = ctx.obj["project"]
    app_config: AppConfig = ctx.obj["config"]
    project_manager = project_manager_from_app(
        app_config,
        logger,
        metagit_config=local_config,
        project_name=project,
    )
    agent_mode = bool(ctx.obj.get("agent_mode", False))
    selected_repo = project_manager.select_repo(
        local_config,
        project,
        show_preview=app_config.workspace.ui_show_preview,
        menu_length=app_config.workspace.ui_menu_length,
        ignore_hidden=app_config.workspace.ui_ignore_hidden,
        agent_mode=agent_mode,
    )
    if isinstance(selected_repo, Exception):
        logger.error(f"Failed to select project repo: {selected_repo}")
        ctx.abort()
    if selected_repo is None:
        logger.info("No repo selected")
        ctx.abort()
    logger.info(f"Selected repo: {selected_repo}")
    if not agent_mode:
        editor_result = open_editor(app_config.editor, selected_repo)
        if isinstance(editor_result, Exception):
            logger.error(f"Failed to open editor: {editor_result}")
        else:
            logger.info(f"Opened {selected_repo} in {app_config.editor}")


@repo.command("list")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def repo_list(ctx: click.Context, as_json: bool) -> None:
    """List repositories for the current workspace project."""
    project: str = ctx.obj["project"]
    local_config: MetagitConfig = ctx.obj["local_config"]
    app_config: AppConfig = ctx.obj["config"]
    if project == "local":
        raise click.UsageError("The local project is not supported for this command")
    workspace_root = str(Path(app_config.workspace.path).expanduser().resolve())
    result = WorkspaceCatalogService().list_repos(
        local_config,
        workspace_root,
        project_name=project,
    )
    if as_json:
        emit_json(result)
        return
    for row in (result.data or {}).get("repos", []):
        repo_row = row.get("repo", {})
        click.echo(
            f"{repo_row.get('name')} path={row.get('configured_path')} "
            f"status={row.get('status') or 'unknown'}"
        )


@repo.command("remove")
@click.option("--name", "-n", required=True, help="Repository name")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def repo_remove(ctx: click.Context, name: str, as_json: bool) -> None:
    """Remove a repository from the manifest (does not delete files)."""
    project: str = ctx.obj["project"]
    local_config: MetagitConfig = ctx.obj["local_config"]
    config_path: str = ctx.obj["config_path"]
    if project == "local":
        raise click.UsageError("The local project is not supported for this command")
    result = WorkspaceCatalogService().remove_repo(
        local_config,
        config_path,
        project_name=project,
        repo_name=name,
    )
    exit_on_catalog_mutation(result, as_json=as_json)


@repo.command("rename")
@click.option(
    "--name", "-n", "from_name", required=True, help="Current repository name"
)
@click.argument("to_name")
@click.option("--dry-run", is_flag=True, default=False)
@click.option("--manifest-only", is_flag=True, default=False)
@click.option("--force", is_flag=True, default=False)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def repo_rename(
    ctx: click.Context,
    from_name: str,
    to_name: str,
    dry_run: bool,
    manifest_only: bool,
    force: bool,
    as_json: bool,
) -> None:
    """Rename a repository in the current project."""
    project: str = ctx.obj["project"]
    local_config: MetagitConfig = ctx.obj["local_config"]
    config_path: str = ctx.obj["config_path"]
    app_config: AppConfig = ctx.obj["config"]
    workspace_root = str(Path(app_config.workspace.path).expanduser().resolve())
    dedupe = resolve_dedupe_for_layout(
        app_config.workspace.dedupe,
        local_config,
        project,
    )
    result = WorkspaceLayoutService().rename_repo(
        local_config,
        config_path,
        workspace_root,
        project_name=project,
        from_name=from_name,
        to_name=to_name,
        dedupe=dedupe,
        dry_run=dry_run,
        move_disk=not manifest_only,
        force=force,
    )
    exit_on_layout_mutation(result, as_json=as_json)


@repo.command("move")
@click.option("--name", "-n", "repo_name", required=True, help="Repository name")
@click.option("--to-project", required=True, help="Target workspace project")
@click.option("--dry-run", is_flag=True, default=False)
@click.option("--manifest-only", is_flag=True, default=False)
@click.option("--force", is_flag=True, default=False)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def repo_move(
    ctx: click.Context,
    repo_name: str,
    to_project: str,
    dry_run: bool,
    manifest_only: bool,
    force: bool,
    as_json: bool,
) -> None:
    """Move a repository to another workspace project."""
    project: str = ctx.obj["project"]
    local_config: MetagitConfig = ctx.obj["local_config"]
    config_path: str = ctx.obj["config_path"]
    app_config: AppConfig = ctx.obj["config"]
    workspace_root = str(Path(app_config.workspace.path).expanduser().resolve())
    dedupe = resolve_dedupe_for_layout(
        app_config.workspace.dedupe,
        local_config,
        to_project,
    )
    result = WorkspaceLayoutService().move_repo(
        local_config,
        config_path,
        workspace_root,
        repo_name=repo_name,
        from_project=project,
        to_project=to_project,
        dedupe=dedupe,
        dry_run=dry_run,
        move_disk=not manifest_only,
        force=force,
    )
    exit_on_layout_mutation(result, as_json=as_json)


@repo.command("add")
@click.option("--name", "-n", help="Repository name")
@click.option("--description", "-d", help="Repository description")
@click.option(
    "--kind", type=click.Choice([k.value for k in ProjectKind]), help="Project kind"
)
@click.option("--ref", help="Reference in the current project for the target project")
@click.option("--path", help="Local project path")
@click.option("--url", help="Repository URL")
@click.option("--sync/--no-sync", default=None, help="Sync setting")
@click.option("--language", help="Programming language")
@click.option("--language-version", help="Language version")
@click.option("--package-manager", help="Package manager")
@click.option(
    "--frameworks",
    multiple=True,
    help="Frameworks used (can be specified multiple times)",
)
@click.option(
    "--prompt",
    is_flag=True,
    help="Use interactive prompts instead of command line parameters",
)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def repo_add(
    ctx: click.Context,
    name: Optional[str],
    description: Optional[str],
    kind: Optional[str],
    ref: Optional[str],
    path: Optional[str],
    url: Optional[str],
    sync: Optional[bool],
    language: Optional[str],
    language_version: Optional[str],
    package_manager: Optional[str],
    frameworks: tuple[str, ...],
    prompt: bool,
    as_json: bool,
) -> None:
    """Add a repository to the current project"""
    logger: UnifiedLogger = ctx.obj["logger"]
    project: str = ctx.obj["project"]
    app_config: AppConfig = ctx.obj["config"]
    local_config = ctx.obj["local_config"]
    config_path = ctx.obj["config_path"]

    if project == "local":
        raise click.UsageError("The local project is not supported for this command")

    try:
        # Initialize ProjectManager and MetagitConfigManager
        project_manager = project_manager_from_app(
            app_config,
            logger,
            metagit_config=local_config,
            project_name=project,
        )
    except Exception as e:
        logger.warning(f"Failed to initialize ProjectManager: {e}")
        ctx.abort()

    catalog = WorkspaceCatalogService()
    try:
        agent_mode = bool(ctx.obj.get("agent_mode", False))
        if (not name or prompt) and agent_mode:
            raise click.UsageError(
                "Interactive repo add is disabled in agent mode; "
                "pass --name and --path or --url, or use workspace repo add --json"
            )
        if not name or prompt:
            result = project_manager.add(
                config_path,
                project,
                None,
                metagit_config=local_config,
                agent_mode=agent_mode,
            )
            if isinstance(result, Exception):
                raise result
            if as_json:
                emit_json(
                    CatalogMutationResult(
                        ok=True,
                        entity="repo",
                        operation="add",
                        project_name=project,
                        repo_name=result.name,
                        config_path=str(Path(config_path).resolve()),
                    )
                )
                return
        else:
            repo_data = {
                "name": name,
                "description": description,
                "kind": ProjectKind(kind) if kind else None,
                "ref": ref,
                "path": path,
                "url": url,
                "sync": sync,
                "language": language,
                "language_version": language_version,
                "package_manager": package_manager,
                "frameworks": list(frameworks) if frameworks else None,
            }
            repo_data = {k: v for k, v in repo_data.items() if v is not None}
            project_path = ProjectPath(**repo_data)
            if as_json:
                mutation = catalog.add_repo(
                    local_config,
                    config_path,
                    project_name=project,
                    repo=project_path,
                )
                exit_on_catalog_mutation(mutation, as_json=True)
                return
            result = project_manager.add(
                config_path,
                project,
                project_path,
                local_config,
                agent_mode=agent_mode,
            )

        if isinstance(result, Exception):
            raise result

        repo_name = result.name if result.name else "repository"
        logger.info(
            f"Successfully added repository '{repo_name}' to project '{project}'"
        )
        logger.info(
            f"You can now use `metagit repo sync --project {project}` to sync the repository"
        )

    except Exception as e:
        logger.warning(f"Failed to add repository: {e}")


@repo.command("prune")
@click.option(
    "--dry-run",
    is_flag=True,
    default=False,
    help="List unmanaged directories only; do not prompt or delete.",
)
@click.option(
    "--include-hidden",
    is_flag=True,
    default=False,
    help="Include dot-directories (overrides workspace.ui_ignore_hidden).",
)
@click.option(
    "--force",
    is_flag=True,
    default=False,
    help="Remove all listed unmanaged paths without prompting (no effect with --dry-run).",
)
@click.pass_context
def repo_prune(
    ctx: click.Context,
    dry_run: bool,
    include_hidden: bool,
    force: bool,
) -> None:
    """
    Walk the project sync folder and offer to remove directories not in .metagit.yml.

    Only considers immediate children of the project directory (same layout as sync).
    """
    logger: UnifiedLogger = ctx.obj["logger"]
    project: str = ctx.obj["project"]
    app_config: AppConfig = ctx.obj["config"]
    local_config = ctx.obj["local_config"]

    if project == "local":
        raise click.UsageError("The local project is not supported for this command")

    if not local_config.workspace:
        logger.error("No workspace configuration found")
        ctx.abort()

    try:
        project_manager = project_manager_from_app(
            app_config,
            logger,
            metagit_config=local_config,
            project_name=project,
        )
    except Exception as exc:
        logger.warning(f"Failed to initialize ProjectManager: {exc}")
        ctx.abort()

    workspace_root = Path(app_config.workspace.path).expanduser().resolve()
    project_sync_folder = (workspace_root / project).resolve()
    click.echo("Prune context:")
    click.echo(f"  workspace.path (sync root): {workspace_root}")
    click.echo(f"  project: {project}")
    click.echo(f"  project sync folder: {project_sync_folder}")

    ignore_hidden = (
        False if include_hidden else bool(app_config.workspace.ui_ignore_hidden)
    )
    candidates = project_manager.list_unmanaged_sync_directories(
        local_config,
        project,
        ignore_hidden=ignore_hidden,
    )
    dedupe = resolve_effective_dedupe_for_project(
        app_config.workspace.dedupe,
        local_config,
        project,
    )
    if dedupe is not None:
        references = workspace_dedupe.list_canonical_references(
            local_config,
            workspace_root,
            dedupe,
        )
        orphans = workspace_dedupe.list_orphan_canonical_dirs(
            workspace_root,
            dedupe,
            references,
        )
        if orphans:
            click.echo("Orphan canonical directories (not referenced in .metagit.yml):")
            for orphan in orphans:
                click.echo(f"  - {orphan}")
        else:
            click.echo("No orphan canonical directories under _canonical/.")
    if not candidates:
        click.echo("No unmanaged sync directories found under the project sync folder.")
        return

    click.echo(
        f"Found {len(candidates)} unmanaged entr{'y' if len(candidates) == 1 else 'ies'}:"
    )
    for path in candidates:
        click.echo(f"  - {path}")

    if dry_run:
        click.echo("Dry run: no changes made.")
        return

    if ctx.obj.get("agent_mode") and not force:
        raise click.UsageError(
            "Interactive prune prompts are disabled in agent mode; use --force or --dry-run"
        )

    removed = 0
    for path in candidates:
        rel = path.name
        if not force and not click.confirm(
            f"Remove unmanaged path {rel!r} at {path}?", default=False
        ):
            continue
        try:
            project_manager.remove_sync_directory(path)
            removed += 1
            logger.success(f"Removed {path}")
        except OSError as exc:
            logger.error(f"Failed to remove {path}: {exc}")

    if force:
        click.echo(f"Prune finished (--force): {removed} removed.")
    else:
        click.echo(f"Prune finished: {removed} removed.")
`````

## File: src/metagit/cli/main.py
`````python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
This is the entry point for the command-line interface (CLI) application.

It can be used as a handy facility for running the task from a command line.

.. note::

    To learn more about Click visit the
    `project website <http://click.pocoo.org/5/>`_.  There is also a very
    helpful `tutorial video <https://www.youtube.com/watch?v=kNke39OZ2k0>`_.

    To learn more about running Luigi, visit the Luigi project's
    `Read-The-Docs <http://luigi.readthedocs.io/en/stable/>`_ page.

.. currentmodule:: metagit_detect.cli
.. moduleauthor:: Zachary Loeber <zloeber@gmail.com>
"""

from pathlib import Path

import click

from metagit import DEFAULT_CONFIG, __version__
from metagit.cli.commands.api import api
from metagit.cli.commands.appconfig import appconfig
from metagit.cli.commands.config import config
from metagit.cli.commands.detect import detect
from metagit.cli.commands.init import init
from metagit.cli.commands.mcp import mcp
from metagit.cli.commands.project import project
from metagit.cli.commands.prompt import prompt
from metagit.cli.commands.record import record
from metagit.cli.commands.search import search
from metagit.cli.commands.skills import skills
from metagit.cli.commands.web import web
from metagit.cli.commands.workspace import workspace
from metagit.core.appconfig import load_config, resolve_agent_mode
from metagit.core.utils.logging import LoggerConfig, UnifiedLogger

CONTEXT_SETTINGS: dict = {
    "help_option_names": ["-h", "--help"],
    "max_content_width": 120,
}


@click.group(context_settings=CONTEXT_SETTINGS, invoke_without_command=True)
@click.version_option(__version__)
@click.option(
    "--config",
    "-c",
    default="metagit.config.yaml",
    help="Path to the configuration file",
)
@click.option("--debug/--no-debug", default=False, help="Enable or disable debug mode")
@click.option(
    "--verbose/--no-verbose", default=False, help="Enable or disable verbose output"
)
@click.pass_context
def cli(ctx: click.Context, config: str, debug: bool, verbose: bool) -> None:
    """
    Metagit CLI: A multi-purpose CLI tool with YAML configuration.
    """
    # If no subcommand is provided, show help
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())
        return
    log_level: str = "INFO"
    minimal_console: bool = True
    if verbose:
        log_level = "INFO"
        minimal_console = False
    if debug:
        log_level = "DEBUG"
        minimal_console = False

    try:
        logger: UnifiedLogger = UnifiedLogger(
            LoggerConfig(log_level=log_level, minimal_console=minimal_console)
        )

        if not Path(config).exists():
            logger.debug(
                f"Config file '{config}' not found, using default: {DEFAULT_CONFIG}"
            )
            config = DEFAULT_CONFIG
        cfg = load_config(config)
        if isinstance(cfg, Exception):
            logger.error(f"Error loading config: {cfg}")
            ctx.abort()

        # Store the configuration and logger in the context
        ctx.obj = {
            "config_path": config,
            "config": cfg,
            "agent_mode": resolve_agent_mode(cfg),
            "logger": logger,
            "verbose": verbose,
            "debug": debug,
        }
    except Exception as e:
        logger = UnifiedLogger(LoggerConfig())
        logger.error(f"An unexpected error occurred in CLI setup: {e}")
        ctx.abort()


@cli.command()
@click.pass_context
def info(ctx: click.Context) -> None:
    """
    Display the current configuration.
    """
    logger = ctx.obj.get("logger") or UnifiedLogger(LoggerConfig())

    logger.config_element(name="version", value=__version__, console=True)
    logger.config_element(
        name="config_path", value=ctx.obj["config_path"], console=True
    )
    logger.config_element(name="debug", value=ctx.obj["debug"], console=True)
    logger.config_element(name="verbose", value=ctx.obj["verbose"], console=True)


@cli.command()
@click.pass_context
def version(ctx: click.Context) -> None:
    """Get the application version."""
    logger = ctx.obj.get("logger") or UnifiedLogger(LoggerConfig())
    logger.config_element(name="version", value=__version__, console=True)


cli.add_command(detect)
cli.add_command(appconfig)
cli.add_command(project)
cli.add_command(workspace)
cli.add_command(config)
cli.add_command(record)
cli.add_command(skills)
cli.add_command(init)
cli.add_command(mcp)
cli.add_command(api)
cli.add_command(web)
cli.add_command(search)
cli.add_command(search, name="find")
cli.add_command(prompt)


def main() -> None:
    cli()


if __name__ == "__main__":
    main()
`````

## File: src/metagit/core/mcp/tool_registry.py
`````python
#!/usr/bin/env python
"""
Tool registry for Metagit MCP runtime.
"""

from metagit.core.mcp.models import McpActivationState, WorkspaceStatus


class ToolRegistry:
    """State-aware registry of available MCP tool names."""

    _inactive_tools: list[str] = [
        "metagit_workspace_status",
        "metagit_bootstrap_config_plan_only",
    ]
    _active_tools: list[str] = [
        "metagit_workspace_status",
        "metagit_workspace_index",
        "metagit_workspace_search",
        "metagit_workspace_semantic_search",
        "metagit_repo_search",
        "metagit_upstream_hints",
        "metagit_repo_inspect",
        "metagit_repo_sync",
        "metagit_workspace_sync",
        "metagit_bootstrap_config",
        "metagit_project_context_switch",
        "metagit_workspace_state_snapshot",
        "metagit_workspace_state_restore",
        "metagit_session_update",
        "metagit_cross_project_dependencies",
        "metagit_export_workspace_graph_cypher",
        "metagit_workspace_health_check",
        "metagit_workspace_discover",
        "metagit_workspace_list",
        "metagit_workspace_projects_list",
        "metagit_workspace_project_add",
        "metagit_workspace_project_remove",
        "metagit_workspace_repos_list",
        "metagit_workspace_repo_add",
        "metagit_workspace_repo_remove",
        "metagit_workspace_project_rename",
        "metagit_workspace_repo_rename",
        "metagit_workspace_repo_move",
        "metagit_project_template_apply",
    ]

    def list_tools(self, status: WorkspaceStatus) -> list[str]:
        """List available tools for the provided workspace status."""
        if status.state == McpActivationState.ACTIVE:
            return self._active_tools.copy()
        return self._inactive_tools.copy()
`````

## File: src/metagit/data/web/index.html
`````html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>web</title>
    <script type="module" crossorigin src="/assets/index-DOullneW.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-B315j_NF.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
`````

## File: pyproject.toml
`````toml
[project]
name = "metagit-cli"
dynamic = ["version"]
description = "Metagit-ai multi-repo management tool"
readme = "README.md"
license-files = ["LICENSE"]

authors = [
    {name = "Zachary Loeber", email = "zloeber@gmail.com"}
]
classifiers = [
    "Programming Language :: Python :: 3",
    "Operating System :: OS Independent",
]
requires-python = ">=3.12"
dependencies = [
    "logging>=0.4.9.6",
    "loguru>=0.7.3",
    "pydantic>=2.11.4",
    "requests>=2.33.0",
    "click>=8.1.7",
    "dotenv>=0.9.9",
    "gitpython>=3.1.50",
    "rich>=14.0.0",
    "pyyaml>=6.0.2",
    "pygithub>=2.6.1",
    "jsonschema>=4.24.0",
    "python-dotenv>=1.2.2",
    "rapidfuzz>=3.13.0",
    "tqdm>=4.66.4",
    "types-tqdm>=4.67.0.20250516",
    "aiofiles>=23.2.1",
    "async>=0.6.2",
    "setuptools-scm>=8.3.1",
    "litellm>=1.83.7",
    "crewai>=0.95.0",
    "textual-dev>=1.7.0",
    "tomli>=2.2.1",
    "python-hcl2>=7.3.1",
    "prompt-toolkit>=3.0.51",
]

[project.urls]
Homepage = "https://github.com/metagit-ai/metagit-cli"
Documentation = "https://metagit-ai.github.io/metagit-cli/"
Issues = "https://github.com/metagit-ai/metagit-cli/issues"
CI = "https://github.com/metagit-ai/metagit-cli/actions"

[project.optional-dependencies]
standard = []
test = ["pytest>=9.0.3", "pytest-cov", "mypy", "ruff", "httpx", "pytest-asyncio", "black>=26.3.1"]
dev = ["ruff", "black>=26.3.1", "isort", "mypy"]
docs = ["mkdocs", "mkdocs-material", "mkdocstrings", "mkdocs-click", "mdtoc", "mkdocs-mermaid2-plugin", "mkdocs-material"]
lint = ["ruff", "black", "isort", "mypy"]
format = ["black>=26.3.1", "isort"]
typecheck = ["mypy"]

[project.scripts]
metagit = "metagit.cli.main:cli"

[lint]
select = ['E', 'W', 'F', 'I', 'B', 'C4', 'ARG', 'SIM']
ignore = ['W291', 'W292', 'W293', 'E501', 'SIM115']

[tool.ruff]
exclude = [".venv", "records", ".metagit", "examples", "docs", "tests"]

[tool.uv]
override-dependencies = [
    "aiohttp>=3.13.4",
    "cryptography>=46.0.7",
    "filelock>=3.20.3",
    "idna>=3.15",
    "orjson>=3.11.5",
    "pdfminer-six>=20251230",
    "pillow>=12.2.0",
    "protobuf>=6.33.5",
    "pyasn1>=0.6.3",
    "pygments>=2.20.0",
    "pyjwt>=2.12.0",
    "pymdown-extensions>=10.21.3",
    "pynacl>=1.6.2",
    "urllib3>=2.7.0",
    "uv>=0.11.6",
    "virtualenv>=20.36.1",
]

[dependency-groups]
dev = [
    "mypy>=1.16.1",
    "ruff>=0.11.11",
    "bandit",
    "pip-audit",
]
# docs/architecture-diagram.py only — not default sync (OSV false positive PYSEC-2024-270).
diagrams = ["diagrams>=0.25.1"]

[tool.setuptools]
package-dir = {"" = "src"}

[tool.setuptools_scm]
version_file = "src/metagit/_version.py"

[tool.setuptools.packages.find]
where = ["src"]
include = ["metagit*"]

[tool.setuptools.package-data]
metagit = ["data/**/*"]

[build-system]
requires = ["setuptools>=64", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
`````

## File: src/metagit/cli/commands/config.py
`````python
"""
Config subcommand
"""

import json
import os
from pathlib import Path
from typing import Union

import click

from metagit.cli.config_patch_ops import (
    emit_patch_result,
    emit_preview_result,
    emit_tree_result,
    resolve_operations,
)
from metagit.cli.json_output import emit_json
from metagit.core.appconfig import AppConfig
from metagit.core.config.manager import MetagitConfigManager, create_metagit_config
from metagit.core.config.graph_cypher_export import GraphCypherExportService
from metagit.core.config.patch_service import ConfigPatchService
from metagit.core.config.yaml_display import dump_config_dict


@click.group(name="config", invoke_without_command=True)
@click.option(
    "--config-path",
    "-c",
    help="Path to the metagit configuration file",
    default=".metagit.yml",
)
@click.pass_context
def config(ctx: click.Context, config_path: str) -> None:
    """Configuration subcommands"""
    try:
        # If no subcommand is provided, show help
        if ctx.invoked_subcommand is None:
            click.echo(ctx.get_help())
            return
        ctx.ensure_object(dict)
        ctx.obj["config_path"] = config_path
        # Initialize a dummy logger for testing purposes if not already present
        if "logger" not in ctx.obj:
            from metagit.core.utils.logging import LoggerConfig, UnifiedLogger

            ctx.obj["logger"] = UnifiedLogger(
                LoggerConfig(log_level="INFO", minimal_console=True)
            )
    except Exception as e:
        logger = ctx.obj.get("logger")
        if logger:
            logger.error(f"An error occurred in the config command: {e}")
        else:
            click.echo(f"An error occurred: {e}", err=True)
        ctx.abort()


@config.command("show")
@click.option(
    "--normalized",
    is_flag=True,
    default=False,
    help="Re-serialize from the loaded model (readable YAML, not the file on disk)",
)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def config_show(ctx: click.Context, normalized: bool, as_json: bool) -> None:
    """Show metagit configuration (source file by default)."""
    logger = ctx.obj["logger"]
    try:
        config_path = ctx.obj["config_path"]
        config_manager = MetagitConfigManager(config_path=config_path)
        config_result = config_manager.load_config()
        if isinstance(config_result, Exception):
            raise config_result

        if as_json:
            emit_json(config_result.model_dump(mode="json", exclude_none=True))
            return

        path = Path(config_path)
        if not normalized and path.is_file():
            raw = path.read_text(encoding="utf-8")
            click.echo(raw, nl=raw.endswith("\n"))
            return

        output = dump_config_dict(
            config_result.model_dump(mode="json", exclude_none=True)
        )
        click.echo(output, nl=False)
        if not output.endswith("\n"):
            click.echo()
    except Exception as e:
        logger.error(f"Failed to load metagit configuration file: {e}")
        logger.debug(f"Error: {e}")
        ctx.abort()


@config.command("create")
@click.option(
    "--output-path",
    help="Path to the metagit configuration file",
    default=None,
)
@click.option("--name", help="Project name", default=None)
@click.option(
    "--description",
    help="Project description",
    default=None,
)
@click.option(
    "--url",
    help="Project URL",
    default=None,
)
@click.option(
    "--kind",
    help="Project kind",
    default=None,
)
@click.pass_context
def config_create(
    ctx: click.Context,
    output_path: str,
    name: str,
    description: str,
    url: str,
    kind: str,
) -> None:
    """Create metagit config files"""
    logger = ctx.obj["logger"]

    try:
        config_file = create_metagit_config(
            name=name, description=description, url=url, kind=kind, as_yaml=True
        )
        if isinstance(config_file, Exception):
            raise config_file
    except Exception as e:
        logger.error(f"Failed to create config: {e}")
        ctx.abort()

    if output_path is None:
        logger.echo(config_file)
    else:
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(config_file)
        logger.success(f"Configuration file {output_path} created")


@config.command("validate")
@click.option("--config-path", help="Path to the configuration file", default=None)
@click.pass_context
def config_validate(ctx: click.Context, config_path: Union[str, None] = None) -> None:
    """Validate metagit configuration"""
    logger = ctx.obj["logger"]
    target_path = config_path or ctx.obj["config_path"]
    try:
        config_manager = MetagitConfigManager(config_path=target_path)
        result = config_manager.load_config()
        if isinstance(result, Exception):
            raise result
        logger.success(f"Configuration file {target_path} is valid")
    except Exception as e:
        logger.error(f"Failed to load metagit configuration file: {e}")
        logger.debug(f"Error: {e}")
        ctx.abort()


@config.command("providers")
@click.option(
    "--show",
    is_flag=True,
    default=False,
    help="Show current provider configuration.",
)
@click.option(
    "--enable-github",
    is_flag=True,
    default=False,
    help="Enable GitHub provider.",
)
@click.option(
    "--disable-github",
    is_flag=True,
    default=False,
    help="Disable GitHub provider.",
)
@click.option(
    "--enable-gitlab",
    is_flag=True,
    default=False,
    help="Enable GitLab provider.",
)
@click.option(
    "--disable-gitlab",
    is_flag=True,
    default=False,
    help="Disable GitLab provider.",
)
@click.option(
    "--github-token",
    help="Set GitHub API token.",
)
@click.option(
    "--gitlab-token",
    help="Set GitLab API token.",
)
@click.option(
    "--github-url",
    help="Set GitHub API base URL (for GitHub Enterprise).",
)
@click.option(
    "--gitlab-url",
    help="Set GitLab API base URL (for self-hosted GitLab).",
)
@click.option(
    "--config-path",
    help="Path to configuration file (default: ~/.config/metagit/config.yml).",
)
@click.pass_context
def providers(
    ctx: click.Context,
    show: bool,
    enable_github: bool,
    disable_github: bool,
    enable_gitlab: bool,
    disable_gitlab: bool,
    github_token: str,
    gitlab_token: str,
    github_url: str,
    gitlab_url: str,
    config_path: str,
) -> None:
    """Manage git provider plugin configuration."""
    logger = ctx.obj["logger"]

    try:
        # Load current configuration
        app_config = AppConfig.load(config_path)
        if isinstance(app_config, Exception):
            logger.error(f"Failed to load configuration: {app_config}")
            ctx.abort()

        # Show current configuration
        if show:
            click.echo("Current Provider Configuration:")
            click.echo(
                f"  GitHub: {'Enabled' if app_config.providers.github.enabled else 'Disabled'}"
            )
            if app_config.providers.github.api_token:
                click.echo(
                    f"    Token: {'*' * 10}{app_config.providers.github.api_token[-4:]}"
                )
            else:
                click.echo("    Token: Not set")
            click.echo(f"    Base URL: {app_config.providers.github.base_url}")

            click.echo(
                f"  GitLab: {'Enabled' if app_config.providers.gitlab.enabled else 'Disabled'}"
            )
            if app_config.providers.gitlab.api_token:
                click.echo(
                    f"    Token: {'*' * 10}{app_config.providers.gitlab.api_token[-4:]}"
                )
            else:
                click.echo("    Token: Not set")
            click.echo(f"    Base URL: {app_config.providers.gitlab.base_url}")
            return

        # Update configuration
        modified = False

        # GitHub configuration
        if enable_github:
            app_config.providers.github.enabled = True
            modified = True
            click.echo("✅ GitHub provider enabled")

        if disable_github:
            app_config.providers.github.enabled = False
            modified = True
            click.echo("✅ GitHub provider disabled")

        if github_token:
            app_config.providers.github.api_token = github_token
            modified = True
            click.echo("✅ GitHub token updated")

        if github_url:
            app_config.providers.github.base_url = github_url
            modified = True
            click.echo("✅ GitHub base URL updated")

        # GitLab configuration
        if enable_gitlab:
            app_config.providers.gitlab.enabled = True
            modified = True
            click.echo("✅ GitLab provider enabled")

        if disable_gitlab:
            app_config.providers.gitlab.enabled = False
            modified = True
            click.echo("✅ GitLab provider disabled")

        if gitlab_token:
            app_config.providers.gitlab.api_token = gitlab_token
            modified = True
            click.echo("✅ GitLab token updated")

        if gitlab_url:
            app_config.providers.gitlab.base_url = gitlab_url
            modified = True
            click.echo("✅ GitLab base URL updated")

        # Save configuration if modified
        if modified:
            result = app_config.save(config_path)
            if isinstance(result, Exception):
                logger.error(f"Failed to save configuration: {result}")
                ctx.abort()
            click.echo("✅ Configuration saved")
        else:
            click.echo("No changes made. Use --show to view current configuration.")

    except Exception as e:
        logger.error(f"Error managing provider configuration: {e}")
        ctx.abort()


@config.command("set")
@click.argument("key")
@click.argument("value")
@click.pass_context
def config_set(ctx: click.Context, key: str, value: str) -> None:
    """Set a specific configuration value."""
    logger = ctx.obj["logger"]
    config_path = ctx.obj["config_path"]

    try:
        config_manager = MetagitConfigManager(config_path=config_path)
        current_config = config_manager.load_config()
        if isinstance(current_config, Exception):
            raise current_config

        # Helper to set nested attributes
        def set_nested_attr(obj, attr_path, val):
            parts = attr_path.split(".")
            for i, part in enumerate(parts):
                if i == len(parts) - 1:
                    setattr(obj, part, val)
                else:
                    obj = getattr(obj, part)

        set_nested_attr(current_config, key, value)

        # Save the updated configuration
        result = config_manager.save_config(current_config)
        if isinstance(result, Exception):
            raise result

        logger.success(f"Configuration key '{key}' set to '{value}' in {config_path}")

    except Exception as e:
        logger.error(f"Failed to set configuration key '{key}': {e}")
        logger.debug(f"Error: {e}")
        ctx.abort()


@config.command("info")
@click.pass_context
def config_info(ctx: click.Context) -> None:
    """
    Display information about the local project configuration.
    """
    logger = ctx.obj["logger"]
    config_path = ctx.obj["config_path"]

    if os.path.exists(ctx.obj["config_path"]):
        logger.config_element(name="config_path", value=config_path, console=True)
        config_manager = MetagitConfigManager(config_path=config_path)
        current_config = config_manager.load_config()
        if isinstance(current_config, Exception):
            logger.error(f"Failed to load configuration: {current_config}")
            ctx.abort()
        logger.config_element(
            name="project_name",
            value=current_config.name or "N/A",
            console=True,
        )
        logger.config_element(
            name="project_kind",
            value=current_config.kind or "N/A",
            console=True,
        )
        project_count = (
            len(current_config.workspace.projects)
            if current_config.workspace.projects
            else 0
        )
        logger.config_element(
            name="project_count",
            value=project_count,
            console=True,
        )
        if project_count > 0:
            for project in current_config.workspace.projects:
                logger.config_element(
                    name=f"project_{project.name}_entry_count",
                    value=len(project.repos) if project.repos else 0,
                    console=True,
                )
    else:
        logger.echo("No project config file found!")
        logger.echo(
            "Create a new config file with 'metagit config create' or 'metagit init'"
        )


@config.command("example")
@click.option(
    "--output",
    "output_path",
    help="Write exemplar YAML to this path (default: stdout)",
    default=None,
)
@click.option(
    "--include-workspace/--no-include-workspace",
    default=True,
    help="Include the workspace block in the generated exemplar",
)
@click.option(
    "--comment-style",
    type=click.Choice(["line", "none"], case_sensitive=False),
    default="line",
    help="Emit Field descriptions as YAML comments (line) or plain YAML only",
)
@click.pass_context
def config_example(
    ctx: click.Context,
    output_path: str | None,
    include_workspace: bool,
    comment_style: str,
) -> None:
    """
    Generate a non-production YAML exemplar with optional field descriptions.

    Merges src/metagit/data/config-example-overrides.yml when present.
    """
    from metagit.core.config.example_generator import (
        ConfigExampleGenerator,
        load_example_overrides,
    )

    logger = ctx.obj["logger"]
    try:
        generator = ConfigExampleGenerator(overrides=load_example_overrides())
        rendered = generator.render_yaml(
            include_workspace=include_workspace,
            comment_style=comment_style,
        )
        if output_path:
            with open(output_path, "w", encoding="utf-8") as handle:
                handle.write(rendered)
            logger.success(f"Config exemplar written to {output_path}")
            return
        click.echo(rendered, nl=False)
    except Exception as exc:
        logger.error(f"Failed to generate config exemplar: {exc}")
        ctx.abort()


@config.command("tree")
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def config_tree(ctx: click.Context, as_json: bool) -> None:
    """Show schema-backed field tree for .metagit.yml (same model as web Config Studio)."""
    logger = ctx.obj["logger"]
    config_path = ctx.obj["config_path"]
    result = ConfigPatchService().build_tree("metagit", config_path)
    if isinstance(result, Exception):
        logger.error(f"Failed to build config tree: {result}")
        ctx.abort()
    emit_tree_result(result, as_json=as_json)


@config.command("preview")
@click.option(
    "--style",
    type=click.Choice(["normalized", "minimal", "disk"], case_sensitive=False),
    default="normalized",
    show_default=True,
    help="YAML preview style",
)
@click.option(
    "--file",
    "operations_file",
    type=click.Path(exists=True, dir_okay=False),
    default=None,
    help="JSON file with operations array or {operations, save}",
)
@click.option(
    "--op",
    type=click.Choice(["enable", "disable", "set", "append", "remove"]),
    default=None,
    help="Single operation kind (use with --path)",
)
@click.option("--path", default=None, help="Field path for a single operation")
@click.option("--value", default=None, help="Value for set (JSON or scalar)")
@click.option(
    "--output",
    "output_path",
    default=None,
    help="Write preview YAML to this path instead of stdout",
)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def config_preview(
    ctx: click.Context,
    style: str,
    operations_file: str | None,
    op: str | None,
    path: str | None,
    value: str | None,
    output_path: str | None,
    as_json: bool,
) -> None:
    """Preview .metagit.yml after applying draft operations (no save)."""
    logger = ctx.obj["logger"]
    config_path = ctx.obj["config_path"]
    operations = (
        resolve_operations(
            operations_file=operations_file,
            op=op,
            path=path,
            value=value,
        )
        if operations_file or op
        else []
    )
    result = ConfigPatchService().preview(
        "metagit",
        config_path,
        operations,
        style=style,
    )
    if isinstance(result, Exception):
        logger.error(f"Failed to preview config: {result}")
        ctx.abort()
    emit_preview_result(
        result,
        as_json=as_json,
        logger=logger,
        output_path=output_path,
    )


@config.command("patch")
@click.option(
    "--file",
    "operations_file",
    type=click.Path(exists=True, dir_okay=False),
    default=None,
    help="JSON file with operations array or {operations, save}",
)
@click.option(
    "--op",
    type=click.Choice(["enable", "disable", "set", "append", "remove"]),
    default=None,
    help="Single operation kind (use with --path)",
)
@click.option("--path", default=None, help="Field path for a single operation")
@click.option("--value", default=None, help="Value for set (JSON or scalar)")
@click.option(
    "--save",
    is_flag=True,
    default=False,
    help="Write changes to disk when validation passes",
)
@click.option(
    "--tree",
    "include_tree",
    is_flag=True,
    default=False,
    help="Include updated schema tree in JSON output",
)
@click.option(
    "--json", "as_json", is_flag=True, default=False, help="Print JSON for agents"
)
@click.pass_context
def config_patch(
    ctx: click.Context,
    operations_file: str | None,
    op: str | None,
    path: str | None,
    value: str | None,
    save: bool,
    include_tree: bool,
    as_json: bool,
) -> None:
    """
    Apply schema operations to .metagit.yml (enable/disable/set/append/remove).

    Same operation model as the web Config Studio PATCH API. Example operations file:

    {"operations": [{"op": "set", "path": "name", "value": "my-workspace"}]}
    """
    logger = ctx.obj["logger"]
    config_path = ctx.obj["config_path"]
    operations = resolve_operations(
        operations_file=operations_file,
        op=op,
        path=path,
        value=value,
    )
    result = ConfigPatchService().patch(
        "metagit",
        config_path,
        operations,
        save=save,
        include_tree=include_tree or as_json,
    )
    if isinstance(result, Exception):
        logger.error(f"Failed to patch config: {result}")
        ctx.abort()
    emit_patch_result(result, as_json=as_json, logger=logger)


@config.group("graph")
@click.pass_context
def config_graph(ctx: click.Context) -> None:
    """Export workspace graph data for GitNexus / Cypher ingest."""
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())


@config_graph.command("export")
@click.option(
    "--workspace-root",
    default=None,
    help="Workspace root (default: appconfig workspace.path)",
)
@click.option(
    "--gitnexus-repo",
    default=None,
    help="Target GitNexus repo name for tool_calls (default: manifest name)",
)
@click.option(
    "--include-structure/--no-include-structure",
    default=True,
    help="Include project/repo nodes and contains edges",
)
@click.option(
    "--include-documentation/--no-include-documentation",
    default=False,
    help="Include documentation nodes and documents edges",
)
@click.option(
    "--manual-only",
    is_flag=True,
    default=False,
    help="Only export graph.relationships (still ensures endpoint nodes)",
)
@click.option(
    "--with-schema/--no-with-schema",
    default=True,
    help="Emit CREATE NODE/REL TABLE statements first",
)
@click.option(
    "--format",
    "output_format",
    type=click.Choice(["cypher", "json", "tool-calls"], case_sensitive=False),
    default="json",
    show_default=True,
    help="Output: full JSON bundle, tool-calls array, or raw Cypher lines",
)
@click.option("--output", "output_path", default=None, help="Write output to file")
@click.pass_context
def config_graph_export(
    ctx: click.Context,
    workspace_root: str | None,
    gitnexus_repo: str | None,
    include_structure: bool,
    include_documentation: bool,
    manual_only: bool,
    with_schema: bool,
    output_format: str,
    output_path: str | None,
) -> None:
    """
    Export manual graph.relationships as GitNexus-ingestible Cypher.

    Emits MERGE/CREATE statements for MetagitEntity / MetagitLink overlay tables and
    matching gitnexus_cypher MCP tool_calls. Run schema statements once per target index.
    """
    logger = ctx.obj["logger"]
    config_path = ctx.obj["config_path"]
    app_config = ctx.obj.get("config")
    if app_config is None:
        logger.error("App config missing from CLI context")
        ctx.abort()

    try:
        manager = MetagitConfigManager(config_path=config_path)
        loaded = manager.load_config()
        if isinstance(loaded, Exception):
            raise loaded
        root = workspace_root or str(
            Path(app_config.workspace.path).expanduser().resolve()
        )
        result = GraphCypherExportService().export(
            loaded,
            root,
            gitnexus_repo=gitnexus_repo,
            include_structure=include_structure,
            include_documentation=include_documentation,
            manual_only=manual_only,
            with_schema=with_schema,
        )
    except Exception as exc:
        logger.error(f"Failed to export graph Cypher: {exc}")
        ctx.abort()

    if output_format == "cypher":
        lines = [*result.schema_statements, *result.statements]
        rendered = "\n".join(lines) + ("\n" if lines else "")
    elif output_format == "tool-calls":
        rendered = json.dumps(
            [item.model_dump(mode="json") for item in result.tool_calls],
            indent=2,
        )
    else:
        rendered = json.dumps(result.model_dump(mode="json"), indent=2)

    if output_path:
        Path(output_path).write_text(rendered, encoding="utf-8")
        logger.success(f"Graph Cypher export written to {output_path}")
        if result.warnings:
            for warning in result.warnings:
                logger.warning(warning)
        return

    click.echo(rendered, nl=rendered.endswith("\n"))
    if result.warnings:
        for warning in result.warnings:
            logger.warning(warning)


@config.command("schema")
@click.option(
    "--output-path",
    help="Path to output the JSON schema file",
    default="metagit_config.schema.json",
)
@click.pass_context
def config_schema(ctx: click.Context, output_path: str) -> None:
    """
    Generate a JSON schema for the MetagitConfig class and write it to a file.
    """
    from metagit.core.config.models import MetagitConfig

    logger = ctx.obj["logger"]
    try:
        schema = MetagitConfig.model_json_schema()
        with open(output_path, "w", encoding="utf-8") as f:
            json.dump(schema, f, indent=2)
        logger.success(f"JSON schema written to {output_path}")
    except Exception as e:
        logger.error(f"Failed to generate JSON schema: {e}")
        ctx.abort()
`````

## File: mkdocs.yml
`````yaml
site_name: Metagit-AI Documentation
theme:
  palette:
    scheme: slate
  name: material
nav:
  - 'index.md'
  - 'Installation': 'install.md'
  - 'Terminology': 'terminology.md'
  - 'Application Logic': 'app.logic.md'
  - 'Development': 'development.md'
  - 'Secrets': 'secrets.analysis.md'
  - 'CLI Reference': 'cli_reference.md'
  - 'Features':
    - 'Repository Detection': 'repository_detection.md'
    - 'Hermes & org IaC guide': 'hermes-iac-workspace-guide.md'
    - 'Hermes orchestrator workspace': 'hermes-orchestrator-workspace.md'
    - 'Configuration exemplar': 'reference/metagit-config.md'
    - 'Workspace layout API': 'reference/workspace-layout-api.md'
    - 'Metagit Web UI': 'reference/metagit-web.md'
    - 'Skills': 'skills.md'
markdown_extensions:
  - mkdocs-click
  - admonition         # Adds boxes for Notes, Warnings, etc.
  - attr_list          # Adds attribute lists to generated HTML
  - codehilite         # Syntax coloring for code blocks
  - def_list           # Adds syntax for definition lists
  - sane_lists         # Enables list items with multiple paragraphs
  - toc:               # Builds a table of contents
      permalink: "#"   # Puts a "#" at the end of each header
  - pymdownx.superfences:
        # make exceptions to highlighting of code:
      custom_fences:
        - name: mermaid
          class: mermaid
          format: !!python/name:mermaid2.fence_mermaid_custom
`````

## File: src/metagit/core/appconfig/models.py
`````python
#!/usr/bin/env python

import os
from enum import Enum
from pathlib import Path
from typing import List, Optional, Union

from pydantic import BaseModel, ConfigDict, Field

from metagit import DATA_PATH
from metagit.core.utils.yaml_class import yaml

success_blurb: str = "Success! ✅"
failure_blurb: str = "Failed! ❌"


class WorkspaceDedupeScope(str, Enum):
    """Where repository deduplication is applied."""

    WORKSPACE = "workspace"
    GLOBAL = "global"


class WorkspaceDedupeStrategy(str, Enum):
    """How duplicate repository identities share disk layout."""

    SYMLINK = "symlink"


class WorkspaceDedupeConfig(BaseModel):
    """Deduplicate synced repos within a workspace via a canonical directory."""

    model_config = ConfigDict(use_enum_values=True, extra="forbid")

    enabled: bool = Field(
        default=False,
        description="When true, clone once under canonical_dir and symlink per project",
    )
    scope: WorkspaceDedupeScope = Field(
        default=WorkspaceDedupeScope.WORKSPACE,
        description="Dedupe scope (v1 implements workspace only)",
    )
    strategy: WorkspaceDedupeStrategy = Field(
        default=WorkspaceDedupeStrategy.SYMLINK,
        description="How project mounts reference canonical checkouts",
    )
    canonical_dir: str = Field(
        default="_canonical",
        description="Directory under workspace.path holding canonical checkouts",
    )


class WorkspaceConfig(BaseModel):
    """Model for workspace configuration in AppConfig."""

    path: str = Field(default="./.metagit", description="Workspace path")
    default_project: str = Field(default="default", description="Default project")
    dedupe: WorkspaceDedupeConfig = Field(
        default_factory=WorkspaceDedupeConfig,
        description="Optional workspace-scoped repository deduplication settings",
    )
    ui_show_preview: Optional[bool] = Field(
        default=True, description="Show preview in fuzzy finder console UI"
    )
    ui_menu_length: Optional[int] = Field(
        default=10, description="Number of items to show in menu"
    )
    ui_preview_height: Optional[int] = Field(
        default=3, description="Height of preview in fuzzy finder console UI"
    )
    ui_ignore_hidden: bool = Field(
        default=True,
        description="When true, hide dotfiles and dot-directories from repo picker UI",
    )

    class Config:
        """Pydantic configuration."""

        extra = "forbid"


class LLM(BaseModel):
    """Model for LLM configuration in AppConfig."""

    enabled: bool = Field(default=False, description="Whether LLM is enabled")
    provider: str = Field(default="openrouter", description="LLM provider")
    provider_model: str = Field(default="gpt-4o-mini", description="LLM provider model")
    embedder: str = Field(default="ollama", description="Embedding provider")
    embedder_model: str = Field(
        default="nomic-embed-text", description="Embedding model"
    )
    api_key: str = Field(default="", description="API key for LLM provider")

    class Config:
        """Pydantic configuration."""

        extra = "forbid"


class Boundary(BaseModel):
    """Model for organization boundaries in AppConfig."""

    name: str = Field(..., description="Boundary name")
    values: List[str] = Field(default_factory=list, description="Boundary values")

    class Config:
        """Pydantic configuration."""

        extra = "forbid"


class Profile(BaseModel):
    """Model for profile configuration."""

    name: str = Field(default="default", description="Profile name")
    boundaries: Optional[List[Boundary]] = Field(
        description="Organization boundaries. Items in this list are internal to the profile.",
        default=[
            Boundary(name="github", values=[]),
            Boundary(name="jfrog", values=[]),
            Boundary(name="gitlab", values=[]),
            Boundary(name="bitbucket", values=[]),
            Boundary(name="azure_devops", values=[]),
            Boundary(name="dockerhub", values=[]),
            Boundary(
                name="domain",
                values=[
                    "localhost",
                    "127.0.0.1",
                    "0.0.0.0",  # nosec B104 — domain boundary allowlist, not a bind address
                    "192.168.*",
                    "10.0.*",
                    "172.16.*",
                ],
            ),
        ],
    )

    class Config:
        """Pydantic configuration."""

        extra = "forbid"


class GitHubProvider(BaseModel):
    """Model for GitHub provider configuration in AppConfig."""

    enabled: bool = Field(
        default=False, description="Whether GitHub provider is enabled"
    )
    api_token: str = Field(default="", description="GitHub API token")
    base_url: str = Field(
        default="https://api.github.com", description="GitHub API base URL"
    )

    class Config:
        """Pydantic configuration."""

        extra = "forbid"


class GitLabProvider(BaseModel):
    """Model for GitLab provider configuration in AppConfig."""

    enabled: bool = Field(
        default=False, description="Whether GitLab provider is enabled"
    )
    api_token: str = Field(default="", description="GitLab API token")
    base_url: str = Field(
        default="https://gitlab.com/api/v4", description="GitLab API base URL"
    )

    class Config:
        """Pydantic configuration."""

        extra = "forbid"


class Providers(BaseModel):
    """Model for Git provider configuration in AppConfig."""

    github: GitHubProvider = Field(
        default_factory=GitHubProvider, description="GitHub provider configuration"
    )
    gitlab: GitLabProvider = Field(
        default_factory=GitLabProvider, description="GitLab provider configuration"
    )

    class Config:
        """Pydantic configuration."""

        extra = "forbid"


class AppConfig(BaseModel):
    """Application-level settings (not the Metagit package release version — use `metagit version`)."""

    model_config = ConfigDict(extra="ignore")

    agent_mode: bool = Field(
        default=False,
        description=(
            "When true, disable interactive UIs (fuzzy finder, prompts, editor). "
            "Overridden by METAGIT_AGENT_MODE when set."
        ),
    )
    description: str = "Metagit configuration"
    editor: str = Field(default="code", description="The editor to use for the CLI")
    # Reserved for future use
    api_url: str = Field(default="", description="The API URL to use for the CLI")
    # Reserved for future use
    api_version: str = Field(
        default="",
        description="Reserved for a future remote API contract version (METAGIT_API_VERSION)",
    )
    # Reserved for future use
    api_key: str = Field(default="", description="The API key to use for the CLI")
    # Reserved for future use
    cicd_file_data: str = Field(
        default=os.path.join(DATA_PATH, "cicd-files.json"),
        description="The path to the cicd file data",
    )
    file_type_data: str = Field(
        default=os.path.join(DATA_PATH, "file-types.json"),
        description="The path to the file type data",
    )
    package_manager_data: str = Field(
        default=os.path.join(DATA_PATH, "package-managers.json"),
        description="The path to the package manager data",
    )
    llm: LLM = Field(default=LLM(), description="The LLM configuration")
    workspace: WorkspaceConfig = Field(
        default=WorkspaceConfig(), description="The workspace configuration"
    )
    profiles: List[Profile] = Field(
        default=[Profile()], description="The profiles available to this appconfig"
    )
    providers: Providers = Field(
        default=Providers(), description="Git provider plugin configuration"
    )

    @classmethod
    def load(cls, config_path: str = None) -> Union["AppConfig", Exception]:
        """
        Load AppConfig from file.

        Args:
            config_path: Path to configuration file (optional)

        Returns:
            AppConfig object or Exception
        """
        try:
            if not config_path:
                config_path = os.path.join(
                    Path.home(), ".config", "metagit", "config.yml"
                )

            config_file = Path(config_path)
            if not config_file.exists():
                return cls()

            with config_file.open("r") as f:
                config_data = yaml.safe_load(f)

            if "config" in config_data:
                config = cls(**config_data["config"])
            else:
                config = cls(**config_data)

            # Override with environment variables
            config = cls._override_from_environment(config)

            return config

        except Exception as e:
            return e

    @classmethod
    def _override_from_environment(cls, config: "AppConfig") -> "AppConfig":
        """
        Override configuration with environment variables.

        Args:
            config: AppConfig to override

        Returns:
            Updated AppConfig
        """
        if os.getenv("METAGIT_AGENT_MODE") is not None:
            config.agent_mode = os.getenv("METAGIT_AGENT_MODE", "").strip().lower() in {
                "true",
                "1",
                "yes",
                "on",
            }

        # LLM configuration
        if os.getenv("METAGIT_LLM_ENABLED"):
            config.llm.enabled = os.getenv("METAGIT_LLM_ENABLED").lower() == "true"
        if os.getenv("METAGIT_LLM_PROVIDER"):
            config.llm.provider = os.getenv("METAGIT_LLM_PROVIDER")
        if os.getenv("METAGIT_LLM_PROVIDER_MODEL"):
            config.llm.provider_model = os.getenv("METAGIT_LLM_PROVIDER_MODEL")
        if os.getenv("METAGIT_LLM_EMBEDDER"):
            config.llm.embedder = os.getenv("METAGIT_LLM_EMBEDDER")
        if os.getenv("METAGIT_LLM_EMBEDDER_MODEL"):
            config.llm.embedder_model = os.getenv("METAGIT_LLM_EMBEDDER_MODEL")
        if os.getenv("METAGIT_LLM_API_KEY"):
            config.llm.api_key = os.getenv("METAGIT_LLM_API_KEY")

        # API configuration
        if os.getenv("METAGIT_API_KEY"):
            config.api_key = os.getenv("METAGIT_API_KEY")
        if os.getenv("METAGIT_API_URL"):
            config.api_url = os.getenv("METAGIT_API_URL")
        if os.getenv("METAGIT_API_VERSION"):
            config.api_version = os.getenv("METAGIT_API_VERSION")

        # Workspace configuration
        if os.getenv("METAGIT_WORKSPACE_PATH"):
            config.workspace.path = os.getenv("METAGIT_WORKSPACE_PATH")
        if os.getenv("METAGIT_WORKSPACE_DEFAULT_PROJECT"):
            config.workspace.default_project = os.getenv(
                "METAGIT_WORKSPACE_DEFAULT_PROJECT"
            )
        if os.getenv("METAGIT_WORKSPACE_DEDUPE_ENABLED"):
            config.workspace.dedupe.enabled = (
                os.getenv("METAGIT_WORKSPACE_DEDUPE_ENABLED", "").lower() == "true"
            )

        # GitHub provider configuration
        if os.getenv("METAGIT_GITHUB_ENABLED"):
            config.providers.github.enabled = (
                os.getenv("METAGIT_GITHUB_ENABLED").lower() == "true"
            )
        if os.getenv("METAGIT_GITHUB_API_TOKEN"):
            config.providers.github.api_token = os.getenv("METAGIT_GITHUB_API_TOKEN")
        if os.getenv("METAGIT_GITHUB_BASE_URL"):
            config.providers.github.base_url = os.getenv("METAGIT_GITHUB_BASE_URL")

        # GitLab provider configuration
        if os.getenv("METAGIT_GITLAB_ENABLED"):
            config.providers.gitlab.enabled = (
                os.getenv("METAGIT_GITLAB_ENABLED").lower() == "true"
            )
        if os.getenv("METAGIT_GITLAB_API_TOKEN"):
            config.providers.gitlab.api_token = os.getenv("METAGIT_GITLAB_API_TOKEN")
        if os.getenv("METAGIT_GITLAB_BASE_URL"):
            config.providers.gitlab.base_url = os.getenv("METAGIT_GITLAB_BASE_URL")

        return config

    def save(self, config_path: str = None) -> Union[bool, Exception]:
        """
        Save AppConfig to file.

        Args:
            config_path: Path to save configuration file (optional)

        Returns:
            True if successful, Exception if failed
        """
        try:
            if not config_path:
                config_path = os.path.join(
                    Path.home(), ".config", "metagit", "config.yml"
                )

            config_file = Path(config_path)
            config_file.parent.mkdir(parents=True, exist_ok=True)

            with config_file.open("w") as f:
                yaml.dump(
                    {
                        "config": self.model_dump(
                            exclude_none=True, exclude_unset=True, mode="json"
                        )
                    },
                    f,
                    default_flow_style=False,
                    sort_keys=False,
                    indent=2,
                )

            return True
        except Exception as e:
            return e
`````

## File: schemas/metagit_appconfig.schema.json
`````json
{
  "$defs": {
    "Boundary": {
      "additionalProperties": false,
      "description": "Model for organization boundaries in AppConfig.",
      "properties": {
        "name": {
          "description": "Boundary name",
          "title": "Name",
          "type": "string"
        },
        "values": {
          "description": "Boundary values",
          "items": {
            "type": "string"
          },
          "title": "Values",
          "type": "array"
        }
      },
      "required": [
        "name"
      ],
      "title": "Boundary",
      "type": "object"
    },
    "GitHubProvider": {
      "additionalProperties": false,
      "description": "Model for GitHub provider configuration in AppConfig.",
      "properties": {
        "enabled": {
          "default": false,
          "description": "Whether GitHub provider is enabled",
          "title": "Enabled",
          "type": "boolean"
        },
        "api_token": {
          "default": "",
          "description": "GitHub API token",
          "title": "Api Token",
          "type": "string"
        },
        "base_url": {
          "default": "https://api.github.com",
          "description": "GitHub API base URL",
          "title": "Base Url",
          "type": "string"
        }
      },
      "title": "GitHubProvider",
      "type": "object"
    },
    "GitLabProvider": {
      "additionalProperties": false,
      "description": "Model for GitLab provider configuration in AppConfig.",
      "properties": {
        "enabled": {
          "default": false,
          "description": "Whether GitLab provider is enabled",
          "title": "Enabled",
          "type": "boolean"
        },
        "api_token": {
          "default": "",
          "description": "GitLab API token",
          "title": "Api Token",
          "type": "string"
        },
        "base_url": {
          "default": "https://gitlab.com/api/v4",
          "description": "GitLab API base URL",
          "title": "Base Url",
          "type": "string"
        }
      },
      "title": "GitLabProvider",
      "type": "object"
    },
    "LLM": {
      "additionalProperties": false,
      "description": "Model for LLM configuration in AppConfig.",
      "properties": {
        "enabled": {
          "default": false,
          "description": "Whether LLM is enabled",
          "title": "Enabled",
          "type": "boolean"
        },
        "provider": {
          "default": "openrouter",
          "description": "LLM provider",
          "title": "Provider",
          "type": "string"
        },
        "provider_model": {
          "default": "gpt-4o-mini",
          "description": "LLM provider model",
          "title": "Provider Model",
          "type": "string"
        },
        "embedder": {
          "default": "ollama",
          "description": "Embedding provider",
          "title": "Embedder",
          "type": "string"
        },
        "embedder_model": {
          "default": "nomic-embed-text",
          "description": "Embedding model",
          "title": "Embedder Model",
          "type": "string"
        },
        "api_key": {
          "default": "",
          "description": "API key for LLM provider",
          "title": "Api Key",
          "type": "string"
        }
      },
      "title": "LLM",
      "type": "object"
    },
    "Profile": {
      "additionalProperties": false,
      "description": "Model for profile configuration.",
      "properties": {
        "name": {
          "default": "default",
          "description": "Profile name",
          "title": "Name",
          "type": "string"
        },
        "boundaries": {
          "anyOf": [
            {
              "items": {
                "$ref": "#/$defs/Boundary"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": [
            {
              "name": "github",
              "values": []
            },
            {
              "name": "jfrog",
              "values": []
            },
            {
              "name": "gitlab",
              "values": []
            },
            {
              "name": "bitbucket",
              "values": []
            },
            {
              "name": "azure_devops",
              "values": []
            },
            {
              "name": "dockerhub",
              "values": []
            },
            {
              "name": "domain",
              "values": [
                "localhost",
                "127.0.0.1",
                "0.0.0.0",
                "192.168.*",
                "10.0.*",
                "172.16.*"
              ]
            }
          ],
          "description": "Organization boundaries. Items in this list are internal to the profile.",
          "title": "Boundaries"
        }
      },
      "title": "Profile",
      "type": "object"
    },
    "Providers": {
      "additionalProperties": false,
      "description": "Model for Git provider configuration in AppConfig.",
      "properties": {
        "github": {
          "$ref": "#/$defs/GitHubProvider",
          "description": "GitHub provider configuration"
        },
        "gitlab": {
          "$ref": "#/$defs/GitLabProvider",
          "description": "GitLab provider configuration"
        }
      },
      "title": "Providers",
      "type": "object"
    },
    "WorkspaceConfig": {
      "additionalProperties": false,
      "description": "Model for workspace configuration in AppConfig.",
      "properties": {
        "path": {
          "default": "./.metagit",
          "description": "Workspace path",
          "title": "Path",
          "type": "string"
        },
        "default_project": {
          "default": "default",
          "description": "Default project",
          "title": "Default Project",
          "type": "string"
        },
        "dedupe": {
          "$ref": "#/$defs/WorkspaceDedupeConfig",
          "description": "Optional workspace-scoped repository deduplication settings"
        },
        "ui_show_preview": {
          "anyOf": [
            {
              "type": "boolean"
            },
            {
              "type": "null"
            }
          ],
          "default": true,
          "description": "Show preview in fuzzy finder console UI",
          "title": "Ui Show Preview"
        },
        "ui_menu_length": {
          "anyOf": [
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": 10,
          "description": "Number of items to show in menu",
          "title": "Ui Menu Length"
        },
        "ui_preview_height": {
          "anyOf": [
            {
              "type": "integer"
            },
            {
              "type": "null"
            }
          ],
          "default": 3,
          "description": "Height of preview in fuzzy finder console UI",
          "title": "Ui Preview Height"
        },
        "ui_ignore_hidden": {
          "default": true,
          "description": "When true, hide dotfiles and dot-directories from repo picker UI",
          "title": "Ui Ignore Hidden",
          "type": "boolean"
        }
      },
      "title": "WorkspaceConfig",
      "type": "object"
    },
    "WorkspaceDedupeConfig": {
      "additionalProperties": false,
      "description": "Deduplicate synced repos within a workspace via a canonical directory.",
      "properties": {
        "enabled": {
          "default": false,
          "description": "When true, clone once under canonical_dir and symlink per project",
          "title": "Enabled",
          "type": "boolean"
        },
        "scope": {
          "$ref": "#/$defs/WorkspaceDedupeScope",
          "default": "workspace",
          "description": "Dedupe scope (v1 implements workspace only)"
        },
        "strategy": {
          "$ref": "#/$defs/WorkspaceDedupeStrategy",
          "default": "symlink",
          "description": "How project mounts reference canonical checkouts"
        },
        "canonical_dir": {
          "default": "_canonical",
          "description": "Directory under workspace.path holding canonical checkouts",
          "title": "Canonical Dir",
          "type": "string"
        }
      },
      "title": "WorkspaceDedupeConfig",
      "type": "object"
    },
    "WorkspaceDedupeScope": {
      "description": "Where repository deduplication is applied.",
      "enum": [
        "workspace",
        "global"
      ],
      "title": "WorkspaceDedupeScope",
      "type": "string"
    },
    "WorkspaceDedupeStrategy": {
      "description": "How duplicate repository identities share disk layout.",
      "enum": [
        "symlink"
      ],
      "title": "WorkspaceDedupeStrategy",
      "type": "string"
    }
  },
  "description": "Application-level settings (not the Metagit package release version \u2014 use `metagit version`).",
  "properties": {
    "agent_mode": {
      "default": false,
      "description": "When true, disable interactive UIs (fuzzy finder, prompts, editor). Overridden by METAGIT_AGENT_MODE when set.",
      "title": "Agent Mode",
      "type": "boolean"
    },
    "description": {
      "default": "Metagit configuration",
      "title": "Description",
      "type": "string"
    },
    "editor": {
      "default": "code",
      "description": "The editor to use for the CLI",
      "title": "Editor",
      "type": "string"
    },
    "api_url": {
      "default": "",
      "description": "The API URL to use for the CLI",
      "title": "Api Url",
      "type": "string"
    },
    "api_version": {
      "default": "",
      "description": "Reserved for a future remote API contract version (METAGIT_API_VERSION)",
      "title": "Api Version",
      "type": "string"
    },
    "api_key": {
      "default": "",
      "description": "The API key to use for the CLI",
      "title": "Api Key",
      "type": "string"
    },
    "cicd_file_data": {
      "default": "/Users/zacharyloeber/Zach-Projects/Personal/active/metagit-cli/src/metagit/data/cicd-files.json",
      "description": "The path to the cicd file data",
      "title": "Cicd File Data",
      "type": "string"
    },
    "file_type_data": {
      "default": "/Users/zacharyloeber/Zach-Projects/Personal/active/metagit-cli/src/metagit/data/file-types.json",
      "description": "The path to the file type data",
      "title": "File Type Data",
      "type": "string"
    },
    "package_manager_data": {
      "default": "/Users/zacharyloeber/Zach-Projects/Personal/active/metagit-cli/src/metagit/data/package-managers.json",
      "description": "The path to the package manager data",
      "title": "Package Manager Data",
      "type": "string"
    },
    "llm": {
      "$ref": "#/$defs/LLM",
      "default": {
        "enabled": false,
        "provider": "openrouter",
        "provider_model": "gpt-4o-mini",
        "embedder": "ollama",
        "embedder_model": "nomic-embed-text",
        "api_key": ""
      },
      "description": "The LLM configuration"
    },
    "workspace": {
      "$ref": "#/$defs/WorkspaceConfig",
      "default": {
        "path": "./.metagit",
        "default_project": "default",
        "dedupe": {
          "canonical_dir": "_canonical",
          "enabled": false,
          "scope": "workspace",
          "strategy": "symlink"
        },
        "ui_show_preview": true,
        "ui_menu_length": 10,
        "ui_preview_height": 3,
        "ui_ignore_hidden": true
      },
      "description": "The workspace configuration"
    },
    "profiles": {
      "default": [
        {
          "name": "default",
          "boundaries": [
            {
              "name": "github",
              "values": []
            },
            {
              "name": "jfrog",
              "values": []
            },
            {
              "name": "gitlab",
              "values": []
            },
            {
              "name": "bitbucket",
              "values": []
            },
            {
              "name": "azure_devops",
              "values": []
            },
            {
              "name": "dockerhub",
              "values": []
            },
            {
              "name": "domain",
              "values": [
                "localhost",
                "127.0.0.1",
                "0.0.0.0",
                "192.168.*",
                "10.0.*",
                "172.16.*"
              ]
            }
          ]
        }
      ],
      "description": "The profiles available to this appconfig",
      "items": {
        "$ref": "#/$defs/Profile"
      },
      "title": "Profiles",
      "type": "array"
    },
    "providers": {
      "$ref": "#/$defs/Providers",
      "default": {
        "github": {
          "api_token": "",
          "base_url": "https://api.github.com",
          "enabled": false
        },
        "gitlab": {
          "api_token": "",
          "base_url": "https://gitlab.com/api/v4",
          "enabled": false
        }
      },
      "description": "Git provider plugin configuration"
    }
  },
  "title": "AppConfig",
  "type": "object"
}
`````

## File: src/metagit/core/mcp/runtime.py
`````python
#!/usr/bin/env python
"""
Minimal MCP stdio runtime for Metagit tools and resources.
"""

import json
import os
import sys
from pathlib import Path
from typing import Any, Optional

from metagit.core.config.manager import MetagitConfigManager
from metagit.core.mcp.gate import WorkspaceGate
from metagit.core.mcp.models import McpActivationState, WorkspaceStatus
from metagit.core.mcp.resources import ResourcePublisher
from metagit.core.mcp.root_resolver import WorkspaceRootResolver
from metagit.core.mcp.services.bootstrap_sampling import BootstrapSamplingService
from metagit.core.mcp.services.discovery_context import DiscoveryContextService
from metagit.core.mcp.services.ops_log import OperationsLogService
from metagit.core.mcp.services.project_context import ProjectContextService
from metagit.core.mcp.services.repo_ops import RepoOperationsService
from metagit.core.mcp.services.workspace_snapshot import WorkspaceSnapshotService
from metagit.core.mcp.services.upstream_hints import UpstreamHintService
from metagit.core.mcp.services.workspace_index import WorkspaceIndexService
from metagit.core.mcp.services.workspace_search import WorkspaceSearchService
from metagit.core.mcp.services.workspace_semantic_search import (
    WorkspaceSemanticSearchService,
)
from metagit.core.config.graph_cypher_export import GraphCypherExportService
from metagit.core.mcp.services.cross_project_dependencies import (
    CrossProjectDependencyService,
)
from metagit.core.mcp.services.workspace_health import WorkspaceHealthService
from metagit.core.workspace.catalog_models import CatalogError
from metagit.core.workspace.catalog_service import WorkspaceCatalogService
from metagit.core.workspace.layout_context import resolve_sync_context
from metagit.core.workspace.layout_service import WorkspaceLayoutService
from metagit.core.mcp.services.workspace_sync import WorkspaceSyncService
from metagit.core.mcp.services.workspace_template import WorkspaceTemplateService
from metagit.core.mcp.tool_registry import ToolRegistry
from metagit.core.project.search_service import ManagedRepoSearchService
from metagit.core.mcp.tools.bootstrap_plan_only import (
    metagit_bootstrap_config_plan_only,
)
from metagit.core.mcp.tools.workspace_status import metagit_workspace_status


class InvalidToolArgumentsError(Exception):
    """Raised when tool call arguments are invalid."""


class MetagitMcpRuntime:
    """MCP runtime over stdio transport."""

    def __init__(self, root: Optional[str] = None) -> None:
        self._root_override = root
        self._resolver = WorkspaceRootResolver()
        self._gate = WorkspaceGate()
        self._registry = ToolRegistry()
        self._index_service = WorkspaceIndexService()
        self._search_service = WorkspaceSearchService()
        self._semantic_search = WorkspaceSemanticSearchService()
        self._workspace_sync = WorkspaceSyncService()
        self._cross_project_deps = CrossProjectDependencyService()
        self._graph_cypher_export = GraphCypherExportService()
        self._workspace_health = WorkspaceHealthService()
        self._workspace_catalog = WorkspaceCatalogService()
        self._workspace_layout = WorkspaceLayoutService()
        self._workspace_template = WorkspaceTemplateService()
        self._managed_repo_search = ManagedRepoSearchService()
        self._hints_service = UpstreamHintService()
        self._repo_ops = RepoOperationsService()
        self._project_context = ProjectContextService()
        self._workspace_snapshot = WorkspaceSnapshotService()
        self._discovery_service = DiscoveryContextService()
        self._bootstrap_service = BootstrapSamplingService(sampling_supported=False)
        self._ops_log = OperationsLogService()
        self._resources = ResourcePublisher(ops_log=self._ops_log)
        self._initialized = False
        self._sampling_supported = False
        self._next_server_request_id = 10_000
        self._last_init_params: dict[str, Any] = {}
        self._tool_schemas: dict[str, dict[str, Any]] = {
            "metagit_workspace_status": {"type": "object", "properties": {}},
            "metagit_bootstrap_config_plan_only": {"type": "object", "properties": {}},
            "metagit_workspace_index": {"type": "object", "properties": {}},
            "metagit_workspace_search": {
                "type": "object",
                "required": ["query"],
                "properties": {
                    "query": {"type": "string"},
                    "preset": {"type": "string"},
                    "max_results": {"type": "integer", "minimum": 1},
                    "repos": {"type": "array", "items": {"type": "string"}},
                    "paths": {"type": "array", "items": {"type": "string"}},
                    "exclude": {"type": "array", "items": {"type": "string"}},
                    "context_lines": {"type": "integer", "minimum": 0},
                    "include_paths": {"type": "boolean"},
                    "intent": {"type": "string"},
                },
                "additionalProperties": False,
            },
            "metagit_workspace_semantic_search": {
                "type": "object",
                "required": ["query"],
                "properties": {
                    "query": {"type": "string"},
                    "repos": {"type": "array", "items": {"type": "string"}},
                    "task_context": {"type": "string"},
                    "goal": {"type": "string"},
                    "limit_per_repo": {"type": "integer", "minimum": 1},
                    "timeout_seconds": {"type": "integer", "minimum": 5},
                },
                "additionalProperties": False,
            },
            "metagit_repo_search": {
                "type": "object",
                "required": ["query"],
                "properties": {
                    "query": {"type": "string"},
                    "project": {"type": "string"},
                    "exact": {"type": "boolean"},
                    "synced_only": {"type": "boolean"},
                    "limit": {"type": "integer", "minimum": 1},
                    "sort": {
                        "type": "string",
                        "enum": ["score", "project", "name"],
                    },
                    "status": {
                        "type": "array",
                        "items": {"type": "string"},
                    },
                    "has_url": {"type": "boolean"},
                    "sync_enabled": {"type": "boolean"},
                    "tags": {
                        "type": "object",
                        "additionalProperties": {"type": "string"},
                    },
                },
                "additionalProperties": False,
            },
            "metagit_workspace_sync": {
                "type": "object",
                "properties": {
                    "repos": {
                        "type": "array",
                        "items": {"type": "string"},
                    },
                    "mode": {
                        "type": "string",
                        "enum": ["fetch", "pull", "clone"],
                    },
                    "only_if": {
                        "type": "string",
                        "enum": ["any", "missing", "dirty", "behind_origin"],
                    },
                    "allow_mutation": {"type": "boolean"},
                    "max_parallel": {"type": "integer", "minimum": 1},
                    "dry_run": {"type": "boolean"},
                },
                "additionalProperties": False,
            },
            "metagit_upstream_hints": {
                "type": "object",
                "required": ["blocker"],
                "properties": {"blocker": {"type": "string"}},
                "additionalProperties": False,
            },
            "metagit_repo_inspect": {
                "type": "object",
                "required": ["repo_path"],
                "properties": {"repo_path": {"type": "string"}},
                "additionalProperties": False,
            },
            "metagit_repo_sync": {
                "type": "object",
                "required": ["repo_path"],
                "properties": {
                    "repo_path": {"type": "string"},
                    "mode": {
                        "type": "string",
                        "enum": ["fetch", "pull", "clone"],
                    },
                    "allow_mutation": {"type": "boolean"},
                    "origin_url": {"type": "string"},
                },
                "additionalProperties": False,
            },
            "metagit_bootstrap_config": {
                "type": "object",
                "properties": {"confirm_write": {"type": "boolean"}},
                "additionalProperties": False,
            },
            "metagit_project_context_switch": {
                "type": "object",
                "required": ["project_name"],
                "properties": {
                    "project_name": {"type": "string"},
                    "setup_env": {"type": "boolean"},
                    "restore_session": {"type": "boolean"},
                    "save_previous": {"type": "boolean"},
                    "primary_repo": {"type": "string"},
                },
                "additionalProperties": False,
            },
            "metagit_workspace_state_snapshot": {
                "type": "object",
                "properties": {
                    "label": {"type": "string"},
                    "project_name": {"type": "string"},
                    "include_all_projects": {"type": "boolean"},
                    "include_env_state": {"type": "boolean"},
                    "link_session": {"type": "boolean"},
                },
                "additionalProperties": False,
            },
            "metagit_workspace_state_restore": {
                "type": "object",
                "required": ["snapshot_id"],
                "properties": {
                    "snapshot_id": {"type": "string"},
                    "switch_project": {"type": "boolean"},
                    "restore_session": {"type": "boolean"},
                },
                "additionalProperties": False,
            },
            "metagit_cross_project_dependencies": {
                "type": "object",
                "required": ["source_project"],
                "properties": {
                    "source_project": {"type": "string"},
                    "dependency_types": {
                        "type": "array",
                        "items": {
                            "type": "string",
                            "enum": [
                                "declared",
                                "imports",
                                "shared_config",
                                "url_match",
                                "ref",
                                "manual",
                            ],
                        },
                    },
                    "depth": {"type": "integer", "minimum": 1},
                    "include_external_repos": {"type": "boolean"},
                },
                "additionalProperties": False,
            },
            "metagit_export_workspace_graph_cypher": {
                "type": "object",
                "properties": {
                    "gitnexus_repo": {"type": "string"},
                    "include_structure": {"type": "boolean"},
                    "include_documentation": {"type": "boolean"},
                    "manual_only": {"type": "boolean"},
                    "with_schema": {"type": "boolean"},
                },
                "additionalProperties": False,
            },
            "metagit_workspace_health_check": {
                "type": "object",
                "properties": {
                    "check_git_status": {"type": "boolean"},
                    "check_dependencies": {"type": "boolean"},
                    "check_stale_branches": {"type": "boolean"},
                    "check_gitnexus": {"type": "boolean"},
                    "project_name": {"type": "string"},
                    "branch_head_warning_days": {"type": "number", "minimum": 0},
                    "branch_head_critical_days": {"type": "number", "minimum": 0},
                    "integration_stale_days": {"type": "number", "minimum": 0},
                },
                "additionalProperties": False,
            },
            "metagit_workspace_discover": {
                "type": "object",
                "properties": {
                    "intent": {"type": "string"},
                    "pattern": {"type": "string"},
                    "repos": {"type": "array", "items": {"type": "string"}},
                    "project_scope": {
                        "type": "array",
                        "items": {"type": "string"},
                    },
                    "exclude_generated": {"type": "boolean"},
                    "max_results": {"type": "integer", "minimum": 1},
                    "categorize": {"type": "boolean"},
                },
                "additionalProperties": False,
            },
            "metagit_project_template_apply": {
                "type": "object",
                "required": ["template", "target_projects"],
                "properties": {
                    "template": {"type": "string"},
                    "target_projects": {
                        "type": "array",
                        "items": {"type": "string"},
                        "minItems": 1,
                    },
                    "dry_run": {"type": "boolean"},
                    "confirm_apply": {"type": "boolean"},
                },
                "additionalProperties": False,
            },
            "metagit_session_update": {
                "type": "object",
                "required": ["project_name"],
                "properties": {
                    "project_name": {"type": "string"},
                    "recent_repos": {
                        "type": "array",
                        "items": {"type": "string"},
                    },
                    "primary_repo_path": {"type": "string"},
                    "agent_notes": {"type": "string"},
                    "env_overrides": {
                        "type": "object",
                        "additionalProperties": {"type": "string"},
                    },
                },
                "additionalProperties": False,
            },
            "metagit_workspace_list": {"type": "object", "properties": {}},
            "metagit_workspace_projects_list": {"type": "object", "properties": {}},
            "metagit_workspace_project_add": {
                "type": "object",
                "required": ["name"],
                "properties": {
                    "name": {"type": "string"},
                    "description": {"type": "string"},
                    "agent_instructions": {"type": "string"},
                },
                "additionalProperties": False,
            },
            "metagit_workspace_project_remove": {
                "type": "object",
                "required": ["name"],
                "properties": {"name": {"type": "string"}},
                "additionalProperties": False,
            },
            "metagit_workspace_repos_list": {
                "type": "object",
                "properties": {"project_name": {"type": "string"}},
                "additionalProperties": False,
            },
            "metagit_workspace_repo_add": {
                "type": "object",
                "required": ["project_name", "name"],
                "properties": {
                    "project_name": {"type": "string"},
                    "name": {"type": "string"},
                    "description": {"type": "string"},
                    "kind": {"type": "string"},
                    "path": {"type": "string"},
                    "url": {"type": "string"},
                    "sync": {"type": "boolean"},
                    "agent_instructions": {"type": "string"},
                    "tags": {
                        "type": "object",
                        "additionalProperties": {"type": "string"},
                    },
                },
                "additionalProperties": False,
            },
            "metagit_workspace_repo_remove": {
                "type": "object",
                "required": ["project_name", "name"],
                "properties": {
                    "project_name": {"type": "string"},
                    "name": {"type": "string"},
                },
                "additionalProperties": False,
            },
            "metagit_workspace_project_rename": {
                "type": "object",
                "required": ["from_name", "to_name"],
                "properties": {
                    "from_name": {"type": "string"},
                    "to_name": {"type": "string"},
                    "dry_run": {"type": "boolean"},
                    "move_disk": {"type": "boolean"},
                    "update_sessions": {"type": "boolean"},
                    "force": {"type": "boolean"},
                },
                "additionalProperties": False,
            },
            "metagit_workspace_repo_rename": {
                "type": "object",
                "required": ["project_name", "from_name", "to_name"],
                "properties": {
                    "project_name": {"type": "string"},
                    "from_name": {"type": "string"},
                    "to_name": {"type": "string"},
                    "dry_run": {"type": "boolean"},
                    "move_disk": {"type": "boolean"},
                    "force": {"type": "boolean"},
                },
                "additionalProperties": False,
            },
            "metagit_workspace_repo_move": {
                "type": "object",
                "required": ["repo_name", "from_project", "to_project"],
                "properties": {
                    "repo_name": {"type": "string"},
                    "from_project": {"type": "string"},
                    "to_project": {"type": "string"},
                    "dry_run": {"type": "boolean"},
                    "move_disk": {"type": "boolean"},
                    "force": {"type": "boolean"},
                },
                "additionalProperties": False,
            },
        }

    def status_snapshot(self) -> dict[str, Any]:
        """Return a one-shot runtime status snapshot."""
        status, _ = self._resolve_status_and_config()
        return {
            "state": status.state.value,
            "root": status.root_path,
            "tools": len(self._registry.list_tools(status=status)),
        }

    def run_stdio(self) -> None:
        """Run JSON-RPC message loop over stdio framing."""
        while True:
            request = self._read_message()
            if request is None:
                return

            response = self._handle_request(request=request)
            if response is not None:
                self._write_message(response)

    def _handle_request(self, request: dict[str, Any]) -> Optional[dict[str, Any]]:
        request_id = request.get("id")
        method = request.get("method")
        params = request.get("params", {})

        if method == "notifications/initialized":
            self._initialized = True
            return None

        try:
            if method == "initialize":
                self._last_init_params = params
                result = self._handle_initialize()
            elif method == "tools/list":
                result = self._handle_tools_list()
            elif method == "tools/call":
                result = self._handle_tools_call(params=params)
            elif method == "resources/list":
                result = self._handle_resources_list()
            elif method == "resources/read":
                result = self._handle_resources_read(params=params)
            elif method == "ping":
                result = {}
            else:
                return self._error_response(
                    request_id=request_id,
                    code=-32601,
                    message=f"Method not found: {method}",
                )
        except InvalidToolArgumentsError as exc:
            return self._error_response(
                request_id=request_id,
                code=-32602,
                message=str(exc),
                data={"kind": "invalid_arguments"},
            )
        except Exception as exc:
            return self._error_response(
                request_id=request_id,
                code=-32000,
                message=str(exc),
            )

        return {
            "jsonrpc": "2.0",
            "id": request_id,
            "result": result,
        }

    def _handle_initialize(self) -> dict[str, Any]:
        # MCP clients advertise capabilities at initialize time.
        # We use this to decide whether server-driven sampling can be requested.
        params = self._last_init_params
        client_capabilities = params.get("capabilities", {}) if params else {}
        self._sampling_supported = "sampling" in client_capabilities
        return {
            "protocolVersion": "2024-11-05",
            "serverInfo": {"name": "metagit-mcp", "version": "0.1.0"},
            "capabilities": {
                "tools": {"listChanged": False},
                "resources": {"listChanged": False},
            },
        }

    def _handle_tools_list(self) -> dict[str, Any]:
        status, _ = self._resolve_status_and_config()
        tool_names = self._registry.list_tools(status=status)
        tools: list[dict[str, Any]] = []
        for name in tool_names:
            tools.append(
                {
                    "name": name,
                    "description": f"Metagit MCP tool: {name}",
                    "inputSchema": self._tool_schemas.get(
                        name, {"type": "object", "properties": {}}
                    ),
                }
            )
        return {"tools": tools}

    def _handle_tools_call(self, params: dict[str, Any]) -> dict[str, Any]:
        name = params.get("name", "")
        arguments = params.get("arguments", {}) or {}
        status, config = self._resolve_status_and_config()
        allowed = set(self._registry.list_tools(status=status))
        if name not in allowed:
            raise InvalidToolArgumentsError(
                f"Tool not available in current state: {name}"
            )

        result = self._dispatch_tool(
            name=name,
            arguments=arguments,
            status=status,
            config=config,
        )
        self._ops_log.append(action="tool_call", detail=name)
        return {"content": [{"type": "text", "text": json.dumps(result)}]}

    def _handle_resources_list(self) -> dict[str, Any]:
        status, _ = self._resolve_status_and_config()
        resources = []
        if status.state == McpActivationState.ACTIVE:
            resources.extend(
                [
                    {
                        "uri": "metagit://workspace/config",
                        "name": "Workspace Config",
                    },
                    {
                        "uri": "metagit://workspace/repos/status",
                        "name": "Workspace Repos Status",
                    },
                    {
                        "uri": "metagit://workspace/health",
                        "name": "Workspace Health",
                    },
                    {
                        "uri": "metagit://workspace/context",
                        "name": "Workspace Context",
                    },
                ]
            )
        resources.append(
            {"uri": "metagit://workspace/ops-log", "name": "Operations Log"}
        )
        return {"resources": resources}

    def _handle_resources_read(self, params: dict[str, Any]) -> dict[str, Any]:
        uri = params.get("uri")
        if not uri:
            raise ValueError("uri is required")
        status, config = self._resolve_status_and_config()
        repos = self._build_repo_index(status=status, config=config)
        health_payload = None
        if uri == "metagit://workspace/health" and config and status.root_path:
            health_payload = self._workspace_health.check(
                config=config,
                workspace_root=status.root_path,
            ).model_dump(mode="json")
        payload = self._resources.get_resource(
            uri=uri,
            config=config,
            repos_status=repos,
            workspace_root=status.root_path,
            health_payload=health_payload,
        )
        return {
            "contents": [
                {
                    "uri": uri,
                    "mimeType": "application/json",
                    "text": json.dumps(payload),
                }
            ]
        }

    def _dispatch_tool(
        self,
        name: str,
        arguments: dict[str, Any],
        status: WorkspaceStatus,
        config: Any,
    ) -> dict[str, Any]:
        if name == "metagit_workspace_status":
            return metagit_workspace_status(status)

        if name == "metagit_bootstrap_config_plan_only":
            return metagit_bootstrap_config_plan_only(reason=status.reason)

        if name == "metagit_workspace_index":
            return {"repos": self._build_repo_index(status=status, config=config)}

        if name == "metagit_workspace_search":
            repos = self._build_repo_index(status=status, config=config)
            raw_repos = arguments.get("repos")
            repo_selectors = (
                [str(item) for item in raw_repos]
                if isinstance(raw_repos, list)
                else None
            )
            repo_paths = self._search_service.filter_repo_paths(
                repo_rows=repos,
                repos=repo_selectors,
            )
            query = str(arguments.get("query", "")).strip()
            if not query:
                raise InvalidToolArgumentsError("query is required")
            raw_paths = arguments.get("paths")
            raw_exclude = arguments.get("exclude")
            return {
                "hits": self._search_service.search(
                    query=query,
                    repo_paths=repo_paths,
                    preset=arguments.get("preset"),
                    max_results=int(arguments.get("max_results", 25)),
                    paths=[str(item) for item in raw_paths]
                    if isinstance(raw_paths, list)
                    else None,
                    exclude=[str(item) for item in raw_exclude]
                    if isinstance(raw_exclude, list)
                    else None,
                    context_lines=int(arguments.get("context_lines", 0)),
                    include_paths=bool(arguments.get("include_paths", False)),
                    intent=arguments.get("intent"),
                )
            }

        if name == "metagit_workspace_semantic_search":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "semantic workspace search requires an active workspace"
                )
            repos = self._build_repo_index(status=status, config=config)
            raw_sem_repos = arguments.get("repos")
            sem_selectors = (
                [str(item) for item in raw_sem_repos]
                if isinstance(raw_sem_repos, list)
                else None
            )
            repo_paths = self._search_service.filter_repo_paths(
                repo_rows=repos,
                repos=sem_selectors,
            )
            sem_query = str(arguments.get("query", "")).strip()
            if not sem_query:
                raise InvalidToolArgumentsError("query is required")
            limit_sem = int(arguments.get("limit_per_repo", 5))
            if limit_sem < 1:
                raise InvalidToolArgumentsError("limit_per_repo must be at least 1")
            timeout_sem = int(arguments.get("timeout_seconds", 120))
            if timeout_sem < 5:
                raise InvalidToolArgumentsError("timeout_seconds must be at least 5")
            return self._semantic_search.search_across_repos(
                query=sem_query,
                repo_paths=repo_paths,
                task_context=arguments.get("task_context"),
                goal=arguments.get("goal"),
                limit_per_repo=limit_sem,
                timeout_seconds=timeout_sem,
            )

        if name == "metagit_repo_search":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "managed repo search requires an active workspace"
                )
            query = str(arguments.get("query", "")).strip()
            if not query:
                raise InvalidToolArgumentsError("query is required")
            raw_tags = arguments.get("tags")
            tag_filter: dict[str, str] | None = None
            if isinstance(raw_tags, dict) and raw_tags:
                tag_filter = {str(k): str(v) for k, v in raw_tags.items()}
            limit_raw = arguments.get("limit", 10)
            try:
                limit_val = int(limit_raw)
            except (TypeError, ValueError) as exc:
                raise InvalidToolArgumentsError("limit must be an integer") from exc
            if limit_val < 1:
                raise InvalidToolArgumentsError("limit must be at least 1")
            raw_status = arguments.get("status")
            status_filter = (
                [str(item) for item in raw_status]
                if isinstance(raw_status, list)
                else None
            )
            sort_val = str(arguments.get("sort", "score"))
            if sort_val not in {"score", "project", "name"}:
                raise InvalidToolArgumentsError("sort must be score, project, or name")
            has_url = arguments.get("has_url")
            has_url_val = bool(has_url) if isinstance(has_url, bool) else None
            sync_enabled = arguments.get("sync_enabled")
            sync_enabled_val = (
                bool(sync_enabled) if isinstance(sync_enabled, bool) else None
            )
            result = self._managed_repo_search.search(
                config=config,
                workspace_root=status.root_path,
                query=query,
                project=arguments.get("project"),
                exact=bool(arguments.get("exact", False)),
                synced_only=bool(arguments.get("synced_only", False)),
                tags=tag_filter,
                status=status_filter,
                has_url=has_url_val,
                sync_enabled=sync_enabled_val,
                sort=sort_val,
                limit=limit_val,
            )
            return result.model_dump(mode="json")

        if name == "metagit_upstream_hints":
            blocker = str(arguments.get("blocker", "")).strip()
            if not blocker:
                raise InvalidToolArgumentsError("blocker is required")
            repos = self._build_repo_index(status=status, config=config)
            return {
                "hints": self._hints_service.rank(blocker=blocker, repo_context=repos)
            }

        if name == "metagit_repo_inspect":
            repo_path = str(arguments.get("repo_path", "")).strip()
            if not repo_path:
                raise InvalidToolArgumentsError("repo_path is required")
            return self._repo_ops.inspect(repo_path=repo_path)

        if name == "metagit_repo_sync":
            return self._repo_ops.sync(
                repo_path=str(arguments.get("repo_path", "")),
                mode=str(arguments.get("mode", "fetch")),
                allow_mutation=bool(arguments.get("allow_mutation", False)),
                origin_url=arguments.get("origin_url"),
            )

        if name == "metagit_workspace_sync":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "workspace sync requires an active workspace"
                )
            repos = self._build_repo_index(status=status, config=config)
            raw_repos = arguments.get("repos")
            repo_selectors = (
                [str(item) for item in raw_repos]
                if isinstance(raw_repos, list)
                else ["all"]
            )
            only_if = str(arguments.get("only_if", "any"))
            if only_if not in {"any", "missing", "dirty", "behind_origin"}:
                raise InvalidToolArgumentsError(
                    "only_if must be any, missing, dirty, or behind_origin"
                )
            max_parallel_raw = arguments.get("max_parallel", 4)
            try:
                max_parallel = int(max_parallel_raw)
            except (TypeError, ValueError) as exc:
                raise InvalidToolArgumentsError(
                    "max_parallel must be an integer"
                ) from exc
            if max_parallel < 1:
                raise InvalidToolArgumentsError("max_parallel must be at least 1")
            return self._workspace_sync.sync_many(
                repo_rows=repos,
                repos=repo_selectors,
                mode=str(arguments.get("mode", "fetch")),
                only_if=only_if,
                allow_mutation=bool(arguments.get("allow_mutation", False)),
                max_parallel=max_parallel,
                dry_run=bool(arguments.get("dry_run", False)),
            )

        if name == "metagit_project_context_switch":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "project context requires an active workspace"
                )
            project_name = str(arguments.get("project_name", "")).strip()
            if not project_name:
                raise InvalidToolArgumentsError("project_name is required")
            bundle = self._project_context.switch(
                config=config,
                workspace_root=status.root_path,
                project_name=project_name,
                setup_env=bool(arguments.get("setup_env", True)),
                restore_session=bool(arguments.get("restore_session", True)),
                save_previous=bool(arguments.get("save_previous", True)),
                primary_repo=arguments.get("primary_repo"),
            )
            return bundle.model_dump(mode="json")

        if name == "metagit_workspace_state_snapshot":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "workspace snapshot requires an active workspace"
                )
            return self._workspace_snapshot.create(
                config=config,
                workspace_root=status.root_path,
                label=arguments.get("label"),
                project_name=arguments.get("project_name"),
                include_all_projects=bool(arguments.get("include_all_projects", False)),
                include_env_state=bool(arguments.get("include_env_state", True)),
                link_session=bool(arguments.get("link_session", True)),
            )

        if name == "metagit_workspace_state_restore":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "workspace snapshot restore requires an active workspace"
                )
            snapshot_id = str(arguments.get("snapshot_id", "")).strip()
            if not snapshot_id:
                raise InvalidToolArgumentsError("snapshot_id is required")
            return self._workspace_snapshot.restore(
                config=config,
                workspace_root=status.root_path,
                snapshot_id=snapshot_id,
                switch_project=bool(arguments.get("switch_project", True)),
                restore_session=bool(arguments.get("restore_session", True)),
            ).model_dump(mode="json")

        if name == "metagit_workspace_health_check":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "workspace health check requires an active workspace"
                )
            raw_warn = arguments.get("branch_head_warning_days")
            raw_crit = arguments.get("branch_head_critical_days")
            raw_integration = arguments.get("integration_stale_days")
            try:
                branch_warn = float(raw_warn) if raw_warn is not None else 180.0
                branch_crit = float(raw_crit) if raw_crit is not None else 365.0
                integration_td = (
                    float(raw_integration) if raw_integration is not None else 90.0
                )
            except (TypeError, ValueError) as exc:
                raise InvalidToolArgumentsError(
                    "branch age thresholds must be numbers"
                ) from exc
            if branch_warn < 0 or branch_crit < 0 or integration_td < 0:
                raise InvalidToolArgumentsError(
                    "branch age thresholds must be non-negative"
                )
            from metagit.core.appconfig.models import AppConfig

            loaded_app = AppConfig.load()
            dedupe_cfg = (
                loaded_app.workspace.dedupe
                if not isinstance(loaded_app, Exception)
                else None
            )
            return self._workspace_health.check(
                config=config,
                workspace_root=status.root_path,
                check_git_status=bool(arguments.get("check_git_status", True)),
                check_dependencies=bool(arguments.get("check_dependencies", True)),
                check_stale_branches=bool(arguments.get("check_stale_branches", True)),
                check_gitnexus=bool(arguments.get("check_gitnexus", True)),
                project_name=arguments.get("project_name"),
                branch_head_warning_days=branch_warn,
                branch_head_critical_days=branch_crit,
                integration_stale_days=integration_td,
                dedupe=dedupe_cfg,
            ).model_dump(mode="json")

        if name == "metagit_workspace_discover":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "workspace discover requires an active workspace"
                )
            intent = arguments.get("intent")
            pattern = arguments.get("pattern")
            if not intent and not pattern:
                raise InvalidToolArgumentsError("intent or pattern is required")
            repos = self._build_repo_index(status=status, config=config)
            raw_repos = arguments.get("repos")
            raw_scope = arguments.get("project_scope")
            selectors = (
                [str(item) for item in raw_repos]
                if isinstance(raw_repos, list)
                else (
                    [str(item) for item in raw_scope]
                    if isinstance(raw_scope, list)
                    else None
                )
            )
            repo_paths = self._search_service.filter_repo_paths(
                repo_rows=repos,
                repos=selectors,
            )
            return self._search_service.discover_files(
                repo_paths=repo_paths,
                intent=str(intent) if intent else None,
                pattern=str(pattern) if pattern else None,
                exclude_generated=bool(arguments.get("exclude_generated", True)),
                max_results=int(arguments.get("max_results", 200)),
                categorize=bool(arguments.get("categorize", True)),
            )

        if name == "metagit_project_template_apply":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "template apply requires an active workspace"
                )
            template = str(arguments.get("template", "")).strip()
            raw_targets = arguments.get("target_projects")
            if not template:
                raise InvalidToolArgumentsError("template is required")
            if not isinstance(raw_targets, list) or not raw_targets:
                raise InvalidToolArgumentsError("target_projects is required")
            return self._workspace_template.apply(
                config=config,
                workspace_root=status.root_path,
                template=template,
                target_projects=[str(item) for item in raw_targets],
                dry_run=bool(arguments.get("dry_run", True)),
                confirm_apply=bool(arguments.get("confirm_apply", False)),
            )

        if name == "metagit_cross_project_dependencies":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "cross-project dependencies require an active workspace"
                )
            source_project = str(arguments.get("source_project", "")).strip()
            if not source_project:
                raise InvalidToolArgumentsError("source_project is required")
            raw_types = arguments.get("dependency_types")
            dependency_types = (
                [str(item) for item in raw_types]
                if isinstance(raw_types, list)
                else None
            )
            depth_raw = arguments.get("depth", 2)
            try:
                depth_val = int(depth_raw)
            except (TypeError, ValueError) as exc:
                raise InvalidToolArgumentsError("depth must be an integer") from exc
            if depth_val < 1:
                raise InvalidToolArgumentsError("depth must be at least 1")
            return self._cross_project_deps.map_dependencies(
                config=config,
                workspace_root=status.root_path,
                source_project=source_project,
                dependency_types=dependency_types,
                depth=depth_val,
                include_external_repos=bool(
                    arguments.get("include_external_repos", False)
                ),
            ).model_dump(mode="json")

        if name == "metagit_export_workspace_graph_cypher":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "graph cypher export requires an active workspace"
                )
            gitnexus_repo = arguments.get("gitnexus_repo")
            repo_name = (
                str(gitnexus_repo).strip()
                if isinstance(gitnexus_repo, str) and gitnexus_repo.strip()
                else None
            )
            return self._graph_cypher_export.export(
                config=config,
                workspace_root=status.root_path,
                gitnexus_repo=repo_name,
                include_structure=bool(arguments.get("include_structure", True)),
                include_documentation=bool(
                    arguments.get("include_documentation", False)
                ),
                manual_only=bool(arguments.get("manual_only", False)),
                with_schema=bool(arguments.get("with_schema", True)),
            ).model_dump(mode="json")

        if name == "metagit_workspace_list":
            config_path, workspace_root = self._catalog_paths(
                status=status, config=config
            )
            return self._workspace_catalog.list_workspace(
                config=config,
                config_path=config_path,
                workspace_root=workspace_root,
            ).model_dump(mode="json")

        if name == "metagit_workspace_projects_list":
            config_path, workspace_root = self._catalog_paths(
                status=status, config=config
            )
            _ = (config_path, workspace_root)
            return self._workspace_catalog.list_projects(config=config).model_dump(
                mode="json"
            )

        if name == "metagit_workspace_project_add":
            config_path, _ = self._catalog_paths(status=status, config=config)
            project_name = str(arguments.get("name", "")).strip()
            return self._workspace_catalog.add_project(
                config=config,
                config_path=config_path,
                name=project_name,
                description=arguments.get("description"),
                agent_instructions=arguments.get("agent_instructions"),
            ).model_dump(mode="json")

        if name == "metagit_workspace_project_remove":
            config_path, _ = self._catalog_paths(status=status, config=config)
            return self._workspace_catalog.remove_project(
                config=config,
                config_path=config_path,
                name=str(arguments.get("name", "")).strip(),
            ).model_dump(mode="json")

        if name == "metagit_workspace_repos_list":
            config_path, workspace_root = self._catalog_paths(
                status=status, config=config
            )
            _ = config_path
            project_filter = arguments.get("project_name")
            return self._workspace_catalog.list_repos(
                config=config,
                workspace_root=workspace_root,
                project_name=str(project_filter).strip()
                if isinstance(project_filter, str) and project_filter.strip()
                else None,
            ).model_dump(mode="json")

        if name == "metagit_workspace_repo_add":
            config_path, _ = self._catalog_paths(status=status, config=config)
            built = self._workspace_catalog.build_repo_from_fields(
                name=str(arguments.get("name", "")),
                description=arguments.get("description"),
                kind=arguments.get("kind"),
                path=arguments.get("path"),
                url=arguments.get("url"),
                sync=arguments.get("sync"),
                agent_instructions=arguments.get("agent_instructions"),
                tags=arguments.get("tags")
                if isinstance(arguments.get("tags"), dict)
                else None,
            )
            if isinstance(built, CatalogError):
                return {"ok": False, "error": built.model_dump(mode="json")}
            return self._workspace_catalog.add_repo(
                config=config,
                config_path=config_path,
                project_name=str(arguments.get("project_name", "")).strip(),
                repo=built,
            ).model_dump(mode="json")

        if name == "metagit_workspace_repo_remove":
            config_path, _ = self._catalog_paths(status=status, config=config)
            return self._workspace_catalog.remove_repo(
                config=config,
                config_path=config_path,
                project_name=str(arguments.get("project_name", "")).strip(),
                repo_name=str(arguments.get("name", "")).strip(),
            ).model_dump(mode="json")

        if name in {
            "metagit_workspace_project_rename",
            "metagit_workspace_repo_rename",
            "metagit_workspace_repo_move",
        }:
            config_path, definition_root = self._catalog_paths(
                status=status, config=config
            )
            sync_root, dedupe = resolve_sync_context(definition_root)
            dry_run = bool(arguments.get("dry_run", False))
            move_disk = bool(arguments.get("move_disk", True))
            force = bool(arguments.get("force", False))
            if name == "metagit_workspace_project_rename":
                return self._workspace_layout.rename_project(
                    config=config,
                    config_path=config_path,
                    workspace_path=sync_root,
                    from_name=str(arguments.get("from_name", "")).strip(),
                    to_name=str(arguments.get("to_name", "")).strip(),
                    dedupe=dedupe,
                    dry_run=dry_run,
                    move_disk=move_disk,
                    update_sessions=bool(arguments.get("update_sessions", True)),
                    force=force,
                ).model_dump(mode="json")
            if name == "metagit_workspace_repo_rename":
                return self._workspace_layout.rename_repo(
                    config=config,
                    config_path=config_path,
                    workspace_path=sync_root,
                    project_name=str(arguments.get("project_name", "")).strip(),
                    from_name=str(arguments.get("from_name", "")).strip(),
                    to_name=str(arguments.get("to_name", "")).strip(),
                    dedupe=dedupe,
                    dry_run=dry_run,
                    move_disk=move_disk,
                    force=force,
                ).model_dump(mode="json")
            return self._workspace_layout.move_repo(
                config=config,
                config_path=config_path,
                workspace_path=sync_root,
                repo_name=str(arguments.get("repo_name", "")).strip(),
                from_project=str(arguments.get("from_project", "")).strip(),
                to_project=str(arguments.get("to_project", "")).strip(),
                dedupe=dedupe,
                dry_run=dry_run,
                move_disk=move_disk,
                force=force,
            ).model_dump(mode="json")

        if name == "metagit_session_update":
            if not config or not status.root_path:
                raise InvalidToolArgumentsError(
                    "session update requires an active workspace"
                )
            project_name = str(arguments.get("project_name", "")).strip()
            if not project_name:
                raise InvalidToolArgumentsError("project_name is required")
            recent = arguments.get("recent_repos")
            recent_repos = (
                [str(item) for item in recent] if isinstance(recent, list) else None
            )
            env_raw = arguments.get("env_overrides")
            env_overrides = (
                {str(k): str(v) for k, v in env_raw.items()}
                if isinstance(env_raw, dict)
                else None
            )
            try:
                return self._project_context.update_session(
                    config=config,
                    workspace_root=status.root_path,
                    project_name=project_name,
                    recent_repos=recent_repos,
                    primary_repo_path=arguments.get("primary_repo_path"),
                    agent_notes=arguments.get("agent_notes"),
                    env_overrides=env_overrides,
                )
            except ValueError as exc:
                raise InvalidToolArgumentsError(str(exc)) from exc

        if name == "metagit_bootstrap_config":
            root = status.root_path or str(Path.cwd())
            context = self._discovery_service.build_context(repo_root=root)
            if self._sampling_supported:
                sampling_payload = self._request_client_sampling(context=context)
                if sampling_payload and sampling_payload.get("content"):
                    sampled_text = self._extract_sampling_text(sampling_payload)
                    if sampled_text:

                        def sampler(_payload: dict[str, str]) -> str:
                            _ = _payload
                            return sampled_text

                        sampled_service = BootstrapSamplingService(
                            sampling_supported=True,
                            sampler=sampler,
                        )
                        return sampled_service.generate(
                            context=context,
                            confirm_write=bool(arguments.get("confirm_write", False)),
                        )
            return self._bootstrap_service.generate(
                context=context,
                confirm_write=bool(arguments.get("confirm_write", False)),
            )

        raise ValueError(f"Unsupported tool: {name}")

    def _resolve_status_and_config(self) -> tuple[WorkspaceStatus, Any]:
        resolved_root = self._resolver.resolve(
            cwd=os.getcwd(), cli_root=self._root_override
        )
        status = self._gate.evaluate(root_path=resolved_root)
        config = None
        if status.state == McpActivationState.ACTIVE and status.root_path:
            manager = MetagitConfigManager(
                config_path=Path(status.root_path) / ".metagit.yml"
            )
            loaded = manager.load_config()
            config = None if isinstance(loaded, Exception) else loaded
        return status, config

    def _build_repo_index(
        self, status: WorkspaceStatus, config: Any
    ) -> list[dict[str, Any]]:
        if (
            status.state != McpActivationState.ACTIVE
            or not config
            or not status.root_path
        ):
            return []
        return self._index_service.build_index(
            config=config, workspace_root=status.root_path
        )

    def _catalog_paths(self, status: WorkspaceStatus, config: Any) -> tuple[str, str]:
        if not config or not status.root_path:
            raise InvalidToolArgumentsError(
                "catalog operations require an active workspace"
            )
        config_path = str(Path(status.root_path) / ".metagit.yml")
        return config_path, status.root_path

    def _error_response(
        self,
        request_id: Any,
        code: int,
        message: str,
        data: Optional[dict[str, Any]] = None,
    ) -> dict[str, Any]:
        error: dict[str, Any] = {"code": code, "message": message}
        if data:
            error["data"] = data
        return {
            "jsonrpc": "2.0",
            "id": request_id,
            "error": error,
        }

    def _read_message(self) -> Optional[dict[str, Any]]:
        """Read one MCP-framed JSON-RPC message from stdin."""
        content_length: Optional[int] = None

        while True:
            header_line = sys.stdin.buffer.readline()
            if not header_line:
                return None
            if header_line in {b"\n", b"\r\n"}:
                break

            header_text = header_line.decode("utf-8").strip()
            if not header_text:
                continue
            if ":" not in header_text:
                continue
            key, value = header_text.split(":", 1)
            if key.lower().strip() == "content-length":
                content_length = int(value.strip())

        if content_length is None:
            return None

        body = sys.stdin.buffer.read(content_length)
        if not body:
            return None
        return json.loads(body.decode("utf-8"))

    def _write_message(self, payload: dict[str, Any]) -> None:
        """Write one MCP-framed JSON-RPC message to stdout."""
        body = json.dumps(payload).encode("utf-8")
        header = f"Content-Length: {len(body)}\r\n\r\n".encode("utf-8")
        sys.stdout.buffer.write(header)
        sys.stdout.buffer.write(body)
        sys.stdout.buffer.flush()

    def _request_client_sampling(self, context: dict[str, str]) -> dict[str, Any]:
        """Request client sampling synchronously when supported."""
        request_id = self._next_server_request_id
        self._next_server_request_id += 1
        sampling_request = {
            "jsonrpc": "2.0",
            "id": request_id,
            "method": "sampling/createMessage",
            "params": {
                "messages": [
                    {
                        "role": "user",
                        "content": {
                            "type": "text",
                            "text": (
                                "Create a valid .metagit.yml from this context. "
                                "Output YAML only.\n"
                                f"{context}"
                            ),
                        },
                    }
                ],
                "maxTokens": 1400,
            },
        }
        self._write_message(sampling_request)

        while True:
            inbound = self._read_message()
            if inbound is None:
                return {}

            if inbound.get("id") == request_id:
                if "result" in inbound:
                    return inbound["result"]
                return {}

            # Handle regular inbound requests while waiting on sampling response.
            response = self._handle_request(request=inbound)
            if response is not None:
                self._write_message(response)

    def _extract_sampling_text(self, sampling_result: dict[str, Any]) -> Optional[str]:
        """Extract sampled text from sampling/createMessage result."""
        content = sampling_result.get("content")
        if isinstance(content, dict):
            if content.get("type") == "text":
                return content.get("text")
        if isinstance(content, list):
            for item in content:
                if isinstance(item, dict) and item.get("type") == "text":
                    return item.get("text")
        return None
`````

## File: src/metagit/core/utils/fuzzyfinder.py
`````python
#! /usr/bin/env python3

from typing import Any, Callable, Dict, List, Optional, Union

from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Input, Static, ListView, ListItem, Label
from textual.binding import Binding
from pydantic import BaseModel, Field, field_validator
from rapidfuzz import fuzz, process

"""
This is a fuzzy finder that uses Textual and rapidfuzz to find items in a list.
I'm only doing this because I don't want to have to wrap the fzf binary in a python script.
"""


class FuzzyFinderTarget(BaseModel):
    """A target for a fuzzy finder."""

    name: str
    description: str
    color: Optional[str] = None
    opacity: Optional[float] = None


class FuzzyFinderConfig(BaseModel):
    """Configuration for a fuzzy finder using Textual and rapidfuzz."""

    items: List[Union[str, Any]] = Field(
        ..., description="List of items to search. Can be strings or objects."
    )
    display_field: Optional[str] = Field(
        None, description="Field name to use for display/search if items are objects."
    )
    score_threshold: float = Field(
        70.0,
        ge=0.0,
        le=100.0,
        description="Minimum score (0-100) for a match to be included.",
    )
    max_results: int = Field(
        10, ge=1, description="Maximum number of results to display."
    )
    scorer: str = Field(
        "partial_ratio",
        description="Fuzzy matching scorer: 'partial_ratio', 'ratio', or 'token_sort_ratio'.",
    )
    prompt_text: str = Field(
        "> ", description="Prompt text displayed in the input field."
    )
    case_sensitive: bool = Field(
        False, description="Whether matching is case-sensitive."
    )
    multi_select: bool = Field(False, description="Allow selecting multiple items.")
    enable_preview: bool = Field(
        False, description="Enable preview pane for selected item."
    )
    preview_field: Optional[str] = Field(
        None, description="Field name to use for preview if items are objects."
    )
    preview_header: Optional[str] = Field(None, description="Header for preview pane.")
    sort_items: bool = Field(True, description="Whether to sort the items.")
    # Styling options
    highlight_color: str = Field(
        "bold white bg:#4444aa", description="Color/style for highlighted items."
    )
    normal_color: str = Field("white", description="Color/style for normal items.")
    prompt_color: str = Field("bold cyan", description="Color/style for prompt text.")
    separator_color: str = Field("gray", description="Color/style for separator line.")
    item_opacity: Optional[float] = Field(
        None, ge=0.0, le=1.0, description="Optional opacity for list items (0.0-1.0)."
    )
    custom_colors: Optional[Dict[str, str]] = Field(
        None,
        description="Optional mapping of item keys to custom colors. Keys can be item values (for strings) or field values (for objects).",
    )
    color_field: Optional[str] = Field(
        None,
        description="Field name to use for color mapping if items are objects. If not specified, uses display_field or string value.",
    )
    total_count: Optional[int] = Field(
        None,
        ge=0,
        description="Total candidates available before filtering/capping (for UI status text).",
    )
    query_mode_label: str = Field(
        "filtered", description="Label used in UI status text for matched results."
    )

    @field_validator("items")
    @classmethod
    def validate_items(cls, v: List[Any], info: Any) -> List[Any]:
        """Ensure items are valid and consistent with display_field."""
        if not v:
            raise ValueError("Items list cannot be empty.")
        if (
            info.data.get("display_field")
            and not isinstance(v[0], str)
            and not hasattr(v[0], info.data["display_field"])
        ):
            raise ValueError(f"Objects must have field '{info.data['display_field']}'.")
        return v

    @field_validator("scorer")
    @classmethod
    def validate_scorer(cls, v: str) -> str:
        """Ensure scorer is valid."""
        valid_scorers = ["partial_ratio", "ratio", "token_sort_ratio"]
        if v not in valid_scorers:
            raise ValueError(f"Scorer must be one of {valid_scorers}.")
        return v

    @field_validator("preview_field")
    @classmethod
    def validate_preview_field(cls, v: Optional[str], info: Any) -> Optional[str]:
        """Ensure preview_field is valid if enable_preview is True."""
        if info.data.get("enable_preview") and not v:
            raise ValueError(
                "preview_field must be specified when enable_preview is True."
            )
        if (
            v
            and info.data.get("items")
            and not isinstance(info.data["items"][0], str)
            and not hasattr(info.data["items"][0], v)
        ):
            raise ValueError(f"Objects must have field '{v}' for preview.")
        return v

    def get_scorer_function(self) -> Union[Callable[..., float], Exception]:
        """Return the rapidfuzz scorer function based on configuration."""
        try:
            scorer_map: Dict[str, Callable[..., float]] = {
                "partial_ratio": fuzz.partial_ratio,
                "ratio": fuzz.ratio,
                "token_sort_ratio": fuzz.token_sort_ratio,
            }
            return scorer_map[self.scorer]
        except Exception as e:
            return e

    def get_display_value(self, item: Any) -> Union[str, Exception]:
        """Extract the display value from an item."""
        try:
            if isinstance(item, str):
                return item
            if self.display_field:
                return str(getattr(item, self.display_field))
            return ValueError("display_field must be specified for non-string items.")
        except Exception as e:
            return e

    def get_preview_value(self, item: Any) -> Union[Optional[str], Exception]:
        """Extract the preview value from an item if preview is enabled."""
        try:
            if not self.enable_preview or not self.preview_field:
                return None
            if isinstance(item, str):
                return item
            return str(getattr(item, self.preview_field))
        except Exception as e:
            return e

    def get_item_color(self, item: Any) -> Optional[str]:
        """Get the color for an item, prioritizing FuzzyFinderTarget.color over custom_colors."""
        try:
            # First check if item is a FuzzyFinderTarget with a color property
            if isinstance(item, FuzzyFinderTarget) and item.color:
                return item.color

            # Fall back to custom_colors mapping if available
            if not self.custom_colors:
                return None

            # Determine the key to use for color lookup
            if self.color_field:
                # Use specified color field
                if isinstance(item, str):
                    color_key = item
                else:
                    color_key = str(getattr(item, self.color_field))
            elif self.display_field and not isinstance(item, str):
                # Use display field
                color_key = str(getattr(item, self.display_field))
            else:
                # Use string representation
                color_key = str(item)

            return self.custom_colors.get(color_key)
        except Exception:
            return None

    def get_item_opacity(self, item: Any) -> Optional[float]:
        """Get the opacity for an item, prioritizing FuzzyFinderTarget.opacity over config.item_opacity."""
        try:
            # First check if item is a FuzzyFinderTarget with an opacity property
            if isinstance(item, FuzzyFinderTarget) and item.opacity is not None:
                return item.opacity

            # Fall back to config's item_opacity
            return self.config.item_opacity
        except Exception:
            return self.config.item_opacity


class FuzzyFinderApp(App):
    """A Textual app for fuzzy finding."""

    CSS = """
    .fuzzy-finder-input {
        dock: top;
        height: 3;
        border: solid $primary;
    }
    
    .fuzzy-finder-split {
        height: 1fr;
    }

    .fuzzy-finder-results {
        width: 35%;
        border: solid $primary;
        scrollbar-gutter: stable;
        overflow-y: auto;
        height: 1fr;
    }
    
    .fuzzy-finder-preview {
        width: 65%;
        border: solid $primary;
        overflow-y: auto;
        height: 1fr;
    }
    
    .highlighted {
        background: $primary;
        color: $text;
    }
    
    .list-item-normal {
        opacity: 1.0;
    }
    
    .list-item-opacity {
        /* Opacity will be set dynamically */
    }
    
    .list-item-custom-color {
        /* Custom color will be set dynamically via styles */
    }
    """

    BINDINGS = [
        Binding("ctrl+c", "quit", "Quit"),
        Binding("escape", "quit", "Quit"),
        Binding("enter", "select", "Select", priority=True),
        Binding("up", "cursor_up", "Up", show=False, priority=True),
        Binding("down", "cursor_down", "Down", show=False, priority=True),
        Binding("pageup", "page_up", "Page Up", show=False, priority=True),
        Binding("pagedown", "page_down", "Page Down", show=False, priority=True),
        Binding("home", "cursor_home", "Home", show=False, priority=True),
        Binding("end", "cursor_end", "End", show=False, priority=True),
    ]

    def __init__(self, config: FuzzyFinderConfig, **kwargs):
        super().__init__(**kwargs)
        self.config = config
        self.current_results: List[Any] = []
        self.selected_item: Optional[Any] = None
        self.highlighted_index = 0

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        with Vertical():
            # Input field
            yield Input(
                placeholder=self.config.prompt_text,
                id="search_input",
                classes="fuzzy-finder-input",
            )

            if self.config.enable_preview:
                with Horizontal(classes="fuzzy-finder-split"):
                    yield ListView(id="results_list", classes="fuzzy-finder-results")
                    yield Static("", id="preview_pane", classes="fuzzy-finder-preview")
            else:
                # Just results
                yield ListView(id="results_list", classes="fuzzy-finder-results")
            yield Static("", id="results_meta")

    def on_mount(self) -> None:
        """Called when app starts."""
        # Initial search with empty query
        self._perform_search("")
        # Focus the input
        self.query_one("#search_input", Input).focus()

    def on_input_changed(self, event: Input.Changed) -> None:
        """Called when the input changes."""
        if event.input.id == "search_input":
            self._perform_search(event.value)

    def _perform_search(self, query: str) -> None:
        """Perform fuzzy search and update results."""
        try:
            results = self._search(query)
            if isinstance(results, Exception):
                # Handle error - for now just show empty results
                results = []

            self.current_results = results
            self.highlighted_index = 0
            self._update_results_list()
            self._update_results_meta(query)

            if self.config.enable_preview:
                self._update_preview()

        except Exception:
            # Handle error gracefully
            self.current_results = []
            self._update_results_list()
            self._update_results_meta(query)

    def _update_results_meta(self, query: str) -> None:
        """Show concise result counters for current query."""
        try:
            meta = self.query_one("#results_meta", Static)
            shown_count = len(self.current_results)
            total_count = (
                self.config.total_count
                if self.config.total_count is not None
                else len(self.config.items)
            )
            cap_count = self.config.max_results
            mode_label = self.config.query_mode_label
            query_label = query if query else "all"
            meta.update(
                f"Showing {shown_count}/{total_count} ({mode_label}, limit={cap_count}) | query: {query_label}"
            )
        except Exception:
            return

    def _update_results_list(self) -> None:
        """Update the results ListView."""
        results_list = self.query_one("#results_list", ListView)
        results_list.clear()

        for i, result in enumerate(self.current_results):
            display_value = self.config.get_display_value(result)
            if isinstance(display_value, Exception):
                display_value = str(result)

            # Create list item
            item = ListItem(Label(display_value))

            # Apply highlighting
            if i == self.highlighted_index:
                item.add_class("highlighted")

            # Apply custom color if configured
            custom_color = self.config.get_item_color(result)
            if custom_color:
                item.add_class("list-item-custom-color")
                # Parse and apply the custom color
                self._apply_custom_color(item, custom_color)

            # Apply opacity - prioritize FuzzyFinderTarget.opacity over config.item_opacity
            item_opacity = self.config.get_item_opacity(result)
            if item_opacity is not None:
                item.add_class("list-item-opacity")
                # Set opacity via inline style
                item.styles.opacity = item_opacity
            else:
                item.add_class("list-item-normal")

            results_list.append(item)

        # Set the ListView's index to match our highlighted_index
        if self.current_results and 0 <= self.highlighted_index < len(
            self.current_results
        ):
            results_list.index = self.highlighted_index

    def _apply_custom_color(self, item: ListItem, color_spec: str) -> None:
        """Apply custom color to a list item based on color specification."""
        try:
            # Handle different color formats
            if color_spec.startswith("#"):
                # Hex color
                item.styles.color = color_spec
            elif color_spec.startswith("bg:"):
                # Background color
                bg_color = color_spec[3:]  # Remove 'bg:' prefix
                item.styles.background = bg_color
            elif " bg:" in color_spec:
                # Color with background (e.g., "white bg:#ff0000")
                parts = color_spec.split(" bg:")
                if len(parts) == 2:
                    text_color, bg_color = parts
                    item.styles.color = text_color.strip()
                    item.styles.background = bg_color.strip()
            elif color_spec in [
                "red",
                "green",
                "blue",
                "yellow",
                "cyan",
                "magenta",
                "white",
                "black",
            ]:
                # Basic color names
                item.styles.color = color_spec
            else:
                # Try to apply as-is (could be a rich color spec)
                item.styles.color = color_spec
        except Exception:
            # If color application fails, silently continue
            pass

    def _update_preview(self) -> None:
        """Update the preview pane."""
        if not self.config.enable_preview:
            return

        preview_pane = self.query_one("#preview_pane", Static)

        if not self.current_results or self.highlighted_index >= len(
            self.current_results
        ):
            preview_pane.update("No preview available")
            return

        highlighted_item = self.current_results[self.highlighted_index]
        preview_value = self.config.get_preview_value(highlighted_item)

        if isinstance(preview_value, Exception) or preview_value is None:
            preview_value = str(highlighted_item)

        if self.config.preview_header:
            preview_text = f"{self.config.preview_header}\n\n{preview_value}"
        else:
            preview_text = preview_value

        preview_pane.update(preview_text)

    def on_list_view_selected(self, event: ListView.Selected) -> None:
        """Called when a list item is selected."""
        if event.list_view.id == "results_list" and self.current_results:
            # Update highlighted index based on selection
            results_list = self.query_one("#results_list", ListView)
            if results_list.index is not None and 0 <= results_list.index < len(
                self.current_results
            ):
                self.highlighted_index = results_list.index
                self.selected_item = self.current_results[self.highlighted_index]
                self.exit(self.selected_item)

    def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
        """Called when a list item is highlighted (but not selected)."""
        if event.list_view.id == "results_list" and self.current_results:
            # Keep our highlighted_index in sync with ListView
            results_list = self.query_one("#results_list", ListView)
            if results_list.index is not None and 0 <= results_list.index < len(
                self.current_results
            ):
                self.highlighted_index = results_list.index
                if self.config.enable_preview:
                    self._update_preview()

    def action_cursor_up(self) -> None:
        """Move cursor up."""
        if self.current_results and self.highlighted_index > 0:
            self.highlighted_index -= 1
            self._update_results_list()
            self._scroll_to_highlighted()
            if self.config.enable_preview:
                self._update_preview()

    def action_cursor_down(self) -> None:
        """Move cursor down."""
        if (
            self.current_results
            and self.highlighted_index < len(self.current_results) - 1
        ):
            self.highlighted_index += 1
            self._update_results_list()
            self._scroll_to_highlighted()
            if self.config.enable_preview:
                self._update_preview()

    def _scroll_to_highlighted(self) -> None:
        """Scroll the results list to ensure the highlighted item is visible."""
        try:
            results_list = self.query_one("#results_list", ListView)
            if self.highlighted_index < len(results_list.children):
                # Get the highlighted list item
                highlighted_item = results_list.children[self.highlighted_index]
                # Scroll to make the item visible
                results_list.scroll_to_widget(highlighted_item)
        except Exception:
            # If scrolling fails, continue without it
            pass

    def action_page_up(self) -> None:
        """Move cursor up by a page (10 items)."""
        if self.current_results:
            page_size = 10
            self.highlighted_index = max(0, self.highlighted_index - page_size)
            self._update_results_list()
            self._scroll_to_highlighted()
            if self.config.enable_preview:
                self._update_preview()

    def action_page_down(self) -> None:
        """Move cursor down by a page (10 items)."""
        if self.current_results:
            page_size = 10
            max_index = len(self.current_results) - 1
            self.highlighted_index = min(max_index, self.highlighted_index + page_size)
            self._update_results_list()
            self._scroll_to_highlighted()
            if self.config.enable_preview:
                self._update_preview()

    def action_cursor_home(self) -> None:
        """Move cursor to the first item."""
        if self.current_results:
            self.highlighted_index = 0
            self._update_results_list()
            self._scroll_to_highlighted()
            if self.config.enable_preview:
                self._update_preview()

    def action_cursor_end(self) -> None:
        """Move cursor to the last item."""
        if self.current_results:
            self.highlighted_index = len(self.current_results) - 1
            self._update_results_list()
            self._scroll_to_highlighted()
            if self.config.enable_preview:
                self._update_preview()

    def action_select(self) -> None:
        """Select the highlighted item."""
        # First try to get the current selection from the ListView
        try:
            results_list = self.query_one("#results_list", ListView)
            if results_list.index is not None and 0 <= results_list.index < len(
                self.current_results
            ):
                self.highlighted_index = results_list.index
        except Exception:
            pass

        # Select the highlighted item
        if self.current_results and self.highlighted_index < len(self.current_results):
            self.selected_item = self.current_results[self.highlighted_index]
            self.exit(self.selected_item)
        else:
            self.exit(None)

    def action_quit(self) -> None:
        """Quit the application."""
        self.exit(None)

    def _search(self, query: str) -> Union[List[Any], Exception]:
        """Perform fuzzy search based on the query."""
        try:
            items_to_search = self.config.items
            if self.config.sort_items:
                try:
                    # Sort items based on their display value
                    items_to_search = sorted(
                        items_to_search,
                        key=lambda item: str(self.config.get_display_value(item) or ""),
                    )
                except Exception:
                    # If sorting fails, proceed without sorting
                    pass

            choices_with_originals = [
                (self.config.get_display_value(item), item) for item in items_to_search
            ]
            # Check for exceptions
            choice_exceptions = [
                c[0] for c in choices_with_originals if isinstance(c[0], Exception)
            ]
            if choice_exceptions:
                return choice_exceptions[0]

            choices = [str(c[0]) for c in choices_with_originals]

            if not query:
                return [item[1] for item in choices_with_originals]

            # Prepare query for case-insensitive matching
            query_lower = query.lower() if not self.config.case_sensitive else query

            scorer_func = self.config.get_scorer_function()
            if isinstance(scorer_func, Exception):
                return scorer_func

            # Get fuzzy search results
            results = process.extract(
                query,
                choices,
                scorer=scorer_func,
                limit=len(choices),  # Get all results for custom sorting
            )

            # Custom scoring and sorting to prioritize exact matches
            scored_results = []
            for result_str, score, index in results:
                if score < self.config.score_threshold:
                    continue

                choice_lower = (
                    result_str.lower() if not self.config.case_sensitive else result_str
                )

                # Calculate custom score based on match type
                custom_score = score

                # Bonus for exact matches
                if choice_lower == query_lower:
                    custom_score += 1000
                # Bonus for prefix matches
                elif choice_lower.startswith(query_lower):
                    custom_score += 500
                # Bonus for longer matches (more specific)
                elif len(choice_lower) > len(query_lower):
                    length_bonus = min(100, (len(choice_lower) - len(query_lower)) * 10)
                    custom_score += length_bonus

                scored_results.append(
                    (custom_score, result_str, choices_with_originals[index][1])
                )

            # Sort by custom score (highest first) and then by original string length (shorter first for same score)
            scored_results.sort(key=lambda x: (-x[0], len(x[1])))

            # Return all matched results to allow full manual scrolling.
            return [item[2] for item in scored_results]

        except Exception as e:
            return e


class FuzzyFinder:
    """A reusable fuzzy finder using Textual and rapidfuzz with navigation support."""

    def __init__(self, config: FuzzyFinderConfig):
        """Initialize the fuzzy finder with a configuration."""
        self.config = config

    def run(self) -> Union[Optional[Union[str, List[str], Any]], Exception]:
        """Run the fuzzy finder application."""
        try:
            app = FuzzyFinderApp(self.config)
            result = app.run()

            if self.config.multi_select:
                # Multi-select not fully implemented yet
                return [result] if result else []
            return result
        except Exception as e:
            return e


def fuzzyfinder(query: str, collection: List[str]) -> List[str]:
    """
    Simple fuzzy finder function that returns matching items from a collection.

    Args:
        query: Search query string
        collection: List of strings to search in

    Returns:
        List of matching strings
    """
    if not query:
        return collection

    from rapidfuzz import fuzz, process

    # Use rapidfuzz to find matches
    results = process.extract(
        query, collection, scorer=fuzz.partial_ratio, limit=len(collection)
    )

    # Return items with score >= 70
    return [item for item, score, _ in results if score >= 70]
`````

## File: README.md
`````markdown
# Metagit

Metagit gives you situational awareness across Git repositories. It helps multi-repo projects feel manageable by keeping stack details, generated artifacts, dependencies, and related metadata in one place.

## About

This tool works well for scenarios like:

1. At-a-glance view of a project's technical stacks, languages, external dependencies, and generated artifacts.
2. Switching between many Git projects during the day without losing context.
3. Isolating outside dependencies that weaken the security and dependability of your software delivery pipelines.
4. Automated documentation of a code's provenance.
5. Helping new contributors get from onboarding to first commit faster.

Metagit is designed for developers, SREs, and AI agents who work across connected repositories. It tracks the dependencies and project relationships that are easy to miss when you only look at one repo at a time.

## Quick start

Install or upgrade the CLI globally with [uv](https://docs.astral.sh/uv/):

```bash
uv tool install metagit-cli
uv tool install -U metagit-cli   # upgrade later
metagit version
```

> Use the PyPI package name **`metagit-cli`**. The `metagit` package on PyPI is a different project.

Install bundled agent skills (OpenClaw, Hermes, Claude Code, and others):

```bash
metagit skills list
metagit skills install --scope user --target openclaw --target hermes
```

Use `--scope project` when installing into a specific umbrella repository checkout. See [Skills](docs/skills.md) for targets, MCP install, and the project-management skill for agents.

## Audience

This tool targets:

- DevOps Engineers
- Polyglot developers
- New team members
- Project Managers
- SREs
- Solution Engineers
- AI Agents (more to come!)

## Metagit is NOT...

### ...an SBOM tool

SBOM output is often thousands of lines and includes full transitive dependency trees. That level of detail is usually too heavy for day-to-day situational awareness and agent context. Metagit may read SBOM manifests as an input in the future, but it is not trying to replace SBOM tooling.

Metagit uses common project files (for example `go.mod`, `package.json`, and `requirements.txt`) for detection and validation boundaries. These are used to identify stack composition, not to provide exhaustive version intelligence.

### ...a Git client

Despite the name, this still relies on Git and your existing hosting platform.

## ...a full project packer

Metagit intentionally focuses on the highest-value project signals. It does not package full repositories. If you need full-project packing, use [repomix](https://github.com/yamadashy/repomix/tree/main).

### Why brevity?

One of the core goals is reducing cognitive load when understanding project relationships. A practical side effect is lower token usage for automated AI workflows.

## How It Works

Metagit stores project configuration metadata in `.metagit.yml` inside the repository. That file follows a schema that the CLI can validate and read.

If you use Metagit for dozens of repositories (an umbrella workspace), you can edit the config manually or refresh it with heuristics and AI-assisted workflows.

## Modes

Metagit supports several operating modes:

### Workspace Mode

This is the first planned open-source CLI mode.

In this mode, you group related repositories into one workspace that you can open in VS Code or access individually from the terminal.

> **AKA** Multi-repo as Monorepo

You use one top-level umbrella project with a single metagit definition file that tracks related repositories and local target folders. You can then sync that workspace locally.

The metagit configuration file is committed to version control as its own project artifact.

**Managed repo lookup:** Use `metagit search` / `metagit find` for quick CLI lookup of repos declared in `.metagit.yml` (with optional tags and JSON output). MCP clients can call `metagit_repo_search`, and `metagit api serve` exposes the same search and resolve behavior over a small local JSON HTTP API for agents and scripts.

This mode is useful for:

- Creating umbrella projects for new team members of a multi-repo project
- Individual power users that need to quickly pivot between several project repositories that comprise a larger team effort
- Keeping loosely coupled Git projects grouped without relying on submodules

## Metadata Mode

This mode uses the same config file as workspace mode, with additional metadata such as primary language, frameworks, and other context you want available when entering a repo.

Configuring this by hand for one project is simple. Doing it across dozens or thousands of repos is not. Metagit uses detection heuristics to automate as much as possible and can use AI workflows where deterministic code is not enough.

> **Note**: AI-assisted detection should be monitored and converted into deterministic logic over time.

In this mode, Metagit helps answer questions like:

- What other projects are related to this project?
- What application and development stacks does this project use?
- What external dependencies exist for this project?
- What artifacts does this project create?
- What branch strategy is employed?
- What version strategy is employed?

> **External dependencies** are a common source of pipeline instability.

## Install

Global install and skill setup are covered in [Quick start](#quick-start) above.

### Local first-run

Inside any Git repository:

```bash
metagit init
```

That creates `.metagit.yml` and updates `.gitignore`.

## Skills

Bundled skills ship with the package and install via `metagit skills install` (see [docs/skills.md](docs/skills.md)). For development in this repository, `skills/` is the source tree; run `task skills:sync` to mirror into `.cursor/skills/`.

## Agent guides

- [Hermes agents and organization-wide IaC](hermes-iac-workspace-guide.md) — illustrated workflow for using Metagit as a control plane across Terraform, policy, and module repositories (controller + subagents, layered `agent_instructions`, MCP tools).

## Documentation

For installation guidance, detailed usage, including full CLI command surface, local MCP runtime setup, API-oriented flows, and advanced examples, use the documentation site:

- [Documentation](https://metagit-ai.github.io/metagit-cli/)

## License

This project is licensed under the MIT License. See the [LICENSE](./LICENSE.md) file for details.
`````

## File: src/metagit/core/project/manager.py
`````python
#!/usr/bin/env python
"""
Class for managing projects.
"""

import concurrent.futures
import os
import shutil
from pathlib import Path
from typing import List, Optional, Union

import git
from tqdm import tqdm

from metagit.core.appconfig.models import (
    AppConfig,
    WorkspaceDedupeConfig,
    WorkspaceDedupeScope,
)
from metagit.core.config.manager import MetagitConfigManager
from metagit.core.config.models import MetagitConfig
from metagit.core.project.models import ProjectPath
from metagit.core.workspace import workspace_dedupe
from metagit.core.workspace.hydrate import materialize_symlink_mount
from metagit.core.utils.common import create_vscode_workspace
from metagit.core.utils.fuzzyfinder import (
    FuzzyFinder,
    FuzzyFinderConfig,
    FuzzyFinderTarget,
)
from metagit.core.utils.files import parse_gitignore, should_ignore_path
from metagit.core.utils.logging import UnifiedLogger
from metagit.core.utils.userprompt import UserPrompt
from metagit.core.workspace.dedupe_resolver import resolve_effective_dedupe
from metagit.core.workspace.layout_resolver import find_project
from metagit.core.workspace.models import WorkspaceProject


def project_manager_from_app(
    app_config: AppConfig,
    logger: UnifiedLogger,
    *,
    metagit_config: Optional[MetagitConfig] = None,
    project_name: Optional[str] = None,
) -> "ProjectManager":
    """Construct a ProjectManager using workspace path and effective dedupe settings."""
    project: Optional[WorkspaceProject] = None
    if metagit_config is not None and project_name:
        project = find_project(metagit_config, project_name)
    dedupe = resolve_effective_dedupe(app_config.workspace.dedupe, project)
    return ProjectManager(
        app_config.workspace.path,
        logger,
        dedupe=dedupe,
    )


class ProjectManager:
    """
    Manager class for handling projects within a workspace.
    """

    def __init__(
        self,
        workspace_path: Union[str, Path],
        logger: UnifiedLogger,
        *,
        dedupe: Optional[WorkspaceDedupeConfig] = None,
    ) -> None:
        """
        Initialize the ProjectManager.

        Args:
            workspace_path: The root path of the workspace.
            logger: The logger instance for output.
            dedupe: When enabled, sync uses a canonical directory and project symlinks.
        """
        self.workspace_path = Path(workspace_path).expanduser().resolve()
        self.logger = logger
        self.logger.set_level("INFO")
        self._dedupe = dedupe if dedupe is not None and dedupe.enabled else None

    def add(
        self,
        config_path: Path,
        project_name: str,
        repo: Union[ProjectPath, None],
        metagit_config: MetagitConfig,
        *,
        agent_mode: bool = False,
    ) -> Union[ProjectPath, Exception]:
        """
        Add a repository to a specific project in the configuration.

        Args:
            project_name: The name of the project to add the repository to.
            repo: The ProjectPath object representing the repository to add. If None, will prompt for data.
            metagit_config: The MetagitConfig instance to work with configuration data.

        Returns:
            Union[ProjectPath, Exception]: ProjectPath if successful, Exception if failed.
        """
        config_manager = MetagitConfigManager(metagit_config=metagit_config)
        try:
            # Validate inputs
            if not project_name or not isinstance(project_name, str):
                raise ValueError("Project name must be a non-empty string")

            # Check if workspace configuration exists
            if not metagit_config.workspace:
                raise ValueError("No workspace configuration found in the config file")
            # Find the target project
            target_project = None
            for project in metagit_config.workspace.projects:
                if project.name == project_name:
                    target_project = project
                    break

            if not target_project:
                raise ValueError(
                    f"Project '{project_name}' not found in workspace configuration"
                )

            if repo is None and agent_mode:
                return ValueError(
                    "Interactive repo add is disabled in agent mode; "
                    "pass --name/--path/--url or use catalog/MCP tools"
                )
            if repo is None:
                self.logger.debug(
                    "No repository data provided. Prompting for information..."
                )
                repo_result = UserPrompt.prompt_for_model(
                    ProjectPath,
                    title="Add git repository or local path to project group",
                    fields_to_prompt=["name", "path", "url", "description"],
                )
                if isinstance(repo_result, Exception):
                    return repo_result
                if repo_result.path is None and repo_result.url is None:
                    raise ValueError(
                        "No local path or remote URL provided. Please provide one of them."
                    )
                repo = repo_result

            # Check if name already exists in the project
            for existing_repo in target_project.repos:
                if existing_repo.name == repo.name:
                    raise ValueError(
                        f"Repository '{repo.name}' already exists in project '{project_name}'"
                    )

            duplicates = workspace_dedupe.find_duplicate_identities(
                metagit_config,
                repo,
            )
            if duplicates:
                locations = ", ".join(f"{proj}/{name}" for proj, name in duplicates)
                raise ValueError(
                    "Repo identity already registered as "
                    f"{locations}; reuse that entry or enable workspace dedupe"
                )

            # Add the repository to the project
            target_project.repos.append(repo)

            # Save the updated configuration
            save_result = config_manager.save_config(metagit_config, config_path)
            if isinstance(save_result, Exception):
                return save_result

            self.logger.debug(
                f"Successfully added repository '{repo.name}' to project '{project_name}' in configuration"
            )
            return repo

        except Exception as e:
            self.logger.error(
                f"Failed to add repository '{repo.name if repo else 'unknown'}' to project '{project_name}': {str(e)}"
            )
            return e

    def sync(self, project: WorkspaceProject, *, hydrate: bool = False) -> bool:
        """
        Sync a workspace project concurrently.

        Iterates through each repository in the project and either creates a
        symbolic link for local paths or clones it if it's a remote repository.
        After syncing, creates a VS Code workspace file.

        When ``hydrate`` is True, symlink mounts are replaced with full directory
        copies (see ``hydrate_project``).

        Returns:
            bool: True if sync is successful, False otherwise.
        """
        project_dir = os.path.join(self.workspace_path, project.name)
        os.makedirs(project_dir, exist_ok=True)
        tqdm.write(f"Syncing {project.name} project to {project_dir}...")

        with concurrent.futures.ThreadPoolExecutor() as executor:
            future_to_repo = {
                executor.submit(
                    self._sync_repo,
                    repo,
                    project_dir,
                    i,
                    project_name=project.name,
                ): repo
                for i, repo in enumerate(project.repos)
            }
            for future in concurrent.futures.as_completed(future_to_repo):
                repo = future_to_repo[future]
                try:
                    future.result()
                except Exception as exc:
                    tqdm.write(f"{repo.name} generated an exception: {exc}")
                    return False

        if hydrate:
            hydrate_ok = self.hydrate_project(project)
            if not hydrate_ok:
                return False

        # Create VS Code workspace file after successful sync
        workspace_result = self._create_vscode_workspace(project, project_dir)
        if isinstance(workspace_result, Exception):
            tqdm.write(f"Failed to create VS Code workspace file: {workspace_result}")
            # Don't fail the entire sync for workspace file creation issues
        # else:
        #     tqdm.write(f"Created VS Code workspace file: {workspace_result}")

        return True

    def hydrate_project(self, project: WorkspaceProject) -> bool:
        """
        Materialize symlink mounts under a project into full directory copies.

        Returns:
            bool: True when all hydrate steps succeed or are skipped cleanly.
        """
        project_dir = self.workspace_path / project.name
        if not project_dir.is_dir():
            tqdm.write(f"Hydrate skipped: project folder missing: {project_dir}")
            return False

        tqdm.write(f"Hydrating {project.name} (symlink mounts → full copies)...")
        ok = True
        for index, repo in enumerate(project.repos):
            mount = project_dir / repo.name
            if not mount.exists() and not mount.is_symlink():
                tqdm.write(f"  ⏭️  {repo.name}: mount missing (sync first)")
                continue
            if mount.is_dir() and not mount.is_symlink():
                tqdm.write(f"  ✓  {repo.name}: already a directory")
                continue
            changed, error = materialize_symlink_mount(
                mount,
                position=index,
                repo_label=repo.name,
            )
            if error:
                tqdm.write(f"  ❌ {repo.name}: {error}")
                ok = False
            elif not changed:
                tqdm.write(f"  ⏭️  {repo.name}: not a symlink")
        return ok

    def _create_vscode_workspace(
        self, project: WorkspaceProject, project_dir: str
    ) -> Union[str, Exception]:
        """
        Create a VS Code workspace file for the project.

        Args:
            project: The workspace project containing repository information
            project_dir: The directory where the project is located

        Returns:
            Path to the created workspace file on success, Exception on failure
        """
        try:
            # Get list of repository names that were successfully synced
            repo_names = []
            for repo in project.repos:
                repo_path = os.path.join(project_dir, repo.name)
                if os.path.exists(repo_path):
                    repo_names.append(repo.name)

            if not repo_names:
                return Exception("No repositories found to include in workspace")

            # Create workspace file content
            workspace_content = create_vscode_workspace(project.name, repo_names)
            if isinstance(workspace_content, Exception):
                return workspace_content

            # Write workspace file
            workspace_file_path = os.path.join(project_dir, "workspace.code-workspace")
            with open(workspace_file_path, "w") as f:
                f.write(workspace_content)

            return workspace_file_path

        except Exception as e:
            return e

    def _sync_repo(
        self,
        repo: ProjectPath,
        project_dir: str,
        position: int,
        *,
        project_name: str,
    ) -> None:
        """
        Sync a single repository.

        This method is called by the thread pool executor.
        """
        mount_path = os.path.join(project_dir, repo.name)
        if (
            self._dedupe is not None
            and self._dedupe.scope == WorkspaceDedupeScope.WORKSPACE
        ):
            self._sync_repo_deduped(
                repo=repo,
                project_name=project_name,
                mount_path=mount_path,
                position=position,
            )
            return

        if repo.path:
            self._sync_local(repo, mount_path, position)
        elif repo.url:
            self._sync_remote(repo, mount_path, position)
        else:
            tqdm.write(f"Skipping {repo.name}: No local path or remote URL provided.")

    def _sync_repo_deduped(
        self,
        *,
        repo: ProjectPath,
        project_name: str,
        mount_path: str,
        position: int,
    ) -> None:
        """Sync using canonical storage and a per-project symlink mount."""
        if self._dedupe is None:
            return
        identity = workspace_dedupe.build_repo_identity(repo)
        if identity is None:
            tqdm.write(f"Skipping {repo.name}: No local path or remote URL provided.")
            return

        canonical = workspace_dedupe.canonical_path(
            self.workspace_path,
            self._dedupe,
            identity.repo_key,
        )
        mount = Path(mount_path)

        if repo.path:
            self._sync_local_canonical(
                repo=repo,
                source_path=Path(repo.path).expanduser().resolve(),
                canonical=canonical,
                mount=mount,
                position=position,
            )
            return

        self._sync_remote_canonical(
            repo=repo,
            canonical=canonical,
            mount=mount,
            position=position,
        )

    def _sync_local_canonical(
        self,
        *,
        repo: ProjectPath,
        source_path: Path,
        canonical: Path,
        mount: Path,
        position: int,
    ) -> None:
        """Place source under canonical (symlink) and mount project symlink."""
        if not source_path.exists():
            tqdm.write(f"Source path for {repo.name} does not exist: {source_path}")
            return

        desc = f"  ✅   🔗 {repo.name}"
        if not canonical.exists():
            canonical.parent.mkdir(parents=True, exist_ok=True)
            try:
                os.symlink(
                    source_path,
                    canonical,
                    target_is_directory=source_path.is_dir(),
                )
            except OSError as exc:
                tqdm.write(f"Failed to create canonical link for {repo.name}: {exc}")
                return

        changed, error = workspace_dedupe.ensure_symlink(mount, canonical)
        if error:
            tqdm.write(f"Failed to mount {repo.name}: {error}")
            return
        bar_format = (
            "{l_bar}Symlinked{r_bar}" if changed else "{l_bar} 🟠 Already exists{r_bar}"
        )
        with tqdm(total=1, desc=desc, position=position, bar_format=bar_format) as pbar:
            pbar.update(1)

    def _sync_remote_canonical(
        self,
        *,
        repo: ProjectPath,
        canonical: Path,
        mount: Path,
        position: int,
    ) -> None:
        """Clone into canonical when missing, then symlink the project mount."""
        if not canonical.exists():
            self._sync_remote(repo, str(canonical), position)
        if not canonical.exists():
            return
        changed, error = workspace_dedupe.ensure_symlink(mount, canonical)
        if error:
            tqdm.write(f"Failed to mount {repo.name}: {error}")
            return
        desc = f"  🔗 {repo.name}"
        bar_format = (
            "{l_bar}Mounted{r_bar}" if changed else "{l_bar} 🟠 Already exists{r_bar}"
        )
        with tqdm(total=1, desc=desc, position=position, bar_format=bar_format) as pbar:
            pbar.update(1)

    def _sync_local(self, repo: ProjectPath, target_path: str, position: int) -> None:
        """Handle syncing of a local repository via symlink."""
        source_path = Path(repo.path).expanduser().resolve()
        if not source_path.exists():
            tqdm.write(f"Source path for {repo.name} does not exist: {source_path}")
            return

        desc = f"  ✅   🔗 {repo.name}"
        mount = Path(target_path)
        if mount.exists() or mount.is_symlink():
            desc = f"  🔗 {repo.name}"
            with tqdm(
                total=1,
                desc=desc,
                position=position,
                bar_format="{l_bar} 🟠 Already exists{r_bar}",
            ) as pbar:
                pbar.update(1)
            return

        changed, error = workspace_dedupe.ensure_symlink(mount, source_path)
        if error:
            tqdm.write(f"Failed to create symbolic link for {repo.name}: {error}")
            return
        with tqdm(
            total=1,
            desc=desc,
            position=position,
            bar_format="{l_bar}Symlinked{r_bar}",
        ) as pbar:
            pbar.update(1)

    def _sync_remote(self, repo: ProjectPath, target_path: str, position: int) -> None:
        """Handle syncing of a remote repository via git clone."""

        class CloneProgressHandler(git.RemoteProgress):
            def __init__(self, pbar: tqdm) -> None:
                super().__init__()
                self.pbar = pbar
                self.pbar.total = 100

            def update(
                self,
                op_code: int,  # noqa: ARG002
                cur_count: Union[str, float],
                max_count: Union[str, float, None] = None,
                message: str = "",
            ) -> None:
                if max_count:
                    self.pbar.total = float(max_count)
                self.pbar.n = float(cur_count)
                if message:
                    self.pbar.set_postfix_str(message.strip(), refresh=True)
                self.pbar.update(0)  # Manually update the progress bar

        desc = f"  ⤵️ {repo.name}"
        if os.path.exists(target_path):
            with tqdm(
                total=1,
                desc=desc,
                position=position,
                bar_format="{l_bar} 🟠 Already exists{r_bar}",
            ) as pbar:
                pbar.update(1)
            return

        with tqdm(
            desc=desc,
            position=position,
            unit="B",
            unit_scale=True,
            unit_divisor=1024,
            miniters=1,
        ) as pbar:
            try:
                git.Repo.clone_from(
                    str(repo.url),
                    target_path,
                    progress=CloneProgressHandler(pbar),
                )
                pbar.set_description(f"  ✅ {desc} Cloned")
            except git.exc.GitCommandError as e:
                pbar.set_description(f"  ❌ {desc} Failed")
                tqdm.write(
                    f"Failed to clone repository {repo.name}.\n"
                    f"URL: {repo.url}\n"
                    f"Error: {e.stderr}"
                )

    def list_unmanaged_sync_directories(
        self,
        metagit_config: MetagitConfig,
        project: str,
        *,
        ignore_hidden: bool,
    ) -> List[Path]:
        """
        Directories or symlinks under the project sync folder that are not declared repos.

        Uses the same .gitignore rules as the repo picker. When ``ignore_hidden`` is true,
        dotfiles and dot-directories are skipped.
        """
        project_path = Path(self.workspace_path) / project
        if project == "local":
            workspace_project = metagit_config.local_workspace_project
        else:
            matches = [
                p
                for p in (metagit_config.workspace.projects or [])
                if p.name == project
            ]
            if not matches:
                return []
            workspace_project = matches[0]

        if not project_path.exists(follow_symlinks=True):
            return []

        managed = {repo.name for repo in workspace_project.repos}
        ignore_patterns = parse_gitignore(project_path / ".gitignore")
        unmanaged: List[Path] = []
        for entry in project_path.iterdir():
            if not (entry.is_dir() or entry.is_symlink()):
                continue
            if ignore_hidden and entry.name.startswith("."):
                continue
            if should_ignore_path(entry, ignore_patterns, project_path):
                continue
            if entry.name in managed:
                continue
            unmanaged.append(entry)
        return sorted(unmanaged, key=lambda p: p.name.lower())

    @staticmethod
    def remove_sync_directory(path: Path) -> None:
        """Remove a synced repo directory or symlink under a project folder."""
        if path.is_symlink() or path.is_file():
            path.unlink(missing_ok=False)
        elif path.is_dir():
            shutil.rmtree(path)

    def select_repo(
        self,
        metagit_config: MetagitConfig,
        project: str,
        show_preview: bool = False,
        menu_length: int = 10,
        ignore_hidden: bool = True,
        agent_mode: bool = False,
    ) -> ProjectPath:
        """
        Select a repository from a synced project.
        """
        if agent_mode:
            return ValueError(
                "Interactive repo selection is disabled in agent mode; "
                "use `metagit project repo list --json` or specify a repo in commands"
            )
        project_path: str = os.path.join(self.workspace_path, project)
        if project == "local":
            workspace_project = metagit_config.local_workspace_project
        else:
            workspace_project = [
                target_project
                for target_project in metagit_config.workspace.projects
                if target_project.name == project
            ][0]

        if not Path(project_path).exists(follow_symlinks=True):
            self.logger.warning(
                f"Project path does not exist for project: {project_path}"
            )
            self.logger.warning(
                f"You can sync the project with `metagit workspace sync --project {project_path}`"
            )
            return
        project_dict = {}
        ignore_patterns = parse_gitignore(Path(project_path) / ".gitignore")
        # Iterate through the project path and add the directories and symlinks to the project_dict
        for f in Path(project_path).iterdir():
            if f.is_dir() or f.is_symlink():
                if ignore_hidden and f.name.startswith("."):
                    continue
                if should_ignore_path(f, ignore_patterns, Path(project_path)):
                    continue
                # Determine type
                item_type = "Symlink" if f.is_symlink() else "Directory"

                # Check for git repository
                has_git = (f / ".git").exists() if f.is_dir() else False

                # Check for .metagit.yml file
                has_metagit_yml = (f / ".metagit.yml").exists() if f.is_dir() else False

                # Build initial description
                description_parts = [
                    f"Name: {f.name}",
                    f"Type: {item_type}",
                ]

                if f.is_symlink():
                    target_path = f.readlink()
                    # Convert absolute path to relative path from project_path
                    try:
                        if target_path.is_absolute():
                            relative_target = os.path.relpath(target_path, project_path)
                            description_parts.append(f"Target: {relative_target}")
                        else:
                            description_parts.append(f"Target: {target_path}")
                    except (ValueError, OSError):
                        # Fallback to original path if relative path calculation fails
                        description_parts.append(f"Target: {target_path}")

                # Add git and metagit info
                if has_git:
                    description_parts.append("Git: ✅ Repository")
                else:
                    description_parts.append("Git: ❌ No repository")

                if has_metagit_yml:
                    description_parts.append("MetaGit: ✅ .metagit.yml found")
                else:
                    description_parts.append("MetaGit: ❌ No .metagit.yml")

                project_dict[f.name] = self._build_preview_sections(description_parts)

        # Track which items exist in the project configuration
        managed_repos = {repo.name for repo in workspace_project.repos}

        # Iterate through the workspace project and add the repo descriptions to the project_dict
        for repo in workspace_project.repos:
            if repo.name in project_dict:
                # Update the description with management status and repo description
                summary_lines = self._build_project_repo_summary(repo)
                project_dict[repo.name] = self._append_preview_lines(
                    project_dict[repo.name], summary_lines
                )
            else:
                # This repo is configured but doesn't exist on filesystem
                description_parts = [
                    f"Name: {repo.name}",
                    "Type: Missing (configured but not synced)",
                    "Status: ⚠️ Configured but missing",
                    "Git: ❓ Unknown (not synced)",
                    "MetaGit: ❓ Unknown (not synced)",
                ]

                description_parts.extend(self._build_project_repo_summary(repo))
                project_dict[repo.name] = self._build_preview_sections(
                    description_parts
                )

        # Add unmanaged status to items that exist on filesystem but not in config
        for item_name in list(project_dict.keys()):
            if item_name not in managed_repos:
                current_description = project_dict[item_name]
                current_description += "\nStatus: ❌ Unmanaged"
                project_dict[item_name] = current_description

        projects: List[FuzzyFinderTarget] = []
        for target in project_dict:
            # Determine color based on directory type (check filesystem)
            target_path = Path(project_path) / target
            is_symlink = target_path.is_symlink()

            # Set color: white for directories, light blue for symlinks
            color = (
                "#87ceeb" if is_symlink else "white"
            )  # light blue for symlinks, white for directories

            # Set opacity: 1.0 if managed (exists in project list), 0.5 if not managed
            opacity = 1.0 if target in managed_repos else 0.5

            projects.append(
                FuzzyFinderTarget(
                    name=target,
                    description=project_dict[target],
                    color=color,
                    opacity=opacity,
                )
            )
        if len(projects) == 0:
            self.logger.warning(f"No projects found in workspace: {project_path}")
            return
        finder_config = FuzzyFinderConfig(
            items=projects,
            prompt_text="🔍 Search projects: ",
            max_results=menu_length,
            total_count=len(projects),
            query_mode_label="matches",
            score_threshold=60.0,
            highlight_color="bold white bg:#0066cc",
            normal_color="cyan",
            prompt_color="bold green",
            separator_color="gray",
            enable_preview=show_preview,
            display_field="name",
            preview_field="description",
        )
        finder = FuzzyFinder(finder_config)
        selected = finder.run()
        if isinstance(selected, Exception):
            raise selected
        if selected is None:
            return None
        else:
            selected_path = os.path.join(project_path, selected.name)
            # If the path starts with ../../, replace it with ./
            if selected_path.startswith("../../"):
                selected_path = selected_path.replace("../../", "./", 1)
            return selected_path

    @staticmethod
    def _append_preview_lines(existing: str, lines: List[str]) -> str:
        """Append non-empty lines to an existing preview block."""
        final_lines = [line for line in lines if line]
        if not final_lines:
            return existing
        return f"{existing}\n" + "\n".join(final_lines)

    @staticmethod
    def _build_preview_sections(lines: List[str]) -> str:
        """Build a readable preview body from collected lines."""
        return "\n".join([line for line in lines if line])

    @staticmethod
    def _build_project_repo_summary(repo: ProjectPath) -> List[str]:
        """Build metadata lines for configured project repositories."""
        summary_lines = ["Status: ✅ Managed"]
        summary_lines.append(
            f"Description: {repo.description or 'No description available'}"
        )
        if repo.path:
            summary_lines.append(f"Path: {repo.path}")
        if repo.url:
            summary_lines.append(f"URL: {repo.url}")
        if repo.kind:
            summary_lines.append(f"Kind: {repo.kind}")
        if repo.ref:
            summary_lines.append(f"Ref: {repo.ref}")
        if repo.language:
            summary_lines.append(f"Language: {repo.language}")
        if repo.language_version:
            summary_lines.append(f"Language Version: {repo.language_version}")
        if repo.package_manager:
            summary_lines.append(f"Package Manager: {repo.package_manager}")
        if repo.frameworks:
            summary_lines.append(f"Frameworks: {', '.join(repo.frameworks)}")
        if repo.source_provider:
            summary_lines.append(f"Source Provider: {repo.source_provider}")
        if repo.source_namespace:
            summary_lines.append(f"Source Namespace: {repo.source_namespace}")
        if repo.protected is not None:
            summary_lines.append(f"Protected: {repo.protected}")
        return summary_lines
`````

## File: CHANGELOG.md
`````markdown
# Changelog

## Unreleased

### Added

- `.metagit.yml` **documentation** entries support rich objects (`kind`, `path`, `url`, `tags`, `metadata`) plus legacy bare strings; `documentation_graph_nodes()` exports ingest payloads.
- Top-level **graph** block for manual cross-repo **relationships** (merged into cross-project dependency maps and `graph_export_payload()` for GitNexus-style exports).
- Per-project `dedupe.enabled` override on `workspace.projects[]` in `.metagit.yml` (overrides app-config `workspace.dedupe.enabled` for sync and layout under that project).
- `metagit prompt` kind `repo-enrich` (repo scope): CLI workflow to discover repo metadata (`metagit detect`, `project source sync`) and merge into the workspace manifest entry.
- Bundled skill `metagit-cli`: CLI-only shortcuts for agents, including every `metagit prompt` kind and common catalog/detect/sync commands (no MCP or HTTP API).
- `metagit prompt` command group: `list`, `workspace`, `project`, and `repo` subcommands emit built-in operational prompts or composed manifest `agent_instructions` (`--kind`, `--json`, `--text-only`).
- Top-level `agent_mode` in app config (default false), overridable via `METAGIT_AGENT_MODE`; disables interactive UIs (fuzzy finder, prompts, editor, prune confirms) across CLI when enabled.
- `metagit appconfig show` prints the full active configuration with `--format yaml|json|minimal-yaml` (includes `workspace.dedupe` and effective `agent_mode`).

### Added

- `metagit project sync --hydrate` materializes symlink mounts into full directory copies with per-file tqdm progress.

### Changed

- `workspace.dedupe.enabled` defaults to **false** in app config; enable in `metagit.config.yaml` or per-project `dedupe.enabled` in `.metagit.yml` when canonical checkouts are desired.
- `load_config()` applies environment variable overrides (same as `AppConfig.load()`), including `METAGIT_AGENT_MODE` and `METAGIT_WORKSPACE_DEDUPE_ENABLED`.
- `metagit config show` prints the source `.metagit.yml` by default (preserves your formatting); use `--normalized` for a readable model round-trip (`|` blocks, Unicode not escaped) or `--json` for agents.

### Added

- Workspace layout rename/move: rename projects and repos (manifest + sync folders), move repos across projects; CLI (`workspace project rename`, `workspace repo rename|move`), MCP (`metagit_workspace_project_rename`, `metagit_workspace_repo_rename`, `metagit_workspace_repo_move`), HTTP v2 (`POST /v2/projects/{name}/rename`, `/v2/repos/.../rename|move`). Supports `--dry-run`, `--manifest-only`, dedupe symlink mounts, and session file migration on project rename. See `docs/reference/workspace-layout-api.md`.
- Workspace catalog CRUD with JSON output: CLI (`metagit workspace list|project|repo`, `metagit project list --all`, `project add|remove`, `project repo list|remove`, `--json` on catalog commands), MCP tools (`metagit_workspace_list`, `metagit_workspace_projects_list`, `metagit_workspace_project_add|remove`, `metagit_workspace_repos_list`, `metagit_workspace_repo_add|remove`), and HTTP API v2 (`/v2/workspace`, `/v2/projects`, `/v2/repos`). Manifest-only repo/project removal; use `project repo prune` to delete unmanaged directories on disk.
- Docs: [Hermes agents and organization-wide IaC](docs/hermes-iac-workspace-guide.md) — illustrated controller/subagent workflow, manifest examples, and MCP tool map for platform IaC estates.
- Layered `agent_instructions` on `.metagit.yml` (file, workspace, project, repo/path); legacy `agent_prompt` accepted on load. `AgentInstructionsResolver` composes stacks for MCP project context (`instruction_layers`, `effective_agent_instructions`, per-repo `agent_instructions`).
- MCP `metagit_workspace_semantic_search` runs GitNexus `query` per managed repo (requires registry + index) for vector-ranked process results.
- MCP `metagit_workspace_health_check` includes branch age (`head_commit_age_days`, `merge_base_age_days`) when `check_stale_branches` is enabled, with thresholds `branch_head_warning_days` / `branch_head_critical_days` / `integration_stale_days` and summary counters for stale HEAD and integration drift.
- MCP Phase 3 workspace intelligence: `metagit_workspace_health_check`, `metagit_workspace_discover`, and `metagit_project_template_apply` (dry-run by default), plus resources `metagit://workspace/health` and `metagit://workspace/context`.
- MCP `metagit_cross_project_dependencies` to map declared, import-hint, and shared-config relationships between workspace projects with GitNexus index status per repo.
- MCP Phase 1 search/sync improvements: `metagit_repo_search` filters (`status`, `has_url`, `sync_enabled`, `sort`), `metagit_workspace_search` ripgrep-backed hits with `repos`/`paths`/`exclude`/`context_lines`/`intent`, and batch `metagit_workspace_sync` with `only_if` and `dry_run`.
- MCP project context tools: `metagit_project_context_switch`, `metagit_workspace_state_snapshot`, `metagit_workspace_state_restore`, and `metagit_session_update` for switching workspace projects with persisted session state under `.metagit/sessions/` and git-state snapshots under `.metagit/snapshots/`.
- Managed repository search across `.metagit.yml` workspace repos: CLI (`metagit search` / `metagit find`), MCP tool `metagit_repo_search`, and local JSON HTTP API (`metagit api serve` with `/v1/repos/search` and `/v1/repos/resolve`).
- `metagit project repo prune` to review and remove sync-folder directories not declared in `.metagit.yml` (with `--dry-run`, `--include-hidden`, and `--force` to skip prompts).
- `workspace.ui_ignore_hidden` in app config (default true) to hide dot-directories from the repo picker UI.

### Changed

- Removed redundant `config.version` from application config; use `metagit version` for the installed package. Legacy `version` keys in YAML are ignored on load. `api_version` remains for a future remote API contract (default empty; `METAGIT_API_VERSION` still applies).

### Fixed

- Workspace search: preset names that map to intent globs (e.g. `terraform`) now pass `**/*.tf` include globs to ripgrep; if ripgrep returns no hits while a `preset` or `intent` is set, the term-based filesystem fallback runs so Ubuntu/CI still gets matches when `rg` is installed but misbehaves or misparses.
- Workspace search fallback without `rg` matches preset-expanded terms (e.g. `preset=terraform`) instead of treating the composed `|` pattern as one literal string; fixes empty results when ripgrep is not installed.
- `task test` now runs `uv run pytest` so tests use the project virtualenv (fixes `ModuleNotFoundError: loguru` when `pytest` was not the venv binary).

## [0.2.2](https://github.com/metagit-ai/metagit-cli/compare/v0.2.1...v0.2.2) (2026-05-06)


### Bug Fixes

* revamp release workflow ([cafd6da](https://github.com/metagit-ai/metagit-cli/commit/cafd6dac4777c8528cc1c996bb8f1a394c40d53d))
`````

## File: Taskfile.yml
`````yaml
# yaml-language-server: $schema=https://taskfile.dev/schema.json

version: "3"
silent: true
env:
  PROJECT:
    sh: 'echo "$(basename $(pwd))"'
  LOCAL_BIN_PATH:
    sh: 'echo "{{.ROOT_DIR}}/.venv/bin"'
  BUILD_DATE:
    sh: "date '+%Y-%m-%d-%H:%M:%S'"
  BUILD_DATE_SHORT:
    sh: "date '+%Y-%m-%d-%H%M%S'"
  GIT_LATEST_TAG:
    sh: 'git tag -l 2>/dev/null | sort -r -V | head -n 1 2>/dev/null || echo "not a git repo"'
  TERM: screen-256color
  DOCS_PATH: "{{.ROOT_DIR}}/docs"
  PYTHON_VENV_PATH: "{{.ROOT_DIR}}/.venv"
  SCRIPT_PATH: "{{.ROOT_DIR}}/scripts"
  VERSION: '{{default "unknown" .GIT_LATEST_TAG}}'
  COMPOSE_BAKE: true

dotenv:
  - ".env"
  - ".SECRETS.env"

includes:
  docker:
    taskfile: ./tasks/Taskfile.docker.yml
    optional: true
  github:
    taskfile: ./tasks/Taskfile.github.yml
    optional: true
  python:
    taskfile: ./tasks/Taskfile.python.yml
    optional: true
  mcp:
    taskfile: ./tasks/Taskfile.mcp.yml
    optional: true

tasks:
  default:
    cmds:
      - |
        task -l

  show:
    desc: Show task variables
    cmds:
      - |
        echo "ROOT_PATH: {{.ROOT_DIR}}"
        echo "PROJECT: {{.PROJECT}}"
        echo "VERSION: {{.VERSION}}"
        echo "OS: {{OS}}"
        echo "ARCH: {{ARCH}}"
        echo "LOCAL_BIN_PATH: {{.LOCAL_BIN_PATH}}"
        echo "PYTHON_VENV_PATH: {{.PYTHON_VENV_PATH}}"
        echo "SCRIPT_PATH: {{.SCRIPT_PATH}}"
        echo "BUILD_DATE: {{.BUILD_DATE}}"
        echo "GIT_LATEST_TAG: {{.GIT_LATEST_TAG}}"

  show:all:
    desc: Show all variables for task namespaces
    cmds:
      - |
        echo "## Show ##";
        task show
        echo ""
        for taskitem in $(task -l | cut -d " " -f2 | grep show | sed 's/.$//'); do
          if [[ "$taskitem" != "show:all" ]]; then
            echo "## Show - ${taskitem} ##";
            task $taskitem;
            echo "";
          fi
        done

  list:
    desc: List tasks by namespace (task list -- <namespace>)
    cmds:
      - |
        if [[ "{{.CLI_ARGS}}" != "" ]]; then
          task -l | grep {{default "" .CLI_ARGS}}
        else
          task -l
        fi

  toc:
    desc: Update the table of contents in README.md
    cmds:
      - uv pip install -e ".[docs]"
      - uv run mdtoc README.md

  secrets:
    desc: Create template .SECRETS file if one does not already exist.
    cmds:
      - cp ./config/.SECRETS.example.env ./.SECRETS.env
    status:
      - "test -f .SECRETS.env"

  autocomplete:
    desc: Setup task autocomplete (zsh)
    cmds:
      - sudo curl https://raw.githubusercontent.com/go-task/task/main/completion/zsh/_task \
        -o /usr/local/share/zsh/site-functions/_task
    status:
      - "test -f /usr/local/share/zsh/site-functions/_task"

  install:
    desc: Install everything required to run most scripts.
    cmds:
      - |
        uv sync

  run:
    desc: Run the app
    cmds:
      - |
        uv run -m metagit_detect.example

  lint:
    desc: Run python linting
    cmds:
      - |
        uv run ruff check .
        uv run ruff format --check .

  lint:fix:
    desc: Run python linting and fix issues
    cmds:
      - |
        uv run ruff check --fix .

  format:
    desc: Run python formatting
    cmds:
      - |
        uv run ruff format .
  
  start:mcp:
    desc: Start MCP servers
    cmds:
      - |
        docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN} -e GITHUB_READ_ONLY=1 -e GITHUB_DYNAMIC_TOOLSETS=1 ghcr.io/github/github-mcp-server
  
  fake:workspace:
    desc: Create a test workspace folder structure with 10 projects
    cmds:
      - |
        for i in {1..10}; do
          echo "Creating workspace $i"
          mkdir -p "./.metagit/default/project-$i"
        done

  docs:
    desc: Build the docs
    cmds:
      - task: generate:schema
      - |
        uv pip install -e ".[docs]"
        #gemini -y -p 'You are an expert devops and software engineer. Explore this project for its use of secrets and variables paying special attention to only those affecting the runtime behavior of the code and the cicd workflows used to release the projects artifacts. Create a report in ./docs/secrets.analysis.md on each of the secrets found and were they are sourced from. Using this data create a yaml file at ./docs/secrets.definitions.yml that represents the secrets, their provenance, and where they should be placed.'
        #gemini -y -p 'Perform a comprehensive analysis of the files in this project. Update the yaml file named .metagit.example.yml in the root of this project in a manner that adheres to the jsonschema file located at ./schemas/metagit_config.schema.json. Ensure you skip files matching the patterns in .gitignore. Do your best to update even optional elements of this schema. Pay special attention to dependencies found in Dockerfiles, CICD files, and mcp definitions. When complete also create ./docs/app.logic.md with additional documentation on the project components that includes a mermaid diagram of how they interact.'
        uv run mkdocs build

  test:
    desc: Run the tests
    cmds:
      - |
        uv pip install -e ".[test]"
        uv run pytest --maxfail=1 --disable-warnings -v tests

  qa:prepush:
    desc: Token-optimized local pre-push quality gate
    cmds:
      - |
        zsh ./scripts/prepush-gate.zsh

  qa:prepush:loop:
    desc: Repeat local pre-push gate (use -- rounds=N)
    cmds:
      - |
        zsh ./scripts/prepush-gate.zsh --loop --rounds={{default "3" .CLI_ARGS}}

  skills:sync:
    desc: Sync local skills into .cursor/skills mirrors
    cmds:
      - |
        uv run python - <<'PY'
        from pathlib import Path
        import shutil

        src_root = Path("skills")
        dst_root = Path(".cursor/skills")
        dst_root.mkdir(parents=True, exist_ok=True)

        source_skill_dirs = [p for p in src_root.iterdir() if p.is_dir()]
        for src_dir in source_skill_dirs:
            dst_dir = dst_root / src_dir.name
            if dst_dir.exists():
                shutil.rmtree(dst_dir)
            shutil.copytree(src_dir, dst_dir)
            print(f"synced {src_dir} -> {dst_dir}")
        PY

  typecheck:
    desc: Run the type checker
    cmds:
      - |
        uv run mypy metagit/core

  secret:search:
    desc: Find secrets in the codebase and in git history
    cmds:
      - |
        gitleaks detect --source ./ -f json -r secret_results.json
  
  build:
    desc: Build the project
    cmds:
      - |
        uv build

  clean:
    desc: Clean the project
    cmds:
      - |
        find . -type d -name "__pycache__" -exec rm -rf {} +
        find . -type d -name "*.egg-info" -exec rm -rf {} +
        find . -type f -name ".DS_Store" -delete
        find . -type f -name "*.pyc" -delete
        find . -type f -name "*.pyo" -delete
        rm -rf .mypy_cache
        rm -rf .pytest_cache
        rm -rf .ruff_cache
        rm -rf dist
        rm -rf .coverage
        rm -rf .coverage.*
        rm -rf .coverage.xml
        rm -rf .DS_Store
        rm -rf .pytest_cache
        rm -rf .venv
        rm -rf src/metagit/_version.py


  deep:clean:
    desc: Clean the project deeply. Includes uv cache removal.
    cmds:
      - uv cache clean
      - task: clean

  generate:schema:
    desc: Generate the schema for the config and appconfig files
    cmds:
      - |
        uv pip install -e .
        uv run metagit config schema --output-path ./schemas/metagit_config.schema.json
        uv run metagit appconfig schema --output-path ./schemas/metagit_appconfig.schema.json
        mkdir -p docs/reference
        uv run metagit config example --output docs/reference/metagit-config.full-example.yml

  repomix:
    desc: Run the repo mixin
    cmds:
      - |
        repomix --style=markdown --output=docs/llm.txt --compress
        repomix --style=markdown --output=docs/llm-detailed.txt

  update:github:actions:
    desc: Update GitHub Actions in workflows
    cmds:
      - npx actions-up -y

  skills:validate:
    desc: Validate every SKILL.md under skills/ (frontmatter, naming, required fields)
    cmds:
      - |
        uv pip install -e ".[dev]"
        uv run python scripts/validate_skills.py

  web:install:
    desc: Install web UI dependencies
    dir: web
    cmds:
      - npm ci || npm install

  web:build:
    desc: Build web UI into package data (not part of qa:prepush — run explicitly when changing web/)
    deps: [web:install]
    dir: web
    cmds:
      - npm run build

  web:dev:
    desc: Run Vite dev server
    dir: web
    cmds:
      - npm run dev

  security:deps:
    desc: Frozen sync all extras and audit dependencies (pip-audit)
    cmds:
      - |
        uv sync --frozen --all-extras
        uv run pip-audit

  security:bandit:
    desc: Bandit SAST on src/
    cmds:
      - |
        uv run bandit -r src -ll

  security:gate:
    desc: Fast security gate without lockfile sync (pip-audit + bandit)
    cmds:
      - |
        uv run pip-audit
        uv run bandit -r src -ll

  security:scan:
    desc: Full Python security scans (sync + pip-audit + bandit)
    cmds:
      - task: security:deps
      - task: security:bandit
`````

## File: .mex/ROUTER.md
`````markdown
---
name: router
description: Session bootstrap and navigation hub. Read at the start of every session before any task. Contains project state, routing table, and behavioural contract.
edges:
  - target: context/architecture.md
    condition: when working on system design, integrations, or understanding how components connect
  - target: context/stack.md
    condition: when working with specific technologies, libraries, or making tech decisions
  - target: context/conventions.md
    condition: when writing new code, reviewing code, or unsure about project patterns
  - target: context/decisions.md
    condition: when making architectural choices or understanding why something is built a certain way
  - target: context/setup.md
    condition: when setting up the dev environment or running the project for the first time
  - target: context/mcp-runtime.md
    condition: when implementing MCP runtime, tool schemas, resource handlers, or protocol behavior
  - target: patterns/INDEX.md
    condition: when starting a task — check the pattern index for a matching pattern file
last_updated: 2026-05-20
---

# Session Bootstrap

If you haven't already read `AGENTS.md`, read it now — it contains the project identity, non-negotiables, and commands.

Then read this file fully before doing anything else in this session.

## Current Project State
**Working:**
- Core CLI command surface (`config`, `detect`, `project`, `record`, `workspace`, `mcp`, `search` / `find`, `api serve` for local JSON v1 search + **v2 catalog CRUD**, `project repo prune` for sync-folder cleanup) with shared app config + logger bootstrapping.
- **Workspace catalog** (`WorkspaceCatalogService`): list/add/remove projects and repos in `.metagit.yml` via CLI (`--json`), MCP (`metagit_workspace_*` catalog tools), and HTTP `/v2/*`.
- **`metagit prompt`**: scoped prompt emission (`workspace` / `project` / `repo`) for agents — manifest instructions plus operational templates (session-start, catalog-edit, sync-safe, **repo-enrich**, etc.). Bundled **`metagit-cli`** skill documents CLI-only shortcuts (all prompt kinds; no MCP/API).
- **`agent_mode`** / **`METAGIT_AGENT_MODE`**: disables interactive CLI (fuzzy finder, prompts, editor); `metagit appconfig show --format json` exposes full config including `workspace.dedupe` (default **disabled**).
- **Workspace layout** (`WorkspaceLayoutService`): rename/move projects and repos (manifest + sync folders, dedupe-aware, session migration); CLI, MCP, HTTP v2 — see `docs/reference/workspace-layout-api.md`.
- `.metagit.yml` manager/model pipeline for load/create/save/validate operations.
- MCP runtime with state-aware gating, tool/resource handlers (search, **semantic search**, sync, cross-project dependencies, project context, snapshots, health check with branch-age staleness, file discover, template apply), resources for health/context, protocol-framed stdio loop, and runtime tests.
- Workspace index/search/upstream hint services, `ManagedRepoSearchService` for managed-only repo matching, local read-only HTTP routes under `metagit.core.api`, and guarded repo inspect/sync flows.
- Skill scaffold + local wrapper scripts in `skills/*/scripts` for token-efficient agent workflows, including `metagit-projects` for OpenClaw/Hermes workspace project lifecycle (check-before-create, register in `.metagit.yml`).
- `docs/skills.md` documents global install, `metagit skills install`, and bundled skill overview.
- Runtime packaging compatibility path for version lookup and `python -m metagit` entrypoint behavior in minimal Python environments.
- Docs build path resolves CLI imports correctly in CI by including interactive prompt runtime dependency.
- A semantic-release workflow now computes and pushes tags from conventional commits on `main`, and tag pushes drive PyPI/TestPyPI publish workflows.
- **`task qa:prepush`** (via `scripts/prepush-gate.py` / `prepush-gate.zsh`) is mandatory in the behavioural contract whenever a session modifies tracked files — not optional “session closeout only.”
- Provider source sync is available via `metagit project source sync` for GitHub org/user and GitLab group recursive discovery with discover/additive/reconcile modes.
- Fuzzy finder repo selection UX now shows result counters, keeps full scrollable match sets, respects project `.gitignore` entries during filesystem candidate discovery, and provides richer repo metadata in preview.
- New `skills` CLI command (`list`, `show`, `install`) plus `mcp install` now support auto-detected agent targets across project/user scopes, with bundled package skills deployed from `src/metagit/data/skills`.
- Focused `graphify` runs on subtrees produce `graphify-out/` HTML/report artifacts quickly enough to use for local command-surface exploration without analyzing the entire repository.
- **Workspace dedupe:** `workspace.dedupe` in app config (default disabled); optional per-project `workspace.projects[].dedupe.enabled` in `.metagit.yml` overrides the flag. `metagit project sync --hydrate` materializes symlink mounts into full copies.
- **`metagit config example`:** generates `docs/reference/metagit-config.full-example.yml` (via `task generate:schema`) with field-description comments.
- **Hermes orchestrator template:** `hermes-orchestrator` under `src/metagit/data/templates/`, example manifest at `examples/hermes-orchestrator/.metagit.yml`, guide at `docs/hermes-orchestrator-workspace.md`.
- **`metagit init`:** bundled init templates (`application`, `umbrella`, `hermes-orchestrator`) with copier-style `{{ var }}` rendering, `--answers-file`, `--no-prompt`, all `ProjectKind` values via `--minimal`.
- **`metagit web serve` groundwork:** Pydantic request/response models for the local web UI API live in `src/metagit/core/web/models.py` (`ConfigTreeResponse`, sync job shapes, config patch types). Thread-safe in-memory sync job tracking + SSE event buffers live in `src/metagit/core/web/job_store.py` (`SyncJobStore`).
- **`metagit web serve` config HTTP:** `build_web_server` in `src/metagit/core/web/server.py` exposes v3 config tree/patch/validate routes via `ConfigWebHandler` (`metagit` + `appconfig` targets, `SchemaTreeService` mutations). PATCH with `save=true` returns HTTP 422 and skips disk write when validation fails; masked sensitive tokens are preserved on noop set.
- **`metagit web serve` ops HTTP:** `OpsWebHandler` (`src/metagit/core/web/ops_handler.py`) — POST health/prune/sync, GET sync job status, SSE sync events; wired in `build_web_server` with workspace root from appconfig.
- **`metagit web serve` static + full server:** `StaticWebHandler` serves packaged SPA from `src/metagit/data/web/`; `build_web_server` dispatches static, v2 catalog/layout, v3 config/ops; CLI `metagit web serve` (`src/metagit/cli/commands/web.py`).
- **Metagit Web UI scaffold:** Vite + React + TypeScript in `web/` (build output → `src/metagit/data/web/`); typed API client, router shell, Taskfile `web:*` tasks.
- **Metagit Web Config Studio:** schema tree + field editor for `/config/metagit` and `/config/appconfig` (TanStack Query PATCH flow, theme toggle, `enum_options` on schema nodes).
- **Metagit Web:** local `metagit web serve` + packaged SPA (**Config Studio** on `/config/*`, **Workspace Console** on `/workspace`) with `task web:dev` / `task web:build` workflow documented in [`docs/reference/metagit-web.md`](../docs/reference/metagit-web.md).

**Not yet built:**
- **Metagit Web hardened/exposed deployments:** intentional v1 localhost-only framing; authentication and safe non-local binds are future scope.
- Full production-grade MCP lifecycle extras (e.g., richer notifications, broader method surface, advanced capability negotiation details).
- End-to-end enterprise mode features described in README (continuous org-wide code mining).
- Matured sampling execution path with robust timeout/retry/error telemetry across diverse MCP hosts.

**Known issues:**
- Local `black` execution path is unstable in this environment; project lint path currently relies on Ruff workflow.
- Some MCP schema/tool contracts are still evolving and may require downstream client adjustments.
- Pydantic deprecation warnings are present in test output due to existing class-based config usage.

## Routing Table

Load the relevant file based on the current task. Always load `context/architecture.md` first if not already in context this session.

| Task type | Load |
|-----------|------|
| Understanding how the system works | `context/architecture.md` |
| Working with a specific technology | `context/stack.md` |
| Writing or reviewing code | `context/conventions.md` |
| Making a design decision | `context/decisions.md` |
| Setting up or running the project | `context/setup.md` |
| Working on MCP runtime/tools/resources/protocol | `context/mcp-runtime.md` |
| Any specific task | Check `patterns/INDEX.md` for a matching pattern |

## Behavioural Contract

For every task, follow this loop:

1. **CONTEXT** — Load the relevant context file(s) from the routing table above. Check `patterns/INDEX.md` for a matching pattern. If one exists, follow it. Narrate what you load: "Loading architecture context..."
2. **BUILD** — Do the work. If a pattern exists, follow its Steps. If you are about to deviate from an established pattern, say so before writing any code — state the deviation and why.
3. **VERIFY** — Load `context/conventions.md` and run the Verify Checklist item by item. State each item and whether the output passes. Do not summarise — enumerate explicitly.
4. **DEBUG** — If verification fails or something breaks, check `patterns/INDEX.md` for a debug pattern. Follow it. Fix the issue and re-run VERIFY.
5. **GROW** — After completing the task:
   - If no pattern exists for this task type, create one in `patterns/` using the format in `patterns/README.md`. Add it to `patterns/INDEX.md`. Flag it: "Created `patterns/<name>.md` from this session."
   - If a pattern exists but you deviated from it or discovered a new gotcha, update it with what you learned.
   - If any `context/` file is now out of date because of this work, update it surgically — do not rewrite entire files.
   - Update the "Current Project State" section above if the work was significant.
6. **QA GATE (mandatory for any delivered work)** — If you changed or added tracked project files in this conversation, run `task qa:prepush` from the repo root before reporting the task as finished. Fix failures and re-run until green. Also run `task skills:sync generate:schema` when bundled skills/schemas need to stay mirrored (see conventions). Omit the QA gate only for strictly read‑only exploration (no file writes) or when the user explicitly waived it in this thread. Document any intentional blockers plainly.

## Commit Message Semantics
- Use `fix:` by default (patch-level intent).
- Use `feat:` only for additive backward-compatible behavior.
- Use breaking-change markers (`type(scope)!:` or `BREAKING CHANGE:`) only when intentionally breaking schema/config compatibility (for example `.metagit.yml` or app configuration schema changes).
`````
