From 61a81662542ddc0a734a98b2f682920db9813b94 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 15 Apr 2026 15:16:49 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(drone):=20add=20watchdog=20to=20INTERA?= =?UTF-8?q?CTIVE=5FCOMMANDS=20=E2=80=94=20bypass=2030s=20capture=20timeout?= =?UTF-8?q?=20for=20long-running=20poller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: @drone --- src/aipass/drone/apps/drone.py | 2 +- src/aipass/drone/tests/test_activation.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index 2ff9ad36..35da5a32 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -41,7 +41,7 @@ MODULES_DIR = Path(__file__).parent / "modules" # Interactive mode — commands/branches that bypass capture + timeout for live terminal output. -INTERACTIVE_COMMANDS = ("monitor", "audit") +INTERACTIVE_COMMANDS = ("monitor", "audit", "watchdog") INTERACTIVE_BRANCHES = ("cli",) diff --git a/src/aipass/drone/tests/test_activation.py b/src/aipass/drone/tests/test_activation.py index 7e12f0b8..2fd64895 100644 --- a/src/aipass/drone/tests/test_activation.py +++ b/src/aipass/drone/tests/test_activation.py @@ -407,6 +407,20 @@ def test_interactive_detection_for_branch(self, mock_route: MagicMock) -> None: call_kwargs = mock_route.call_args.kwargs assert call_kwargs["interactive"] is True + @patch("aipass.drone.apps.drone.route_command") + def test_watchdog_routes_interactive(self, mock_route: MagicMock) -> None: + """watchdog command should route with interactive=True (long-running poller).""" + from aipass.drone.apps.drone import _handle_target + + mock_route.return_value = CommandResult( + stdout="", stderr="", exit_code=0, branch="devpulse", command="watchdog", + ) + + _handle_target(["@devpulse", "watchdog", "--help"]) + + call_kwargs = mock_route.call_args.kwargs + assert call_kwargs["interactive"] is True + @patch("aipass.drone.apps.drone.route_command") def test_propagates_exit_code(self, mock_route: MagicMock) -> None: """Should return the route_command exit code.""" From e6d4998c8d0d0193b8c5db509880ef2d697b0b22 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 15 Apr 2026 15:19:32 -0700 Subject: [PATCH 2/6] feat(drone): fix merge_plugin: stash unstaged changes before git pull --rebase to prevent dirty-tree abort Co-Authored-By: @drone --- .../apps/plugins/devpulse_ops/merge_plugin.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py index 8ac6ce68..02a47920 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py @@ -65,16 +65,39 @@ def merge_pr(pr_number: str, caller: str) -> dict: logger.error(result["message"]) return result - # Step 2: Sync local main + # Step 2: Sync local main — stash any unstaged changes first so + # git pull --rebase doesn't abort on a dirty working tree. + stash = subprocess.run( + ["git", "stash"], + capture_output=True, text=True, cwd=str(repo_root), + ) + stashed = "No local changes to save" not in stash.stdout + pull = subprocess.run( ["git", "pull", "--rebase"], capture_output=True, text=True, cwd=str(repo_root), ) if pull.returncode != 0: + if stashed: + subprocess.run( + ["git", "stash", "pop"], + capture_output=True, text=True, cwd=str(repo_root), + ) result["message"] = f"Pull after merge failed: {pull.stderr.strip()}" logger.error(result["message"]) return result + if stashed: + pop = subprocess.run( + ["git", "stash", "pop"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if pop.returncode != 0: + logger.warning( + "merge_pr: stash pop after pull failed (manual restore may be needed): %s", + pop.stderr.strip(), + ) + # Step 3: Get the merge commit hash rev = subprocess.run( ["git", "rev-parse", "HEAD"], From b29dc085371e591b9b8d9963f666d760995212f2 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 15 Apr 2026 15:28:16 -0700 Subject: [PATCH 3/6] =?UTF-8?q?feat(system):=20DPLAN-0133=20Phase=201=20(p?= =?UTF-8?q?art=201/2):=20add=20gitignore=20rule=20for=20apps/integrations/?= =?UTF-8?q?**=20with=20README=20negation,=20plus=20scaffold=20README.md=20?= =?UTF-8?q?in=20all=2010=20core=20branches'=20apps/integrations/=20?= =?UTF-8?q?=E2=80=94=20private=20integration=20space=20is=20now=20leak-pro?= =?UTF-8?q?of=20by=20construction.=20Drivers=20and=20wrappers=20dropped=20?= =?UTF-8?q?here=20stay=20local;=20only=20README.md=20is=20tracked.=20See?= =?UTF-8?q?=20DPLAN-0133=20for=20architecture=20rationale=20(three-layer?= =?UTF-8?q?=20design:=20@api=20drivers,=20per-branch=20wrappers,=20public?= =?UTF-8?q?=20generic=20contracts).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: @devpulse --- .gitignore | 7 ++ STATUS.md | 2 +- .../ai_mail/apps/integrations/README.md | 64 +++++++++++++++++++ src/aipass/api/apps/integrations/README.md | 64 +++++++++++++++++++ src/aipass/cli/apps/integrations/README.md | 64 +++++++++++++++++++ src/aipass/drone/apps/integrations/README.md | 64 +++++++++++++++++++ src/aipass/flow/apps/integrations/README.md | 64 +++++++++++++++++++ src/aipass/memory/apps/integrations/README.md | 64 +++++++++++++++++++ src/aipass/prax/apps/integrations/README.md | 64 +++++++++++++++++++ src/aipass/seedgo/apps/integrations/README.md | 64 +++++++++++++++++++ src/aipass/spawn/apps/integrations/README.md | 64 +++++++++++++++++++ .../trigger/apps/integrations/README.md | 64 +++++++++++++++++++ 12 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 src/aipass/ai_mail/apps/integrations/README.md create mode 100644 src/aipass/api/apps/integrations/README.md create mode 100644 src/aipass/cli/apps/integrations/README.md create mode 100644 src/aipass/drone/apps/integrations/README.md create mode 100644 src/aipass/flow/apps/integrations/README.md create mode 100644 src/aipass/memory/apps/integrations/README.md create mode 100644 src/aipass/prax/apps/integrations/README.md create mode 100644 src/aipass/seedgo/apps/integrations/README.md create mode 100644 src/aipass/spawn/apps/integrations/README.md create mode 100644 src/aipass/trigger/apps/integrations/README.md diff --git a/.gitignore b/.gitignore index 343010f5..605784a3 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,10 @@ whiteboard.md README_ORIGINAL_DISABLED.md readme_history/ src/aipass/trigger/trigger_data.lock + +# Private integrations — driver layer (@api) and wrapper layer (all branches) +# Per DPLAN-0133. Contents are gitignored; only the scaffold README.md is tracked. +# Drop project-specific code into src/aipass/{branch}/apps/integrations/{project}/ +# It stays local. Never appears in git. +src/aipass/*/apps/integrations/** +!src/aipass/*/apps/integrations/README.md diff --git a/STATUS.md b/STATUS.md index 7392081d..727bf1ae 100644 --- a/STATUS.md +++ b/STATUS.md @@ -300,7 +300,7 @@ ### Friction notes -- **seedgo __init__.py false positive**: `imports: Failed` and `naming: invalid characters` fire on Python reserved filename. Need special-case in seedgo's naming standard and imports standard. Encountered in another project; likely also in AIPass repo. Open a seedgo standard PR when there's bandwidth. +(none active — seedgo __init__.py false positive FIXED by @seedgo in PR #279, 2026-04-14) ### S92 (2026-04-14 ~12:45 PT) — PRE-COMPACT, system prompt reformat work in flight diff --git a/src/aipass/ai_mail/apps/integrations/README.md b/src/aipass/ai_mail/apps/integrations/README.md new file mode 100644 index 00000000..0fab10ce --- /dev/null +++ b/src/aipass/ai_mail/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for this branch. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/someproject/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/api/apps/integrations/README.md b/src/aipass/api/apps/integrations/README.md new file mode 100644 index 00000000..0fab10ce --- /dev/null +++ b/src/aipass/api/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for this branch. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/someproject/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/cli/apps/integrations/README.md b/src/aipass/cli/apps/integrations/README.md new file mode 100644 index 00000000..0fab10ce --- /dev/null +++ b/src/aipass/cli/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for this branch. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/someproject/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/drone/apps/integrations/README.md b/src/aipass/drone/apps/integrations/README.md new file mode 100644 index 00000000..0fab10ce --- /dev/null +++ b/src/aipass/drone/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for this branch. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/someproject/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/flow/apps/integrations/README.md b/src/aipass/flow/apps/integrations/README.md new file mode 100644 index 00000000..0fab10ce --- /dev/null +++ b/src/aipass/flow/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for this branch. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/someproject/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/memory/apps/integrations/README.md b/src/aipass/memory/apps/integrations/README.md new file mode 100644 index 00000000..0fab10ce --- /dev/null +++ b/src/aipass/memory/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for this branch. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/someproject/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/prax/apps/integrations/README.md b/src/aipass/prax/apps/integrations/README.md new file mode 100644 index 00000000..0fab10ce --- /dev/null +++ b/src/aipass/prax/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for this branch. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/someproject/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/seedgo/apps/integrations/README.md b/src/aipass/seedgo/apps/integrations/README.md new file mode 100644 index 00000000..0fab10ce --- /dev/null +++ b/src/aipass/seedgo/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for this branch. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/someproject/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/spawn/apps/integrations/README.md b/src/aipass/spawn/apps/integrations/README.md new file mode 100644 index 00000000..0fab10ce --- /dev/null +++ b/src/aipass/spawn/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for this branch. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/someproject/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/trigger/apps/integrations/README.md b/src/aipass/trigger/apps/integrations/README.md new file mode 100644 index 00000000..0fab10ce --- /dev/null +++ b/src/aipass/trigger/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for this branch. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/someproject/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. From b638f705184d632bef59aa2fa27db0984c1c37fe Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 15 Apr 2026 15:35:28 -0700 Subject: [PATCH 4/6] feat(spawn): DPLAN-0133 Phase 1 (part 2/2): add apps/integrations/ scaffold to builder template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every new branch spawned via `drone @spawn create` now lands with `apps/integrations/README.md` — the private integration space introduced in DPLAN-0133. The README explains the three-layer architecture (api driver → branch wrapper → public contracts) and is the only tracked file in the folder; everything else dropped there is gitignored by the root rule shipped in PR #289. Changes: - templates/builder/apps/integrations/README.md — new scaffold file - templates/builder/.spawn/.template_registry.json — regenerated (44→45 files, 23→24 dirs) - tests/test_citizen_classes.py — new test asserting integrations/ exists after create; added missing docstrings to 14 test methods - .seedgo/bypass.json — added architecture + encapsulation bypasses for test_citizen_classes.py (standard test-file exemptions) Co-Authored-By: Claude Sonnet 4.6 --- src/aipass/spawn/.seedgo/bypass.json | 10 +++ .../builder/.spawn/.template_registry.json | 25 ++++++-- .../builder/apps/integrations/README.md | 64 +++++++++++++++++++ .../spawn/tests/test_citizen_classes.py | 24 +++++++ 4 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/aipass/spawn/templates/builder/apps/integrations/README.md diff --git a/src/aipass/spawn/.seedgo/bypass.json b/src/aipass/spawn/.seedgo/bypass.json index 4fc4d847..c0e18975 100644 --- a/src/aipass/spawn/.seedgo/bypass.json +++ b/src/aipass/spawn/.seedgo/bypass.json @@ -189,6 +189,16 @@ "file": "apps/modules/regenerate_registry.py", "standard": "json_structure", "reason": "Thin CLI layer — delegates all work to regenerate_registry_ops handler. json_handler imported for log_operation only." + }, + { + "file": "tests/test_citizen_classes.py", + "standard": "architecture", + "reason": "Test file — lives in tests/ by convention, not in the 3-layer app structure. Test files are exempt from layer architecture standard." + }, + { + "file": "tests/test_citizen_classes.py", + "standard": "encapsulation", + "reason": "Test file — tests must import handlers directly to test them in isolation. Handler imports inside test methods are intentional." } ], "notes": { diff --git a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json index f4c09952..cbd426b2 100644 --- a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json @@ -1,7 +1,7 @@ { "metadata": { "version": "1.0.0", - "last_updated": "2026-04-10", + "last_updated": "2026-04-15", "description": "Template file tracking registry for ID-based updates" }, "files": { @@ -155,7 +155,7 @@ "content_hash": "a4cf0a8e3b4f", "has_branch_placeholder": false }, - "f044": { + "f026": { "path": "apps/modules/__init__.py", "name": "__init__.py", "content_hash": "e3b0c44298fc", @@ -179,6 +179,12 @@ "content_hash": "de20d11e5cfd", "has_branch_placeholder": false }, + "f029": { + "path": "artifacts/birth_certificate.json", + "name": "birth_certificate.json", + "content_hash": "0b6e4319781e", + "has_branch_placeholder": false + }, "f030": { "path": "docs/README.md", "name": "README.md", @@ -257,13 +263,13 @@ "content_hash": "28e9ae373563", "has_branch_placeholder": false }, - "f029": { - "path": "artifacts/birth_certificate.json", - "name": "birth_certificate.json", - "content_hash": "0b6e4319781e", + "f044": { + "path": "apps/integrations/README.md", + "name": "README.md", + "content_hash": "31c09afe1299", "has_branch_placeholder": false }, - "f026": { + "f045": { "path": "apps/plugins/__init__.py", "name": "__init__.py", "content_hash": "e3b0c44298fc", @@ -385,6 +391,11 @@ "path": "{{BRANCH}}_json/custom_config", "name": "custom_config", "has_branch_placeholder": false + }, + "d024": { + "path": "apps/integrations", + "name": "integrations", + "has_branch_placeholder": false } } } diff --git a/src/aipass/spawn/templates/builder/apps/integrations/README.md b/src/aipass/spawn/templates/builder/apps/integrations/README.md new file mode 100644 index 00000000..bc979a44 --- /dev/null +++ b/src/aipass/spawn/templates/builder/apps/integrations/README.md @@ -0,0 +1,64 @@ +# apps/integrations/ + +Private integration space for `{{BRANCHNAME}}`. + +**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline. + +## What goes here + +**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain. + +``` +apps/integrations/ +└── {project}/ + ├── wrapper.py # How this branch uses the driver + ├── config.json # Optional — local config + └── tests/ # Private tests colocated +``` + +Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here. + +## What does NOT go here + +- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer). +- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that. +- **Drone plugins** — use `apps/plugins/` for those. +- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo. + +## Architecture + +The full design is in DPLAN-0133 (private integrations architecture). Three layers: + +1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name. +2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects. +3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call `) — advertise the extension points without naming specifics. Fork-safe. + +## Usage + +```python +# Your public code (committed, in apps/modules/ or apps/handlers/) +from aipass.api import memory_backend + +results = memory_backend.query("when did we ship watchdog?") +# memory_backend is a generic contract. In your local setup it routes to whatever +# driver you registered in @api/apps/integrations/. In a fresh clone with nothing +# registered, it returns NotConfigured gracefully. +``` + +```python +# Your private wrapper (in this folder, gitignored) +# apps/integrations/{project}/wrapper.py + +from aipass.api import memory_backend + +def domain_specific_query(context): + """Branch-specific query pattern for domain needs.""" + hint = build_query_from_context(context) + return memory_backend.query(hint, top_k=5, filter={"kind": "decision"}) +``` + +The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code. + +--- + +See DPLAN-0133 for the full design rationale. diff --git a/src/aipass/spawn/tests/test_citizen_classes.py b/src/aipass/spawn/tests/test_citizen_classes.py index 7941e138..cdd1581c 100644 --- a/src/aipass/spawn/tests/test_citizen_classes.py +++ b/src/aipass/spawn/tests/test_citizen_classes.py @@ -25,38 +25,45 @@ class TestClassRegistry: """Tests for apps/handlers/class_registry.py""" def test_get_available_classes(self): + """Returns list containing 'builder' and 'birthright'.""" from aipass.spawn.apps.handlers.class_registry import get_available_classes classes = get_available_classes() assert "builder" in classes assert "birthright" in classes def test_validate_class_valid(self): + """Known class names validate as True.""" from aipass.spawn.apps.handlers.class_registry import validate_class assert validate_class("builder") is True assert validate_class("birthright") is True def test_validate_class_invalid(self): + """Unknown or empty class names validate as False.""" from aipass.spawn.apps.handlers.class_registry import validate_class assert validate_class("nonexistent") is False assert validate_class("") is False def test_get_default_class(self): + """Default citizen class is 'builder'.""" from aipass.spawn.apps.handlers.class_registry import get_default_class assert get_default_class() == "builder" def test_get_template_dir_builder(self): + """Builder template directory exists and is named 'builder'.""" from aipass.spawn.apps.handlers.class_registry import get_template_dir path = get_template_dir("builder") assert path.name == "builder" assert path.exists() def test_get_template_dir_birthright(self): + """Birthright template directory exists and is named 'birthright'.""" from aipass.spawn.apps.handlers.class_registry import get_template_dir path = get_template_dir("birthright") assert path.name == "birthright" assert path.exists() def test_get_template_dir_invalid_raises(self): + """Requesting an unknown class raises ValueError.""" from aipass.spawn.apps.handlers.class_registry import get_template_dir with pytest.raises(ValueError, match="Unknown citizen class"): get_template_dir("nonexistent") @@ -70,6 +77,7 @@ class TestPassportCommand: """Tests for passport granting (birthright citizenship).""" def test_grant_passport_creates_trinity(self, tmp_path): + """Passport grant creates .trinity/ with all identity files.""" from aipass.spawn.apps.handlers.passport_ops import grant_passport target = tmp_path / "test_citizen" result = grant_passport(str(target), role="tester", purpose="Testing") @@ -81,6 +89,7 @@ def test_grant_passport_creates_trinity(self, tmp_path): assert (target / ".trinity" / "observations.json").exists() def test_grant_passport_creates_aipass(self, tmp_path): + """Passport grant creates .aipass/ with local prompt.""" from aipass.spawn.apps.handlers.passport_ops import grant_passport target = tmp_path / "test_citizen" result = grant_passport(str(target), role="tester") @@ -89,6 +98,7 @@ def test_grant_passport_creates_aipass(self, tmp_path): assert (target / ".aipass" / "aipass_local_prompt.md").exists() def test_grant_passport_creates_readme(self, tmp_path): + """Passport grant creates README.md in target directory.""" from aipass.spawn.apps.handlers.passport_ops import grant_passport target = tmp_path / "test_citizen" result = grant_passport(str(target)) @@ -172,6 +182,16 @@ def test_create_with_citizen_class_in_passport(self, tmp_path): passport = json.loads((target / ".trinity" / "passport.json").read_text()) assert passport["identity"]["citizen_class"] == "builder" + def test_create_builder_includes_integrations_scaffold(self, tmp_path): + """Builder creation includes apps/integrations/README.md (DPLAN-0133).""" + from aipass.spawn.apps.modules.core import _spawn_agent + target = tmp_path / "integrations_test" + result = _spawn_agent(str(target), citizen_class="builder") + + assert result["success"] is True + assert (target / "apps" / "integrations").is_dir() + assert (target / "apps" / "integrations" / "README.md").exists() + # ============================================================================= # CLASS-AWARE UPDATE TESTS @@ -245,6 +265,7 @@ class TestTemplateStructure: """Tests verifying template directory structure.""" def test_builder_template_exists(self): + """Builder template directory has .trinity/passport.json and apps/.""" from aipass.spawn.apps.handlers.class_registry import get_template_dir builder = get_template_dir("builder") assert builder.is_dir() @@ -252,6 +273,7 @@ def test_builder_template_exists(self): assert (builder / "apps").is_dir() def test_birthright_template_exists(self): + """Birthright template has .trinity/passport.json but no apps/.""" from aipass.spawn.apps.handlers.class_registry import get_template_dir birthright = get_template_dir("birthright") assert birthright.is_dir() @@ -259,12 +281,14 @@ def test_birthright_template_exists(self): assert not (birthright / "apps").exists() def test_builder_passport_has_class(self): + """Builder template passport declares citizen_class='builder'.""" from aipass.spawn.apps.handlers.class_registry import get_template_dir builder = get_template_dir("builder") passport = json.loads((builder / ".trinity" / "passport.json").read_text()) assert passport["identity"]["citizen_class"] == "builder" def test_birthright_passport_has_class(self): + """Birthright template passport declares citizen_class='birthright'.""" from aipass.spawn.apps.handlers.class_registry import get_template_dir birthright = get_template_dir("birthright") passport = json.loads((birthright / ".trinity" / "passport.json").read_text()) From 912cf3ebdf2b04b026928009030b749819058e6e Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 15 Apr 2026 15:44:59 -0700 Subject: [PATCH 5/6] =?UTF-8?q?feat(api):=20DPLAN-0133=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20bridge=20contract=20registry=20+=20driver=20auto-di?= =?UTF-8?q?scovery=20+=20integrations=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the @api side of the three-layer private-integration architecture (DPLAN-0133). Branches call generic contracts; @api resolves them at runtime to whatever private driver is registered locally. New modules: apps/modules/bridge.py — module-level dict registry (register/resolve/list_contracts/clear) apps/modules/registry.py — auto-discovery walker for apps/integrations/*/driver.py apps/modules/integrations_manager.py — drone @api integrations {list,call} command routing apps/handlers/integrations/list.py — handler: log + return contract listing apps/handlers/integrations/call.py — handler: invoke driver, return result dict apps/integrations/testcontract/driver.py — canary driver (contract "test" → "pong", gitignored) tests/test_integrations.py — 17 tests (bridge, registry, fetch_contracts, call_contract) All 306 existing tests remain green. Empty integrations/ exits 0 cleanly. Co-Authored-By: Claude Sonnet 4.6 --- src/aipass/api/.seedgo/bypass.json | 10 + .../apps/handlers/integrations/__init__.py | 0 .../api/apps/handlers/integrations/call.py | 40 ++++ .../api/apps/handlers/integrations/list.py | 33 +++ src/aipass/api/apps/modules/bridge.py | 58 +++++ .../api/apps/modules/integrations_manager.py | 170 +++++++++++++++ src/aipass/api/apps/modules/registry.py | 109 ++++++++++ src/aipass/api/tests/test_integrations.py | 203 ++++++++++++++++++ 8 files changed, 623 insertions(+) create mode 100644 src/aipass/api/apps/handlers/integrations/__init__.py create mode 100644 src/aipass/api/apps/handlers/integrations/call.py create mode 100644 src/aipass/api/apps/handlers/integrations/list.py create mode 100644 src/aipass/api/apps/modules/bridge.py create mode 100644 src/aipass/api/apps/modules/integrations_manager.py create mode 100644 src/aipass/api/apps/modules/registry.py create mode 100644 src/aipass/api/tests/test_integrations.py diff --git a/src/aipass/api/.seedgo/bypass.json b/src/aipass/api/.seedgo/bypass.json index 63cb4b9c..5bb1f5ac 100644 --- a/src/aipass/api/.seedgo/bypass.json +++ b/src/aipass/api/.seedgo/bypass.json @@ -79,6 +79,16 @@ "file": "apps/modules/openrouter_client.py", "standard": "modules", "reason": "make_call() is a CLI orchestrator — parses args then delegates to client.get_response(). All business logic lives in handlers/openrouter/client.py." + }, + { + "file": "apps/integrations/testcontract/driver.py", + "standard": "architecture", + "reason": "Integration drivers live in apps/integrations/{project}/driver.py — gitignored private space intentionally outside the 3-layer structure. Loaded via importlib, not standard package import. See DPLAN-0133." + }, + { + "file": "tests/test_integrations.py", + "standard": "architecture", + "reason": "Test file — lives in tests/ by convention, not in the 3-layer app structure. Test files are exempt from layer architecture standard." } ], "notes": { diff --git a/src/aipass/api/apps/handlers/integrations/__init__.py b/src/aipass/api/apps/handlers/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/api/apps/handlers/integrations/call.py b/src/aipass/api/apps/handlers/integrations/call.py new file mode 100644 index 00000000..9acf0d39 --- /dev/null +++ b/src/aipass/api/apps/handlers/integrations/call.py @@ -0,0 +1,40 @@ +# =================== AIPass ==================== +# Name: call.py +# Description: Handler for invoking a registered integration contract driver +# Version: 1.0.0 +# Created: 2026-04-15 +# Modified: 2026-04-15 +# ============================================= +""" +Call a registered integration contract. + +Accepts a resolved driver callable and args from the calling module layer. +No module imports — the module layer owns bridge access and passes the driver in. +Returns a result dict; display is handled by the module layer. +""" +from typing import Callable + +from aipass.prax import logger +from aipass.api.apps.handlers.json import json_handler + + +def invoke(driver_fn: Callable, contract_name: str, args: list[str]) -> dict: + """ + Invoke a contract driver with the given args. + + Args: + driver_fn: Resolved callable from bridge (already looked up by module layer). + contract_name: Name of the contract being called (for logging). + args: Arguments forwarded to the driver function. + + Returns: + dict with keys: result (str | None), success (bool), error (str | None). + """ + try: + result = driver_fn(*args) + json_handler.log_operation("integrations_call", {"contract": contract_name, "args": args, "success": True}) + return {"result": str(result) if result is not None else None, "success": True, "error": None} + except Exception as e: + logger.error(f"[call] Driver '{contract_name}' raised: {e}") + json_handler.log_operation("integrations_call", {"contract": contract_name, "success": False, "error": str(e)}) + return {"result": None, "success": False, "error": str(e)} diff --git a/src/aipass/api/apps/handlers/integrations/list.py b/src/aipass/api/apps/handlers/integrations/list.py new file mode 100644 index 00000000..955c7aff --- /dev/null +++ b/src/aipass/api/apps/handlers/integrations/list.py @@ -0,0 +1,33 @@ +# =================== AIPass ==================== +# Name: list.py +# Description: Handler for listing registered integration contracts +# Version: 1.0.0 +# Created: 2026-04-15 +# Modified: 2026-04-15 +# ============================================= +""" +List registered integration contracts. + +Accepts a pre-fetched list of contract names from the calling module layer. +No module imports — the module layer owns bridge access and passes data in. +""" +from aipass.prax import logger +from aipass.api.apps.handlers.json import json_handler + + +def get_contracts(contracts: list[str]) -> dict: + """ + Log and return contract listing result. + + Args: + contracts: Pre-fetched sorted list of contract names from bridge. + + Returns: + dict with keys: contracts (list[str]), count (int), success (bool). + """ + try: + json_handler.log_operation("integrations_list", {"count": len(contracts), "contracts": contracts}) + return {"contracts": contracts, "count": len(contracts), "success": True} + except Exception as e: + logger.error(f"[list] Failed to process contracts: {e}") + return {"contracts": [], "count": 0, "success": False} diff --git a/src/aipass/api/apps/modules/bridge.py b/src/aipass/api/apps/modules/bridge.py new file mode 100644 index 00000000..777a98e3 --- /dev/null +++ b/src/aipass/api/apps/modules/bridge.py @@ -0,0 +1,58 @@ +# =================== AIPass ==================== +# Name: bridge.py +# Description: Generic contract registry — maps contract names to driver functions +# Version: 1.0.0 +# Created: 2026-04-15 +# Modified: 2026-04-15 +# ============================================= +""" +Generic contract registry for @api driver layer. + +Contracts are string names (e.g. "memory", "search") that map to driver functions. +Drivers register themselves; callers resolve by name. +Bridge itself is stateless beyond the registry dict — no threading, no startup side effects. +""" +from typing import Callable + +from aipass.api.apps.handlers.json import json_handler +from aipass.cli.apps.modules import console, header + + +def print_introspection() -> None: + """Show bridge registry introspection.""" + console.print() + header("Bridge — Contract Registry") + console.print() + console.print("[cyan]Purpose:[/cyan] Generic contract registry mapping names to driver functions") + console.print() + contracts = list_contracts() + if contracts: + console.print("[cyan]Registered contracts:[/cyan]") + for name in contracts: + console.print(f" • {name}") + else: + console.print("[dim]No contracts registered.[/dim]") + console.print() + json_handler.log_operation("bridge_introspection", {"contracts": contracts}) + +_registry: dict[str, Callable] = {} + + +def register(contract_name: str, driver_fn: Callable) -> None: + """Register a driver function under a contract name.""" + _registry[contract_name] = driver_fn + + +def resolve(contract_name: str) -> Callable | None: + """Return the registered driver for contract_name, or None.""" + return _registry.get(contract_name) + + +def list_contracts() -> list[str]: + """Return all registered contract names, sorted.""" + return sorted(_registry.keys()) + + +def clear() -> None: + """Clear all registrations. Intended for test teardown only.""" + _registry.clear() diff --git a/src/aipass/api/apps/modules/integrations_manager.py b/src/aipass/api/apps/modules/integrations_manager.py new file mode 100644 index 00000000..716a241d --- /dev/null +++ b/src/aipass/api/apps/modules/integrations_manager.py @@ -0,0 +1,170 @@ +# =================== AIPass ==================== +# Name: integrations_manager.py +# Description: Integrations command module — list and call generic contracts +# Version: 1.0.0 +# Created: 2026-04-15 +# Modified: 2026-04-15 +# ============================================= +""" +Integrations Module + +Handles `drone @api integrations` subcommands: + integrations list — list all registered contracts + integrations call ... — call a registered contract +""" +import sys +from typing import List + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.api.apps.handlers.json import json_handler +from aipass.cli.apps.modules import console, header, error +from aipass.api.apps.modules import registry +from aipass.api.apps.modules.bridge import list_contracts, resolve +from aipass.api.apps.handlers.integrations.list import get_contracts +from aipass.api.apps.handlers.integrations.call import invoke + + +def _ensure_loaded() -> None: + """Load drivers on first command invocation.""" + registry.load_drivers() + + +def print_introspection() -> None: + """Show module introspection — connected handlers and capabilities.""" + console.print() + header("Integrations Module Introspection") + console.print() + console.print("[cyan]Purpose:[/cyan] Generic contract dispatch for private integration drivers") + console.print() + console.print("[cyan]Subcommands:[/cyan]") + console.print(" • integrations list — list registered contracts") + console.print(" • integrations call ... — invoke a contract driver") + console.print() + json_handler.log_operation("integrations_introspection", {}) + + +def print_help() -> None: + """Print usage help for the integrations command.""" + console.print() + header("Integrations Module") + console.print() + console.print("[bold cyan]USAGE:[/bold cyan]") + console.print(" drone @api integrations list") + console.print(" drone @api integrations call [args...]") + console.print() + console.print("[dim]Drivers live in apps/integrations/{project}/driver.py (gitignored)[/dim]") + console.print() + + +def _run_list() -> int: + """Fetch contracts from bridge, pass to handler, display result.""" + contracts = list_contracts() + result = get_contracts(contracts) + + console.print() + header("Integrations — Registered Contracts") + console.print() + + if not result["contracts"]: + console.print("[dim]No integrations configured.[/dim]") + console.print() + console.print("[dim]Drop a driver in apps/integrations/{project}/driver.py to register one.[/dim]") + console.print() + return 0 + + for name in result["contracts"]: + console.print(f" [cyan]•[/cyan] {name}") + console.print() + return 0 + + +def _run_call(contract_name: str, args: List[str]) -> int: + """Resolve contract from bridge, pass driver to handler, display result.""" + driver_fn = resolve(contract_name) + + if driver_fn is None: + error( + f"contract '{contract_name}' not registered", + suggestion="Configure a driver in apps/integrations/", + ) + return 1 + + result = invoke(driver_fn, contract_name, args) + + if not result["success"]: + error(f"Driver '{contract_name}' failed: {result['error']}") + return 1 + + if result["result"] is not None: + console.print(result["result"]) + return 0 + + +def fetch_contracts() -> dict: + """ + Return contract listing result dict from the list handler. + + Returns: + dict with keys: contracts (list[str]), count (int), success (bool). + """ + return get_contracts(list_contracts()) + + +def call_contract(contract_name: str, args: List[str]) -> dict: + """ + Resolve and invoke a contract driver, returning a result dict. + + Args: + contract_name: The contract name to look up in the bridge. + args: Arguments forwarded to the driver function. + + Returns: + dict with keys: result (str | None), success (bool), error (str | None). + If contract not registered: success=False, error='not registered'. + """ + driver_fn = resolve(contract_name) + if driver_fn is None: + return {"result": None, "success": False, "error": f"contract '{contract_name}' not registered"} + return invoke(driver_fn, contract_name, args) + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle integrations subcommands. + + Returns True if command was handled (including errors), False if not our command. + """ + if command != "integrations": + return False + + try: + if args and args[0] in ("--help", "-h", "help"): + print_help() + return True + + if not args: + print_introspection() + return True + + subcommand = args[0] + sub_args = args[1:] + + if subcommand == "list": + _ensure_loaded() + sys.exit(_run_list()) + + if subcommand == "call": + if not sub_args: + error("Usage: drone @api integrations call [args...]") + sys.exit(1) + _ensure_loaded() + sys.exit(_run_call(sub_args[0], sub_args[1:])) + + error(f"Unknown integrations subcommand: {subcommand}", suggestion="Try: list, call") + return True + + except SystemExit: + raise + except Exception as e: + logger.error(f"Error in integrations_manager.handle_command: {e}") + raise diff --git a/src/aipass/api/apps/modules/registry.py b/src/aipass/api/apps/modules/registry.py new file mode 100644 index 00000000..d238353a --- /dev/null +++ b/src/aipass/api/apps/modules/registry.py @@ -0,0 +1,109 @@ +# =================== AIPass ==================== +# Name: registry.py +# Description: Auto-discovery walker for apps/integrations/*/driver.py +# Version: 1.0.0 +# Created: 2026-04-15 +# Modified: 2026-04-15 +# ============================================= +""" +Driver auto-discovery for @api integrations layer. + +On first call to load_drivers(), walks apps/integrations/*/driver.py, imports each module, +and calls its register() hook. Subsequent calls are no-ops (idempotent via _loaded flag). + +Each driver module is expected to: + 1. Import bridge: from aipass.api.apps.modules.bridge import register + 2. Implement a register() function that calls bridge.register(contract_name, fn) + +Failure modes — all non-fatal: + - Empty integrations/ dir → no drivers loaded, no log noise + - Folder without driver.py → skipped silently + - Import error → logged as WARNING, driver skipped, no crash +""" +import importlib.util +import sys +from pathlib import Path + +from aipass.api.apps.handlers.json import json_handler +from aipass.cli.apps.modules import console, header +from aipass.prax.apps.modules.logger import system_logger as logger + +_INTEGRATIONS_DIR = Path(__file__).parent.parent / "integrations" +_loaded: bool = False + + +def print_introspection() -> None: + """Show registry introspection.""" + console.print() + header("Registry — Driver Auto-Discovery") + console.print() + console.print("[cyan]Purpose:[/cyan] Auto-discover and load integration drivers from apps/integrations/*/driver.py") + console.print() + console.print(f"[cyan]Integrations dir:[/cyan] {_INTEGRATIONS_DIR}") + console.print(f"[cyan]Loaded:[/cyan] {_loaded}") + console.print() + json_handler.log_operation("registry_introspection", {"loaded": _loaded, "dir": str(_INTEGRATIONS_DIR)}) + + +def load_drivers(integrations_dir: Path | None = None) -> int: + """ + Walk integrations_dir and load all driver.py files. + + Args: + integrations_dir: Override path for testing. Defaults to apps/integrations/. + + Returns: + Number of drivers successfully loaded. + """ + global _loaded + if _loaded and integrations_dir is None: + return 0 # idempotent for production path + + target = integrations_dir if integrations_dir is not None else _INTEGRATIONS_DIR + + if not target.exists(): + return 0 + + loaded = 0 + for project_dir in sorted(target.iterdir()): + if not project_dir.is_dir(): + continue + driver_path = project_dir / "driver.py" + if not driver_path.exists(): + continue + try: + _import_driver(driver_path, project_dir.name) + loaded += 1 + except Exception as exc: + logger.warning(f"[registry] Skipping driver {project_dir.name}: {exc}") + + if integrations_dir is None: + _loaded = True + + return loaded + + +def _import_driver(driver_path: Path, project_name: str) -> None: + """Import a single driver.py and call its register() hook.""" + module_name = f"_aipass_integration_{project_name}" + + # Remove stale module if present (supports reload in tests) + sys.modules.pop(module_name, None) + + spec = importlib.util.spec_from_file_location(module_name, driver_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot create spec for {driver_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + if hasattr(module, "register"): + module.register() + # If no register() hook, driver is still loaded (might use bridge.register directly at module level) + + +def reset() -> None: + """Reset loaded state. Intended for test teardown only.""" + global _loaded + _loaded = False diff --git a/src/aipass/api/tests/test_integrations.py b/src/aipass/api/tests/test_integrations.py new file mode 100644 index 00000000..98dd6aae --- /dev/null +++ b/src/aipass/api/tests/test_integrations.py @@ -0,0 +1,203 @@ +# =================== AIPass ==================== +# Name: test_integrations.py +# Description: Tests for bridge, registry, and integrations handlers +# Version: 1.0.0 +# Created: 2026-04-15 +# Modified: 2026-04-15 +# ============================================= +""" +Tests for DPLAN-0133 Phase 2: bridge + registry + handlers. + +Groups: + TestBridge — contract registration, resolve, list, clear + TestRegistry — auto-discovery walk, empty dir, missing driver, broken import + TestFetchContracts — fetch_contracts() happy path and empty + TestCallContract — call_contract() happy path, unregistered, args forwarding, exception +""" +import pytest + +from aipass.api.apps.modules import bridge, registry +from aipass.api.apps.modules.integrations_manager import fetch_contracts, call_contract + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def clean_bridge(): + """Reset bridge state before and after each test.""" + bridge.clear() + registry.reset() + yield + bridge.clear() + registry.reset() + + +# --------------------------------------------------------------------------- +# TestBridge +# --------------------------------------------------------------------------- + +class TestBridge: + def test_register_and_resolve(self): + """register() then resolve() returns the same callable.""" + fn = lambda: "result" + bridge.register("my_contract", fn) + assert bridge.resolve("my_contract") is fn + + def test_resolve_returns_none_for_unknown(self): + """resolve() on unregistered name returns None.""" + assert bridge.resolve("nonexistent") is None + + def test_list_contracts_empty(self): + """list_contracts() on empty registry returns [].""" + assert bridge.list_contracts() == [] + + def test_list_contracts_sorted(self): + """list_contracts() returns sorted names.""" + bridge.register("zebra", lambda: None) + bridge.register("alpha", lambda: None) + bridge.register("mango", lambda: None) + assert bridge.list_contracts() == ["alpha", "mango", "zebra"] + + def test_register_overwrites(self): + """Registering same name twice replaces the driver.""" + fn1 = lambda: "first" + fn2 = lambda: "second" + bridge.register("dup", fn1) + bridge.register("dup", fn2) + assert bridge.resolve("dup") is fn2 + + +# --------------------------------------------------------------------------- +# TestRegistry +# --------------------------------------------------------------------------- + +class TestRegistry: + def test_load_drivers_empty_dir(self, tmp_path): + """Empty integrations dir → 0 drivers, no error.""" + count = registry.load_drivers(integrations_dir=tmp_path) + assert count == 0 + + def test_load_drivers_skips_missing_driver_py(self, tmp_path): + """Folder without driver.py is skipped silently.""" + (tmp_path / "myproject").mkdir() + count = registry.load_drivers(integrations_dir=tmp_path) + assert count == 0 + + def test_load_drivers_loads_valid_driver(self, tmp_path): + """Valid driver.py with register() hook is loaded and registered.""" + proj = tmp_path / "testproject" + proj.mkdir() + (proj / "driver.py").write_text( + "from aipass.api.apps.modules.bridge import register as br\n" + "def register():\n" + " br('testcontract', lambda: 'hello')\n", + encoding="utf-8", + ) + count = registry.load_drivers(integrations_dir=tmp_path) + assert count == 1 + resolved = bridge.resolve("testcontract") + assert resolved is not None + assert resolved() == "hello" + + def test_load_drivers_skips_broken_import(self, tmp_path): + """Driver with syntax error is skipped; no crash; other drivers still load.""" + bad = tmp_path / "broken" + bad.mkdir() + (bad / "driver.py").write_text("this is not valid python !!!", encoding="utf-8") + + good = tmp_path / "good" + good.mkdir() + (good / "driver.py").write_text( + "from aipass.api.apps.modules.bridge import register as br\n" + "def register():\n" + " br('goodcontract', lambda: 'ok')\n", + encoding="utf-8", + ) + + count = registry.load_drivers(integrations_dir=tmp_path) + assert count == 1 + assert bridge.resolve("goodcontract") is not None + + def test_load_drivers_nonexistent_dir(self, tmp_path): + """Non-existent integrations dir → 0, no error.""" + count = registry.load_drivers(integrations_dir=tmp_path / "nope") + assert count == 0 + + def test_load_drivers_no_register_hook(self, tmp_path): + """Driver without register() still counts as loaded (no crash).""" + proj = tmp_path / "noregister" + proj.mkdir() + (proj / "driver.py").write_text("# no register hook\nPASS = True\n", encoding="utf-8") + count = registry.load_drivers(integrations_dir=tmp_path) + assert count == 1 + + +# --------------------------------------------------------------------------- +# TestFetchContracts +# --------------------------------------------------------------------------- + +class TestFetchContracts: + def test_empty_returns_success(self): + """fetch_contracts() returns success with empty list when bridge is clear.""" + result = fetch_contracts() + assert result["success"] is True + assert result["contracts"] == [] + assert result["count"] == 0 + + def test_returns_registered_contracts(self): + """fetch_contracts() returns sorted contracts from bridge.""" + bridge.register("beta", lambda: None) + bridge.register("alpha", lambda: None) + result = fetch_contracts() + assert result["success"] is True + assert result["contracts"] == ["alpha", "beta"] + assert result["count"] == 2 + + +# --------------------------------------------------------------------------- +# TestCallContract +# --------------------------------------------------------------------------- + +class TestCallContract: + def test_call_registered_contract(self): + """call_contract() resolves and invokes registered driver.""" + bridge.register("ping", lambda *a: "pong") + result = call_contract("ping", []) + assert result["success"] is True + assert result["result"] == "pong" + + def test_call_unregistered_returns_failure(self): + """call_contract() returns failure for unregistered contract.""" + result = call_contract("nope", []) + assert result["success"] is False + assert result["error"] is not None + assert "nope" in result["error"] + + def test_call_passes_args_to_driver(self): + """call_contract() forwards args to the driver function.""" + received: list = [] + + def capturing_driver(*args): + """Capture forwarded args for assertion.""" + received.extend(args) + return "done" + + bridge.register("cap", capturing_driver) + result = call_contract("cap", ["foo", "bar"]) + assert result["success"] is True + assert received == ["foo", "bar"] + + def test_call_driver_exception_returns_failure(self): + """call_contract() returns failure dict when driver raises.""" + + def broken_driver(*args): + """Always raises to simulate a broken driver.""" + raise RuntimeError("boom") + + bridge.register("broken", broken_driver) + result = call_contract("broken", []) + assert result["success"] is False + assert result["error"] is not None + assert "boom" in result["error"] From 5ab2a99098e182dcb1f0efb5cf2957eb2f3ddd8d Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 15 Apr 2026 18:46:21 -0700 Subject: [PATCH 6/6] =?UTF-8?q?fix(api):=20integrations=5Fmanager=20?= =?UTF-8?q?=E2=80=94=20unknown=20subcommand=20exits=201=20+=20fix=20double?= =?UTF-8?q?d=20Try:=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found via stress testing DPLAN-0133 Phase 2: - `drone @api integrations foobar` returned exit 0 (success) instead of 1 - Error message showed "Try: Try: list, call" (doubled prefix) Two-line fix: suggestion string drops "Try:" prefix (error() adds it), return True → sys.exit(1) for proper non-zero exit. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/aipass/api/apps/modules/integrations_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aipass/api/apps/modules/integrations_manager.py b/src/aipass/api/apps/modules/integrations_manager.py index 716a241d..e5ae8c8f 100644 --- a/src/aipass/api/apps/modules/integrations_manager.py +++ b/src/aipass/api/apps/modules/integrations_manager.py @@ -160,8 +160,8 @@ def handle_command(command: str, args: List[str]) -> bool: _ensure_loaded() sys.exit(_run_call(sub_args[0], sub_args[1:])) - error(f"Unknown integrations subcommand: {subcommand}", suggestion="Try: list, call") - return True + error(f"Unknown integrations subcommand: {subcommand}", suggestion="list, call") + sys.exit(1) except SystemExit: raise