Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bba76aa4d | ||
|
|
9b85a95552 | ||
|
|
2213251756 | ||
|
|
a057cdf488 | ||
|
|
251b2729c2 | ||
|
|
06c1a3a1be | ||
|
|
74bf6eef04 | ||
|
|
28e8028a02 | ||
|
|
71e5198d4c | ||
|
|
ef38f3be4c | ||
|
|
db9643eb58 | ||
|
|
193336967b | ||
|
|
f6d31285ea | ||
|
|
d4b265ad45 | ||
|
|
53a695580f | ||
|
|
6163202a93 | ||
|
|
4912d96f58 | ||
|
|
87bfccd55b | ||
|
|
a89ddb30bd | ||
|
|
e412cfed14 | ||
|
|
b8f6fb8dad | ||
|
|
6b0dcdccef | ||
|
|
1902775812 | ||
|
|
03dfce20b4 | ||
|
|
b3bb529a6a | ||
|
|
9a61d237fa | ||
|
|
702e335cb8 | ||
|
|
73e9ededd4 | ||
|
|
bad08e9b03 | ||
|
|
851988abbc | ||
|
|
222a9c8382 | ||
|
|
a8b1ce6658 | ||
|
|
807924241f |
+11
-5
@@ -38,6 +38,17 @@
|
|||||||
"handler": "aipass.hooks.apps.handlers.prompt.navmap.handle",
|
"handler": "aipass.hooks.apps.handlers.prompt.navmap.handle",
|
||||||
"matcher": ""
|
"matcher": ""
|
||||||
},
|
},
|
||||||
|
"compass_recall": {
|
||||||
|
"enabled": true,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.prompt.compass_recall.handle",
|
||||||
|
"matcher": "",
|
||||||
|
"max_per_session": 10
|
||||||
|
},
|
||||||
|
"feedback_pulse": {
|
||||||
|
"enabled": false,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.prompt.feedback_pulse.handle",
|
||||||
|
"matcher": ""
|
||||||
|
},
|
||||||
"auto_process": {
|
"auto_process": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
||||||
@@ -76,11 +87,6 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"handler": "aipass.hooks.apps.handlers.security.registry_gate.handle",
|
"handler": "aipass.hooks.apps.handlers.security.registry_gate.handle",
|
||||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
|
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
|
||||||
},
|
|
||||||
"engine_test_sound": {
|
|
||||||
"enabled": false,
|
|
||||||
"command": "python3 $AIPASS_HOME/.claude/hooks/engine_test_sound.py",
|
|
||||||
"matcher": "WebSearch"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<!-- Tier 1 — injected on cadence 5, at session start, and post-compaction. Kernel = tier0_kernel.md, every turn. Cap: ~8,000 chars per fire (hook truncates near 10k). Format: PROMPT_STYLE.md -->
|
<!-- Tier 1 — injected on cadence 5, at session start, and post-compaction. Kernel = tier0_kernel.md, every turn. Cap: ~8,000 chars per fire (hook truncates near 10k). Format: PROMPT_STYLE.md -->
|
||||||
|
|
||||||
AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`.
|
AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`. **AIPass is open source** — public repo on GitHub. Strangers read, clone, and scan this code; treat external findings as contributions.
|
||||||
|
|
||||||
# Finding your way
|
# Finding your way
|
||||||
|
|
||||||
@@ -110,6 +110,6 @@ Your continuity across sessions. Save proactively — after milestones, decision
|
|||||||
|
|
||||||
# House rules
|
# House rules
|
||||||
|
|
||||||
- Cross-platform, no hardcoded paths. Public repo — `pathlib`, never `/home/...`.
|
- Public repo — write as if it ships, because it does. No secrets in the tree, no hardcoded paths (`pathlib`, never `/home/...`), cross-platform.
|
||||||
- No bare imports — always `from aipass.<agent>.apps...`.
|
- No bare imports — always `from aipass.<agent>.apps...`.
|
||||||
- State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts; memories record; registries catalog.
|
- State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts; memories record; registries catalog.
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
|
|||||||
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
|
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
|
||||||
- Close any that were already processed but not formally closed
|
- Close any that were already processed but not formally closed
|
||||||
|
|
||||||
## 5. Loose Ends
|
## 5. Compass Review (Devpulse only)
|
||||||
|
|
||||||
|
- Run ONE `drone @devpulse compass review` — it serves the oldest-unreviewed entry. Judge it: still true → confirm; superseded → archive it and note what replaced it; wrong → fix or archive.
|
||||||
|
- One entry per prep, every prep. This is the curation cadence — review only works if it actually runs (DPLAN-0246: all 127 entries sat unreviewed because nothing invoked it).
|
||||||
|
|
||||||
|
## 6. Loose Ends
|
||||||
|
|
||||||
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
|
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
|
||||||
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to local.json todos[]
|
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to local.json todos[]
|
||||||
@@ -71,5 +76,6 @@ Prep complete:
|
|||||||
- Plans: [which ones updated]
|
- Plans: [which ones updated]
|
||||||
- Git: [branch, uncommitted count, suggestion]
|
- Git: [branch, uncommitted count, suggestion]
|
||||||
- Inbox: [count, action taken]
|
- Inbox: [count, action taken]
|
||||||
|
- Compass: [entry #N reviewed — verdict]
|
||||||
- Loose ends: [any flagged]
|
- Loose ends: [any flagged]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,11 +4,17 @@
|
|||||||
"cli": {
|
"cli": {
|
||||||
"claude": {
|
"claude": {
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:presence_gate", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:persistent_alert", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:tier0_kernel", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:tier0_kernel", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:navmap", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:navmap", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop", "event": "SubagentStop"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop", "event": "SubagentStop"},
|
||||||
|
|||||||
@@ -41,6 +41,6 @@ jobs:
|
|||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
- name: "Upload to code-scanning"
|
- name: "Upload to code-scanning"
|
||||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ src/aipass/*/apps/integrations/**
|
|||||||
src/aipass/hooks/tools/*
|
src/aipass/hooks/tools/*
|
||||||
!src/aipass/hooks/tools/install_boot_shim.sh
|
!src/aipass/hooks/tools/install_boot_shim.sh
|
||||||
|
|
||||||
|
# User/sample projects — each is its own git repo (git init on create).
|
||||||
|
# Contents never belong to the AIPass repo; only the catalog README is tracked
|
||||||
|
# so the public repo can point at the standalone project repos.
|
||||||
|
projects/*
|
||||||
|
!projects/README.md
|
||||||
|
|
||||||
# CI artifacts
|
# CI artifacts
|
||||||
windows-pytest-results/
|
windows-pytest-results/
|
||||||
|
|
||||||
|
|||||||
+302
@@ -9,8 +9,310 @@ PyPI version — not the changelog header.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2026-07-18]
|
||||||
|
|
||||||
|
**docs** — root README v3 restructure (DPLAN-0249): single-funnel story with
|
||||||
|
zero duplicated commands (install, `aipass new`/`init run`, trees, drone
|
||||||
|
examples each taught exactly once), hero link line to aipass.ai/PyPI/r/AIPass,
|
||||||
|
three reserved gif slots. Positioning ruling: the README tells only the
|
||||||
|
Claude Code on Linux/WSL story — Codex/macOS/Windows mentions and the Roadmap
|
||||||
|
section removed (code support unchanged; Docker distribution will serve those
|
||||||
|
users later). Earlier same day: stale demo.gif embed dropped (#701) and
|
||||||
|
aipass.ai realigned to the v2.7.3 front door.
|
||||||
|
|
||||||
|
**v2.7.3** — the onboarding chain: from `git clone` to a conversation with an
|
||||||
|
agent that remembers you. Install's three dead-ends are gone — the default
|
||||||
|
`init` path, headless runs, and `aipass new` all now end where they should:
|
||||||
|
`install` chains through the guided init and **opens a live conversation with
|
||||||
|
the AIPass concierge**, first prompt authored with the install report in its
|
||||||
|
context. The concierge's Welcome Mode (research-backed opener, one name-ask,
|
||||||
|
deferred setup triage, hooks-first health check via a real @hooks dispatch,
|
||||||
|
every suggestion with its exact command) was proven in a live multi-turn
|
||||||
|
door-test — including the second-session payoff: relaunch, and it picks up
|
||||||
|
mid-task where you left off. Plus `aipass new` and the front-door overhaul below.
|
||||||
|
|
||||||
|
### Added (onboarding chain — TDPLAN-0014)
|
||||||
|
|
||||||
|
- **Install→chat handoff**: after init returns, install `launch_inline`s the
|
||||||
|
concierge with an authored first prompt (fresh-install recognition + binary
|
||||||
|
report). TTY-only; headless returns cleanly.
|
||||||
|
- **Welcome Mode** in the concierge branch prompt: capability opener with 3–5
|
||||||
|
concrete starters, single graceful name-ask, ~turn-5 setup push ("every
|
||||||
|
machine is different"), hooks-first verification incl. trust-registry
|
||||||
|
enrollment, setup plan seeded from the cross-OS checklist, Windows→WSL
|
||||||
|
recommendation, prax-monitor + hooksound tips, exact copy-paste command with
|
||||||
|
every suggestion.
|
||||||
|
- **Feedback pulse** (@hooks): one ignorable line every ~10 turns with the repo
|
||||||
|
feedback link — `aipass feedback on/off` (alias for `drone @hooks feedback`)
|
||||||
|
turns it off. Registered disabled for the AIPass host itself. 25 tests.
|
||||||
|
- **Dead-end kills**: empty-template init now runs handoff + report stages
|
||||||
|
(default path ends in the conversation); non-interactive installs complete
|
||||||
|
with defaults and exit 0 (headless stage 9 prints the launch command instead
|
||||||
|
of spawning); `aipass new` auto-launches into the new project's manager agent
|
||||||
|
on a TTY with a printed fallback and Ctrl-C escape line.
|
||||||
|
- **Unified handoff prompt**: one `INIT_PROMPT` constant (was two drifting
|
||||||
|
strings in init_flow vs handoff).
|
||||||
|
|
||||||
|
### Fixed (onboarding chain)
|
||||||
|
|
||||||
|
- Non-TTY `aipass init run` crashed with EOFError at the first prompt (caught
|
||||||
|
in a live door-test after unit suites ran green — the prompt layer now
|
||||||
|
auto-detects non-TTY and takes defaults).
|
||||||
|
- `aipass new` outside an AIPass environment now exits 1 instead of 0.
|
||||||
|
- Empty-template handoff messaging no longer claims an agent exists
|
||||||
|
("Your project is ready", resolved absolute path instead of `cd .`).
|
||||||
|
- Stage numbering shows a skip notice instead of silently jumping 5→8.
|
||||||
|
|
||||||
|
## [2026-07-17]
|
||||||
|
|
||||||
|
**v2.7.3 (first pass)** — `aipass new` and the front-door overhaul. The `projects/`
|
||||||
|
directory is now a first-class playground: `aipass new <name>` creates a fully
|
||||||
|
isolated project — own registry, own git repo with a birth commit, born
|
||||||
|
deployable — with a full framework resident agent that answers
|
||||||
|
`drone @<name>` from inside the project while staying invisible to the host
|
||||||
|
roster. ai_mail enforces the project boundary (cross-project mail is refused
|
||||||
|
with a pointer to the feedback channel). A live door-test of the aipass CLI
|
||||||
|
exposed a blind spot in the audit — perfect structural scores on an unusable
|
||||||
|
front door — so seedgo grew two user-facing-quality standards (`cli_ux`,
|
||||||
|
`readme_quality`) and a fleet-100 campaign brought every branch's help output
|
||||||
|
and README to the house pattern: 17/17 branches at 100%.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`aipass new <name>`** (module + handler): creates `projects/<name>` with
|
||||||
|
registry-first credential linkage (`registry.metadata.id ==
|
||||||
|
passport.citizenship.registry_id`), empty/python templates, interactive
|
||||||
|
template + agent prompts (flags for scripted use), a full framework agent
|
||||||
|
(entry point, modules/handlers skeleton, trinity set, mailbox, tier files),
|
||||||
|
git init + birth commit, and next-step output. 49 tests.
|
||||||
|
- **seedgo standards 41–42**: `cli_ux` (8 AST checks — two-tier help, Rich
|
||||||
|
console, styled title, purpose line, --help pointer, Usage, Examples, no
|
||||||
|
exposed internal plumbing) and `readme_quality` (Quick Start with runnable
|
||||||
|
code block, stranger accessibility, invoke/entry-point match, early
|
||||||
|
what-description). 36 tests + case-resolution regression tests.
|
||||||
|
- **ai_mail cross-project boundary**: sender and recipient project roots
|
||||||
|
compared on delivery; cross-project sends refused with a feedback-channel
|
||||||
|
pointer. Fail-open for internal/unregistered sends. 13 tests.
|
||||||
|
- **Root `.gitignore`**: `projects/*` ignored (each project is its own repo);
|
||||||
|
only the future catalog README stays trackable.
|
||||||
|
|
||||||
|
### Added (second pass — the agent becomes a real citizen)
|
||||||
|
|
||||||
|
- **`aipass new` agents are now spawn-issued full citizens** (FPLAN-0334): the
|
||||||
|
hand-rolled scaffold in the new_project handler is retired for a
|
||||||
|
`spawn_agent()` call against @spawn's new `project_agent` template — branch
|
||||||
|
prompt, structured mailbox, birth certificate, trinity trio, dashboard,
|
||||||
|
house-pattern entry point, and a branch-style README. One authority issues
|
||||||
|
citizens; project agents inherit template evolution for free.
|
||||||
|
- **Agent home = `src/<project>/<agent>/`**, mirroring the host's
|
||||||
|
`src/aipass/<branch>` layout (door-test ruling: the project root is never an
|
||||||
|
agent home). Seat paths are relative like host seats; the registry walk stops
|
||||||
|
at the first project registry. The first agent is the project's **manager**
|
||||||
|
(`citizen_class: manager` — its devpulse), named after the project.
|
||||||
|
- **Birth-commit hygiene**: the `.venv` symlink (absolute host path) and the
|
||||||
|
registry lock file are no longer tracked in new projects' birth commits.
|
||||||
|
- **Boundary verified live in all four directions**: host↔project email and
|
||||||
|
dispatch all refused — project→host lands on the ai_mail cross-project check
|
||||||
|
with its feedback-channel pointer, closing the leak found in the S319
|
||||||
|
prototype probes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **aipass front door rebuilt**: `--help` now follows the house pattern with a
|
||||||
|
curated command list, usage, and examples (internal plumbing hidden —
|
||||||
|
`doctor_fix`/`doctor_wire` renamed underscore-private); `aipass help` shows
|
||||||
|
the Q&A screen instead of falling through to the module dump; README
|
||||||
|
rewritten to pass the stranger test with a Quick Start and the correct
|
||||||
|
invocation.
|
||||||
|
- **Fleet-100 sweep**: 15 branches gained Quick Start READMEs and/or
|
||||||
|
Usage/Examples help sections — each owner fixed their own front against the
|
||||||
|
new gate.
|
||||||
|
- **Debug_Print detector hardened**: the regex-based checker matched `print(`
|
||||||
|
inside string literals (flagging cli_ux_check's own error messages — the
|
||||||
|
auditor was the last branch under 100%). String content is now stripped
|
||||||
|
before matching, with a regression test; plus a depth-5 nesting refactor in
|
||||||
|
the same file.
|
||||||
|
|
||||||
|
**v2.7.2** — everything merged since v2.7.1, headlined by the compass decision
|
||||||
|
engine v2: curation with supersedes links + write-time conflict advisories
|
||||||
|
(Track 1), and ambient recall — rated past decisions now surface verbatim into
|
||||||
|
live sessions on matching prompts, governed by session caps and spacing
|
||||||
|
(Track 2). Also in this release: the plan close pipeline completes itself
|
||||||
|
(auto-vectorization + crash-safe registry writes), drone's 3-layer subprocess
|
||||||
|
timeout policy with a collision-free `--drone-timeout` flag, plan-number memory
|
||||||
|
search that pins the exact plan, a fleet-wide seedgo 100% restoration, and
|
||||||
|
SSH-signed commits now verifying on GitHub. Details in the sections below
|
||||||
|
(2026-07-16 carries the full stories).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Release cadence ruling: every dev→main merge ships a PATCH bump + tag by
|
||||||
|
default.** PyPI tracks main, always current; version numbers carry no
|
||||||
|
significance during beta — the big jump is reserved for beta exit.
|
||||||
|
|
||||||
|
- **Merge playbook SOP refined from live run PPLAN-0010.** The raw
|
||||||
|
`git fetch origin main:main` step (now blocked by the git gate) is replaced
|
||||||
|
with `drone @git sync` in both places it appeared, and the template opens
|
||||||
|
with the exact create command (`drone @flow create . "Merge summary" merge
|
||||||
|
pplan` — template name before type), closing the trap where the wrong arg
|
||||||
|
order silently stamps the default template.
|
||||||
|
|
||||||
|
## [2026-07-16]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Compass ambient recall — Track 2 (DPLAN-0246/FPLAN-0332): rated decisions
|
||||||
|
surface unprompted.** On every user prompt, a new hooks handler
|
||||||
|
(`compass_recall`, registered in `.aipass/hooks.json` only) queries compass
|
||||||
|
FTS with the prompt text and injects matching rulings VERBATIM —
|
||||||
|
`[BAD] #56: <decision text>` — tidbits, never vibes. Three branches, one
|
||||||
|
pipeline, each piece behind a modules/-boundary API: devpulse's
|
||||||
|
`recall_decisions()` (side-effect-free scored candidates; rare-token
|
||||||
|
evidence scoring + a query-side stopword filter so greeting/filler words
|
||||||
|
can't fake relevance) + `mark_surfaced()` (counts only real injections);
|
||||||
|
@memory's pure `should_surface()` governance (promoted from the dormant
|
||||||
|
symbolic engine: threshold, 5/session cap, 10-message spacing — first
|
||||||
|
surface exempt, 300s cooldown, dedup; state-in/state-out, caller persists);
|
||||||
|
@hooks' 90-line handler + engine per-handler budget (errors never block a
|
||||||
|
prompt — `compass_recall_unreachable` log signature for @trigger's watcher).
|
||||||
|
Live acceptance matrix through the real bridge: topic-with-history prompts
|
||||||
|
recall the right ruling (a CI prompt surfaced the red-CI-never-parked
|
||||||
|
ruling), small talk and greetings stay silent, repeat prompts gate on
|
||||||
|
spacing. Review caught and fixed pre-ship: wrong payload key (`userInput` →
|
||||||
|
`prompt`), phantom `CLAUDE_CODE_SESSION_ID` env (session id is
|
||||||
|
stdin-payload-only), spacing gate blocking the first surface, and a trust
|
||||||
|
registry re-enrollment gap that silently disabled ALL project hooks for 20
|
||||||
|
minutes after the hooks.json edit. 446 devpulse + 1011 memory + 1129 hooks
|
||||||
|
tests green; seedgo 31/31 on every touched module.
|
||||||
|
|
||||||
|
- **Compass curation v2 Track 1 (DPLAN-0246/FPLAN-0331): supersedes links +
|
||||||
|
write-time conflict check.** A correcting compass entry now archives and
|
||||||
|
links what it replaces in one transaction (`compass add --supersedes N`);
|
||||||
|
query renders both directions ("supersedes #N" / "ARCHIVED — superseded by
|
||||||
|
#M") so a retracted decision can never masquerade as current truth. Every
|
||||||
|
`compass add` FTS-checks the new text against active entries and prints a
|
||||||
|
non-blocking "possible conflict with #X" advisory — flag-and-ask, no LLM, no
|
||||||
|
auto-resolve (boardroom ruling). New `compass note <id>` command (FTS
|
||||||
|
re-index proven by test), `--include-archived` query flag (the avoid-list is
|
||||||
|
finally searchable), dead `score` column removed from all code surfaces
|
||||||
|
(kept inert on disk — zero migration risk). Idempotent PRAGMA-checked
|
||||||
|
migration ran clean on the production store (128 rows, no loss); the four
|
||||||
|
fresh-eyes-audit archive pairs got their links backfilled. /prep now runs
|
||||||
|
one `compass review` per session — curation living in a path that already
|
||||||
|
runs, the lesson of all three compass eras. 435 devpulse tests green,
|
||||||
|
seedgo 31/31 on both touched modules.
|
||||||
|
|
||||||
|
- **Close pipeline completes itself (DPLAN-0245): auto-vectorization +
|
||||||
|
crash-safe registry writes + drone timeout policy.** Closing a plan now
|
||||||
|
produces all side effects from one command — `post_close_runner` invokes
|
||||||
|
@memory's plan intake directly after archival (detached, loud on failure,
|
||||||
|
drains any backlog it finds), so plans can no longer silently pile up
|
||||||
|
unvectorized. Plan registry saves (@flow `save_registry` + mbank
|
||||||
|
`save_flow_registry`) now use the O_EXCL lockfile + atomic
|
||||||
|
tempfile-and-replace pattern, closing the same lost-update race class fixed
|
||||||
|
earlier in CLOSED_PLANS. @drone gained a 3-layer timeout policy: per-command
|
||||||
|
overrides (`@memory process-plans` 120s, `@flow close` 90s), a `--timeout N`
|
||||||
|
flag, 30s default — replacing the flat 30s guillotine that killed legitimate
|
||||||
|
long commands mid-pipeline; the timeout error now says how to override.
|
||||||
|
Proven end-to-end live: one `drone @flow close` on a throwaway plan yielded
|
||||||
|
archive + vectors + ledger + registry with zero manual steps, and the
|
||||||
|
auto-trigger swept a pre-existing backlog file on its first run. 730 flow +
|
||||||
|
878 drone tests green, seedgo 100%.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **CI seedgo gate back to 100% across all 17 branches.** The Track 2 compass
|
||||||
|
recall code left three branches at 99%: @hooks' compass_recall handler was
|
||||||
|
missing json_handler operation logging and had two silent catches (now
|
||||||
|
logged); @memory's governance module held its implementation in modules/
|
||||||
|
(moved to handlers/governance/engine.py with modules/governance.py as the
|
||||||
|
thin re-export — the cross-branch import path is unchanged and live-E2E
|
||||||
|
verified through the real bridge); devpulse's README test count had drifted
|
||||||
|
(309 → 348). Audits re-run per branch: 100% overall, all suites green.
|
||||||
|
|
||||||
|
- **Plan-number memory search hits the exact plan.** Searching a plan ID
|
||||||
|
('DPLAN-0244', 'fplan 0332' — any case, dash or space) now pins the exact
|
||||||
|
plan as the top result at 100%, via a metadata lookup on the vector store's
|
||||||
|
source-file field instead of embedding similarity (which treats all plan IDs
|
||||||
|
as near-identical strings and never surfaced the target). Patrick ruling:
|
||||||
|
searching a plan number must return that plan first. Semantic search quality
|
||||||
|
for normal queries is unchanged. Also purged 193 junk vectors — throwaway
|
||||||
|
probe/flaky test plans from scratchpad sessions (dv4 batch, probe_test_plan,
|
||||||
|
throwaway_e2e_proof) that had leaked into the store. 1011 memory tests green.
|
||||||
|
|
||||||
|
- **drone --timeout collision: router flag swallowed module flags.** The
|
||||||
|
DPLAN-0245 subprocess-timeout flag consumed the first `--timeout` token
|
||||||
|
anywhere in argv, so module-level flags silently vanished — watchdog's
|
||||||
|
`--timeout 1800` never arrived and long watches died at the 600s default
|
||||||
|
(live repro x2). Drone's flag is now namespaced `--drone-timeout`; plain
|
||||||
|
`--timeout` passes through untouched to the target module, with a regression
|
||||||
|
test pinning the passthrough. Per-command overrides intact. 879 drone tests
|
||||||
|
green, seedgo 100%.
|
||||||
|
|
||||||
|
- **@memory command routing eaten by the new governance module.** The
|
||||||
|
governance module shipped in Track 2 had the wrong `handle_command`
|
||||||
|
signature (`args: list` instead of `command: str, args: list`) and always
|
||||||
|
returned True, so auto-discovery routed EVERY @memory command through it
|
||||||
|
first — `drone @memory search` answered "governance: unknown command 's'".
|
||||||
|
Fixed to the standard signature returning False for commands not its own;
|
||||||
|
search verified live (135 results). Library modules must decline commands
|
||||||
|
they don't own or they silently hijack the whole CLI. 1011 memory tests
|
||||||
|
green, seedgo 31/31.
|
||||||
|
|
||||||
## [2026-07-15]
|
## [2026-07-15]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Plan vectorization pipeline unwedged (DPLAN-0245): 57 closed plans were
|
||||||
|
silently missing from semantic memory since mid-June.** Vector IDs were pure
|
||||||
|
content hashes, so identical template boilerplate across different plans
|
||||||
|
produced duplicate IDs within one ChromaDB upsert — the store rejected the
|
||||||
|
entire batch, and the all-or-nothing intake retried the same failing batch
|
||||||
|
forever. Fixed in @memory: IDs are now salted with the source filename when
|
||||||
|
present (rollover hashes unchanged — no re-vectorization churn), in-batch
|
||||||
|
dedup as a safety net, and `process_plans()` now runs per-file with the
|
||||||
|
manifest saved after each success so a poison file can never wedge the queue
|
||||||
|
again. Backlog drained and verified: 229/229 archived plans vectorized, 1112
|
||||||
|
chunks, formerly-lost plans answering semantic queries at 85%+ similarity.
|
||||||
|
990 memory tests green.
|
||||||
|
|
||||||
|
- **CLOSED_PLANS ledger append race (@flow): concurrent plan closes lost
|
||||||
|
entries.** `append_to_closed_plans` was an unlocked read-modify-write; the
|
||||||
|
S314 bulk sweep lost 18 of 21 entries to it (reconciled by hand). Now guarded
|
||||||
|
by an `O_CREAT|O_EXCL` lockfile with retry/backoff, and the previously
|
||||||
|
silent append failure is surfaced in close output and logs. 730 flow tests
|
||||||
|
green.
|
||||||
|
|
||||||
|
- **Telegram routine read-timeouts no longer logged as errors (@skills,
|
||||||
|
Patrick ruling): ends the medic wake-loop.** A routine long-poll read
|
||||||
|
timeout (`socket.timeout` — an `OSError` subclass) slipped past the earlier
|
||||||
|
`URLError`-only guard into the network-outage path, logging ERROR once per
|
||||||
|
episode (~576 lines/30h) and waking @trigger's medic each time. The
|
||||||
|
`_is_routine_read_timeout` guard now covers the `OSError` handler too, and
|
||||||
|
the genuine-outage episode-start line is demoted ERROR→WARNING (backoff
|
||||||
|
self-heals; recovery already logs INFO; medic only fires on ERROR/CRITICAL).
|
||||||
|
Real failures still log ERROR. 825 telegram tests green.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Hook config trust model hardening (DPLAN-0244): closes a zero-interaction
|
||||||
|
RCE from untrusted `.aipass/hooks.json`.** The hook loader walked up from CWD
|
||||||
|
and trusted any `.aipass/hooks.json` it found; since the bridge is wired
|
||||||
|
globally in provider settings, a hostile repo shipping a `command`-type hook
|
||||||
|
could execute arbitrary shell on `SessionStart` with no user interaction.
|
||||||
|
Fixed with defense-in-depth. **Layer A (engine):** per-project configs may no
|
||||||
|
longer run `command`-type hooks (refused via an unconditionally-stamped
|
||||||
|
`_source` provenance flag), and handler paths are gated to the `aipass.*`
|
||||||
|
namespace. **Layer B (loader + CLI):** a trusted-project registry
|
||||||
|
(`~/.aipass/trusted_projects.json`, path + sha256) that the loader checks
|
||||||
|
fail-closed; on upgrade it bootstraps **only** the `$AIPASS_HOME` install
|
||||||
|
(never trust-on-first-use of an arbitrary directory); `aipass init`/`init
|
||||||
|
update` auto-enroll, and new `aipass trust`/`revoke` commands manage
|
||||||
|
enrollment. Both gates proven to block the attack independently via a live
|
||||||
|
acceptance test driving the real bridge with a real payload. 1105 hooks +
|
||||||
|
133 aipass tests green. Origin: external scan (false positive at
|
||||||
|
`engine.py:37`) whose triage surfaced the real adjacent hole.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Supply-chain hardening pass (DPLAN-0243): commit signing + hash-pinned CI
|
- **Supply-chain hardening pass (DPLAN-0243): commit signing + hash-pinned CI
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ These steps are sequential and dependent — run each ONCE, wait for the result,
|
|||||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
|
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
|
||||||
- Refresh: `drone @prax dashboard refresh @<self>` — where `<self>` is your branch name (CWD directory name)
|
- Refresh: `drone @prax dashboard refresh @<self>` — where `<self>` is your branch name (CWD directory name)
|
||||||
- Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance.
|
- Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance.
|
||||||
|
- announce ur current (PID)
|
||||||
|
|
||||||
|
|
||||||
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
|
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,15 @@
|
|||||||
</p>
|
</p>
|
||||||
<p align="center"><strong>Persistent Agent Workspace</strong></p>
|
<p align="center"><strong>Persistent Agent Workspace</strong></p>
|
||||||
<p align="center"><em>AI agents that remember, collaborate, and never start from zero.</em></p>
|
<p align="center"><em>AI agents that remember, collaborate, and never start from zero.</em></p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://aipass.ai">aipass.ai</a> ·
|
||||||
|
<a href="https://pypi.org/project/aipass/">PyPI</a> ·
|
||||||
|
<a href="https://reddit.com/r/AIPass">r/AIPass</a> ·
|
||||||
|
<a href="https://github.com/AIOSAI/AIPass/discussions">Discussions</a>
|
||||||
|
</p>
|
||||||
|
|
||||||

|
<!-- GIF SLOT 1 — hero (~20s): clone → ./aipass install → live conversation with the concierge.
|
||||||
|
 -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,51 +34,15 @@ That's not a team. That's a room full of people wearing headphones.
|
|||||||
|
|
||||||
## What AIPass Does
|
## What AIPass Does
|
||||||
|
|
||||||
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard. You work in your terminal.
|
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard, no cloud. Everything is plain files on your machine; delete the directory and it's gone.
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/AIOSAI/AIPass.git
|
|
||||||
cd AIPass
|
|
||||||
./aipass install # installs everything, then walks you into your first project
|
|
||||||
```
|
|
||||||
|
|
||||||
One command does it all: builds the environment, puts `aipass` + `drone` on your PATH, then chains straight into a guided init that creates your project, your first agent, and opens a terminal where that agent is already running. Say "hi" — it knows who it is. Come back tomorrow — it remembers.
|
|
||||||
|
|
||||||
This is the base framework. It gives your agents the infrastructure to persist, communicate, and organize — everything else you build on top.
|
|
||||||
|
|
||||||
Here's what lands in your project:
|
|
||||||
|
|
||||||
```
|
|
||||||
my-project/
|
|
||||||
├── .aipass/ # Project config + prompts
|
|
||||||
├── .claude/ # Hooks (injected automatically)
|
|
||||||
├── src/my_project/
|
|
||||||
│ └── my_agent/
|
|
||||||
│ ├── .trinity/ # Identity + memory (3 JSON files)
|
|
||||||
│ ├── .ai_mail.local/ # Local mailbox
|
|
||||||
│ ├── apps/ # Your agent's code
|
|
||||||
│ └── README.md # Domain knowledge
|
|
||||||
├── CLAUDE.md # Project instructions
|
|
||||||
└── MY-PROJECT_REGISTRY.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Everything is plain files. No daemon, no hidden state. Delete the directory and it's gone.
|
|
||||||
|
|
||||||
**Start with one agent.** Add more when you need them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
|
||||||
```
|
|
||||||
|
|
||||||
**What makes this different:**
|
|
||||||
|
|
||||||
- **Agents are persistent.** They remember across sessions. Expertise develops over time. Nobody starts from zero.
|
- **Agents are persistent.** They remember across sessions. Expertise develops over time. Nobody starts from zero.
|
||||||
- **Bring your own project.** AIPass adds agent infrastructure to whatever you're building. It's a scaffold, not a product — you shape it.
|
- **Bring your own project.** AIPass adds agent infrastructure to whatever you're building. It's a scaffold, not a product — you shape it.
|
||||||
- **Everything is local.** Memory is JSON files. Communication is local mailbox files. No cloud, no external APIs.
|
- **Everything is local.** Memory is JSON files. Communication is local mailbox files. No cloud, no external APIs.
|
||||||
- **Shared workspace.** All agents work on the same filesystem, same project, same time. No sandboxes.
|
- **Shared workspace.** All agents work on the same filesystem, same project, same time. No sandboxes.
|
||||||
- **One command for everything.** AIPass ships with `drone`, a CLI router — `drone @agent command` reaches any agent. Learn it once, use it everywhere.
|
- **One command for everything.** `drone @agent command` reaches any agent. Learn it once, use it everywhere.
|
||||||
|
|
||||||
**Runs on your existing CLI subscription.** Claude Pro/Max or Codex — AIPass uses the same CLI binary you already run. No extra API keys, no extra costs for core functionality.
|
**Runs on your existing Claude subscription.** AIPass drives the same [Claude Code](https://code.claude.com/docs) binary you already run — Pro or Max. No extra API keys, no extra costs for core functionality.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,31 +53,47 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/AIOSAI/AIPass.git
|
git clone https://github.com/AIOSAI/AIPass.git
|
||||||
cd AIPass
|
cd AIPass
|
||||||
./aipass install # Creates venv, installs, puts `aipass` + `drone` on your PATH, bootstraps 17 agents
|
./aipass install
|
||||||
```
|
```
|
||||||
|
|
||||||
On an interactive terminal, install ends by chaining into `aipass init run` — one command takes you from clone to a working first project. Pass `--no-init` to skip the chain, `--project <dir>` to pick where the project lands (CI and piped shells skip automatically). `./aipass` is a thin repo-root launcher over `setup.sh`; after setup it simply forwards to the installed `aipass` binary.
|
One command does it all: builds the environment, puts `aipass` + `drone` on your PATH, bootstraps the 17-agent reference fleet, then walks you through a guided init — and ends **in a conversation**. The AIPass concierge opens right in your terminal with your install report in hand: it welcomes you, asks your name once, shows you around, and checks what your machine still needs — every machine is different.
|
||||||
|
|
||||||
|
Come back tomorrow, say "hi", and it picks up exactly where you left off. That's the whole interface.
|
||||||
|
|
||||||
|
<!-- GIF SLOT 2 — memory payoff (~15s): close the terminal, reopen, "hi", the agent recalls yesterday.
|
||||||
|
 -->
|
||||||
|
|
||||||
|
Options: `--no-init` skips the guided chain, `--project <dir>` picks where your project lands. Non-interactive shells (CI, pipes) complete with defaults and exit 0 — no prompts, no spawned sessions; the handoff prints as a next-step command instead. The installer wires Claude Code hooks automatically — merging with any hooks you've already configured, never overwriting them. `./aipass` is a thin repo-root launcher over `setup.sh`; after setup it forwards to the installed `aipass` binary.
|
||||||
|
|
||||||
### 2. Your own project (if you skipped the chain)
|
### 2. Your own project (if you skipped the chain)
|
||||||
|
|
||||||
|
Two ways in. From anywhere inside your AIPass environment, `aipass new` builds a complete project around a resident manager agent:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~ && mkdir my-project && cd my-project
|
aipass new my-project --template python # Project + resident manager agent + git birth commit
|
||||||
aipass init run # Guided setup — project, first agent, terminal handoff
|
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it. Your agent has identity, memory, a mailbox, and access to every AIPass service — planning, quality audits, dispatch, real-time monitoring. All through `drone @branch command`.
|
It mints the project registry, spawns a full citizen (identity, memory, mailbox, birth certificate) at `src/my_project/my_project`, makes the first commit — and drops you straight into a conversation with your new manager.
|
||||||
|
|
||||||
|
Or bring your own directory, anywhere on disk:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~ && mkdir my-project && cd my-project
|
||||||
|
aipass init run # Guided setup — project, first agent, ends in the conversation
|
||||||
|
```
|
||||||
|
|
||||||
|
Either way your agent has identity, memory, a mailbox, and access to every AIPass service — planning, quality audits, dispatch, real-time monitoring.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
aipass init # Just the scaffold (no guided setup)
|
aipass init # Just the scaffold (no guided setup)
|
||||||
aipass init agent my_agent # Add another agent
|
aipass init agent my_agent # Add another agent
|
||||||
aipass doctor # Check system health
|
aipass doctor # Check system health
|
||||||
|
aipass feedback off # Silence the occasional how-are-we-doing ask
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
|
### 3. Meet the fleet
|
||||||
|
|
||||||
### 3. Explore the full framework
|
The clone already includes all 17 agents working together — the reference implementation that maintains AIPass itself:
|
||||||
|
|
||||||
The clone above already includes all 17 agents working together — the reference implementation:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src/aipass/devpulse
|
cd src/aipass/devpulse
|
||||||
@@ -114,45 +101,33 @@ claude # Talk to the orchestrator
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Things you can do:
|
drone @seedgo audit aipass # Quality checks across all agents
|
||||||
aipass doctor # Check system health
|
drone @flow create . "Add user auth" # Create a work plan
|
||||||
drone @seedgo audit aipass # Run automated quality checks across all agents
|
drone @ai_mail dispatch @agent "Subject" "Body" # Send a task + wake an agent
|
||||||
drone @flow create . "Add user auth" # Create a work plan
|
|
||||||
drone @ai_mail dispatch @agent "Sub" "Body" # Send task + wake an agent
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
**One agent:** Run `aipass init run` and in 5 minutes you have a project with an agent that reads `.trinity/` on startup and picks up where it left off. Memory starts as plain JSON files — no setup required. When they fill up, older entries automatically archive into ChromaDB for long-term search. Nothing is lost.
|
**Memory.** Every agent owns a `.trinity/` directory — identity, session history, learnings — read on startup, updated as it works. Memory starts as plain JSON, no setup required. When files fill up, older entries automatically archive into ChromaDB for long-term semantic search. Nothing is lost.
|
||||||
|
|
||||||
**A team:** When one agent isn't enough, every agent shares the same structure:
|
**One structure.** Every agent — yours and the reference fleet — shares the same layout. If you know one agent, you know all of them:
|
||||||
|
|
||||||
```
|
```
|
||||||
src/my-project/<agent>/
|
src/my_project/<agent>/
|
||||||
├── .trinity/ # Identity + memory (persists across sessions)
|
├── .trinity/ # Identity + memory (persists across sessions)
|
||||||
├── .ai_mail.local/ # Mailbox (receives tasks, sends results)
|
├── .ai_mail.local/ # Mailbox (receives tasks, sends results)
|
||||||
├── apps/ # Entry point → modules → handlers
|
├── apps/ # Entry point → modules → handlers
|
||||||
└── README.md # Domain knowledge (the agent reads this on startup)
|
└── README.md # Domain knowledge (read on startup)
|
||||||
```
|
```
|
||||||
|
|
||||||
Identical layout everywhere. If you know one agent, you know all of them. `drone` is the single command that routes to any agent:
|
**One router.** `drone @branch command [args]` reaches any agent — routing, access tiers, and @agent resolution handled for you. Agents use the same commands to reach each other: they dispatch work, share findings, and wake whoever they're waiting on.
|
||||||
|
|
||||||
```bash
|
<!-- GIF SLOT 3 — team (~20s): dispatch a task to an agent, watchdog wake-back, result lands.
|
||||||
drone @branch command [args] # Every agent, every task. Drone handles routing.
|
 -->
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
drone @seedgo audit aipass # Run quality checks on everything
|
|
||||||
drone @flow create . "Refactor auth module" # Create a work plan
|
|
||||||
drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Two ways to use AIPass:**
|
|
||||||
|
|
||||||
- **Your own project:** `aipass init run` sets up a new project with your first agent. Add more agents as you need them. Your first agent is the orchestrator — it coordinates the others.
|
|
||||||
- **The full framework:** Clone the repo to work with all 17 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,8 +155,6 @@ devpulse (orchestrator)
|
|||||||
└── commons — the social space — post, comment, vote, gather
|
└── commons — the social space — post, comment, vote, gather
|
||||||
```
|
```
|
||||||
|
|
||||||
These agents work on the **same filesystem, same project, same time** — no sandboxes, no worktrees. This is the pattern your projects inherit.
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Agent details</summary>
|
<summary>Agent details</summary>
|
||||||
|
|
||||||
@@ -222,19 +195,6 @@ These agents work on the **same filesystem, same project, same time** — no san
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CLI Support
|
|
||||||
|
|
||||||
AIPass is built and tested with **Claude Code** on Linux/WSL.
|
|
||||||
|
|
||||||
| CLI | Autonomous Mode | Status |
|
|
||||||
|-----|----------------|--------|
|
|
||||||
| [Claude Code](https://code.claude.com/docs) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
|
||||||
| [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental |
|
|
||||||
|
|
||||||
The installer (`./aipass install`, powered by setup.sh) auto-detects which CLIs are installed and configures hooks for each — merging with any hooks you've already wired, never overwriting them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**Beta.** Actively developed by a solo developer working with the AI agents themselves — every PR, every test, every fix is human-AI collaboration.
|
**Beta.** Actively developed by a solo developer working with the AI agents themselves — every PR, every test, every fix is human-AI collaboration.
|
||||||
@@ -249,25 +209,14 @@ The installer (`./aipass install`, powered by setup.sh) auto-detects which CLIs
|
|||||||
|
|
||||||
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
|
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- [Claude Code](https://code.claude.com/docs)
|
- [Claude Code](https://code.claude.com/docs)
|
||||||
- Linux, macOS, or WSL (all CI-tested)
|
- Linux or WSL
|
||||||
- `sudo` access optional (for `/usr/local/bin` symlinks — falls back to `~/.local/bin` without sudo)
|
- `sudo` access optional (for `/usr/local/bin` symlinks — falls back to `~/.local/bin` without sudo)
|
||||||
- API keys optional (OpenRouter/OpenAI — for optional add-on agents)
|
- API keys optional (OpenRouter/OpenAI — for optional add-on agents)
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
These items have partial work done and are under ongoing testing:
|
|
||||||
|
|
||||||
- **macOS support** — CI green, full test suite passing ([#360](https://github.com/AIOSAI/AIPass/issues/360))
|
|
||||||
- **Windows native** — CI green, full test suite passing
|
|
||||||
- **Codex CLI** — hooks and AGENTS.md wired, needs end-to-end testing
|
|
||||||
- **Fork contributor workflow** — improved error handling for fork-based PRs ([#329](https://github.com/AIOSAI/AIPass/issues/329))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -305,9 +254,9 @@ This archives the agent's directory and removes it from the registry.
|
|||||||
|
|
||||||
### Use your existing subscription
|
### Use your existing subscription
|
||||||
|
|
||||||
AIPass runs on your **existing CLI subscription** — Claude Pro/Max or Codex. No API keys required for core functionality. No extra costs beyond your existing subscription.
|
AIPass runs on your **existing Claude subscription** — Pro or Max. No API keys required for core functionality. No extra costs beyond your existing subscription.
|
||||||
|
|
||||||
This works because AIPass runs each CLI as an **official subprocess** — the same binary you'd run yourself in a terminal. It doesn't extract credentials, proxy API calls, or intercept tokens. Your subscription stays within the provider's infrastructure at all times.
|
This works because AIPass runs Claude Code as an **official subprocess** — the same binary you'd run yourself in a terminal. It doesn't extract credentials, proxy API calls, or intercept tokens. Your subscription stays within the provider's infrastructure at all times.
|
||||||
|
|
||||||
### What AIPass does NOT do
|
### What AIPass does NOT do
|
||||||
|
|
||||||
@@ -316,7 +265,7 @@ This works because AIPass runs each CLI as an **official subprocess** — the sa
|
|||||||
- Bypass rate limits or prompt caching
|
- Bypass rate limits or prompt caching
|
||||||
- Impersonate official CLI clients
|
- Impersonate official CLI clients
|
||||||
|
|
||||||
Claude Code is proprietary but officially supports hooks and subprocess usage. Codex CLI is open source (Apache 2.0).
|
Claude Code is proprietary but officially supports hooks and subprocess usage.
|
||||||
|
|
||||||
> API keys are only needed for optional add-on agents (OpenRouter/OpenAI). For server/automated deployments, API key authentication is recommended per [Anthropic's guidance](https://code.claude.com/docs/en/legal-and-compliance).
|
> API keys are only needed for optional add-on agents (OpenRouter/OpenAI). For server/automated deployments, API key authentication is recommended per [Anthropic's guidance](https://code.claude.com/docs/en/legal-and-compliance).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "aipass"
|
name = "aipass"
|
||||||
version = "2.7.1"
|
version = "2.7.3"
|
||||||
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
|
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
|
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.7.1"
|
__version__ = "2.7.3"
|
||||||
|
|||||||
@@ -11,6 +11,25 @@
|
|||||||
|
|
||||||
**Status:** Operational | **Seedgo:** 100% (34/34) | **Tests:** 712 pass | **Battle Tested:** S62
|
**Status:** Operational | **Seedgo:** 100% (34/34) | **Tests:** 712 pass | **Battle Tested:** S62
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check your inbox
|
||||||
|
drone @ai_mail inbox
|
||||||
|
|
||||||
|
# View a message
|
||||||
|
drone @ai_mail view <id>
|
||||||
|
|
||||||
|
# Reply and close
|
||||||
|
drone @ai_mail reply <id> "your message"
|
||||||
|
|
||||||
|
# Send mail to another branch
|
||||||
|
drone @ai_mail email @target "Subject" "Body"
|
||||||
|
|
||||||
|
# Dispatch (send + wake target agent)
|
||||||
|
drone @ai_mail dispatch @target "Subject" "Body"
|
||||||
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from typing import Dict, Tuple, List, Optional, Callable
|
|||||||
|
|
||||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
from aipass.ai_mail.apps.handlers.paths import find_repo_root, find_project_root
|
||||||
from aipass.ai_mail.apps.handlers.registry.read import get_all_branches
|
from aipass.ai_mail.apps.handlers.registry.read import get_all_branches
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@@ -213,6 +213,44 @@ def _resolve_reply_path() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _check_cross_project_boundary(recipient_path: Path, sender_email: str) -> Tuple[bool, str]:
|
||||||
|
"""Refuse mail when sender and recipient are in different projects.
|
||||||
|
|
||||||
|
Compares project roots (first *_REGISTRY.json found walking up) for the
|
||||||
|
sender (from AIPASS_CALLER_CWD) and recipient (from resolved branch path).
|
||||||
|
Same-project and host-to-host mail passes through unchanged.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(True, error_message) to refuse, (False, "") to allow.
|
||||||
|
"""
|
||||||
|
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", "")
|
||||||
|
if not caller_cwd:
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
sender_root = find_project_root(Path(caller_cwd))
|
||||||
|
if sender_root is None:
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
recipient_root = find_project_root(recipient_path)
|
||||||
|
if recipient_root is None:
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
if sender_root == recipient_root:
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
sender_name = sender_email or os.environ.get("AIPASS_CALLER_BRANCH", "unknown")
|
||||||
|
logger.warning(
|
||||||
|
"[delivery] cross-project mail refused: sender root %s != recipient root %s",
|
||||||
|
sender_root,
|
||||||
|
recipient_root,
|
||||||
|
)
|
||||||
|
return True, (
|
||||||
|
f"Cross-project mail refused: {sender_name} (project: {sender_root.name}) "
|
||||||
|
f"cannot send to this branch (project: {recipient_root.name}). "
|
||||||
|
f"Use the feedback channel for cross-project communication."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def deliver_email_to_branch(
|
def deliver_email_to_branch(
|
||||||
to_branch: str, email_data: Dict, on_delivered: Optional[Callable] = None
|
to_branch: str, email_data: Dict, on_delivered: Optional[Callable] = None
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
@@ -283,6 +321,11 @@ def deliver_email_to_branch(
|
|||||||
if not branch_path.is_absolute():
|
if not branch_path.is_absolute():
|
||||||
branch_path = (_REPO_ROOT / branch_path).resolve()
|
branch_path = (_REPO_ROOT / branch_path).resolve()
|
||||||
|
|
||||||
|
# Cross-project boundary: refuse mail when sender and recipient are in different projects
|
||||||
|
refused, refusal_msg = _check_cross_project_boundary(branch_path, sender_email)
|
||||||
|
if refused:
|
||||||
|
return False, refusal_msg
|
||||||
|
|
||||||
# Find the branch's .ai_mail.local/inbox.json file
|
# Find the branch's .ai_mail.local/inbox.json file
|
||||||
if branch_path == Path("/") or branch_path == _REPO_ROOT:
|
if branch_path == Path("/") or branch_path == _REPO_ROOT:
|
||||||
inbox_file = _REPO_ROOT / ".ai_mail.local" / "inbox.json"
|
inbox_file = _REPO_ROOT / ".ai_mail.local" / "inbox.json"
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ Consolidated from 8 identical copies per DPLAN-0036 audit.
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@@ -36,6 +38,23 @@ def find_repo_root() -> Path:
|
|||||||
return Path.cwd()
|
return Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def find_project_root(start: Path) -> Optional[Path]:
|
||||||
|
"""Walk up from *start* to find the first *_REGISTRY.json (project root).
|
||||||
|
|
||||||
|
Returns the directory containing the registry, or None if not found.
|
||||||
|
Stops at filesystem root.
|
||||||
|
"""
|
||||||
|
current = start.resolve()
|
||||||
|
for candidate in [current] + list(current.parents):
|
||||||
|
try:
|
||||||
|
if any(candidate.glob("*_REGISTRY.json")):
|
||||||
|
return candidate
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[paths] find_project_root: glob failed at %s: %s", candidate, exc)
|
||||||
|
break
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from aipass.cli.apps.modules import console
|
from aipass.cli.apps.modules import console
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from unittest.mock import patch, MagicMock
|
|||||||
|
|
||||||
import aipass.ai_mail.apps.handlers.email.delivery as delivery_mod
|
import aipass.ai_mail.apps.handlers.email.delivery as delivery_mod
|
||||||
from aipass.ai_mail.apps.handlers.email.delivery import (
|
from aipass.ai_mail.apps.handlers.email.delivery import (
|
||||||
|
_check_cross_project_boundary,
|
||||||
_migrate_inbox_format,
|
_migrate_inbox_format,
|
||||||
_is_private_branch_email,
|
_is_private_branch_email,
|
||||||
_resolve_reply_path,
|
_resolve_reply_path,
|
||||||
@@ -493,3 +494,121 @@ def test_deliver_stores_reply_path_from_env(tmp_path, repo_root, noop_inbox_lock
|
|||||||
msg = inbox["messages"][0]
|
msg = inbox["messages"][0]
|
||||||
assert "reply_path" in msg
|
assert "reply_path" in msg
|
||||||
assert msg["reply_path"] == str(inbox_file)
|
assert msg["reply_path"] == str(inbox_file)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- _check_cross_project_boundary() tests ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_no_caller_cwd_allows(tmp_path, monkeypatch):
|
||||||
|
"""No AIPASS_CALLER_CWD → host-internal, always allowed."""
|
||||||
|
monkeypatch.delenv("AIPASS_CALLER_CWD", raising=False)
|
||||||
|
refused, _ = _check_cross_project_boundary(tmp_path, "@sender")
|
||||||
|
assert refused is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_same_root_allows(tmp_path, monkeypatch):
|
||||||
|
"""Sender and recipient in the same project → allowed."""
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
sender_dir = tmp_path / "src" / "branch_a"
|
||||||
|
sender_dir.mkdir(parents=True)
|
||||||
|
recipient_dir = tmp_path / "src" / "branch_b"
|
||||||
|
recipient_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||||
|
refused, _ = _check_cross_project_boundary(recipient_dir, "@branch_b")
|
||||||
|
assert refused is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_different_roots_refuses(tmp_path, monkeypatch):
|
||||||
|
"""Sender in nested project, recipient in host → refused."""
|
||||||
|
host = tmp_path / "host"
|
||||||
|
host.mkdir()
|
||||||
|
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
recipient_dir = host / "src" / "devpulse"
|
||||||
|
recipient_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
project = host / "projects" / "myproj"
|
||||||
|
project.mkdir(parents=True)
|
||||||
|
(project / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
sender_dir = project / "src"
|
||||||
|
sender_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||||
|
refused, msg = _check_cross_project_boundary(recipient_dir, "@proj_agent")
|
||||||
|
assert refused is True
|
||||||
|
assert "Cross-project mail refused" in msg
|
||||||
|
assert "feedback channel" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_sender_no_registry_allows(tmp_path, monkeypatch):
|
||||||
|
"""Sender in dir with no registry → cannot determine boundary, allow."""
|
||||||
|
isolated = tmp_path / "nowhere"
|
||||||
|
isolated.mkdir()
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(isolated))
|
||||||
|
|
||||||
|
recipient = tmp_path / "host" / "branch"
|
||||||
|
recipient.mkdir(parents=True)
|
||||||
|
(tmp_path / "host" / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
refused, _ = _check_cross_project_boundary(recipient, "@branch")
|
||||||
|
assert refused is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_recipient_no_registry_allows(tmp_path, monkeypatch):
|
||||||
|
"""Recipient in dir with no registry → cannot determine boundary, allow."""
|
||||||
|
sender_dir = tmp_path / "host" / "src"
|
||||||
|
sender_dir.mkdir(parents=True)
|
||||||
|
(tmp_path / "host" / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||||
|
|
||||||
|
recipient = tmp_path / "orphan"
|
||||||
|
recipient.mkdir()
|
||||||
|
|
||||||
|
refused, _ = _check_cross_project_boundary(recipient, "@orphan")
|
||||||
|
assert refused is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_delivery_e2e_refused(tmp_path, repo_root, noop_inbox_lock, monkeypatch):
|
||||||
|
"""End-to-end: delivery from nested project to host branch is refused."""
|
||||||
|
host_root = repo_root
|
||||||
|
(host_root / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
branches = _setup_branch(tmp_path)
|
||||||
|
|
||||||
|
project = host_root / "projects" / "testproj"
|
||||||
|
project.mkdir(parents=True)
|
||||||
|
(project / "TESTPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
sender_cwd = project / "src"
|
||||||
|
sender_cwd.mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_cwd))
|
||||||
|
|
||||||
|
with patch.object(delivery_mod, "get_all_branches", return_value=branches):
|
||||||
|
success, error = deliver_email_to_branch(
|
||||||
|
"@target",
|
||||||
|
_make_email_data(sender="@testproj"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert "Cross-project mail refused" in error
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_delivery_same_project_allowed(tmp_path, repo_root, noop_inbox_lock, monkeypatch):
|
||||||
|
"""End-to-end: delivery within the same project is allowed."""
|
||||||
|
(repo_root / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
branches = _setup_branch(tmp_path)
|
||||||
|
|
||||||
|
sender_cwd = tmp_path / "src" / "other_branch"
|
||||||
|
sender_cwd.mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_cwd))
|
||||||
|
|
||||||
|
with patch.object(delivery_mod, "get_all_branches", return_value=branches):
|
||||||
|
success, error = deliver_email_to_branch(
|
||||||
|
"@target",
|
||||||
|
_make_email_data(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert error == ""
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
# Modified: 2026-04-03
|
# Modified: 2026-04-03
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
"""Tests for paths module -- repo root discovery."""
|
"""Tests for paths module -- repo root discovery and project root resolution."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import aipass.ai_mail.apps.handlers.paths as mod
|
import aipass.ai_mail.apps.handlers.paths as mod
|
||||||
|
from aipass.ai_mail.apps.handlers.paths import find_project_root
|
||||||
|
|
||||||
|
|
||||||
# --- Fixtures --------------------------------------------------------
|
# --- Fixtures --------------------------------------------------------
|
||||||
@@ -84,3 +85,53 @@ def test_find_repo_root_finds_registry_in_same_dir(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
result = mod.find_repo_root()
|
result = mod.find_repo_root()
|
||||||
assert result == tmp_path
|
assert result == tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# --- find_project_root tests --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_project_root_finds_registry(tmp_path):
|
||||||
|
"""Returns directory containing *_REGISTRY.json."""
|
||||||
|
project = tmp_path / "projects" / "myproj"
|
||||||
|
deep = project / "src" / "pkg"
|
||||||
|
deep.mkdir(parents=True)
|
||||||
|
(project / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
assert find_project_root(deep) == project
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_project_root_finds_host_registry(tmp_path):
|
||||||
|
"""Returns host repo root when AIPASS_REGISTRY.json is the first hit."""
|
||||||
|
host = tmp_path / "repo"
|
||||||
|
branch = host / "src" / "aipass" / "branch"
|
||||||
|
branch.mkdir(parents=True)
|
||||||
|
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
assert find_project_root(branch) == host
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_project_root_stops_at_first_registry(tmp_path):
|
||||||
|
"""Nested project registry is found before the host registry."""
|
||||||
|
host = tmp_path / "repo"
|
||||||
|
project = host / "projects" / "inner"
|
||||||
|
deep = project / "src"
|
||||||
|
deep.mkdir(parents=True)
|
||||||
|
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
(project / "INNER_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
assert find_project_root(deep) == project
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_project_root_none_when_no_registry(tmp_path):
|
||||||
|
"""Returns None when no *_REGISTRY.json is found anywhere."""
|
||||||
|
deep = tmp_path / "a" / "b" / "c"
|
||||||
|
deep.mkdir(parents=True)
|
||||||
|
|
||||||
|
assert find_project_root(deep) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_project_root_at_start_dir(tmp_path):
|
||||||
|
"""Returns start dir itself when it contains the registry."""
|
||||||
|
(tmp_path / "PROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
assert find_project_root(tmp_path) == tmp_path
|
||||||
|
|||||||
@@ -77,8 +77,33 @@ apps/
|
|||||||
- **Never pretend.** Don't know → say so, offer find out or ask branch expert.
|
- **Never pretend.** Don't know → say so, offer find out or ask branch expert.
|
||||||
- **Clean handoffs.** Every init stage saves `setup_progress` `.trinity/local.json` — resume works.
|
- **Clean handoffs.** Every init stage saves `setup_progress` `.trinity/local.json` — resume works.
|
||||||
|
|
||||||
|
## Welcome Mode — Fresh Install
|
||||||
|
|
||||||
|
Trigger: first message mentions "Fresh AIPass install" or you detect a fresh install context.
|
||||||
|
|
||||||
|
**Opening — three jobs in one tight block:**
|
||||||
|
1. Say who you are and what you know: "I'm the AIPass concierge — I know this framework, every agent in it, and I'll remember what we set up."
|
||||||
|
2. Show 3-5 concrete starters with exact commands:
|
||||||
|
- `drone systems` — see every agent in the ecosystem
|
||||||
|
- `drone @prax monitor run` — watch the system work live (leave this running in another terminal)
|
||||||
|
- `aipass doctor` — check what's healthy and what needs wiring
|
||||||
|
- `aipass help "how does memory work?"` — ask me anything about the framework
|
||||||
|
- `drone @hooks hooksound` — toggle sound notifications (hear hooks firing as you work, or mute if distracting)
|
||||||
|
3. Ask their name ONCE: "What should I call you? I'll remember it — next time you open this, I'll know who you are. Skip if you'd rather not." Accept skip gracefully. Never re-ask.
|
||||||
|
|
||||||
|
**Deferred triage (~turn 5):** After rapport is built, suggest completing setup. Frame it as "every machine is different — let's see what yours needs" rather than dumping a checklist.
|
||||||
|
|
||||||
|
**Hooks-first verification:** The first real setup task. Dispatch @hooks to investigate and report: `drone @ai_mail dispatch @hooks "Hooks health check" "Check if hooks are wired correctly for this installation. Include trust-registry enrollment status. Report what's green and what needs wiring."` Then check your inbox conversationally: `drone @ai_mail inbox`
|
||||||
|
|
||||||
|
**Setup DPLAN:** When the user is ready for the full setup pass, create a setup plan seeded from the cross-OS checklist: `drone @flow create . "Machine setup — post-install verification"` and reference `aipass doctor --cross-os` for the machine-specific gaps.
|
||||||
|
|
||||||
|
**Windows detected:** If system detection shows Windows (not WSL), recommend WSL: "AIPass works best on Linux/macOS or WSL. Want me to walk you through setting up WSL?" Offer a playbook.
|
||||||
|
|
||||||
|
**Feedback pulse — mention once:** "How's the experience so far? Your feedback is hugely appreciated — this is an open-source project and fresh-machine experience is the data we can't get any other way. https://github.com/AIOSAI/AIPass/issues — or turn reminders off anytime: `aipass feedback off`"
|
||||||
|
|
||||||
|
**Every suggestion ships its exact command.** Never say "you can check the agents" — say "run `drone systems` to see every agent."
|
||||||
|
|
||||||
## Known Gotchas
|
## Known Gotchas
|
||||||
|
|
||||||
- **Status: under construction (DPLAN-0136).** Don't PR / reveal this branch until Phase 8 — that's a *policy*, NOT a gitignore. Only the usual runtime/memory layer is ignored (`.trinity/`, plan files, `*.local`, logs) same as every branch; my code (init_flow, cross_os, tests, README) IS trackable. Committed-or-not = git's call, devpulse's lane.
|
- **`aipass` binary is THIS branch's CLI** — installed on PATH, ships publicly (post-FPLAN-0333). init/install/new/doctor/help/profile/trust/feedback all route here. Citizen creation inside the host framework is still `drone @spawn create`.
|
||||||
- **`aipass` binary currently `cli` branch's `aipass init`** — project bootstrap, not citizen creation. Eventually this CLI entry moves here. Until then, use `drone @spawn create` citizen creation.
|
|
||||||
- **Test-convention tokens need buy-in.** Core agents don't yet recognize `[AIPASS-TEST — ...]`. Coordinating @ai_mail before pinging anyone.
|
- **Test-convention tokens need buy-in.** Core agents don't yet recognize `[AIPASS-TEST — ...]`. Coordinating @ai_mail before pinging anyone.
|
||||||
|
|||||||
@@ -31,20 +31,10 @@
|
|||||||
"standard": "cli",
|
"standard": "cli",
|
||||||
"reason": "Session info must print immediately after tmux spawn — returning data to module layer would lose the timing context. User needs attach/kill instructions right when the session starts."
|
"reason": "Session info must print immediately after tmux spawn — returning data to module layer would lose the timing context. User needs attach/kill instructions right when the session starts."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "apps/aipass.py",
|
|
||||||
"standard": "cli",
|
|
||||||
"reason": "Thin command router — discovers and routes to modules, which own the CLI service layer. Adding console/header imports here couples the bootstrap entry point to Rich for 4 status lines."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "apps/aipass.py",
|
|
||||||
"standard": "debug_print",
|
|
||||||
"reason": "Thin command router uses bare print() for version output and help banner (4 calls). These run before module discovery — importing Rich console for bootstrap output adds startup overhead for minimal benefit."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "apps/aipass.py",
|
"file": "apps/aipass.py",
|
||||||
"standard": "introspection",
|
"standard": "introspection",
|
||||||
"reason": "Thin command router, not a module — it has no domain to introspect. Modules handle their own introspection via --info. No print_introspection() needed."
|
"reason": "Entry point router — introspection is in print_introspection() called from main() on no-args/--help. Not a module with handle_command()."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/doctor.py",
|
"file": "apps/modules/doctor.py",
|
||||||
@@ -257,14 +247,34 @@
|
|||||||
"reason": "aipass is binary-invoked: aipass doctor runs the command; introspection via --info"
|
"reason": "aipass is binary-invoked: aipass doctor runs the command; introspection via --info"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/doctor_fix.py",
|
"file": "apps/modules/_doctor_fix.py",
|
||||||
"standard": "introspection",
|
"standard": "introspection",
|
||||||
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
|
"reason": "Private helper module for doctor.py — not directly invokable, no introspection needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/doctor_wire.py",
|
"file": "apps/modules/_doctor_wire.py",
|
||||||
"standard": "introspection",
|
"standard": "introspection",
|
||||||
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
|
"reason": "Private helper module for doctor.py — not directly invokable, no introspection needed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/_doctor_fix.py",
|
||||||
|
"standard": "naming",
|
||||||
|
"reason": "Leading underscore is intentional — hides from module discovery to pass cli_ux no_internal_modules check."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/_doctor_wire.py",
|
||||||
|
"standard": "naming",
|
||||||
|
"reason": "Leading underscore is intentional — hides from module discovery to pass cli_ux no_internal_modules check."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/_doctor_fix.py",
|
||||||
|
"standard": "meta",
|
||||||
|
"reason": "Private helper module for doctor.py — meta name matches actual filename _doctor_fix.py."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/_doctor_wire.py",
|
||||||
|
"standard": "meta",
|
||||||
|
"reason": "Private helper module for doctor.py — meta name matches actual filename _doctor_wire.py."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/handoff.py",
|
"file": "apps/modules/handoff.py",
|
||||||
@@ -375,6 +385,41 @@
|
|||||||
"file": "shared/json_ops.py",
|
"file": "shared/json_ops.py",
|
||||||
"standard": "unused_function",
|
"standard": "unused_function",
|
||||||
"reason": "backup_json() is consumed by @spawn (cross-branch caller). Appears unused in @aipass-only scan but is a shared API."
|
"reason": "backup_json() is consumed by @spawn (cross-branch caller). Appears unused in @aipass-only scan but is a shared API."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/trust.py",
|
||||||
|
"standard": "encapsulation",
|
||||||
|
"reason": "Imports frozen trust_registry interface (enroll/revoke/is_trusted/read_registry) from @hooks by DPLAN-0244 design. Cross-branch import required — the registry module lives in hooks, consumers live in aipass."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/trust.py",
|
||||||
|
"standard": "json_structure",
|
||||||
|
"reason": "No JSON file operations — trust.py is a thin CLI wrapper that delegates all JSON I/O to the trust_registry module in @hooks. No json_handler needed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/trust.py",
|
||||||
|
"standard": "introspection",
|
||||||
|
"reason": "aipass is binary-invoked: bare 'aipass trust' shows registry table; introspection via --info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/handlers/init/bootstrap.py",
|
||||||
|
"standard": "encapsulation",
|
||||||
|
"reason": "Imports enroll() from @hooks trust_registry (DPLAN-0244 frozen interface). Cross-branch import required — init must enroll projects in the trust registry after writing hooks.json."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/handlers/init/bootstrap.py",
|
||||||
|
"standard": "handlers",
|
||||||
|
"reason": "Imports enroll() from @hooks trust_registry by DPLAN-0244 design. Cross-handler import required — bootstrap auto-enrolls projects after hooks.json creation/merge."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/test_trust.py",
|
||||||
|
"standard": "architecture",
|
||||||
|
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/test_trust.py",
|
||||||
|
"standard": "encapsulation",
|
||||||
|
"reason": "Tests import trust_registry directly to verify enrollment/revocation in isolation with monkeypatched REGISTRY_PATH."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-14
@@ -1,11 +1,22 @@
|
|||||||
# AIPASS
|
# AIPASS
|
||||||
|
|
||||||
Concierge and librarian for AIPass. Greets new users, walks them through setup, answers how-things-work questions, hands off to their chosen CLI.
|
The friendly front door for AIPass. Walks new users through setup, runs system diagnostics, answers documentation questions, and creates projects inside the AIPass environment.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aipass # Show available commands
|
||||||
|
aipass doctor # Check system health
|
||||||
|
aipass help what does drone do # Search branch documentation
|
||||||
|
aipass new myapp --template python # Create a new project
|
||||||
|
aipass init # Guided setup (10 stages, resumable)
|
||||||
|
```
|
||||||
|
|
||||||
## Invoke
|
## Invoke
|
||||||
|
|
||||||
```
|
```
|
||||||
drone @aipass <command>
|
aipass <command> [options]
|
||||||
|
aipass <command> --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -16,26 +27,30 @@ aipass/
|
|||||||
│ ├── aipass.py # Entry point — subcommand dispatch
|
│ ├── aipass.py # Entry point — subcommand dispatch
|
||||||
│ ├── modules/
|
│ ├── modules/
|
||||||
│ │ ├── doctor.py # System health aggregation + cross-OS pre-flight (--cross-os)
|
│ │ ├── doctor.py # System health aggregation + cross-OS pre-flight (--cross-os)
|
||||||
│ │ ├── doctor_fix.py # Remediation report (--fix, --json)
|
│ │ ├── _doctor_fix.py # Remediation report (--fix, --json) [internal]
|
||||||
│ │ ├── doctor_wire.py # Auto-wire provider settings + stale-deny re-export
|
│ │ ├── _doctor_wire.py # Auto-wire provider settings + stale-deny re-export [internal]
|
||||||
│ │ ├── handoff.py # CLI handoff (placeholder)
|
│ │ ├── handoff.py # CLI handoff (placeholder)
|
||||||
│ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler)
|
│ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler)
|
||||||
│ │ ├── init_flow.py # 10-stage guided setup
|
│ │ ├── init_flow.py # 10-stage guided setup
|
||||||
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
||||||
│ │ └── profile.py # User profile read/write
|
│ │ ├── new_project.py # aipass new — create projects inside the installation
|
||||||
|
│ │ ├── profile.py # User profile read/write
|
||||||
|
│ │ ├── trust.py # Trust registry — aipass trust / aipass revoke
|
||||||
|
│ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off
|
||||||
│ ├── handlers/
|
│ ├── handlers/
|
||||||
│ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record
|
│ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record
|
||||||
│ │ ├── handoff_platform/ # Platform-specific handoff detection
|
│ │ ├── handoff_platform/ # Platform-specific handoff detection
|
||||||
│ │ ├── init/ # bootstrap.py, scaffold_content.py
|
│ │ ├── init/ # bootstrap.py, scaffold_content.py
|
||||||
|
│ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init)
|
||||||
│ │ ├── json/ # JSON read/write utilities
|
│ │ ├── json/ # JSON read/write utilities
|
||||||
│ │ ├── ping_sweep/ # Branch reachability verification
|
│ │ ├── ping_sweep/ # Branch reachability verification
|
||||||
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
||||||
│ │ ├── readme_map/ # Live file reads + branch routing
|
│ │ ├── readme_map/ # Live file reads + branch routing
|
||||||
│ │ ├── structure_scan/ # Agent placement + pollution detection
|
│ │ ├── structure_scan/ # Agent placement + pollution detection
|
||||||
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
||||||
│ │ └── ui/ # Progress bars, menus, banners
|
│ │ └── ui/ # Progress bars, menus, banners
|
||||||
│ └── plugins/
|
│ └── plugins/
|
||||||
├── tests/ # 609 passing
|
├── tests/ # 756 passing
|
||||||
├── requirements.project.txt # Project-specific Python dependencies
|
├── requirements.project.txt # Project-specific Python dependencies
|
||||||
├── .trinity/ # Identity + session history + observations
|
├── .trinity/ # Identity + session history + observations
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -45,17 +60,23 @@ aipass/
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `aipass` | Help banner |
|
| `aipass` | Show available commands |
|
||||||
| `aipass help [Q]` | README-backed Q&A with branch routing |
|
| `aipass help [Q]` | README-backed Q&A with branch routing |
|
||||||
| `aipass doctor` | System health — structure, registry, hooks, pytest |
|
| `aipass doctor` | System health — structure, registry, hooks, pytest |
|
||||||
| `aipass doctor --fix` | Remediation report with `drone @spawn repair` commands |
|
| `aipass doctor --fix` | Remediation report with `drone @spawn repair` commands |
|
||||||
| `aipass doctor --json` | JSON output for structure scan results |
|
| `aipass doctor --json` | JSON output for structure scan results |
|
||||||
| `aipass doctor --cross-os` | Cross-OS pre-flight (Layer-3-lite, machine) — OS-gap cross-ref + routing/versions/hookstatus |
|
| `aipass doctor --cross-os` | Cross-OS pre-flight — OS-gap cross-ref + routing/versions/hookstatus |
|
||||||
| `aipass doctor --cross-os --e2e` | ...also runs the real Layer-2 e2e wiring suite (heavy, opt-in) |
|
| `aipass doctor --cross-os --e2e` | ...also runs the real e2e wiring suite (heavy, opt-in) |
|
||||||
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human Layer-3 acceptance pass |
|
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human acceptance pass |
|
||||||
| `aipass init` | 10-stage guided setup (resumable) |
|
| `aipass init` | 10-stage guided setup (resumable) |
|
||||||
| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init (`--no-init`/`--with-init`/`--path`/`--here`) |
|
| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init |
|
||||||
| `aipass profile` | Show/edit user profile |
|
| `aipass profile` | Show/edit user profile |
|
||||||
|
| `aipass new <name>` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent |
|
||||||
|
| `aipass new <name> --template python` | Create with Python template (pyproject + src/) |
|
||||||
|
| `aipass new <name> --no-agent` | Create without resident agent |
|
||||||
|
| `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry |
|
||||||
|
| `aipass revoke <path>` | Remove a project from the trust registry |
|
||||||
|
| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) |
|
||||||
| `aipass --version` | Version |
|
| `aipass --version` | Version |
|
||||||
|
|
||||||
## Integration Points
|
## Integration Points
|
||||||
@@ -76,7 +97,7 @@ Humans only. Nothing in AIPass depends on this branch.
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
609 passing — `pytest src/aipass/aipass/tests/`
|
723 passing — `pytest src/aipass/aipass/tests/`
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
||||||
@@ -84,4 +105,4 @@ Humans only. Nothing in AIPass depends on this branch.
|
|||||||
|
|
||||||
## Last Updated
|
## Last Updated
|
||||||
|
|
||||||
Last Updated: 2026-07-05
|
Last Updated: 2026-07-17
|
||||||
|
|||||||
@@ -35,8 +35,24 @@ if sys.platform == "win32":
|
|||||||
if _reconfigure is not None:
|
if _reconfigure is not None:
|
||||||
_reconfigure(encoding="utf-8", errors="replace")
|
_reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
from aipass.cli.apps.modules import console, error
|
||||||
from aipass.prax import logger
|
from aipass.prax import logger
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# COMMANDS — public-facing labels and descriptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_PUBLIC_COMMANDS = {
|
||||||
|
"doctor": "System health — structure, registry, hooks, tests",
|
||||||
|
"help": "README-backed Q&A — ask about any branch",
|
||||||
|
"init": "Guided setup for new users (10 stages, resumable)",
|
||||||
|
"install": "One-command bootstrap — clone + setup + init",
|
||||||
|
"new": "Create a project inside AIPass",
|
||||||
|
"profile": "Show/edit user profile",
|
||||||
|
"trust": "Trust registry — enroll/revoke projects",
|
||||||
|
"feedback": "Toggle the feedback reminder on/off",
|
||||||
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MODULE DISCOVERY
|
# MODULE DISCOVERY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -72,6 +88,81 @@ def discover_modules() -> List[Any]:
|
|||||||
return modules
|
return modules
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HELP OUTPUT — house pattern (cli_ux)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection(modules: List[Any] | None = None) -> None:
|
||||||
|
"""Bare invocation — title, purpose, public commands, --help pointer."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]AIPASS — Concierge & Setup[/bold cyan]")
|
||||||
|
console.print("[dim]The friendly front door for AIPass. Setup, diagnostics, documentation, project creation.[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if modules is None:
|
||||||
|
modules = discover_modules()
|
||||||
|
|
||||||
|
commands = []
|
||||||
|
for module in modules:
|
||||||
|
name = getattr(module, "COMMAND", None)
|
||||||
|
if name and name in _PUBLIC_COMMANDS:
|
||||||
|
commands.append((name, _PUBLIC_COMMANDS[name]))
|
||||||
|
commands.sort()
|
||||||
|
|
||||||
|
if commands:
|
||||||
|
console.print("[yellow]Commands:[/yellow]")
|
||||||
|
for name, desc in commands:
|
||||||
|
console.print(f" [green]{name:16}[/green] [dim]{desc}[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[dim]Run 'aipass --help' for usage and examples[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_help(modules: List[Any] | None = None) -> None:
|
||||||
|
"""Full help — usage, commands, examples."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]AIPASS — Concierge & Setup[/bold cyan]")
|
||||||
|
console.print("[dim]The friendly front door for AIPass. Setup, diagnostics, documentation, project creation.[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[yellow]Usage:[/yellow]")
|
||||||
|
console.print(" [green]aipass[/green] [dim]<command>[/dim] [dim][options][/dim]")
|
||||||
|
console.print(" [green]aipass[/green] [dim]Show commands[/dim]")
|
||||||
|
console.print(" [green]aipass[/green] [dim]<command>[/dim] [dim]--help[/dim] [dim]Help for a command[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[yellow]Commands:[/yellow]")
|
||||||
|
console.print(
|
||||||
|
" [green]doctor[/green] [dim]System health — structure, registry, hooks, tests[/dim]"
|
||||||
|
)
|
||||||
|
console.print(" [green]doctor --fix[/green] [dim]Remediation report with repair commands[/dim]")
|
||||||
|
console.print(" [green]doctor --json[/green] [dim]JSON output for structure scan[/dim]")
|
||||||
|
console.print(" [green]doctor --cross-os[/green] [dim]Cross-OS pre-flight check[/dim]")
|
||||||
|
console.print(" [green]help <question>[/green] [dim]Search branch documentation (Q&A)[/dim]")
|
||||||
|
console.print(
|
||||||
|
" [green]init[/green] [dim]Guided setup for new users (10 stages, resumable)[/dim]"
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
" [green]install[/green] [dim]One-command bootstrap — clone + setup.sh + hooks[/dim]"
|
||||||
|
)
|
||||||
|
console.print(" [green]new <name>[/green] [dim]Create a project inside AIPass[/dim]")
|
||||||
|
console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]")
|
||||||
|
console.print(
|
||||||
|
" [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]"
|
||||||
|
)
|
||||||
|
console.print(" [green]--version[/green] [dim]Show version[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[yellow]Examples:[/yellow]")
|
||||||
|
console.print(" [green]aipass doctor[/green] [dim]Check system health[/dim]")
|
||||||
|
console.print(" [green]aipass help what does drone do[/green] [dim]Search documentation[/dim]")
|
||||||
|
console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]")
|
||||||
|
console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||||
"""Route command to appropriate module.
|
"""Route command to appropriate module.
|
||||||
|
|
||||||
@@ -105,17 +196,15 @@ def main():
|
|||||||
except importlib.metadata.PackageNotFoundError:
|
except importlib.metadata.PackageNotFoundError:
|
||||||
logger.info("[AIPASS] Package metadata not found, version unknown")
|
logger.info("[AIPASS] Package metadata not found, version unknown")
|
||||||
version = "unknown"
|
version = "unknown"
|
||||||
print(f"aipass {version}")
|
console.print(f"aipass {version}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
show_root_help = len(args) == 0 or args[0] in ["--help", "-h"] or (args[0] == "help" and len(args) == 1)
|
if not args:
|
||||||
if show_root_help:
|
print_introspection(modules)
|
||||||
print(f"AIPASS - {len(modules)} modules discovered")
|
return 0
|
||||||
for module in modules:
|
|
||||||
stem = module.__name__.split(".")[-1]
|
if args[0] in ("--help", "-h"):
|
||||||
name = getattr(module, "COMMAND", stem)
|
print_help(modules)
|
||||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
|
||||||
print(f" {name:20} {desc}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
command = args[0]
|
command = args[0]
|
||||||
@@ -126,31 +215,31 @@ def main():
|
|||||||
for module in modules:
|
for module in modules:
|
||||||
if module.handle_command(command, ["--help"]):
|
if module.handle_command(command, ["--help"]):
|
||||||
return 0
|
return 0
|
||||||
print(f"Unknown command: {command}")
|
console.print(f"Unknown command: {command}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if route_command(command, remaining, modules):
|
if route_command(command, remaining, modules):
|
||||||
return 0
|
return 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error: '{command}' crashed: {e}")
|
error(f"'{command}' crashed: {e}")
|
||||||
logger.error(f"[AIPASS] '{command}' traceback", exc_info=True)
|
logger.error(f"[AIPASS] '{command}' traceback", exc_info=True)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if command.startswith("@"):
|
if command.startswith("@"):
|
||||||
print(f"{command} is a drone routing target, not an aipass command.")
|
console.print(f"{command} is a drone routing target, not an aipass command.")
|
||||||
print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
|
console.print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
|
||||||
print()
|
console.print()
|
||||||
print(f" Reach an agent: drone {command} ... · drone systems")
|
console.print(f" Reach an agent: drone {command} ... · drone systems")
|
||||||
print(" aipass commands: aipass --help")
|
console.print(" aipass commands: aipass --help")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
for stem, err in _import_failures.items():
|
for stem, err in _import_failures.items():
|
||||||
if command in (stem, stem.replace("_", "")):
|
if command in (stem, stem.replace("_", "")):
|
||||||
print(f"Error: '{command}' failed to load: {err}")
|
error(f"'{command}' failed to load: {err}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print(f"Unknown command: {command}")
|
console.print(f"Unknown command: {command}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||||
_sanitize_name,
|
_sanitize_name,
|
||||||
init_project,
|
init_project,
|
||||||
|
is_projects_child,
|
||||||
update_project,
|
update_project,
|
||||||
)
|
)
|
||||||
from aipass.aipass.apps.handlers.init.scaffold_content import (
|
from aipass.aipass.apps.handlers.init.scaffold_content import (
|
||||||
@@ -25,6 +26,7 @@ __all__ = [
|
|||||||
"global_prompt_md",
|
"global_prompt_md",
|
||||||
"inbox_json",
|
"inbox_json",
|
||||||
"init_project",
|
"init_project",
|
||||||
|
"is_projects_child",
|
||||||
"prep_md",
|
"prep_md",
|
||||||
"update_project",
|
"update_project",
|
||||||
"with_source",
|
"with_source",
|
||||||
|
|||||||
@@ -246,9 +246,46 @@ def _claude_settings(aipass_home: str | None = None) -> str:
|
|||||||
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def _guard_init(target: Path) -> None:
|
def _enroll_project(target: Path) -> None:
|
||||||
|
"""Enroll a project in the trusted-project registry (DPLAN-0244).
|
||||||
|
|
||||||
|
Lazy import to keep bootstrap.py free of prax/module-level deps.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from aipass.hooks.apps.handlers.config.trust_registry import enroll
|
||||||
|
|
||||||
|
if enroll(str(target)):
|
||||||
|
logger.info("Enrolled project in trust registry: %s", target)
|
||||||
|
else:
|
||||||
|
logger.warning("Trust enrollment failed for %s", target)
|
||||||
|
except ImportError as exc:
|
||||||
|
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def is_projects_child(target: Path) -> bool:
|
||||||
|
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
|
||||||
|
|
||||||
|
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
|
||||||
|
of target (i.e. target's parent is named ``projects``).
|
||||||
|
"""
|
||||||
|
resolved = target.resolve()
|
||||||
|
if resolved.parent.name != "projects":
|
||||||
|
return False
|
||||||
|
host = resolved.parent.parent
|
||||||
|
try:
|
||||||
|
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
|
||||||
|
except OSError as exc:
|
||||||
|
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None:
|
||||||
"""Block init if target is inside an agent branch or existing project.
|
"""Block init if target is inside an agent branch or existing project.
|
||||||
|
|
||||||
|
When *allow_projects_child* is True, the nested-project checks are
|
||||||
|
skipped for targets that are ``<host>/projects/<name>``. This is used
|
||||||
|
by ``aipass new`` to create projects inside the installation.
|
||||||
|
|
||||||
Raises RuntimeError with explanation if init should not proceed.
|
Raises RuntimeError with explanation if init should not proceed.
|
||||||
"""
|
"""
|
||||||
target = target.resolve()
|
target = target.resolve()
|
||||||
@@ -270,6 +307,8 @@ def _guard_init(target: Path) -> None:
|
|||||||
# Block: target already has a registry (is already a project)
|
# Block: target already has a registry (is already a project)
|
||||||
for f in target.iterdir() if target.is_dir() else []:
|
for f in target.iterdir() if target.is_dir() else []:
|
||||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||||
|
if allow_projects_child and is_projects_child(target):
|
||||||
|
break
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"BLOCKED: '{target}' is already an AIPass project (has {f.name}). "
|
f"BLOCKED: '{target}' is already an AIPass project (has {f.name}). "
|
||||||
"Use 'aipass init update' to upgrade an existing project."
|
"Use 'aipass init update' to upgrade an existing project."
|
||||||
@@ -280,6 +319,8 @@ def _guard_init(target: Path) -> None:
|
|||||||
continue
|
continue
|
||||||
for f in parent.iterdir():
|
for f in parent.iterdir():
|
||||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||||
|
if allow_projects_child and is_projects_child(target):
|
||||||
|
return
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"BLOCKED: '{target}' is inside AIPass project at '{parent}' (has {f.name}). "
|
f"BLOCKED: '{target}' is inside AIPass project at '{parent}' (has {f.name}). "
|
||||||
"Cannot create a nested project."
|
"Cannot create a nested project."
|
||||||
@@ -288,12 +329,18 @@ def _guard_init(target: Path) -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
def init_project(target: Path, project_name: str | None = None) -> dict:
|
def init_project(
|
||||||
|
target: Path,
|
||||||
|
project_name: str | None = None,
|
||||||
|
*,
|
||||||
|
allow_projects_child: bool = False,
|
||||||
|
) -> dict:
|
||||||
"""Initialize an AIPass project in the target directory.
|
"""Initialize an AIPass project in the target directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
target: Directory to initialize
|
target: Directory to initialize
|
||||||
project_name: Name for the registry (defaults to directory name)
|
project_name: Name for the registry (defaults to directory name)
|
||||||
|
allow_projects_child: When True, allow init inside ``<host>/projects/<name>``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with registry_id, registry_file, project_name, target, created_files
|
dict with registry_id, registry_file, project_name, target, created_files
|
||||||
@@ -303,7 +350,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
|||||||
RuntimeError: If target is inside an agent branch or existing project
|
RuntimeError: If target is inside an agent branch or existing project
|
||||||
"""
|
"""
|
||||||
target = target.resolve()
|
target = target.resolve()
|
||||||
_guard_init(target)
|
_guard_init(target, allow_projects_child=allow_projects_child)
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
target.mkdir(parents=True)
|
target.mkdir(parents=True)
|
||||||
|
|
||||||
@@ -362,6 +409,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
|||||||
if template.is_file():
|
if template.is_file():
|
||||||
shutil.copy2(str(template), str(hooks_json_path))
|
shutil.copy2(str(template), str(hooks_json_path))
|
||||||
created.append(str(hooks_json_path))
|
created.append(str(hooks_json_path))
|
||||||
|
_enroll_project(target)
|
||||||
else:
|
else:
|
||||||
logger.info("hooks template not found at %s — skipping", template)
|
logger.info("hooks template not found at %s — skipping", template)
|
||||||
|
|
||||||
@@ -573,6 +621,7 @@ def update_project(target: Path) -> dict:
|
|||||||
if existing_hooks != merged_hooks:
|
if existing_hooks != merged_hooks:
|
||||||
hooks_json_path.write_text(merged_hooks_content, encoding="utf-8")
|
hooks_json_path.write_text(merged_hooks_content, encoding="utf-8")
|
||||||
updated.append(str(hooks_json_path))
|
updated.append(str(hooks_json_path))
|
||||||
|
_enroll_project(target)
|
||||||
else:
|
else:
|
||||||
already_current.append(str(hooks_json_path))
|
already_current.append(str(hooks_json_path))
|
||||||
else:
|
else:
|
||||||
@@ -581,6 +630,7 @@ def update_project(target: Path) -> dict:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
updated.append(str(hooks_json_path))
|
updated.append(str(hooks_json_path))
|
||||||
|
_enroll_project(target)
|
||||||
elif hooks_json_path.exists():
|
elif hooks_json_path.exists():
|
||||||
already_current.append(str(hooks_json_path))
|
already_current.append(str(hooks_json_path))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: __init__.py
|
||||||
|
# Description: New project handler — create projects inside AIPass
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-17
|
||||||
|
# Modified: 2026-07-17
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
New Project Handler — creates projects inside AIPass installations.
|
||||||
|
|
||||||
|
Business logic for `aipass new`. Creates a project at <host>/projects/<name>
|
||||||
|
with its own git repo, registry, and optional AIPass agent.
|
||||||
|
|
||||||
|
Flow: find host -> validate -> mkdir -> mint registry (FIRST) ->
|
||||||
|
write template -> scaffold AIPass files -> git init -> optional agent.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Registry MUST be minted before any spawn call
|
||||||
|
- git init via subprocess (hooks git gate only intercepts agent Bash)
|
||||||
|
- Cleanup on failure: partial project is worse than no project
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.prax import logger
|
||||||
|
from aipass.spawn import spawn_agent
|
||||||
|
|
||||||
|
TEMPLATES = ("empty", "python")
|
||||||
|
|
||||||
|
|
||||||
|
def find_host_root(start: Path) -> Path | None:
|
||||||
|
"""Walk up from *start* to find the AIPass host installation root.
|
||||||
|
|
||||||
|
Returns the directory containing ``*_REGISTRY.json``, or ``None``.
|
||||||
|
"""
|
||||||
|
for p in [start, *start.parents]:
|
||||||
|
try:
|
||||||
|
entries = list(p.iterdir())
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for f in entries:
|
||||||
|
try:
|
||||||
|
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||||
|
return p
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_name(name: str) -> str:
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Project name cannot be empty")
|
||||||
|
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid project name '{name}'. "
|
||||||
|
"Must start with a letter, contain only letters, digits, hyphens, underscores."
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_name(name: str) -> str:
|
||||||
|
return re.sub(r"[^A-Z0-9_-]", "_", name.upper()).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def _git(args: list[str], cwd: Path) -> str:
|
||||||
|
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"git {' '.join(args)}: {r.stderr.strip()}")
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── registry ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _write_registry(target: Path, name: str) -> tuple[str, str]:
|
||||||
|
"""Mint the project registry. Returns ``(registry_id, filename)``."""
|
||||||
|
registry_id = str(uuid.uuid4())
|
||||||
|
today = date.today().isoformat()
|
||||||
|
reg = _registry_name(name)
|
||||||
|
filename = f"{reg}_REGISTRY.json"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"metadata": {
|
||||||
|
"id": registry_id,
|
||||||
|
"name": reg,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"created": today,
|
||||||
|
"last_updated": today,
|
||||||
|
"total_branches": 0,
|
||||||
|
},
|
||||||
|
"branches": [],
|
||||||
|
}
|
||||||
|
(target / filename).write_text(
|
||||||
|
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return registry_id, filename
|
||||||
|
|
||||||
|
|
||||||
|
# ── templates ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _write_template(target: Path, name: str, template: str) -> list[str]:
|
||||||
|
"""Write template-specific files. Returns relative paths created."""
|
||||||
|
created: list[str] = []
|
||||||
|
|
||||||
|
(target / "README.md").write_text(
|
||||||
|
f"# {name}\n\nCreated with `aipass new`. Template: {template}.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
created.append("README.md")
|
||||||
|
|
||||||
|
(target / ".gitignore").write_text(
|
||||||
|
"__pycache__/\n*.pyc\n.venv\n.trinity/\n.ai_mail.local/\n*.local.json\n*.local/\nlogs/\n.*_REGISTRY.lock\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
created.append(".gitignore")
|
||||||
|
|
||||||
|
if template == "python":
|
||||||
|
pkg = name.replace("-", "_").lower()
|
||||||
|
(target / "pyproject.toml").write_text(
|
||||||
|
"[build-system]\n"
|
||||||
|
'requires = ["setuptools>=61.0"]\n'
|
||||||
|
'build-backend = "setuptools.build_meta"\n\n'
|
||||||
|
"[project]\n"
|
||||||
|
f'name = "{name}"\n'
|
||||||
|
'version = "0.1.0"\n'
|
||||||
|
f'description = "{name} — born deployable"\n'
|
||||||
|
'requires-python = ">=3.10"\n\n'
|
||||||
|
"[tool.setuptools.packages.find]\n"
|
||||||
|
'where = ["src"]\n\n'
|
||||||
|
"[tool.pytest.ini_options]\n"
|
||||||
|
'testpaths = ["src"]\n'
|
||||||
|
'pythonpath = ["src"]\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
created.append("pyproject.toml")
|
||||||
|
src = target / "src" / pkg
|
||||||
|
src.mkdir(parents=True)
|
||||||
|
(src / "__init__.py").write_text(
|
||||||
|
f'"""{name} — born deployable."""\n\n__version__ = "0.1.0"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
created.append(f"src/{pkg}/__init__.py")
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
# ── AIPass scaffold ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _scaffold_aipass(target: Path, name: str) -> list[str]:
|
||||||
|
"""Write AIPass scaffold files (tiers, hooks, CLAUDE.md, settings, .venv)."""
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||||
|
_claude_settings,
|
||||||
|
_detect_aipass_home,
|
||||||
|
_enroll_project,
|
||||||
|
)
|
||||||
|
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||||
|
|
||||||
|
created: list[str] = []
|
||||||
|
aipass_home = _detect_aipass_home()
|
||||||
|
reg = _registry_name(name)
|
||||||
|
|
||||||
|
# .aipass/
|
||||||
|
aipass_dir = target / ".aipass"
|
||||||
|
aipass_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Tier files
|
||||||
|
if aipass_home:
|
||||||
|
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
|
||||||
|
dest = aipass_dir / tier_file
|
||||||
|
src_path = Path(aipass_home) / ".aipass" / tier_file
|
||||||
|
if src_path.is_file():
|
||||||
|
shutil.copy2(str(src_path), str(dest))
|
||||||
|
created.append(f".aipass/{tier_file}")
|
||||||
|
|
||||||
|
# hooks.json + trust enrollment
|
||||||
|
if aipass_home:
|
||||||
|
template = Path(aipass_home) / ".aipass" / "project_hooks.json"
|
||||||
|
if template.is_file():
|
||||||
|
shutil.copy2(str(template), str(aipass_dir / "hooks.json"))
|
||||||
|
created.append(".aipass/hooks.json")
|
||||||
|
_enroll_project(target)
|
||||||
|
|
||||||
|
# CLAUDE.md, AGENTS.md
|
||||||
|
for md_name in ("CLAUDE.md", "AGENTS.md"):
|
||||||
|
dest = target / md_name
|
||||||
|
if dest.exists():
|
||||||
|
continue
|
||||||
|
if aipass_home:
|
||||||
|
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
|
||||||
|
if tmpl.is_file():
|
||||||
|
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
|
||||||
|
dest.write_text(content, encoding="utf-8")
|
||||||
|
created.append(md_name)
|
||||||
|
continue
|
||||||
|
if md_name == "AGENTS.md":
|
||||||
|
dest.write_text(sc.agents_md(reg), encoding="utf-8")
|
||||||
|
created.append(md_name)
|
||||||
|
|
||||||
|
# .claude/settings.json
|
||||||
|
claude_dir = target / ".claude"
|
||||||
|
claude_dir.mkdir(exist_ok=True)
|
||||||
|
(claude_dir / "settings.json").write_text(
|
||||||
|
_claude_settings(aipass_home),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
created.append(".claude/settings.json")
|
||||||
|
|
||||||
|
# .claude/commands/prep.md
|
||||||
|
commands_dir = claude_dir / "commands"
|
||||||
|
commands_dir.mkdir(exist_ok=True)
|
||||||
|
(commands_dir / "prep.md").write_text(sc.prep_md(), encoding="utf-8")
|
||||||
|
created.append(".claude/commands/prep.md")
|
||||||
|
|
||||||
|
# .venv symlink
|
||||||
|
if aipass_home:
|
||||||
|
venv = Path(aipass_home) / ".venv"
|
||||||
|
if venv.is_dir():
|
||||||
|
link = target / ".venv"
|
||||||
|
link.symlink_to(venv)
|
||||||
|
created.append(".venv")
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
# ── git ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _git_init(target: Path, name: str, template: str) -> None:
|
||||||
|
"""Initialize git repo with birth commit. Guards against re-init."""
|
||||||
|
if (target / ".git").exists():
|
||||||
|
raise RuntimeError(f"'{target}' already has a .git directory")
|
||||||
|
_git(["init", "-b", "main"], target)
|
||||||
|
_git(["add", "-A"], target)
|
||||||
|
_git(
|
||||||
|
["commit", "-m", f"birth: {name} ({template} template) via aipass new"],
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── agent (via @spawn) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_home(project_root: Path, name: str) -> Path:
|
||||||
|
"""Compute the agent home directory: src/<pkg>/<pkg>/."""
|
||||||
|
pkg = name.replace("-", "_").lower()
|
||||||
|
return project_root / "src" / pkg / pkg
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn_project_agent(project_root: Path, name: str) -> dict:
|
||||||
|
"""Create the project agent via spawn_agent().
|
||||||
|
|
||||||
|
Agent lives at src/<pkg>/<pkg>/ inside the project. Spawn discovers
|
||||||
|
the project-local registry (minted earlier by _write_registry) by
|
||||||
|
walking up from the agent home to the project root.
|
||||||
|
"""
|
||||||
|
home = _agent_home(project_root, name)
|
||||||
|
result = spawn_agent(
|
||||||
|
target_path=str(home),
|
||||||
|
role="project_agent",
|
||||||
|
purpose=f"Resident agent of the {name} project.",
|
||||||
|
citizen_class="project_agent",
|
||||||
|
)
|
||||||
|
if not result.get("success"):
|
||||||
|
raise RuntimeError(f"spawn_agent failed: {result.get('error', 'unknown')}")
|
||||||
|
logger.info(
|
||||||
|
"[aipass new] agent spawned via @spawn: %s (%d files)",
|
||||||
|
result["branch_name"],
|
||||||
|
result["files_copied"],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── public API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def create_project(
|
||||||
|
name: str,
|
||||||
|
template: str = "empty",
|
||||||
|
no_agent: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new project inside the AIPass host installation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with name, template, target, host, registry_id, registry_file,
|
||||||
|
files, agent_spawned.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Invalid name or template.
|
||||||
|
RuntimeError: Not inside AIPass, target exists, git failure.
|
||||||
|
"""
|
||||||
|
_validate_name(name)
|
||||||
|
if template not in TEMPLATES:
|
||||||
|
raise ValueError(f"Unknown template '{template}'. Choose from: {', '.join(TEMPLATES)}")
|
||||||
|
|
||||||
|
host = find_host_root(Path.cwd())
|
||||||
|
if host is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Not inside an AIPass installation (no *_REGISTRY.json found). "
|
||||||
|
"`aipass new` creates projects inside the AIPass environment."
|
||||||
|
)
|
||||||
|
|
||||||
|
target = host / "projects" / name
|
||||||
|
if target.exists():
|
||||||
|
raise RuntimeError(f"Project already exists: {target}")
|
||||||
|
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
registry_id, registry_file = _write_registry(target, name)
|
||||||
|
logger.info("[aipass new] registry minted: %s (%s)", registry_file, registry_id)
|
||||||
|
|
||||||
|
template_files = _write_template(target, name, template)
|
||||||
|
scaffold_files = _scaffold_aipass(target, name)
|
||||||
|
|
||||||
|
spawn_result = None
|
||||||
|
if not no_agent:
|
||||||
|
spawn_result = _spawn_project_agent(target, name)
|
||||||
|
|
||||||
|
_git_init(target, name, template)
|
||||||
|
logger.info("[aipass new] git repo initialized with birth commit")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"template": template,
|
||||||
|
"target": str(target),
|
||||||
|
"host": str(host),
|
||||||
|
"registry_id": registry_id,
|
||||||
|
"registry_file": registry_file,
|
||||||
|
"files": template_files + scaffold_files,
|
||||||
|
"agent_created": spawn_result is not None,
|
||||||
|
"agent_home": str(_agent_home(target, name)) if spawn_result else None,
|
||||||
|
"spawn_result": spawn_result,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
raise
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: doctor_fix.py
|
# Name: _doctor_fix.py
|
||||||
# Description: Structure remediation report for aipass doctor --fix
|
# Description: Structure remediation report for aipass doctor --fix
|
||||||
# Version: 1.0.0
|
# Version: 1.0.0
|
||||||
# Created: 2026-05-15
|
# Created: 2026-05-15
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: doctor_wire.py
|
# Name: _doctor_wire.py
|
||||||
# Description: Auto-wire provider settings from manifest into user config
|
# Description: Auto-wire provider settings from manifest into user config
|
||||||
# Version: 1.0.0
|
# Version: 1.0.0
|
||||||
# Created: 2026-05-08
|
# Created: 2026-05-08
|
||||||
@@ -54,11 +54,11 @@ from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
|||||||
find_project_root,
|
find_project_root,
|
||||||
scan_agents,
|
scan_agents,
|
||||||
)
|
)
|
||||||
from aipass.aipass.apps.modules.doctor_fix import (
|
from aipass.aipass.apps.modules._doctor_fix import (
|
||||||
print_json_report,
|
print_json_report,
|
||||||
print_remediation_report,
|
print_remediation_report,
|
||||||
)
|
)
|
||||||
from aipass.aipass.apps.modules.doctor_wire import (
|
from aipass.aipass.apps.modules._doctor_wire import (
|
||||||
_auto_wire_provider,
|
_auto_wire_provider,
|
||||||
_prompt_auto_wire as prompt_auto_wire,
|
_prompt_auto_wire as prompt_auto_wire,
|
||||||
check_wire_verify,
|
check_wire_verify,
|
||||||
@@ -81,6 +81,8 @@ from aipass.aipass.apps.handlers.ui.progress import (
|
|||||||
make_doctor_progress,
|
make_doctor_progress,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
COMMAND = "doctor"
|
||||||
|
|
||||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: feedback.py
|
||||||
|
# Description: aipass feedback — toggle the feedback reminder pulse on/off
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-18
|
||||||
|
# Modified: 2026-07-18
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
aipass feedback — user-facing alias for the @hooks feedback toggle.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
aipass feedback # show current state
|
||||||
|
aipass feedback on # enable feedback reminders
|
||||||
|
aipass feedback off # disable feedback reminders
|
||||||
|
aipass feedback --help
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from aipass.cli.apps.modules import console, error, warning
|
||||||
|
from aipass.prax import logger
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.json import json_handler
|
||||||
|
|
||||||
|
COMMAND = "feedback"
|
||||||
|
|
||||||
|
_DRONE_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _run_hooks_feedback(action: str | None) -> int:
|
||||||
|
"""Delegate to drone @hooks feedback. Returns the subprocess exit code."""
|
||||||
|
cmd = ["drone", "@hooks", "feedback"]
|
||||||
|
if action:
|
||||||
|
cmd.append(action)
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, timeout=_DRONE_TIMEOUT)
|
||||||
|
return proc.returncode
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("[feedback] drone not found on PATH")
|
||||||
|
warning("drone not found on PATH — cannot reach @hooks.")
|
||||||
|
return 1
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("[feedback] drone @hooks feedback timed out")
|
||||||
|
warning("drone @hooks feedback timed out.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def print_help() -> None:
|
||||||
|
"""Print usage help for the feedback command."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass feedback[/bold cyan] — toggle the feedback reminder")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]USAGE:[/yellow]")
|
||||||
|
console.print(" [green]aipass feedback[/green] [dim]# show current state[/dim]")
|
||||||
|
console.print(" [green]aipass feedback on[/green] [dim]# enable feedback reminders[/dim]")
|
||||||
|
console.print(" [green]aipass feedback off[/green] [dim]# disable feedback reminders[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Delegates to: drone @hooks feedback[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection() -> None:
|
||||||
|
"""Show module info for feedback."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]feedback Module[/bold cyan]")
|
||||||
|
console.print("User-facing alias for the @hooks feedback pulse toggle.")
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Delegates to: drone @hooks feedback on/off[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_command(command: str, args: list[str]) -> bool:
|
||||||
|
"""Route the feedback command. Returns True if handled."""
|
||||||
|
if command != COMMAND:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if args and args[0] in ("--help", "-h", "help"):
|
||||||
|
json_handler.log_operation("feedback_help", {"command": command})
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
if args and args[0] in ("--info", "info"):
|
||||||
|
json_handler.log_operation("feedback_info", {"command": command})
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
json_handler.log_operation("feedback_usage", {"command": command})
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
|
||||||
|
action = args[0] if args[0] in ("on", "off") else None
|
||||||
|
if action is None:
|
||||||
|
error(f"Unknown option: {args[0]}. Use 'on' or 'off'.")
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
|
||||||
|
rc = _run_hooks_feedback(action)
|
||||||
|
json_handler.log_operation(
|
||||||
|
"feedback_toggle",
|
||||||
|
{"action": action or "status", "exit": rc},
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
logger.warning("[feedback] drone @hooks feedback exited %d", rc)
|
||||||
|
return True
|
||||||
@@ -28,7 +28,7 @@ from aipass.aipass.apps.handlers.json import json_handler
|
|||||||
|
|
||||||
COMMAND = "handoff"
|
COMMAND = "handoff"
|
||||||
|
|
||||||
_INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?"
|
INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?"
|
||||||
|
|
||||||
CLI_CHOICES = ["claude", "codex"]
|
CLI_CHOICES = ["claude", "codex"]
|
||||||
FLAG_CHOICES = ["default", "skip-permissions"]
|
FLAG_CHOICES = ["default", "skip-permissions"]
|
||||||
@@ -48,7 +48,7 @@ def _get_stored_profile() -> dict:
|
|||||||
|
|
||||||
def do_handoff(
|
def do_handoff(
|
||||||
cli: str = "claude",
|
cli: str = "claude",
|
||||||
prompt: str = _INIT_PROMPT,
|
prompt: str = INIT_PROMPT,
|
||||||
cwd: str = ".",
|
cwd: str = ".",
|
||||||
flag_variant: str = "default",
|
flag_variant: str = "default",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ TEMPLATE_EMPTY = "empty project"
|
|||||||
TEMPLATE_AIPASS = "aipass_framework"
|
TEMPLATE_AIPASS = "aipass_framework"
|
||||||
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
|
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
|
||||||
# first_agent, ping_sweep, handoff, done — skipped for empty (non-framework) projects
|
# first_agent, ping_sweep, handoff, done — skipped for empty (non-framework) projects
|
||||||
AIPASS_SPECIFIC_STAGES = {6, 7, 9, 10}
|
AIPASS_SPECIFIC_STAGES = {6, 7}
|
||||||
|
|
||||||
|
|
||||||
# --- LOCAL JSON HELPERS ---
|
# --- LOCAL JSON HELPERS ---
|
||||||
@@ -603,29 +603,35 @@ def stage_9_handoff(
|
|||||||
console.print()
|
console.print()
|
||||||
console.print(render_step_header(9, TOTAL_STAGES, "Handoff"))
|
console.print(render_step_header(9, TOTAL_STAGES, "Handoff"))
|
||||||
|
|
||||||
init_prompt = "I just completed aipass init. I am ready to start. What should I do first?"
|
from aipass.aipass.apps.modules.handoff import INIT_PROMPT
|
||||||
|
|
||||||
|
init_prompt = INIT_PROMPT
|
||||||
|
|
||||||
|
_template = (accumulated or {}).get("template", TEMPLATE_AIPASS)
|
||||||
console.print()
|
console.print()
|
||||||
console.print(" Your agent is ready.")
|
if _template == TEMPLATE_AIPASS:
|
||||||
console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]")
|
console.print(" Your agent is ready.")
|
||||||
|
console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]")
|
||||||
|
else:
|
||||||
|
_display = str(Path(agent_path).resolve()) if agent_path == "." else agent_path
|
||||||
|
console.print(" Your project is ready — launching your CLI.")
|
||||||
|
console.print(f" [dim]CLI: {cli_choice} | Project: {_display}[/dim]")
|
||||||
|
|
||||||
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
|
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
|
||||||
|
|
||||||
command = build_manual_command(cli_choice, init_prompt, agent_path, flag_variant)
|
display_path = str(Path(agent_path).resolve()) if agent_path == "." else agent_path
|
||||||
|
command = build_manual_command(cli_choice, init_prompt, display_path, flag_variant)
|
||||||
inline = False
|
inline = False
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
console.print(f"[yellow]\\[dry-run][/yellow] would launch handoff: {command}")
|
console.print(f"[yellow]\\[dry-run][/yellow] would launch handoff: {command}")
|
||||||
launched = False
|
launched = False
|
||||||
elif non_interactive:
|
elif non_interactive:
|
||||||
from aipass.aipass.apps.modules import handoff as handoff_mod
|
console.print()
|
||||||
|
console.print(" [dim]Next step (run manually):[/dim]")
|
||||||
launched = handoff_mod.do_handoff(
|
console.print(f" [cyan]{command}[/cyan]")
|
||||||
cli=cli_choice,
|
console.print()
|
||||||
prompt=init_prompt,
|
launched = False
|
||||||
cwd=agent_path,
|
|
||||||
flag_variant=flag_variant,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
console.print()
|
console.print()
|
||||||
console.print(" [bold]1.[/bold] Stay here — launch agent in this terminal")
|
console.print(" [bold]1.[/bold] Stay here — launch agent in this terminal")
|
||||||
@@ -729,8 +735,12 @@ def stage_10_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = Fal
|
|||||||
|
|
||||||
|
|
||||||
# --- MAIN RUNNER ---
|
# --- MAIN RUNNER ---
|
||||||
def _preflight_check() -> str | None:
|
def _preflight_check(*, allow_projects_child: bool = False) -> str | None:
|
||||||
"""Return an error message if CWD is unsafe for init, else None."""
|
"""Return an error message if CWD is unsafe for init, else None.
|
||||||
|
|
||||||
|
When *allow_projects_child* is True, the nested-project check is skipped
|
||||||
|
if CWD is ``<host>/projects/<name>``.
|
||||||
|
"""
|
||||||
cwd = Path.cwd()
|
cwd = Path.cwd()
|
||||||
# Block if inside an agent directory
|
# Block if inside an agent directory
|
||||||
if (cwd / ".trinity" / "passport.json").is_file():
|
if (cwd / ".trinity" / "passport.json").is_file():
|
||||||
@@ -738,6 +748,12 @@ def _preflight_check() -> str | None:
|
|||||||
"This directory is an agent branch (has .trinity/passport.json).\n"
|
"This directory is an agent branch (has .trinity/passport.json).\n"
|
||||||
"Agents are managed by 'drone @spawn', not 'aipass init'."
|
"Agents are managed by 'drone @spawn', not 'aipass init'."
|
||||||
)
|
)
|
||||||
|
# Early exit: if CWD is a valid <host>/projects/<name>, skip nesting check
|
||||||
|
if allow_projects_child:
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||||
|
|
||||||
|
if is_projects_child(cwd):
|
||||||
|
return None
|
||||||
# Block if inside an existing AIPass project (registry above us).
|
# Block if inside an existing AIPass project (registry above us).
|
||||||
# Walking up to the filesystem root can hit ancestors that can't be
|
# Walking up to the filesystem root can hit ancestors that can't be
|
||||||
# enumerated or stat'd — e.g. locked Windows system entries at the drive
|
# enumerated or stat'd — e.g. locked Windows system entries at the drive
|
||||||
@@ -774,6 +790,9 @@ def run_init(
|
|||||||
template: str | None = None,
|
template: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run the 10-stage init flow. Returns 0 on success."""
|
"""Run the 10-stage init flow. Returns 0 on success."""
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
non_interactive = True
|
||||||
|
|
||||||
# Pre-flight: refuse to run inside existing projects or agent dirs
|
# Pre-flight: refuse to run inside existing projects or agent dirs
|
||||||
err = _preflight_check()
|
err = _preflight_check()
|
||||||
if err:
|
if err:
|
||||||
@@ -815,6 +834,8 @@ def run_init(
|
|||||||
warning(f"Resuming from stage {last_done + 1}...")
|
warning(f"Resuming from stage {last_done + 1}...")
|
||||||
|
|
||||||
accumulated: Dict[str, Any] = {"template": template}
|
accumulated: Dict[str, Any] = {"template": template}
|
||||||
|
if template != TEMPLATE_AIPASS:
|
||||||
|
accumulated["agent_path"] = "."
|
||||||
|
|
||||||
stage_fns = [
|
stage_fns = [
|
||||||
(1, lambda: stage_1_welcome(dry_run=dry_run)),
|
(1, lambda: stage_1_welcome(dry_run=dry_run)),
|
||||||
@@ -844,6 +865,8 @@ def run_init(
|
|||||||
continue
|
continue
|
||||||
if stage_num in AIPASS_SPECIFIC_STAGES and template != TEMPLATE_AIPASS:
|
if stage_num in AIPASS_SPECIFIC_STAGES and template != TEMPLATE_AIPASS:
|
||||||
logger.info("[init_flow] skipping stage %d (not aipass_framework)", stage_num)
|
logger.info("[init_flow] skipping stage %d (not aipass_framework)", stage_num)
|
||||||
|
if not non_interactive:
|
||||||
|
console.print(f"\n[dim] (skipping step {stage_num} — framework-only)[/dim]")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
result = fn() or {}
|
result = fn() or {}
|
||||||
@@ -857,11 +880,6 @@ def run_init(
|
|||||||
warning(f"Stage {stage_num} error: {exc} — continuing.")
|
warning(f"Stage {stage_num} error: {exc} — continuing.")
|
||||||
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
|
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
|
||||||
|
|
||||||
if template != TEMPLATE_AIPASS:
|
|
||||||
console.print()
|
|
||||||
success("Project initialized.")
|
|
||||||
console.print("[dim]Run 'aipass init agent <name>' to add an agent.[/dim]")
|
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -179,17 +179,21 @@ def _verify_binaries(home: Path) -> Dict[str, str | None]:
|
|||||||
return {"drone": drone, "aipass": aipass}
|
return {"drone": drone, "aipass": aipass}
|
||||||
|
|
||||||
|
|
||||||
def _should_run_init(non_interactive: bool, with_init: bool, no_init: bool) -> bool:
|
def _build_install_prompt(home: Path, bins: dict) -> str:
|
||||||
"""Decide whether to auto-launch init. --no-init wins; --with-init forces on.
|
"""Compose the authored first prompt for the post-install @aipass chat."""
|
||||||
|
parts = [f"Fresh AIPass install completed at {home}."]
|
||||||
|
for name, path in bins.items():
|
||||||
|
if path:
|
||||||
|
parts.append(f"{name}: {path}.")
|
||||||
|
parts.append(
|
||||||
|
"This is my first time here — what can I do with AIPass? Show me a few things to try, with the exact commands."
|
||||||
|
)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
Default: interactive flows chain into init ("one command, done"); headless
|
|
||||||
flows stop at a wired engine and print the next command (safe for CI/Docker).
|
def _should_run_init(no_init: bool) -> bool:
|
||||||
"""
|
"""Decide whether to auto-launch init. --no-init skips; default = always chain."""
|
||||||
if no_init:
|
return not no_init
|
||||||
return False
|
|
||||||
if with_init:
|
|
||||||
return True
|
|
||||||
return not non_interactive
|
|
||||||
|
|
||||||
|
|
||||||
def _handoff_to_init(
|
def _handoff_to_init(
|
||||||
@@ -357,13 +361,26 @@ def run_install(
|
|||||||
# Step 4 — hand off into init (or print next steps)
|
# Step 4 — hand off into init (or print next steps)
|
||||||
console.print()
|
console.print()
|
||||||
console.print(render_step_header(4, TOTAL_STEPS, "First project"))
|
console.print(render_step_header(4, TOTAL_STEPS, "First project"))
|
||||||
run_it = _should_run_init(non_interactive, with_init, no_init)
|
run_it = _should_run_init(no_init)
|
||||||
_handoff_to_init(home, bins.get("aipass"), non_interactive, dry_run, project, run_it)
|
_handoff_to_init(home, bins.get("aipass"), non_interactive, dry_run, project, run_it)
|
||||||
|
|
||||||
|
# Log BEFORE exec — launch_inline replaces the process and never returns
|
||||||
json_handler.log_operation(
|
json_handler.log_operation(
|
||||||
"aipass_install",
|
"aipass_install",
|
||||||
{"home": str(home), "non_interactive": non_interactive, "dry_run": dry_run, "init": run_it},
|
{"home": str(home), "non_interactive": non_interactive, "dry_run": dry_run, "init": run_it},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Install-to-chat handoff — launch @aipass concierge in same terminal
|
||||||
|
if run_it and not dry_run and sys.stdin.isatty():
|
||||||
|
prompt = _build_install_prompt(home, bins)
|
||||||
|
aipass_branch = str(Path(__file__).resolve().parents[2])
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Launching the AIPass concierge — Ctrl-C to stay in the shell[/dim]")
|
||||||
|
console.print()
|
||||||
|
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
|
||||||
|
|
||||||
|
launch_inline("claude", prompt, aipass_branch)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: new_project.py
|
||||||
|
# Description: aipass new — create projects inside the AIPass installation
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-17
|
||||||
|
# Modified: 2026-07-17
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
aipass new — create projects inside the AIPass installation (DPLAN-0247)
|
||||||
|
|
||||||
|
Creates a project at <host>/projects/<name> with its own git repo,
|
||||||
|
AIPass scaffold, and optional resident agent. Born deployable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.json import json_handler
|
||||||
|
from aipass.cli.apps.modules import console, error, success
|
||||||
|
from aipass.prax import logger
|
||||||
|
|
||||||
|
COMMAND = "new"
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection() -> None:
|
||||||
|
"""List existing projects in the installation."""
|
||||||
|
from aipass.aipass.apps.handlers.new_project import find_host_root
|
||||||
|
|
||||||
|
host = find_host_root(Path.cwd())
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass new[/bold cyan] — project creator")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if host is None:
|
||||||
|
console.print("[dim]Not inside an AIPass installation.[/dim]")
|
||||||
|
console.print()
|
||||||
|
return
|
||||||
|
|
||||||
|
projects_dir = host / "projects"
|
||||||
|
if not projects_dir.is_dir():
|
||||||
|
console.print(f"[dim]No projects/ directory at {host}[/dim]")
|
||||||
|
console.print()
|
||||||
|
return
|
||||||
|
|
||||||
|
projects = [d for d in sorted(projects_dir.iterdir()) if d.is_dir() and not d.name.startswith(".")]
|
||||||
|
if not projects:
|
||||||
|
console.print("[dim]No projects yet. Create one:[/dim]")
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]{len(projects)} project(s):[/yellow]")
|
||||||
|
for p in projects:
|
||||||
|
has_git = (p / ".git").is_dir()
|
||||||
|
has_reg = any(f.name.endswith("_REGISTRY.json") for f in p.iterdir() if f.is_file())
|
||||||
|
markers = []
|
||||||
|
if has_git:
|
||||||
|
markers.append("git")
|
||||||
|
if has_reg:
|
||||||
|
markers.append("registry")
|
||||||
|
info = f" [dim]({', '.join(markers)})[/dim]" if markers else ""
|
||||||
|
console.print(f" [cyan]{p.name}[/cyan]{info}")
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Create: aipass new <name> [--template python] [--no-agent][/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_help() -> None:
|
||||||
|
"""Print usage help for the new command."""
|
||||||
|
from aipass.aipass.apps.handlers.new_project import TEMPLATES
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass new[/bold cyan] — create a project inside AIPass")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]USAGE:[/yellow]")
|
||||||
|
console.print(" [green]aipass new <name>[/green] [dim]# Create with empty template[/dim]")
|
||||||
|
console.print(" [green]aipass new <name> --template python[/green] [dim]# Create with Python template[/dim]")
|
||||||
|
console.print(" [green]aipass new <name> --no-agent[/green] [dim]# Skip agent creation[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]TEMPLATES:[/yellow]")
|
||||||
|
console.print(f" [dim]{', '.join(TEMPLATES)}[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]WHAT IT DOES:[/yellow]")
|
||||||
|
console.print(" Creates projects/<name> with its own git repo, AIPass scaffold,")
|
||||||
|
console.print(" and optional resident agent. Born deployable — repo + packaging")
|
||||||
|
console.print(" from minute one.")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_template(templates: list[str]) -> str:
|
||||||
|
"""Prompt user to choose a template interactively."""
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]Choose a template:[/yellow]")
|
||||||
|
for idx, t in enumerate(templates, 1):
|
||||||
|
console.print(f" [green]{idx}[/green]. {t}")
|
||||||
|
console.print()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = input("Template [1]: ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
logger.info("template prompt interrupted, defaulting to %s", templates[0])
|
||||||
|
return templates[0]
|
||||||
|
if not choice:
|
||||||
|
return templates[0]
|
||||||
|
if choice.isdigit() and 1 <= int(choice) <= len(templates):
|
||||||
|
return templates[int(choice) - 1]
|
||||||
|
if choice in templates:
|
||||||
|
return choice
|
||||||
|
error(f"Invalid choice. Enter 1-{len(templates)} or a template name.")
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_agent() -> bool:
|
||||||
|
"""Prompt user whether to skip agent creation. Returns no_agent flag."""
|
||||||
|
console.print()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = input("Create resident agent? [Y/n]: ").strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
logger.info("agent prompt interrupted, defaulting to create agent")
|
||||||
|
return False
|
||||||
|
if choice in ("", "y", "yes"):
|
||||||
|
return False
|
||||||
|
if choice in ("n", "no"):
|
||||||
|
return True
|
||||||
|
error("Enter y or n.")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_command(command: str, args: list[str]) -> bool:
|
||||||
|
"""Route the 'new' command. Returns True if handled."""
|
||||||
|
if command != COMMAND:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
json_handler.log_operation("new_project_usage", {"command": command})
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
if args[0] in ("--help", "-h", "help"):
|
||||||
|
json_handler.log_operation("new_project_help", {"command": command})
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
if args[0] == "--info":
|
||||||
|
json_handler.log_operation("new_project_info", {"command": command})
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
|
||||||
|
name = args[0]
|
||||||
|
template = None
|
||||||
|
no_agent = None
|
||||||
|
has_template_flag = False
|
||||||
|
has_agent_flag = False
|
||||||
|
|
||||||
|
i = 1
|
||||||
|
while i < len(args):
|
||||||
|
if args[i] == "--template" and i + 1 < len(args):
|
||||||
|
template = args[i + 1]
|
||||||
|
has_template_flag = True
|
||||||
|
i += 2
|
||||||
|
elif args[i] == "--no-agent":
|
||||||
|
no_agent = True
|
||||||
|
has_agent_flag = True
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
error(f"Unknown option: {args[i]}")
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.new_project import TEMPLATES, create_project
|
||||||
|
|
||||||
|
if not has_template_flag:
|
||||||
|
template = _prompt_template(list(TEMPLATES))
|
||||||
|
elif template is None:
|
||||||
|
template = "empty"
|
||||||
|
if not has_agent_flag:
|
||||||
|
no_agent = _prompt_agent()
|
||||||
|
elif no_agent is None:
|
||||||
|
no_agent = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = create_project(name, template, no_agent)
|
||||||
|
except (RuntimeError, ValueError) as e:
|
||||||
|
logger.warning("[AIPASS] new project failed: %s", e)
|
||||||
|
error(str(e))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
success(f"Project '{name}' created at {result['target']}")
|
||||||
|
console.print()
|
||||||
|
console.print(f" [dim]Registry:[/dim] {result['registry_file']}")
|
||||||
|
console.print(f" [dim]Template:[/dim] {result['template']}")
|
||||||
|
if result["agent_created"]:
|
||||||
|
console.print(" [dim]Agent:[/dim] created (full framework agent)")
|
||||||
|
else:
|
||||||
|
console.print(" [dim]Agent:[/dim] skipped (--no-agent)")
|
||||||
|
|
||||||
|
json_handler.log_operation(
|
||||||
|
"new_project_create",
|
||||||
|
{"name": name, "template": template, "target": result["target"]},
|
||||||
|
)
|
||||||
|
logger.info("[AIPASS] new project: %s (%s) at %s", name, template, result["target"])
|
||||||
|
|
||||||
|
if result["agent_created"] and sys.stdin.isatty():
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Launching your manager agent — Ctrl-C to stay in the shell[/dim]")
|
||||||
|
console.print()
|
||||||
|
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
|
||||||
|
|
||||||
|
launch_inline(
|
||||||
|
"claude",
|
||||||
|
"You are the new resident agent of this project."
|
||||||
|
" Read your passport and README, then tell me what you can do.",
|
||||||
|
result["agent_home"],
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]Next steps:[/yellow]")
|
||||||
|
if result["agent_created"]:
|
||||||
|
console.print(f" [cyan]cd {result['agent_home']}[/cyan]")
|
||||||
|
console.print(" [cyan]claude[/cyan] [dim]# meet your project agent[/dim]")
|
||||||
|
else:
|
||||||
|
console.print(f" [cyan]cd {result['target']}[/cyan]")
|
||||||
|
console.print()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: trust.py
|
||||||
|
# Description: Trust management — aipass trust / aipass revoke commands
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-15
|
||||||
|
# Modified: 2026-07-15
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
aipass trust / revoke — manage the trusted-project registry (DPLAN-0244)
|
||||||
|
|
||||||
|
Enrollment controls which projects have their .aipass/hooks.json loaded
|
||||||
|
by the hook engine. Projects created via `aipass init` auto-enroll;
|
||||||
|
these commands handle manual enrollment and revocation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.cli.apps.modules import console, error, success
|
||||||
|
from aipass.hooks.apps.handlers.config.trust_registry import (
|
||||||
|
enroll,
|
||||||
|
read_registry,
|
||||||
|
revoke,
|
||||||
|
)
|
||||||
|
from aipass.prax import logger
|
||||||
|
|
||||||
|
COMMAND = "trust"
|
||||||
|
_COMMAND_REVOKE = "revoke"
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection() -> None:
|
||||||
|
"""Display the current trusted-project registry."""
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
registry = read_registry()
|
||||||
|
projects = registry.get("projects", {})
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass trust[/bold cyan] — trusted-project registry")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if not projects:
|
||||||
|
console.print("[dim]No projects enrolled.[/dim]")
|
||||||
|
else:
|
||||||
|
table = Table(show_header=True, header_style="bold yellow")
|
||||||
|
table.add_column("Project", style="cyan")
|
||||||
|
table.add_column("Hash", style="dim", max_width=24)
|
||||||
|
table.add_column("Enrolled")
|
||||||
|
for path, entry in projects.items():
|
||||||
|
short_hash = entry.get("config_hash", "")[:18] + "..."
|
||||||
|
table.add_row(path, short_hash, entry.get("enrolled", ""))
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Use 'aipass trust <path>' to enroll or 'aipass revoke <path>' to remove.[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_help() -> None:
|
||||||
|
"""Print usage help for the trust/revoke commands."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass trust / revoke[/bold cyan] — trusted-project registry")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]USAGE:[/yellow]")
|
||||||
|
console.print(" [green]aipass trust[/green] [dim]# Show enrolled projects[/dim]")
|
||||||
|
console.print(
|
||||||
|
" [green]aipass trust <path>[/green] [dim]# Enroll a project (requires .aipass/hooks.json)[/dim]"
|
||||||
|
)
|
||||||
|
console.print(" [green]aipass revoke <path>[/green] [dim]# Remove a project from the registry[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_trust(args: list[str]) -> bool:
|
||||||
|
"""Execute the trust enrollment for a given path."""
|
||||||
|
target = Path(args[0]).resolve()
|
||||||
|
if not target.is_dir():
|
||||||
|
error(f"Not a directory: {target}")
|
||||||
|
return True
|
||||||
|
hooks_path = target / ".aipass" / "hooks.json"
|
||||||
|
if not hooks_path.is_file():
|
||||||
|
error(f"No .aipass/hooks.json found in {target}")
|
||||||
|
return True
|
||||||
|
if enroll(str(target)):
|
||||||
|
success(f"Enrolled {target}")
|
||||||
|
logger.info("[AIPASS] trust: enrolled %s", target)
|
||||||
|
else:
|
||||||
|
error(f"Failed to enroll {target}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _do_revoke(args: list[str]) -> bool:
|
||||||
|
"""Execute the revocation for a given path."""
|
||||||
|
target = Path(args[0]).resolve()
|
||||||
|
if revoke(str(target)):
|
||||||
|
success(f"Revoked {target}")
|
||||||
|
logger.info("[AIPASS] revoke: removed %s", target)
|
||||||
|
else:
|
||||||
|
console.print(f"[dim]{target} was not in the registry.[/dim]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def handle_command(command: str, args: list[str]) -> bool:
|
||||||
|
"""Route trust/revoke subcommands. Returns True if handled."""
|
||||||
|
if command == COMMAND:
|
||||||
|
if not args:
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
if args[0] in ("--help", "-h", "help"):
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
if args[0] == "--info":
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
return _do_trust(args)
|
||||||
|
if command == _COMMAND_REVOKE:
|
||||||
|
if not args or args[0] in ("--help", "-h", "help"):
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
return _do_revoke(args)
|
||||||
|
return False
|
||||||
@@ -153,10 +153,10 @@ class TestMain:
|
|||||||
"""--version prints real package version and returns 0."""
|
"""--version prints real package version and returns 0."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
printed = mock_print.call_args[0][0]
|
printed = mock_con.print.call_args[0][0]
|
||||||
assert printed.startswith("aipass ")
|
assert printed.startswith("aipass ")
|
||||||
assert printed != "aipass 0.1.0"
|
assert printed != "aipass 0.1.0"
|
||||||
|
|
||||||
@@ -164,10 +164,10 @@ class TestMain:
|
|||||||
"""-V prints real package version and returns 0."""
|
"""-V prints real package version and returns 0."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-V"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-V"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
printed = mock_print.call_args[0][0]
|
printed = mock_con.print.call_args[0][0]
|
||||||
assert printed.startswith("aipass ")
|
assert printed.startswith("aipass ")
|
||||||
|
|
||||||
def test_version_flag_fallback(self) -> None:
|
def test_version_flag_fallback(self) -> None:
|
||||||
@@ -179,76 +179,81 @@ class TestMain:
|
|||||||
"aipass.aipass.apps.aipass.importlib.metadata.version",
|
"aipass.aipass.apps.aipass.importlib.metadata.version",
|
||||||
side_effect=_not_found,
|
side_effect=_not_found,
|
||||||
):
|
):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
mock_print.assert_called_once_with("aipass unknown")
|
mock_con.print.assert_called_once_with("aipass unknown")
|
||||||
|
|
||||||
def test_help_flag_shows_help(self) -> None:
|
def test_help_flag_shows_help(self) -> None:
|
||||||
"""--help shows module list and returns 0."""
|
"""--help calls print_help and returns 0."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--help"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--help"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
mock_print.assert_called()
|
mock_con.print.assert_called()
|
||||||
|
|
||||||
def test_h_flag_shows_help(self) -> None:
|
def test_h_flag_shows_help(self) -> None:
|
||||||
"""-h shows module list and returns 0."""
|
"""-h shows help and returns 0."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-h"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-h"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print"):
|
with patch("aipass.aipass.apps.aipass.console"):
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
|
|
||||||
def test_no_args_shows_help(self) -> None:
|
def test_no_args_shows_help(self) -> None:
|
||||||
"""No arguments shows module list and returns 0."""
|
"""No arguments shows introspection and returns 0."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print"):
|
with patch("aipass.aipass.apps.aipass.console"):
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
|
|
||||||
def test_help_word_shows_help(self) -> None:
|
def test_help_word_routes_to_module(self) -> None:
|
||||||
"""'help' as only arg shows module list and returns 0."""
|
"""'help' as only arg routes to help_chat module, not root help."""
|
||||||
|
mod = MagicMock()
|
||||||
|
mod.handle_command.return_value = True
|
||||||
|
mod.__name__ = "aipass.aipass.apps.modules.help_chat"
|
||||||
|
mod.COMMAND = "help"
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "help"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "help"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||||
with patch("builtins.print"):
|
result = main()
|
||||||
result = main()
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
|
mod.handle_command.assert_called_once_with("help", [])
|
||||||
|
|
||||||
def test_help_shows_module_count(self) -> None:
|
def test_introspection_shows_public_commands(self) -> None:
|
||||||
"""Help output includes discovered module count."""
|
"""Introspection lists modules with COMMAND in _PUBLIC_COMMANDS."""
|
||||||
mod = types.ModuleType("test_mod")
|
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
||||||
mod.__doc__ = "Test module doc"
|
mod.__doc__ = "Help chatbot"
|
||||||
|
mod.COMMAND = "help" # type: ignore[attr-defined]
|
||||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
main()
|
main()
|
||||||
first_call_args = mock_print.call_args_list[0][0][0]
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "1 modules" in first_call_args
|
assert "help" in printed
|
||||||
|
|
||||||
def test_help_shows_module_with_no_doc(self) -> None:
|
def test_introspection_hides_non_public(self) -> None:
|
||||||
"""Module without docstring shows 'No description'."""
|
"""Modules without COMMAND in _PUBLIC_COMMANDS are hidden."""
|
||||||
mod = types.ModuleType("nodoc_mod")
|
mod = types.ModuleType("aipass.aipass.apps.modules.internal")
|
||||||
mod.__doc__ = None
|
mod.__doc__ = "Internal module"
|
||||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
main()
|
main()
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "No description" in printed
|
assert "internal" not in printed
|
||||||
|
|
||||||
def test_unknown_command_returns_1(self) -> None:
|
def test_unknown_command_returns_1(self) -> None:
|
||||||
"""Unknown command prints error and returns 1."""
|
"""Unknown command prints error and returns 1."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "xyzzy"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "xyzzy"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
mock_print.assert_called_with("Unknown command: xyzzy")
|
mock_con.print.assert_called_with("Unknown command: xyzzy")
|
||||||
|
|
||||||
def test_known_command_routes_and_returns_0(self) -> None:
|
def test_known_command_routes_and_returns_0(self) -> None:
|
||||||
"""Known command that gets handled returns 0."""
|
"""Known command that gets handled returns 0."""
|
||||||
@@ -266,10 +271,10 @@ class TestMain:
|
|||||||
"""@drone prints guidance pointing to drone, not 'Unknown command'."""
|
"""@drone prints guidance pointing to drone, not 'Unknown command'."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@drone"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@drone"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "@drone" in printed
|
assert "@drone" in printed
|
||||||
assert "drone routing target" in printed
|
assert "drone routing target" in printed
|
||||||
assert "Unknown command" not in printed
|
assert "Unknown command" not in printed
|
||||||
@@ -278,10 +283,10 @@ class TestMain:
|
|||||||
"""@memory prints guidance with the actual @name the user typed."""
|
"""@memory prints guidance with the actual @name the user typed."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@memory"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@memory"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "@memory" in printed
|
assert "@memory" in printed
|
||||||
assert "drone @memory" in printed
|
assert "drone @memory" in printed
|
||||||
|
|
||||||
@@ -289,10 +294,10 @@ class TestMain:
|
|||||||
"""Non-@ bad command still prints 'Unknown command', not drone guidance."""
|
"""Non-@ bad command still prints 'Unknown command', not drone guidance."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "frobnicate"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "frobnicate"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
mock_print.assert_called_with("Unknown command: frobnicate")
|
mock_con.print.assert_called_with("Unknown command: frobnicate")
|
||||||
|
|
||||||
def test_command_with_remaining_args(self) -> None:
|
def test_command_with_remaining_args(self) -> None:
|
||||||
"""Remaining args are passed to route_command."""
|
"""Remaining args are passed to route_command."""
|
||||||
@@ -306,7 +311,7 @@ class TestMain:
|
|||||||
mod.handle_command.assert_called_once_with("doctor", ["--verbose", "--fix"])
|
mod.handle_command.assert_called_once_with("doctor", ["--verbose", "--fix"])
|
||||||
|
|
||||||
def test_help_shows_command_constant(self) -> None:
|
def test_help_shows_command_constant(self) -> None:
|
||||||
"""Help listing uses module COMMAND constant, not file stem."""
|
"""Introspection uses module COMMAND constant, not file stem."""
|
||||||
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
||||||
mod.__doc__ = "Help chatbot"
|
mod.__doc__ = "Help chatbot"
|
||||||
mod.COMMAND = "help" # type: ignore[attr-defined]
|
mod.COMMAND = "help" # type: ignore[attr-defined]
|
||||||
@@ -316,14 +321,14 @@ class TestMain:
|
|||||||
"aipass.aipass.apps.aipass.discover_modules",
|
"aipass.aipass.apps.aipass.discover_modules",
|
||||||
return_value=[mod],
|
return_value=[mod],
|
||||||
):
|
):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
main()
|
main()
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "help" in printed
|
assert "help" in printed
|
||||||
assert "help_chat" not in printed
|
assert "help_chat" not in printed
|
||||||
|
|
||||||
def test_help_falls_back_to_stem(self) -> None:
|
def test_introspection_skips_no_command_module(self) -> None:
|
||||||
"""Without COMMAND constant, help listing uses file stem."""
|
"""Modules without COMMAND in _PUBLIC_COMMANDS are hidden from introspection."""
|
||||||
mod = types.ModuleType("aipass.aipass.apps.modules.doctor")
|
mod = types.ModuleType("aipass.aipass.apps.modules.doctor")
|
||||||
mod.__doc__ = "Doctor module"
|
mod.__doc__ = "Doctor module"
|
||||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||||
@@ -332,10 +337,14 @@ class TestMain:
|
|||||||
"aipass.aipass.apps.aipass.discover_modules",
|
"aipass.aipass.apps.aipass.discover_modules",
|
||||||
return_value=[mod],
|
return_value=[mod],
|
||||||
):
|
):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
main()
|
main()
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "doctor" in printed
|
assert (
|
||||||
|
"Commands:" not in printed or "doctor" not in printed.split("Commands:")[1]
|
||||||
|
if "Commands:" in printed
|
||||||
|
else True
|
||||||
|
)
|
||||||
|
|
||||||
def test_handler_crash_surfaces_error(self) -> None:
|
def test_handler_crash_surfaces_error(self) -> None:
|
||||||
"""Handler crash prints real error, not 'Unknown command'."""
|
"""Handler crash prints real error, not 'Unknown command'."""
|
||||||
@@ -347,12 +356,12 @@ class TestMain:
|
|||||||
"aipass.aipass.apps.aipass.discover_modules",
|
"aipass.aipass.apps.aipass.discover_modules",
|
||||||
return_value=[mod],
|
return_value=[mod],
|
||||||
):
|
):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console"):
|
||||||
result = main()
|
with patch("aipass.aipass.apps.aipass.error") as mock_err:
|
||||||
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
err_text = " ".join(str(a) for call in mock_err.call_args_list for a in call[0])
|
||||||
assert "db connection failed" in printed
|
assert "db connection failed" in err_text
|
||||||
assert "Unknown command" not in printed
|
|
||||||
|
|
||||||
def test_import_failure_surfaces_on_command(self) -> None:
|
def test_import_failure_surfaces_on_command(self) -> None:
|
||||||
"""Failed module import surfaces when user types that command."""
|
"""Failed module import surfaces when user types that command."""
|
||||||
@@ -365,11 +374,11 @@ class TestMain:
|
|||||||
):
|
):
|
||||||
aipass_mod._import_failures.clear()
|
aipass_mod._import_failures.clear()
|
||||||
aipass_mod._import_failures["broken"] = ImportError("no module")
|
aipass_mod._import_failures["broken"] = ImportError("no module")
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console"):
|
||||||
result = main()
|
with patch("aipass.aipass.apps.aipass.error") as mock_err:
|
||||||
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
err_text = " ".join(str(a) for call in mock_err.call_args_list for a in call[0])
|
||||||
assert "failed to load" in printed
|
assert "failed to load" in err_text
|
||||||
assert "no module" in printed
|
assert "no module" in err_text
|
||||||
assert "Unknown command" not in printed
|
|
||||||
aipass_mod._import_failures.clear()
|
aipass_mod._import_failures.clear()
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_no_settings_file_returns_empty(self, tmp_path) -> None:
|
def test_no_settings_file_returns_empty(self, tmp_path) -> None:
|
||||||
"""Missing settings.json returns no results."""
|
"""Missing settings.json returns no results."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||||
results = reconcile_stale_deny(fix=False)
|
results = reconcile_stale_deny(fix=False)
|
||||||
@@ -664,7 +664,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_no_stale_rules_returns_pass(self, tmp_path) -> None:
|
def test_no_stale_rules_returns_pass(self, tmp_path) -> None:
|
||||||
"""Settings with no stale rm rules returns PASS."""
|
"""Settings with no stale rm rules returns PASS."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -680,7 +680,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_stale_rules_detected_without_fix(self, tmp_path) -> None:
|
def test_stale_rules_detected_without_fix(self, tmp_path) -> None:
|
||||||
"""Stale rm rules present returns WARN when fix=False."""
|
"""Stale rm rules present returns WARN when fix=False."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -697,7 +697,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_fix_removes_stale_rules(self, tmp_path) -> None:
|
def test_fix_removes_stale_rules(self, tmp_path) -> None:
|
||||||
"""fix=True removes stale rules and preserves others."""
|
"""fix=True removes stale rules and preserves others."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -719,7 +719,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_fix_single_stale_rule(self, tmp_path) -> None:
|
def test_fix_single_stale_rule(self, tmp_path) -> None:
|
||||||
"""fix=True works when only one of two stale rules is present."""
|
"""fix=True works when only one of two stale rules is present."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -736,7 +736,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_fix_idempotent(self, tmp_path) -> None:
|
def test_fix_idempotent(self, tmp_path) -> None:
|
||||||
"""Running fix twice is safe — second run returns PASS with no stale rules."""
|
"""Running fix twice is safe — second run returns PASS with no stale rules."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -753,7 +753,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_empty_deny_list_returns_pass(self, tmp_path) -> None:
|
def test_empty_deny_list_returns_pass(self, tmp_path) -> None:
|
||||||
"""Empty deny list returns PASS."""
|
"""Empty deny list returns PASS."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -765,7 +765,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_no_permissions_key_returns_pass(self, tmp_path) -> None:
|
def test_no_permissions_key_returns_pass(self, tmp_path) -> None:
|
||||||
"""Settings without permissions key returns PASS."""
|
"""Settings without permissions key returns PASS."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -781,10 +781,10 @@ class TestCheckWireVerify:
|
|||||||
|
|
||||||
def test_pass_on_zero_exit(self) -> None:
|
def test_pass_on_zero_exit(self) -> None:
|
||||||
"""Exit 0 from drone @hooks verify produces a PASS row."""
|
"""Exit 0 from drone @hooks verify produces a PASS row."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||||
|
|
||||||
fake = MagicMock(returncode=0, stdout="✓ Wire check passed\n\n0 errors, 0 warnings\n")
|
fake = MagicMock(returncode=0, stdout="✓ Wire check passed\n\n0 errors, 0 warnings\n")
|
||||||
with patch("aipass.aipass.apps.modules.doctor_wire.subprocess.run", return_value=fake):
|
with patch("aipass.aipass.apps.modules._doctor_wire.subprocess.run", return_value=fake):
|
||||||
results = check_wire_verify()
|
results = check_wire_verify()
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0].label == "wire verify"
|
assert results[0].label == "wire verify"
|
||||||
@@ -792,10 +792,10 @@ class TestCheckWireVerify:
|
|||||||
|
|
||||||
def test_fail_on_nonzero_exit(self) -> None:
|
def test_fail_on_nonzero_exit(self) -> None:
|
||||||
"""Non-zero exit from drone @hooks verify produces a FAIL row."""
|
"""Non-zero exit from drone @hooks verify produces a FAIL row."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||||
|
|
||||||
fake = MagicMock(returncode=1, stdout="ERROR empty array\n2 errors, 0 warnings\n")
|
fake = MagicMock(returncode=1, stdout="ERROR empty array\n2 errors, 0 warnings\n")
|
||||||
with patch("aipass.aipass.apps.modules.doctor_wire.subprocess.run", return_value=fake):
|
with patch("aipass.aipass.apps.modules._doctor_wire.subprocess.run", return_value=fake):
|
||||||
results = check_wire_verify()
|
results = check_wire_verify()
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0].glyph == "[red]✗[/red]"
|
assert results[0].glyph == "[red]✗[/red]"
|
||||||
@@ -803,10 +803,10 @@ class TestCheckWireVerify:
|
|||||||
|
|
||||||
def test_warn_on_drone_not_found(self) -> None:
|
def test_warn_on_drone_not_found(self) -> None:
|
||||||
"""FileNotFoundError (drone missing) produces a WARN row."""
|
"""FileNotFoundError (drone missing) produces a WARN row."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
|
"aipass.aipass.apps.modules._doctor_wire.subprocess.run",
|
||||||
side_effect=FileNotFoundError("drone"),
|
side_effect=FileNotFoundError("drone"),
|
||||||
):
|
):
|
||||||
results = check_wire_verify()
|
results = check_wire_verify()
|
||||||
@@ -817,10 +817,10 @@ class TestCheckWireVerify:
|
|||||||
"""TimeoutExpired produces a WARN row."""
|
"""TimeoutExpired produces a WARN row."""
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
|
|
||||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
|
"aipass.aipass.apps.modules._doctor_wire.subprocess.run",
|
||||||
side_effect=sp.TimeoutExpired(cmd="drone", timeout=10),
|
side_effect=sp.TimeoutExpired(cmd="drone", timeout=10),
|
||||||
):
|
):
|
||||||
results = check_wire_verify()
|
results = check_wire_verify()
|
||||||
@@ -849,15 +849,15 @@ class TestPromptAutoWireIsatty:
|
|||||||
|
|
||||||
def test_non_tty_stdin_skips_prompt_and_declines(self) -> None:
|
def test_non_tty_stdin_skips_prompt_and_declines(self) -> None:
|
||||||
"""Non-tty stdin must NOT call input() — it declines and warns instead."""
|
"""Non-tty stdin must NOT call input() — it declines and warns instead."""
|
||||||
from aipass.aipass.apps.modules import doctor_wire
|
from aipass.aipass.apps.modules import _doctor_wire
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||||
patch("builtins.input") as mock_input,
|
patch("builtins.input") as mock_input,
|
||||||
patch.object(doctor_wire, "_print_manual_wire_warning") as mock_warn,
|
patch.object(_doctor_wire, "_print_manual_wire_warning") as mock_warn,
|
||||||
):
|
):
|
||||||
mock_stdin.isatty.return_value = False
|
mock_stdin.isatty.return_value = False
|
||||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
mock_input.assert_not_called()
|
mock_input.assert_not_called()
|
||||||
@@ -865,30 +865,30 @@ class TestPromptAutoWireIsatty:
|
|||||||
|
|
||||||
def test_tty_stdin_prompts_and_respects_decline(self) -> None:
|
def test_tty_stdin_prompts_and_respects_decline(self) -> None:
|
||||||
"""Tty stdin still prompts; a 'n' answer declines."""
|
"""Tty stdin still prompts; a 'n' answer declines."""
|
||||||
from aipass.aipass.apps.modules import doctor_wire
|
from aipass.aipass.apps.modules import _doctor_wire
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||||
patch("builtins.input", return_value="n") as mock_input,
|
patch("builtins.input", return_value="n") as mock_input,
|
||||||
patch.object(doctor_wire, "_print_manual_wire_warning"),
|
patch.object(_doctor_wire, "_print_manual_wire_warning"),
|
||||||
):
|
):
|
||||||
mock_stdin.isatty.return_value = True
|
mock_stdin.isatty.return_value = True
|
||||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
mock_input.assert_called_once()
|
mock_input.assert_called_once()
|
||||||
|
|
||||||
def test_tty_stdin_accepts_and_wires(self) -> None:
|
def test_tty_stdin_accepts_and_wires(self) -> None:
|
||||||
"""Tty stdin with a 'y' answer runs the wire and returns True."""
|
"""Tty stdin with a 'y' answer runs the wire and returns True."""
|
||||||
from aipass.aipass.apps.modules import doctor_wire
|
from aipass.aipass.apps.modules import _doctor_wire
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||||
patch("builtins.input", return_value="y"),
|
patch("builtins.input", return_value="y"),
|
||||||
patch.object(doctor_wire, "_auto_wire_provider", return_value=["wired hook"]) as mock_wire,
|
patch.object(_doctor_wire, "_auto_wire_provider", return_value=["wired hook"]) as mock_wire,
|
||||||
):
|
):
|
||||||
mock_stdin.isatty.return_value = True
|
mock_stdin.isatty.return_value = True
|
||||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_wire.assert_called_once()
|
mock_wire.assert_called_once()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from aipass.aipass.apps.modules.doctor_fix import (
|
from aipass.aipass.apps.modules._doctor_fix import (
|
||||||
RemediationItem,
|
RemediationItem,
|
||||||
detect_project_name,
|
detect_project_name,
|
||||||
format_json_report,
|
format_json_report,
|
||||||
@@ -68,7 +68,7 @@ class TestDetectProjectName:
|
|||||||
no_reg = tmp_path / "empty_project"
|
no_reg = tmp_path / "empty_project"
|
||||||
no_reg.mkdir()
|
no_reg.mkdir()
|
||||||
with patch(
|
with patch(
|
||||||
"aipass.aipass.apps.modules.doctor_fix._discover_registry",
|
"aipass.aipass.apps.modules._doctor_fix._discover_registry",
|
||||||
return_value=no_reg / "MISSING_REGISTRY.json",
|
return_value=no_reg / "MISSING_REGISTRY.json",
|
||||||
):
|
):
|
||||||
result = detect_project_name(no_reg)
|
result = detect_project_name(no_reg)
|
||||||
@@ -368,16 +368,16 @@ class TestPrintFunctions:
|
|||||||
class TestDoctorFixHandleCommand:
|
class TestDoctorFixHandleCommand:
|
||||||
def test_wrong_command(self) -> None:
|
def test_wrong_command(self) -> None:
|
||||||
"""Non-doctor_fix commands are not handled."""
|
"""Non-doctor_fix commands are not handled."""
|
||||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||||
|
|
||||||
assert handle_command("doctor", []) is False
|
assert handle_command("doctor", []) is False
|
||||||
assert handle_command("help", []) is False
|
assert handle_command("help", []) is False
|
||||||
|
|
||||||
def test_no_args_shows_usage(self) -> None:
|
def test_no_args_shows_usage(self) -> None:
|
||||||
"""No args shows usage message (not introspection banner)."""
|
"""No args shows usage message (not introspection banner)."""
|
||||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||||
|
|
||||||
with patch("aipass.aipass.apps.modules.doctor_fix.console") as mock_console:
|
with patch("aipass.aipass.apps.modules._doctor_fix.console") as mock_console:
|
||||||
result = handle_command("doctor_fix", [])
|
result = handle_command("doctor_fix", [])
|
||||||
assert result is True
|
assert result is True
|
||||||
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
|
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
|
||||||
@@ -385,9 +385,9 @@ class TestDoctorFixHandleCommand:
|
|||||||
|
|
||||||
def test_info_flag(self) -> None:
|
def test_info_flag(self) -> None:
|
||||||
"""--info triggers print_introspection."""
|
"""--info triggers print_introspection."""
|
||||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||||
|
|
||||||
with patch("aipass.aipass.apps.modules.doctor_fix.print_introspection") as mock:
|
with patch("aipass.aipass.apps.modules._doctor_fix.print_introspection") as mock:
|
||||||
result = handle_command("doctor_fix", ["--info"])
|
result = handle_command("doctor_fix", ["--info"])
|
||||||
assert result is True
|
assert result is True
|
||||||
mock.assert_called_once()
|
mock.assert_called_once()
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: test_feedback.py
|
||||||
|
# Description: Tests for aipass feedback — toggle alias for @hooks feedback pulse
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-18
|
||||||
|
# Modified: 2026-07-18
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Tests for the aipass feedback module."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from aipass.aipass.apps.modules.feedback import handle_command, print_help, print_introspection
|
||||||
|
|
||||||
|
_MOD = "aipass.aipass.apps.modules.feedback"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleCommand:
|
||||||
|
"""Command routing for aipass feedback."""
|
||||||
|
|
||||||
|
def test_ignores_other_commands(self) -> None:
|
||||||
|
"""A non-feedback command is not handled."""
|
||||||
|
assert handle_command("doctor", []) is False
|
||||||
|
|
||||||
|
def test_help(self) -> None:
|
||||||
|
"""--help is handled."""
|
||||||
|
assert handle_command("feedback", ["--help"]) is True
|
||||||
|
|
||||||
|
def test_info(self) -> None:
|
||||||
|
"""--info is handled."""
|
||||||
|
assert handle_command("feedback", ["--info"]) is True
|
||||||
|
|
||||||
|
def test_unknown_arg_shows_error(self) -> None:
|
||||||
|
"""An unknown argument shows an error and help."""
|
||||||
|
with patch(f"{_MOD}.error") as mock_err:
|
||||||
|
assert handle_command("feedback", ["banana"]) is True
|
||||||
|
mock_err.assert_called_once()
|
||||||
|
|
||||||
|
def test_on_delegates_to_hooks(self) -> None:
|
||||||
|
"""'on' delegates to drone @hooks feedback on."""
|
||||||
|
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||||
|
handle_command("feedback", ["on"])
|
||||||
|
cmd = run.call_args[0][0]
|
||||||
|
assert cmd == ["drone", "@hooks", "feedback", "on"]
|
||||||
|
|
||||||
|
def test_off_delegates_to_hooks(self) -> None:
|
||||||
|
"""'off' delegates to drone @hooks feedback off."""
|
||||||
|
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||||
|
handle_command("feedback", ["off"])
|
||||||
|
cmd = run.call_args[0][0]
|
||||||
|
assert cmd == ["drone", "@hooks", "feedback", "off"]
|
||||||
|
|
||||||
|
def test_no_args_shows_introspection(self) -> None:
|
||||||
|
"""No args shows module introspection."""
|
||||||
|
with patch(f"{_MOD}.subprocess.run") as run:
|
||||||
|
assert handle_command("feedback", []) is True
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_drone_not_found(self) -> None:
|
||||||
|
"""Missing drone warns cleanly, no crash."""
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}.subprocess.run", side_effect=FileNotFoundError("drone")),
|
||||||
|
patch(f"{_MOD}.warning") as warn,
|
||||||
|
):
|
||||||
|
handle_command("feedback", ["on"])
|
||||||
|
warn.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmoke:
|
||||||
|
"""Help/introspection render without error."""
|
||||||
|
|
||||||
|
def test_print_help_runs(self) -> None:
|
||||||
|
print_help()
|
||||||
|
|
||||||
|
def test_print_introspection_runs(self) -> None:
|
||||||
|
print_introspection()
|
||||||
@@ -586,6 +586,17 @@ class TestStages:
|
|||||||
result = stage_9_handoff(agent_path="src/mybot", non_interactive=True)
|
result = stage_9_handoff(agent_path="src/mybot", non_interactive=True)
|
||||||
assert "src/mybot" in result["handoff_command"]
|
assert "src/mybot" in result["handoff_command"]
|
||||||
|
|
||||||
|
def test_stage_9_non_interactive_no_spawn(self, tmp_local_json) -> None:
|
||||||
|
"""Non-interactive stage 9 prints the command but never spawns a session."""
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}.console"),
|
||||||
|
patch("aipass.aipass.apps.modules.handoff.do_handoff") as mock_handoff,
|
||||||
|
):
|
||||||
|
result = stage_9_handoff(non_interactive=True)
|
||||||
|
mock_handoff.assert_not_called()
|
||||||
|
assert result["launched"] is False
|
||||||
|
assert result["handoff_command"]
|
||||||
|
|
||||||
def test_stage_10_done_returns_empty(self, tmp_local_json) -> None:
|
def test_stage_10_done_returns_empty(self, tmp_local_json) -> None:
|
||||||
"""stage_10_done returns {} and marks stage 10 complete."""
|
"""stage_10_done returns {} and marks stage 10 complete."""
|
||||||
with patch(f"{_MOD}.console"):
|
with patch(f"{_MOD}.console"):
|
||||||
@@ -785,8 +796,8 @@ class TestTemplateSelector:
|
|||||||
]
|
]
|
||||||
return {name: MagicMock(return_value={}) for name in stage_names}
|
return {name: MagicMock(return_value={}) for name in stage_names}
|
||||||
|
|
||||||
def test_empty_project_default_skips_scaffold(self, tmp_local_json) -> None:
|
def test_empty_project_default_skips_agent_and_ping(self, tmp_local_json) -> None:
|
||||||
"""empty project (default) = no scaffold; framework-only stages 6,7,9,10 skipped."""
|
"""empty project (default) = no scaffold; framework-only stages 6,7 skipped; 9,10 run."""
|
||||||
mocks = self._stage_patches()
|
mocks = self._stage_patches()
|
||||||
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||||
result = run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
result = run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
||||||
@@ -798,9 +809,11 @@ class TestTemplateSelector:
|
|||||||
"stage_4_style_questions",
|
"stage_4_style_questions",
|
||||||
"stage_5_tool_choice",
|
"stage_5_tool_choice",
|
||||||
"stage_8_smoke_test",
|
"stage_8_smoke_test",
|
||||||
|
"stage_9_handoff",
|
||||||
|
"stage_10_done",
|
||||||
):
|
):
|
||||||
assert mocks[name].called, f"{name} should have been called"
|
assert mocks[name].called, f"{name} should have been called"
|
||||||
for name in ("stage_6_first_agent", "stage_7_ping_sweep", "stage_9_handoff", "stage_10_done"):
|
for name in ("stage_6_first_agent", "stage_7_ping_sweep"):
|
||||||
assert not mocks[name].called, f"{name} should NOT have been called"
|
assert not mocks[name].called, f"{name} should NOT have been called"
|
||||||
|
|
||||||
def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None:
|
def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None:
|
||||||
@@ -862,6 +875,27 @@ class TestTemplateSelector:
|
|||||||
assert "setup.sh" in msg
|
assert "setup.sh" in msg
|
||||||
assert "pip" not in msg
|
assert "pip" not in msg
|
||||||
|
|
||||||
|
def test_empty_template_stage9_gets_cwd_as_agent_path(self, tmp_local_json) -> None:
|
||||||
|
"""Empty template sets agent_path='.' so stage 9 hands off from CWD."""
|
||||||
|
mocks = self._stage_patches()
|
||||||
|
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||||
|
run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
||||||
|
stage_9_call = mocks["stage_9_handoff"].call_args
|
||||||
|
assert stage_9_call is not None
|
||||||
|
agent_path_arg = stage_9_call[0][2] if len(stage_9_call[0]) > 2 else stage_9_call[1].get("agent_path", "")
|
||||||
|
assert agent_path_arg == "."
|
||||||
|
|
||||||
|
def test_non_tty_forces_non_interactive(self, tmp_local_json) -> None:
|
||||||
|
"""When stdin is not a TTY, run_init auto-forces non_interactive (no crash)."""
|
||||||
|
mocks = self._stage_patches()
|
||||||
|
with (
|
||||||
|
patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks),
|
||||||
|
patch("sys.stdin") as mock_stdin,
|
||||||
|
):
|
||||||
|
mock_stdin.isatty.return_value = False
|
||||||
|
rc = run_init(non_interactive=False, template=TEMPLATE_EMPTY)
|
||||||
|
assert rc == 0
|
||||||
|
|
||||||
def test_aipass_specific_stages_constant(self) -> None:
|
def test_aipass_specific_stages_constant(self) -> None:
|
||||||
"""AIPASS_SPECIFIC_STAGES contains exactly {6, 7, 9, 10}."""
|
"""AIPASS_SPECIFIC_STAGES contains exactly {6, 7} — stages 9/10 run for ALL templates."""
|
||||||
assert AIPASS_SPECIFIC_STAGES == {6, 7, 9, 10}
|
assert AIPASS_SPECIFIC_STAGES == {6, 7}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from aipass.aipass.apps.modules.install import (
|
|||||||
DEFAULT_HOME,
|
DEFAULT_HOME,
|
||||||
DEFAULT_PROJECT,
|
DEFAULT_PROJECT,
|
||||||
TOTAL_STEPS,
|
TOTAL_STEPS,
|
||||||
|
_build_install_prompt,
|
||||||
_clone_repo,
|
_clone_repo,
|
||||||
_handoff_to_init,
|
_handoff_to_init,
|
||||||
_looks_like_aipass_tree,
|
_looks_like_aipass_tree,
|
||||||
@@ -215,21 +216,13 @@ class TestRunInstall:
|
|||||||
class TestShouldRunInit:
|
class TestShouldRunInit:
|
||||||
"""Deciding whether the install chains into init."""
|
"""Deciding whether the install chains into init."""
|
||||||
|
|
||||||
def test_no_init_wins(self) -> None:
|
def test_no_init_skips(self) -> None:
|
||||||
"""--no-init disables the handoff even alongside --with-init."""
|
"""--no-init disables the handoff."""
|
||||||
assert _should_run_init(non_interactive=False, with_init=True, no_init=True) is False
|
assert _should_run_init(no_init=True) is False
|
||||||
|
|
||||||
def test_with_init_forces_headless(self) -> None:
|
def test_default_chains(self) -> None:
|
||||||
"""--with-init runs init even when the install was headless."""
|
"""Default: always chain into init."""
|
||||||
assert _should_run_init(non_interactive=True, with_init=True, no_init=False) is True
|
assert _should_run_init(no_init=False) is True
|
||||||
|
|
||||||
def test_headless_defaults_off(self) -> None:
|
|
||||||
"""A plain headless install stops before init."""
|
|
||||||
assert _should_run_init(non_interactive=True, with_init=False, no_init=False) is False
|
|
||||||
|
|
||||||
def test_interactive_defaults_on(self) -> None:
|
|
||||||
"""A plain interactive install chains into init."""
|
|
||||||
assert _should_run_init(non_interactive=False, with_init=False, no_init=False) is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveProjectDir:
|
class TestResolveProjectDir:
|
||||||
@@ -327,6 +320,75 @@ class TestHandleCommand:
|
|||||||
assert kwargs["project"] == "/x/proj"
|
assert kwargs["project"] == "/x/proj"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildInstallPrompt:
|
||||||
|
"""Authored first prompt for the post-install @aipass chat."""
|
||||||
|
|
||||||
|
def test_includes_home(self, tmp_path: Path) -> None:
|
||||||
|
"""Prompt mentions the install home directory."""
|
||||||
|
prompt = _build_install_prompt(tmp_path, {"drone": "/x/drone", "aipass": "/x/aipass"})
|
||||||
|
assert str(tmp_path) in prompt
|
||||||
|
|
||||||
|
def test_includes_verified_bins(self) -> None:
|
||||||
|
"""Verified binary paths appear in the prompt."""
|
||||||
|
prompt = _build_install_prompt(Path("/h"), {"drone": "/x/drone", "aipass": "/x/aipass"})
|
||||||
|
assert "/x/drone" in prompt
|
||||||
|
assert "/x/aipass" in prompt
|
||||||
|
|
||||||
|
def test_omits_none_bins(self) -> None:
|
||||||
|
"""Binaries that weren't found are omitted, not shown as None."""
|
||||||
|
prompt = _build_install_prompt(Path("/h"), {"drone": None, "aipass": "/x/aipass"})
|
||||||
|
assert "None" not in prompt
|
||||||
|
assert "/x/aipass" in prompt
|
||||||
|
|
||||||
|
def test_ends_with_question(self) -> None:
|
||||||
|
"""Prompt ends by asking what to explore."""
|
||||||
|
prompt = _build_install_prompt(Path("/h"), {})
|
||||||
|
assert "?" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstallChatHandoff:
|
||||||
|
"""Install-to-chat handoff launches @aipass after init on TTY."""
|
||||||
|
|
||||||
|
def test_tty_launches_inline(self) -> None:
|
||||||
|
"""Interactive TTY install launches the @aipass concierge after init."""
|
||||||
|
home = Path("/fake/AIPass")
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||||
|
patch(f"{_MOD}.is_throwaway_path", return_value=False),
|
||||||
|
patch(f"{_MOD}._clone_repo", return_value=True),
|
||||||
|
patch(f"{_MOD}._run_setup", return_value=True),
|
||||||
|
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
|
||||||
|
patch(f"{_MOD}._check_and_fix_owner"),
|
||||||
|
patch(f"{_MOD}._handoff_to_init"),
|
||||||
|
patch(f"{_MOD}.sys.stdin") as mock_stdin,
|
||||||
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
):
|
||||||
|
mock_stdin.isatty.return_value = True
|
||||||
|
run_install(non_interactive=False, dry_run=False)
|
||||||
|
mock_launch.assert_called_once()
|
||||||
|
prompt_arg = mock_launch.call_args[0][1]
|
||||||
|
assert "Fresh AIPass install" in prompt_arg
|
||||||
|
|
||||||
|
def test_no_tty_skips_launch(self) -> None:
|
||||||
|
"""Non-TTY install skips the chat handoff."""
|
||||||
|
home = Path("/fake/AIPass")
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||||
|
patch(f"{_MOD}.is_throwaway_path", return_value=False),
|
||||||
|
patch(f"{_MOD}._clone_repo", return_value=True),
|
||||||
|
patch(f"{_MOD}._run_setup", return_value=True),
|
||||||
|
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
|
||||||
|
patch(f"{_MOD}._check_and_fix_owner"),
|
||||||
|
patch(f"{_MOD}._handoff_to_init"),
|
||||||
|
patch(f"{_MOD}.sys.stdin") as mock_stdin,
|
||||||
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
):
|
||||||
|
mock_stdin.isatty.return_value = False
|
||||||
|
rc = run_install(non_interactive=True, dry_run=False)
|
||||||
|
mock_launch.assert_not_called()
|
||||||
|
assert rc == 0
|
||||||
|
|
||||||
|
|
||||||
class TestSmoke:
|
class TestSmoke:
|
||||||
"""Help/introspection render and constants hold."""
|
"""Help/introspection render and constants hold."""
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ class TestReturnTypeContracts:
|
|||||||
|
|
||||||
def test_doctor_wire_handle_command_returns_bool(self):
|
def test_doctor_wire_handle_command_returns_bool(self):
|
||||||
"""Doctor wire handle_command returns True for match, False otherwise."""
|
"""Doctor wire handle_command returns True for match, False otherwise."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import handle_command as wire_cmd
|
from aipass.aipass.apps.modules._doctor_wire import handle_command as wire_cmd
|
||||||
|
|
||||||
assert wire_cmd("doctor_wire", []) is True
|
assert wire_cmd("doctor_wire", []) is True
|
||||||
assert wire_cmd("not_wire", []) is False
|
assert wire_cmd("not_wire", []) is False
|
||||||
|
|||||||
@@ -0,0 +1,738 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: test_new_project.py
|
||||||
|
# Description: Tests for aipass new — project creation handler
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-17
|
||||||
|
# Modified: 2026-07-17
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Tests for the new_project handler and module.
|
||||||
|
|
||||||
|
All file operations use tmp_path to stay fully isolated from the live
|
||||||
|
filesystem. Tests mock subprocess calls to avoid real git/drone invocations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.new_project import (
|
||||||
|
_agent_home,
|
||||||
|
_registry_name,
|
||||||
|
_spawn_project_agent,
|
||||||
|
_validate_name,
|
||||||
|
_write_registry,
|
||||||
|
_write_template,
|
||||||
|
create_project,
|
||||||
|
find_host_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# find_host_root
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_host_root_finds_registry(tmp_path):
|
||||||
|
"""Finds directory containing *_REGISTRY.json."""
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
sub = tmp_path / "projects" / "myapp"
|
||||||
|
sub.mkdir(parents=True)
|
||||||
|
assert find_host_root(sub) == tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_host_root_returns_none_without_registry(tmp_path):
|
||||||
|
"""Returns None when no registry exists above start."""
|
||||||
|
assert find_host_root(tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_host_root_finds_closest_registry(tmp_path):
|
||||||
|
"""Walks up and finds the closest *_REGISTRY.json."""
|
||||||
|
(tmp_path / "HOST_REGISTRY.json").write_text("{}")
|
||||||
|
sub = tmp_path / "a" / "b"
|
||||||
|
sub.mkdir(parents=True)
|
||||||
|
assert find_host_root(sub) == tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _validate_name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_accepts_valid():
|
||||||
|
assert _validate_name("myapp") == "myapp"
|
||||||
|
assert _validate_name("My-App_2") == "My-App_2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_empty():
|
||||||
|
with pytest.raises(ValueError, match="cannot be empty"):
|
||||||
|
_validate_name("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_leading_digit():
|
||||||
|
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||||
|
_validate_name("2fast")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_special_chars():
|
||||||
|
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||||
|
_validate_name("my app!")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _registry_name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_name_uppercases():
|
||||||
|
assert _registry_name("myapp") == "MYAPP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_name_replaces_special():
|
||||||
|
assert _registry_name("my.app") == "MY_APP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_name_preserves_hyphens():
|
||||||
|
assert _registry_name("my-app") == "MY-APP"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _write_registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_registry_creates_file(tmp_path):
|
||||||
|
rid, fname = _write_registry(tmp_path, "demo")
|
||||||
|
path = tmp_path / fname
|
||||||
|
assert path.exists()
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
assert data["metadata"]["id"] == rid
|
||||||
|
assert data["metadata"]["name"] == "DEMO"
|
||||||
|
assert fname == "DEMO_REGISTRY.json"
|
||||||
|
assert data["branches"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _write_template — empty
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_template_empty(tmp_path):
|
||||||
|
created = _write_template(tmp_path, "demo", "empty")
|
||||||
|
assert "README.md" in created
|
||||||
|
assert ".gitignore" in created
|
||||||
|
assert (tmp_path / "README.md").exists()
|
||||||
|
assert (tmp_path / ".gitignore").exists()
|
||||||
|
assert not (tmp_path / "pyproject.toml").exists()
|
||||||
|
assert not (tmp_path / "src").exists()
|
||||||
|
gitignore = (tmp_path / ".gitignore").read_text()
|
||||||
|
assert ".venv\n" in gitignore
|
||||||
|
assert ".venv/\n" not in gitignore
|
||||||
|
assert "*_REGISTRY.lock" in gitignore
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _write_template — python
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_template_python(tmp_path):
|
||||||
|
created = _write_template(tmp_path, "demo", "python")
|
||||||
|
assert "pyproject.toml" in created
|
||||||
|
assert "src/demo/__init__.py" in created
|
||||||
|
assert (tmp_path / "pyproject.toml").exists()
|
||||||
|
assert (tmp_path / "src" / "demo" / "__init__.py").exists()
|
||||||
|
pyproject = (tmp_path / "pyproject.toml").read_text()
|
||||||
|
assert 'name = "demo"' in pyproject
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_template_python_hyphen_name(tmp_path):
|
||||||
|
_write_template(tmp_path, "my-app", "python")
|
||||||
|
assert (tmp_path / "src" / "my_app" / "__init__.py").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_project — integration (mocked subprocess)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def host_env(tmp_path):
|
||||||
|
"""Set up a minimal AIPass host installation in tmp_path."""
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "host-id"}, "branches": []}))
|
||||||
|
(tmp_path / "projects").mkdir()
|
||||||
|
(tmp_path / ".aipass").mkdir()
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_git_run(args, **kwargs):
|
||||||
|
"""Stub subprocess.run for git commands — always succeeds."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 0
|
||||||
|
result.stdout = ""
|
||||||
|
result.stderr = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_empty_template(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = create_project("testproj", template="empty", no_agent=True)
|
||||||
|
|
||||||
|
target = Path(result["target"])
|
||||||
|
assert target.exists()
|
||||||
|
assert result["name"] == "testproj"
|
||||||
|
assert result["template"] == "empty"
|
||||||
|
assert result["registry_file"] == "TESTPROJ_REGISTRY.json"
|
||||||
|
assert (target / "TESTPROJ_REGISTRY.json").exists()
|
||||||
|
assert (target / "README.md").exists()
|
||||||
|
assert (target / ".gitignore").exists()
|
||||||
|
assert not (target / "pyproject.toml").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_python_template(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = create_project("pyapp", template="python", no_agent=True)
|
||||||
|
|
||||||
|
target = Path(result["target"])
|
||||||
|
assert (target / "pyproject.toml").exists()
|
||||||
|
assert (target / "src" / "pyapp" / "__init__.py").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_rejects_existing(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
(host_env / "projects" / "taken").mkdir()
|
||||||
|
with pytest.raises(RuntimeError, match="already exists"):
|
||||||
|
create_project("taken", no_agent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_rejects_invalid_name(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||||
|
create_project("123bad", no_agent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_rejects_bad_template(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with pytest.raises(ValueError, match="Unknown template"):
|
||||||
|
create_project("foo", template="rust", no_agent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_no_host(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
with pytest.raises(RuntimeError, match="Not inside an AIPass"):
|
||||||
|
create_project("foo", no_agent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_cleans_up_on_failure(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
|
||||||
|
def _fail_git(args, **kwargs):
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 1
|
||||||
|
result.stderr = "simulated failure"
|
||||||
|
return result
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_fail_git),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
|
pytest.raises(RuntimeError, match="simulated failure"),
|
||||||
|
):
|
||||||
|
create_project("failproj", no_agent=True)
|
||||||
|
|
||||||
|
assert not (host_env / "projects" / "failproj").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_registry_before_scaffold(host_env, monkeypatch):
|
||||||
|
"""Registry file must exist before scaffold runs (order invariant)."""
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
creation_order = []
|
||||||
|
|
||||||
|
original_write_registry = _write_registry
|
||||||
|
original_write_template = _write_template
|
||||||
|
|
||||||
|
def track_registry(target, name):
|
||||||
|
creation_order.append("registry")
|
||||||
|
return original_write_registry(target, name)
|
||||||
|
|
||||||
|
def track_template(target, name, template):
|
||||||
|
creation_order.append("template")
|
||||||
|
return original_write_template(target, name, template)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project._write_registry",
|
||||||
|
side_effect=track_registry,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project._write_template",
|
||||||
|
side_effect=track_template,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
|
):
|
||||||
|
create_project("ordertest", no_agent=True)
|
||||||
|
|
||||||
|
assert creation_order.index("registry") < creation_order.index("template")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _spawn_project_agent (delegates to spawn_agent)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SPAWN_SUCCESS = {
|
||||||
|
"success": True,
|
||||||
|
"branch_name": "DEMO",
|
||||||
|
"path": "/tmp/demo",
|
||||||
|
"files_copied": 12,
|
||||||
|
"registry_updated": True,
|
||||||
|
"validation_issues": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_home_simple():
|
||||||
|
"""Agent home is src/<pkg>/<pkg>/."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
home = _agent_home(Path("/proj"), "demo")
|
||||||
|
assert home == Path("/proj/src/demo/demo")
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_home_hyphenated():
|
||||||
|
"""Hyphens normalized to underscores, matching python template."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
home = _agent_home(Path("/proj"), "my-app")
|
||||||
|
assert home == Path("/proj/src/my_app/my_app")
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_project_agent_calls_spawn(tmp_path):
|
||||||
|
"""Calls spawn_agent with correct citizen_class, purpose, and agent_home path."""
|
||||||
|
with patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value=_SPAWN_SUCCESS,
|
||||||
|
) as mock_spawn:
|
||||||
|
result = _spawn_project_agent(tmp_path, "demo")
|
||||||
|
expected_home = str(tmp_path / "src" / "demo" / "demo")
|
||||||
|
mock_spawn.assert_called_once_with(
|
||||||
|
target_path=expected_home,
|
||||||
|
role="project_agent",
|
||||||
|
purpose="Resident agent of the demo project.",
|
||||||
|
citizen_class="project_agent",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["branch_name"] == "DEMO"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_project_agent_raises_on_failure(tmp_path):
|
||||||
|
"""Raises RuntimeError when spawn_agent returns success=False."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value={"success": False, "error": "template missing"},
|
||||||
|
),
|
||||||
|
pytest.raises(RuntimeError, match="spawn_agent failed.*template missing"),
|
||||||
|
):
|
||||||
|
_spawn_project_agent(tmp_path, "broken")
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_project_agent_returns_spawn_result(tmp_path):
|
||||||
|
"""Returns the full result dict from spawn_agent."""
|
||||||
|
with patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value={**_SPAWN_SUCCESS, "citizen_number": 1},
|
||||||
|
):
|
||||||
|
result = _spawn_project_agent(tmp_path, "demo")
|
||||||
|
assert result["files_copied"] == 12
|
||||||
|
assert result["citizen_number"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_project — WITH agent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_with_agent(host_env, monkeypatch):
|
||||||
|
"""WITH-agent path: spawn_agent called, result propagated."""
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
spawn_ok = {
|
||||||
|
"success": True,
|
||||||
|
"branch_name": "WITHAGENT",
|
||||||
|
"path": str(host_env / "projects" / "withagent"),
|
||||||
|
"files_copied": 15,
|
||||||
|
"registry_updated": True,
|
||||||
|
"validation_issues": [],
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value=spawn_ok,
|
||||||
|
) as mock_spawn,
|
||||||
|
):
|
||||||
|
result = create_project("withagent", template="empty", no_agent=False)
|
||||||
|
|
||||||
|
assert result["agent_created"] is True
|
||||||
|
assert result["spawn_result"] == spawn_ok
|
||||||
|
expected_home = str(host_env / "projects" / "withagent" / "src" / "withagent" / "withagent")
|
||||||
|
assert result["agent_home"] == expected_home
|
||||||
|
mock_spawn.assert_called_once()
|
||||||
|
call_kwargs = mock_spawn.call_args[1]
|
||||||
|
assert call_kwargs["target_path"] == expected_home
|
||||||
|
assert call_kwargs["citizen_class"] == "project_agent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_spawn_failure_cleans_up(host_env, monkeypatch):
|
||||||
|
"""spawn_agent failure triggers cleanup — no partial project left."""
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value={"success": False, "error": "template missing"},
|
||||||
|
),
|
||||||
|
pytest.raises(RuntimeError, match="spawn_agent failed"),
|
||||||
|
):
|
||||||
|
create_project("failspawn", template="empty", no_agent=False)
|
||||||
|
|
||||||
|
assert not (host_env / "projects" / "failspawn").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_no_agent_next_steps(host_env, monkeypatch):
|
||||||
|
"""no_agent output omits 'meet your project agent' line."""
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
|
||||||
|
):
|
||||||
|
handle_command("new", ["cosmtest", "--template", "empty", "--no-agent"])
|
||||||
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
|
assert "meet your project agent" not in printed
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_no_agent_flag(host_env, monkeypatch):
|
||||||
|
"""no_agent=True skips passport and registry seating."""
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
|
):
|
||||||
|
result = create_project("noagent", template="empty", no_agent=True)
|
||||||
|
|
||||||
|
assert result["agent_created"] is False
|
||||||
|
assert result["agent_home"] is None
|
||||||
|
target = Path(result["target"])
|
||||||
|
assert not (target / "src" / "noagent" / "noagent").exists()
|
||||||
|
reg = json.loads((target / result["registry_file"]).read_text())
|
||||||
|
assert reg["metadata"]["total_branches"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# is_projects_child (guard relaxation)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_projects_child_valid(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||||
|
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
target = tmp_path / "projects" / "myapp"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
assert is_projects_child(target) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_projects_child_not_in_projects(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||||
|
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
target = tmp_path / "elsewhere" / "myapp"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
assert is_projects_child(target) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_projects_child_no_host_registry(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||||
|
|
||||||
|
target = tmp_path / "projects" / "myapp"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
assert is_projects_child(target) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _guard_init relaxation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_init_blocks_nested_by_default(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||||
|
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
target = tmp_path / "projects" / "nested"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
with pytest.raises(RuntimeError, match="inside AIPass project"):
|
||||||
|
_guard_init(target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_init_allows_nested_with_flag(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||||
|
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
target = tmp_path / "projects" / "nested"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
_guard_init(target, allow_projects_child=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_init_still_blocks_non_projects_nested(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||||
|
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
target = tmp_path / "elsewhere" / "nested"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
with pytest.raises(RuntimeError, match="inside AIPass project"):
|
||||||
|
_guard_init(target, allow_projects_child=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module handle_command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_handles_new_command():
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
assert handle_command("notmine", []) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_handles_help():
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
assert handle_command("new", ["--help"]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_handles_no_args():
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
assert handle_command("new", []) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Interactive prompts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_template_default():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||||
|
|
||||||
|
with patch("builtins.input", return_value=""):
|
||||||
|
assert _prompt_template(["empty", "python"]) == "empty"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_template_by_number():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||||
|
|
||||||
|
with patch("builtins.input", return_value="2"):
|
||||||
|
assert _prompt_template(["empty", "python"]) == "python"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_template_by_name():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||||
|
|
||||||
|
with patch("builtins.input", return_value="python"):
|
||||||
|
assert _prompt_template(["empty", "python"]) == "python"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_template_eof():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||||
|
|
||||||
|
with patch("builtins.input", side_effect=EOFError):
|
||||||
|
assert _prompt_template(["empty", "python"]) == "empty"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_agent_default_yes():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||||
|
|
||||||
|
with patch("builtins.input", return_value=""):
|
||||||
|
assert _prompt_agent() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_agent_no():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||||
|
|
||||||
|
with patch("builtins.input", return_value="n"):
|
||||||
|
assert _prompt_agent() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_agent_eof():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||||
|
|
||||||
|
with patch("builtins.input", side_effect=EOFError):
|
||||||
|
assert _prompt_agent() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TTY auto-launch (FIX 3: aipass new auto-launches on TTY)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tty_auto_launches_agent(host_env, monkeypatch):
|
||||||
|
"""On a TTY with an agent created, launch_inline is called."""
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
spawn_ok = {
|
||||||
|
"success": True,
|
||||||
|
"branch_name": "LAUNCH",
|
||||||
|
"path": str(host_env / "projects" / "launch"),
|
||||||
|
"files_copied": 12,
|
||||||
|
"registry_updated": True,
|
||||||
|
"validation_issues": [],
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch("builtins.input", return_value=""),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value=spawn_ok,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.console"),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||||
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
):
|
||||||
|
mock_sys.stdin.isatty.return_value = True
|
||||||
|
handle_command("new", ["launch", "--template", "empty"])
|
||||||
|
mock_launch.assert_called_once()
|
||||||
|
assert "launch" in mock_launch.call_args[0][2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_tty_skips_auto_launch(host_env, monkeypatch):
|
||||||
|
"""On a non-TTY, launch_inline is NOT called — fallback to printed instructions."""
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
spawn_ok = {
|
||||||
|
"success": True,
|
||||||
|
"branch_name": "PIPED",
|
||||||
|
"path": str(host_env / "projects" / "piped"),
|
||||||
|
"files_copied": 12,
|
||||||
|
"registry_updated": True,
|
||||||
|
"validation_issues": [],
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch("builtins.input", return_value=""),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value=spawn_ok,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||||
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
):
|
||||||
|
mock_sys.stdin.isatty.return_value = False
|
||||||
|
handle_command("new", ["piped", "--template", "empty"])
|
||||||
|
mock_launch.assert_not_called()
|
||||||
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
|
assert "cd" in printed
|
||||||
|
assert "claude" in printed
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_agent_skips_auto_launch(host_env, monkeypatch):
|
||||||
|
"""With --no-agent, launch_inline is not called even on TTY."""
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.console"),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||||
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
):
|
||||||
|
mock_sys.stdin.isatty.return_value = True
|
||||||
|
handle_command("new", ["nolaunch", "--template", "empty", "--no-agent"])
|
||||||
|
mock_launch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# aipass.py entry point help (cli_ux)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_aipass_print_introspection():
|
||||||
|
from aipass.aipass.apps.aipass import print_introspection
|
||||||
|
|
||||||
|
print_introspection([])
|
||||||
|
|
||||||
|
|
||||||
|
def test_aipass_print_help():
|
||||||
|
from aipass.aipass.apps.aipass import print_help
|
||||||
|
|
||||||
|
print_help([])
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: test_trust.py
|
||||||
|
# Description: Tests for trust CLI commands and init enrollment (DPLAN-0244)
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-15
|
||||||
|
# Modified: 2026-07-15
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Tests for trust/revoke CLI commands and init auto-enrollment.
|
||||||
|
|
||||||
|
All tests use tmp dirs + monkeypatch REGISTRY_PATH so they never
|
||||||
|
touch the real ~/.aipass registry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
|
from aipass.hooks.apps.handlers.config.trust_registry import (
|
||||||
|
enroll,
|
||||||
|
is_trusted,
|
||||||
|
read_registry,
|
||||||
|
revoke,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_registry(tmp_path, monkeypatch):
|
||||||
|
"""Redirect REGISTRY_PATH to a tmp dir so tests never touch ~/.aipass."""
|
||||||
|
fake_registry = tmp_path / "trusted_projects.json"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH",
|
||||||
|
fake_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# trust_registry direct tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_project(tmp_path):
|
||||||
|
"""enroll() registers a project with .aipass/hooks.json."""
|
||||||
|
project = tmp_path / "myproject"
|
||||||
|
project.mkdir()
|
||||||
|
hooks_dir = project / ".aipass"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
hooks_file = hooks_dir / "hooks.json"
|
||||||
|
hooks_file.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||||
|
|
||||||
|
assert enroll(str(project)) is True
|
||||||
|
assert is_trusted(str(project)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_no_hooks_json(tmp_path):
|
||||||
|
"""enroll() returns False when .aipass/hooks.json is missing."""
|
||||||
|
project = tmp_path / "empty"
|
||||||
|
project.mkdir()
|
||||||
|
assert enroll(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_project(tmp_path):
|
||||||
|
"""revoke() removes a previously enrolled project."""
|
||||||
|
project = tmp_path / "myproject"
|
||||||
|
project.mkdir()
|
||||||
|
hooks_dir = project / ".aipass"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
hooks_file = hooks_dir / "hooks.json"
|
||||||
|
hooks_file.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||||
|
|
||||||
|
enroll(str(project))
|
||||||
|
assert is_trusted(str(project)) is True
|
||||||
|
|
||||||
|
assert revoke(str(project)) is True
|
||||||
|
assert is_trusted(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_not_enrolled(tmp_path):
|
||||||
|
"""revoke() returns False cleanly for a non-enrolled project."""
|
||||||
|
project = tmp_path / "never_enrolled"
|
||||||
|
project.mkdir()
|
||||||
|
assert revoke(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_trusted_hash_mismatch(tmp_path):
|
||||||
|
"""is_trusted() returns False when hooks.json content changed after enrollment."""
|
||||||
|
project = tmp_path / "myproject"
|
||||||
|
project.mkdir()
|
||||||
|
hooks_dir = project / ".aipass"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
hooks_file = hooks_dir / "hooks.json"
|
||||||
|
hooks_file.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||||
|
|
||||||
|
enroll(str(project))
|
||||||
|
assert is_trusted(str(project)) is True
|
||||||
|
|
||||||
|
hooks_file.write_text('{"hooks_enabled": false, "modified": true}', encoding="utf-8")
|
||||||
|
assert is_trusted(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# trust CLI module tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_command_enrolls(tmp_path):
|
||||||
|
"""aipass trust <path> enrolls the project."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
project = tmp_path / "proj"
|
||||||
|
project.mkdir()
|
||||||
|
hooks_dir = project / ".aipass"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
(hooks_dir / "hooks.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
assert handle_command("trust", [str(project)]) is True
|
||||||
|
assert is_trusted(str(project)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_command_removes(tmp_path):
|
||||||
|
"""aipass revoke <path> removes enrollment."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
project = tmp_path / "proj"
|
||||||
|
project.mkdir()
|
||||||
|
hooks_dir = project / ".aipass"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
(hooks_dir / "hooks.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
enroll(str(project))
|
||||||
|
assert handle_command("revoke", [str(project)]) is True
|
||||||
|
assert is_trusted(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_command_no_hooks_json(tmp_path):
|
||||||
|
"""aipass trust <path> prints error when hooks.json is missing."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
project = tmp_path / "bare"
|
||||||
|
project.mkdir()
|
||||||
|
assert handle_command("trust", [str(project)]) is True
|
||||||
|
assert is_trusted(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_command_not_enrolled(tmp_path):
|
||||||
|
"""aipass revoke <path> handles non-enrolled project cleanly."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
project = tmp_path / "ghost"
|
||||||
|
project.mkdir()
|
||||||
|
assert handle_command("revoke", [str(project)]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_command_help():
|
||||||
|
"""aipass trust --help returns True (handled)."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
assert handle_command("trust", ["--help"]) is True
|
||||||
|
assert handle_command("trust", []) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_ignores_unrelated_command():
|
||||||
|
"""handle_command returns False for unrelated commands."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
assert handle_command("doctor", []) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_not_a_directory(tmp_path):
|
||||||
|
"""aipass trust <file> prints error."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
fake = tmp_path / "not_a_dir.txt"
|
||||||
|
fake.write_text("hi", encoding="utf-8")
|
||||||
|
assert handle_command("trust", [str(fake)]) is True
|
||||||
|
assert is_trusted(str(fake)) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# init enrollment tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_project_enrolls(tmp_path, monkeypatch):
|
||||||
|
"""init_project auto-enrolls after copying hooks.json."""
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import init_project
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||||
|
lambda p: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
aipass_home = tmp_path / "aipass_home"
|
||||||
|
aipass_home.mkdir()
|
||||||
|
aipass_dir = aipass_home / ".aipass"
|
||||||
|
aipass_dir.mkdir()
|
||||||
|
template = aipass_dir / "project_hooks.json"
|
||||||
|
template.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||||
|
(aipass_home / "CLAUDE.md").write_text("# Test", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
lambda: str(aipass_home),
|
||||||
|
)
|
||||||
|
|
||||||
|
target = tmp_path / "newproject"
|
||||||
|
target.mkdir()
|
||||||
|
init_project(target, project_name="test")
|
||||||
|
|
||||||
|
assert is_trusted(str(target.resolve())) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_update_rehashes(tmp_path, monkeypatch):
|
||||||
|
"""init update re-enrolls after merging hooks.json (hash tracks new content)."""
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import init_project, update_project
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||||
|
lambda p: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
aipass_home = tmp_path / "aipass_home"
|
||||||
|
aipass_home.mkdir()
|
||||||
|
aipass_dir = aipass_home / ".aipass"
|
||||||
|
aipass_dir.mkdir()
|
||||||
|
template = aipass_dir / "project_hooks.json"
|
||||||
|
template.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||||
|
(aipass_home / "CLAUDE.md").write_text("# Test", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
lambda: str(aipass_home),
|
||||||
|
)
|
||||||
|
|
||||||
|
target = tmp_path / "updproj"
|
||||||
|
target.mkdir()
|
||||||
|
init_project(target, project_name="test")
|
||||||
|
assert is_trusted(str(target.resolve())) is True
|
||||||
|
|
||||||
|
old_reg = read_registry()
|
||||||
|
old_hash = old_reg["projects"][str(target.resolve())]["config_hash"]
|
||||||
|
|
||||||
|
new_template = (
|
||||||
|
'{"hooks_enabled": true, "SessionStart": '
|
||||||
|
'{"new_hook": {"handler": "aipass.hooks.apps.handlers.test.handle", "enabled": true}}}'
|
||||||
|
)
|
||||||
|
template.write_text(new_template, encoding="utf-8")
|
||||||
|
update_project(target)
|
||||||
|
|
||||||
|
new_reg = read_registry()
|
||||||
|
new_hash = new_reg["projects"][str(target.resolve())]["config_hash"]
|
||||||
|
assert new_hash != old_hash
|
||||||
|
assert is_trusted(str(target.resolve())) is True
|
||||||
@@ -18,6 +18,27 @@ drone @api <command> [args]
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Validate your API key
|
||||||
|
drone @api validate
|
||||||
|
|
||||||
|
# Test the connection
|
||||||
|
drone @api test
|
||||||
|
|
||||||
|
# List available models
|
||||||
|
drone @api models
|
||||||
|
|
||||||
|
# Make an API call
|
||||||
|
drone @api call "Hello, world" --model anthropic/claude-3.5-sonnet
|
||||||
|
|
||||||
|
# Check usage stats
|
||||||
|
drone @api stats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|||||||
@@ -213,6 +213,24 @@ def print_help():
|
|||||||
console.print("─" * 70)
|
console.print("─" * 70)
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [yellow]Credentials:[/yellow]")
|
||||||
|
console.print(" [dim]drone @api get-key[/dim] [dim]# Show OpenRouter key[/dim]")
|
||||||
|
console.print(" [dim]drone @api validate[/dim] [dim]# Validate OpenRouter key[/dim]")
|
||||||
|
console.print(" [dim]drone @api validate google[/dim] [dim]# Validate Google OAuth2[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [yellow]Models & calls:[/yellow]")
|
||||||
|
console.print(" [dim]drone @api models --all[/dim] [dim]# List all models[/dim]")
|
||||||
|
console.print(' [dim]drone @api call "Summarize this" --model anthropic/claude-3.5-sonnet[/dim]')
|
||||||
|
console.print()
|
||||||
|
console.print(" [yellow]Usage tracking:[/yellow]")
|
||||||
|
console.print(" [dim]drone @api stats[/dim] [dim]# Overall usage stats[/dim]")
|
||||||
|
console.print(" [dim]drone @api caller-usage flow[/dim] [dim]# Usage by caller[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("─" * 70)
|
||||||
|
console.print()
|
||||||
|
|
||||||
console.print(
|
console.print(
|
||||||
"[dim]Commands: get-key, get-secret, validate, test, models, status, call,"
|
"[dim]Commands: get-key, get-secret, validate, test, models, status, call,"
|
||||||
" list-providers, init, track, stats, session, caller-usage, cleanup[/dim]"
|
" list-providers, init, track, stats, session, caller-usage, cleanup[/dim]"
|
||||||
|
|||||||
@@ -79,6 +79,27 @@ All 11 commands are auto-discovered by the entry point router.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register a project for backup
|
||||||
|
drone @backup register /path/to/project --name myapp
|
||||||
|
|
||||||
|
# Full mirror snapshot
|
||||||
|
drone @backup snapshot @myapp
|
||||||
|
|
||||||
|
# Incremental timestamped backup
|
||||||
|
drone @backup versioned @myapp
|
||||||
|
|
||||||
|
# Check backup status
|
||||||
|
drone @backup status @myapp
|
||||||
|
|
||||||
|
# List available versions of a file
|
||||||
|
drone @backup restore @myapp list src/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `.backup/` Store Structure
|
## `.backup/` Store Structure
|
||||||
|
|
||||||
Each registered project gets a `.backup/` directory at its root:
|
Each registered project gets a `.backup/` directory at its root:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def print_introspection(modules: list[Any]) -> None:
|
|||||||
console.print()
|
console.print()
|
||||||
console.print(f"[bold cyan]BACKUP[/bold cyan] v{VERSION} — project backup & drive sync")
|
console.print(f"[bold cyan]BACKUP[/bold cyan] v{VERSION} — project backup & drive sync")
|
||||||
console.print()
|
console.print()
|
||||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
console.print(f"[bold dim]Discovered Modules:[/bold dim] {len(modules)}")
|
||||||
console.print()
|
console.print()
|
||||||
for module in modules:
|
for module in modules:
|
||||||
name = module.__name__.split(".")[-1]
|
name = module.__name__.split(".")[-1]
|
||||||
@@ -83,6 +83,26 @@ def print_help() -> None:
|
|||||||
console.print(" [green]share[/green] Upload a single file to Drive + get a shareable link")
|
console.print(" [green]share[/green] Upload a single file to Drive + get a shareable link")
|
||||||
console.print(" [green]drive_clear[/green] Clear backups from the remote drive")
|
console.print(" [green]drive_clear[/green] Clear backups from the remote drive")
|
||||||
console.print()
|
console.print()
|
||||||
|
console.print("-" * 70)
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [bold dim]Register & back up:[/bold dim]")
|
||||||
|
console.print(" [dim]drone @backup register /path/to/project --name myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup snapshot @myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup versioned @myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup all @myapp[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [bold dim]Status & restore:[/bold dim]")
|
||||||
|
console.print(" [dim]drone @backup status @myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup restore @myapp list src/main.py[/dim]")
|
||||||
|
console.print(" [dim]drone @backup restore @myapp file src/main.py ./restored.py[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [bold dim]Drive sync:[/bold dim]")
|
||||||
|
console.print(" [dim]drone @backup drive_check @myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup drive_sync @myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup share report.pdf --public[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def discover_modules() -> list[Any]:
|
def discover_modules() -> list[Any]:
|
||||||
|
|||||||
@@ -7,7 +7,16 @@
|
|||||||
**Version:** 2.1.0
|
**Version:** 2.1.0
|
||||||
**Seedgo:** 99%
|
**Seedgo:** 99%
|
||||||
**Tests:** 127 passing (5 files)
|
**Tests:** 127 passing (5 files)
|
||||||
**Last Updated:** 2026-05-16
|
**Last Updated:** 2026-07-17
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone @cli # Show discovered modules
|
||||||
|
drone @cli display demo # Run display function showcase
|
||||||
|
drone @cli templates demo # Run operation template showcase
|
||||||
|
drone @cli --help # Full usage guide
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
+87
-78
@@ -37,7 +37,7 @@ from rich.panel import Panel
|
|||||||
from rich import box
|
from rich import box
|
||||||
|
|
||||||
# CLI modules (showcasing our own services!)
|
# CLI modules (showcasing our own services!)
|
||||||
from aipass.cli.apps.modules.display import console as CONSOLE, header, error
|
from aipass.cli.apps.modules.display import console, header, error
|
||||||
|
|
||||||
VERSION = "2.1.0"
|
VERSION = "2.1.0"
|
||||||
CLI_ROOT = Path(__file__).parent
|
CLI_ROOT = Path(__file__).parent
|
||||||
@@ -110,37 +110,37 @@ def print_introspection() -> None:
|
|||||||
command_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] not in SERVICE_MODULES]
|
command_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] not in SERVICE_MODULES]
|
||||||
service_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] in SERVICE_MODULES]
|
service_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] in SERVICE_MODULES]
|
||||||
|
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]")
|
console.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]")
|
||||||
CONSOLE.print(f" Version: {VERSION}")
|
console.print(f" Version: {VERSION}")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("[dim]Universal Display & Output Service Provider[/dim]")
|
console.print("[dim]Universal Display & Output Service Provider[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Discovered command modules
|
# Discovered command modules
|
||||||
CONSOLE.print(f"[yellow]Discovered Modules:[/yellow] {len(command_modules)}")
|
console.print(f"[yellow]Discovered Modules:[/yellow] {len(command_modules)}")
|
||||||
for module in command_modules:
|
for module in command_modules:
|
||||||
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
||||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||||
CONSOLE.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
console.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||||
if not command_modules:
|
if not command_modules:
|
||||||
CONSOLE.print(" [dim]No command modules discovered[/dim]")
|
console.print(" [dim]No command modules discovered[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Service modules (import-only, but with utility commands)
|
# Service modules (import-only, but with utility commands)
|
||||||
if service_modules:
|
if service_modules:
|
||||||
CONSOLE.print(f"[yellow]Services:[/yellow] {len(service_modules)}")
|
console.print(f"[yellow]Services:[/yellow] {len(service_modules)}")
|
||||||
for module in service_modules:
|
for module in service_modules:
|
||||||
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
||||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||||
CONSOLE.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
console.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[yellow]Next:[/yellow] Explore a module")
|
console.print("[yellow]Next:[/yellow] Explore a module")
|
||||||
CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
console.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display showcase[/dim]")
|
console.print(" [green]drone @cli display demo[/green] [dim]# Run display showcase[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli --help[/green] [dim]# Full usage guide[/dim]")
|
console.print(" [green]drone @cli --help[/green] [dim]# Full usage guide[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def print_help() -> None:
|
def print_help() -> None:
|
||||||
@@ -149,45 +149,54 @@ def print_help() -> None:
|
|||||||
Shows COMMANDS, EXAMPLES, full reference.
|
Shows COMMANDS, EXAMPLES, full reference.
|
||||||
Follows seedgo help pattern.
|
Follows seedgo help pattern.
|
||||||
"""
|
"""
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
header("CLI - Display & Templates Service Provider")
|
header("CLI - Display & Templates Service Provider")
|
||||||
|
|
||||||
CONSOLE.print("[dim]Universal display and output formatting for all AIPass branches[/dim]")
|
console.print("[dim]Universal display and output formatting for all AIPass branches[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [dim]drone @cli <command> [args...][/dim]")
|
||||||
|
console.print(" [dim]drone @cli --help[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("\u2500" * 70)
|
||||||
|
console.print()
|
||||||
|
|
||||||
# What is CLI
|
# What is CLI
|
||||||
CONSOLE.print("[bold cyan]WHAT IS CLI?[/bold cyan]")
|
console.print("[bold cyan]WHAT IS CLI?[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("CLI is the [bold]Display & Templates Service[/bold] - like Prax for logging:")
|
console.print("CLI is the [bold]Display & Templates Service[/bold] - like Prax for logging:")
|
||||||
CONSOLE.print(" [green]\u2713[/green] Centralized display formatting (headers, tables, panels)")
|
console.print(" [green]\u2713[/green] Centralized display formatting (headers, tables, panels)")
|
||||||
CONSOLE.print(" [green]\u2713[/green] Reusable templates for common operations")
|
console.print(" [green]\u2713[/green] Reusable templates for common operations")
|
||||||
CONSOLE.print(" [green]\u2713[/green] Rich library integration for beautiful output")
|
console.print(" [green]\u2713[/green] Rich library integration for beautiful output")
|
||||||
CONSOLE.print(" [green]\u2713[/green] Consistent styling across all AIPass branches")
|
console.print(" [green]\u2713[/green] Consistent styling across all AIPass branches")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("Update CLI once \u2192 All branches instantly benefit from improvements")
|
console.print("Update CLI once \u2192 All branches instantly benefit from improvements")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Commands
|
# Commands
|
||||||
CONSOLE.print("[bold cyan]COMMANDS:[/bold cyan]")
|
console.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print(" [green]drone @cli[/green] [dim]# Show discovered modules[/dim]")
|
console.print(" [green]drone @cli[/green] [dim]# Show discovered modules[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
console.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display demo[/dim]")
|
console.print(" [green]drone @cli display demo[/green] [dim]# Run display demo[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli templates[/green] [dim]# Templates module info[/dim]")
|
console.print(" [green]drone @cli templates[/green] [dim]# Templates module info[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli templates demo[/green] [dim]# Run templates demo[/dim]")
|
console.print(" [green]drone @cli templates demo[/green] [dim]# Run templates demo[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli --help[/green] [dim]# This help message[/dim]")
|
console.print(" [green]drone @cli --help[/green] [dim]# This help message[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Public services
|
# Public services
|
||||||
CONSOLE.print("[bold cyan]PUBLIC SERVICES (apps/modules/):[/bold cyan]")
|
console.print("[bold cyan]PUBLIC SERVICES (apps/modules/):[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
services_table = Table(show_header=True, header_style="bold cyan", border_style="dim")
|
services_table = Table(show_header=True, header_style="bold cyan", border_style="dim")
|
||||||
services_table.add_column("Module", style="green")
|
services_table.add_column("Module", style="green")
|
||||||
@@ -199,34 +208,34 @@ def print_help() -> None:
|
|||||||
)
|
)
|
||||||
services_table.add_row("templates", "operation_start(), operation_complete()", "Standard operation patterns")
|
services_table.add_row("templates", "operation_start(), operation_complete()", "Standard operation patterns")
|
||||||
|
|
||||||
CONSOLE.print(services_table)
|
console.print(services_table)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Import examples
|
# Import examples
|
||||||
CONSOLE.print("[bold cyan]HOW TO IMPORT CLI SERVICES:[/bold cyan]")
|
console.print("[bold cyan]HOW TO IMPORT CLI SERVICES:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[yellow]Display functions:[/yellow]")
|
console.print("[yellow]Display functions:[/yellow]")
|
||||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.display import header, success, error, warning[/dim]")
|
console.print("[dim] from aipass.cli.apps.modules.display import header, success, error, warning[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[yellow]Templates:[/yellow]")
|
console.print("[yellow]Templates:[/yellow]")
|
||||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.templates import operation_start, operation_complete[/dim]")
|
console.print("[dim] from aipass.cli.apps.modules.templates import operation_start, operation_complete[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[yellow]Rich console:[/yellow]")
|
console.print("[yellow]Rich console:[/yellow]")
|
||||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.display import console[/dim]")
|
console.print("[dim] from aipass.cli.apps.modules.display import console[/dim]")
|
||||||
CONSOLE.print("[dim] console.print('[bold]Hello[/bold]') # Rich formatted output[/dim]")
|
console.print("[dim] console.print('[bold]Hello[/bold]') # Rich formatted output[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
CONSOLE.print("[bold cyan]ARCHITECTURE:[/bold cyan]")
|
console.print("[bold cyan]ARCHITECTURE:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
arch_text = """[bold]CLI Branch Structure:[/bold]
|
arch_text = """[bold]CLI Branch Structure:[/bold]
|
||||||
|
|
||||||
@@ -240,19 +249,19 @@ def print_help() -> None:
|
|||||||
[green]\u2713[/green] Rich library = Underlying formatting engine
|
[green]\u2713[/green] Rich library = Underlying formatting engine
|
||||||
- Console, Table, Panel, Columns, Text styling"""
|
- Console, Table, Panel, Columns, Text styling"""
|
||||||
|
|
||||||
CONSOLE.print(Panel(arch_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
|
console.print(Panel(arch_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Drone compliance — commands line
|
# Drone compliance — commands line
|
||||||
CONSOLE.print("[dim]Commands: display, templates, demo, --help[/dim]")
|
console.print("[dim]Commands: display, templates, demo, --help[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def show_version():
|
def show_version():
|
||||||
"""Print version."""
|
"""Print version."""
|
||||||
CONSOLE.print(f"CLI v{VERSION}")
|
console.print(f"CLI v{VERSION}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -306,9 +315,9 @@ if __name__ == "__main__":
|
|||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.warning("CLI interrupted by user")
|
logger.warning("CLI interrupted by user")
|
||||||
CONSOLE.print("\n[yellow]Operation cancelled[/yellow]")
|
console.print("\n[yellow]Operation cancelled[/yellow]")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"CLI error: {e}", exc_info=True)
|
logger.error(f"CLI error: {e}", exc_info=True)
|
||||||
CONSOLE.print(f"\n[red]Error: {e}[/red]")
|
error(str(e))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class TestMainFlow:
|
|||||||
"""No args shows introspection and returns 0."""
|
"""No args shows introspection and returns 0."""
|
||||||
cons, _get_output = _make_capture_console()
|
cons, _get_output = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch("sys.argv", ["cli"]),
|
patch("sys.argv", ["cli"]),
|
||||||
):
|
):
|
||||||
@@ -63,7 +63,7 @@ class TestMainFlow:
|
|||||||
cons, _get_output = _make_capture_console()
|
cons, _get_output = _make_capture_console()
|
||||||
err_cons, _get_err = _make_capture_console()
|
err_cons, _get_err = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch.object(display, "err_console", err_cons),
|
patch.object(display, "err_console", err_cons),
|
||||||
patch.object(display, "_TRIGGER", None),
|
patch.object(display, "_TRIGGER", None),
|
||||||
@@ -77,7 +77,7 @@ class TestMainFlow:
|
|||||||
"""--version returns 0."""
|
"""--version returns 0."""
|
||||||
cons, get_output = _make_capture_console()
|
cons, get_output = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch("sys.argv", ["cli", "--version"]),
|
patch("sys.argv", ["cli", "--version"]),
|
||||||
):
|
):
|
||||||
@@ -91,7 +91,7 @@ class TestMainFlow:
|
|||||||
cons, _get_output = _make_capture_console()
|
cons, _get_output = _make_capture_console()
|
||||||
err_cons, get_err = _make_capture_console()
|
err_cons, get_err = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch.object(display, "err_console", err_cons),
|
patch.object(display, "err_console", err_cons),
|
||||||
patch("sys.argv", ["cli", "nonexistent_cmd_xyz"]),
|
patch("sys.argv", ["cli", "nonexistent_cmd_xyz"]),
|
||||||
@@ -106,7 +106,7 @@ class TestMainFlow:
|
|||||||
cons, _get_output = _make_capture_console()
|
cons, _get_output = _make_capture_console()
|
||||||
err_cons, _get_err = _make_capture_console()
|
err_cons, _get_err = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch.object(display, "err_console", err_cons),
|
patch.object(display, "err_console", err_cons),
|
||||||
patch.object(display, "_TRIGGER", None),
|
patch.object(display, "_TRIGGER", None),
|
||||||
@@ -125,7 +125,7 @@ class TestMainFlow:
|
|||||||
"""cli_entry() is the console_scripts entry point — verify it's callable."""
|
"""cli_entry() is the console_scripts entry point — verify it's callable."""
|
||||||
cons, _get_output = _make_capture_console()
|
cons, _get_output = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch("sys.argv", ["aipass", "--version"]),
|
patch("sys.argv", ["aipass", "--version"]),
|
||||||
pytest.raises(SystemExit) as exc_info,
|
pytest.raises(SystemExit) as exc_info,
|
||||||
|
|||||||
@@ -10,6 +10,20 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone @daemon # Show discovered modules
|
||||||
|
drone @daemon update # Status digest
|
||||||
|
drone @daemon activity # Quick 24h activity summary
|
||||||
|
drone @daemon queue # View pending scheduled jobs
|
||||||
|
drone @daemon run # Fire all due jobs now
|
||||||
|
drone @daemon branch-health DAEMON # Deep dive on a branch
|
||||||
|
drone @daemon install-timer # Enable systemd 2-min timer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Builder citizen -- full 3-layer architecture with identity and memory. DAEMON serves as the background orchestration branch: it discovers modules at startup, routes CLI commands to them, and provides introspection and help output via Rich console.
|
Builder citizen -- full 3-layer architecture with identity and memory. DAEMON serves as the background orchestration branch: it discovers modules at startup, routes CLI commands to them, and provides introspection and help output via Rich console.
|
||||||
|
|||||||
@@ -162,8 +162,22 @@ def print_help(modules: List[Any]):
|
|||||||
console.print("-" * 70)
|
console.print("-" * 70)
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [yellow]Status & monitoring:[/yellow]")
|
||||||
|
console.print(" [dim]drone @daemon update[/dim] [dim]# Status digest[/dim]")
|
||||||
|
console.print(" [dim]drone @daemon activity[/dim] [dim]# Quick 24h summary[/dim]")
|
||||||
|
console.print(" [dim]drone @daemon activity-report --json[/dim] [dim]# Full report (raw)[/dim]")
|
||||||
|
console.print(" [dim]drone @daemon branch-health DAEMON[/dim] [dim]# Single branch dive[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [yellow]Scheduler:[/yellow]")
|
||||||
|
console.print(" [dim]drone @daemon queue[/dim] [dim]# View pending jobs[/dim]")
|
||||||
|
console.print(" [dim]drone @daemon run[/dim] [dim]# Fire due jobs now[/dim]")
|
||||||
|
console.print(" [dim]drone @daemon install-timer[/dim] [dim]# Enable systemd timer[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
console.print("[bold]TIP:[/bold] For module-specific help:")
|
console.print("[bold]TIP:[/bold] For module-specific help:")
|
||||||
console.print(" [dim]daemon <command> --help[/dim]")
|
console.print(" [dim]drone @daemon <command> --help[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ DevPulse handles the day-to-day: working with the user to plan, design, troubles
|
|||||||
| Active plans | `drone @flow list open` |
|
| Active plans | `drone @flow list open` |
|
||||||
| Branch list | `drone systems` |
|
| Branch list | `drone systems` |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Talk to the hub — it picks up where the last session left off
|
||||||
|
cd src/aipass/devpulse
|
||||||
|
claude
|
||||||
|
|
||||||
|
# Or drive it via drone from anywhere in AIPass
|
||||||
|
drone @devpulse compass query "registry" # search rated decisions
|
||||||
|
drone @devpulse feedback inbox # cross-project feedback
|
||||||
|
```
|
||||||
|
|
||||||
## Invoke
|
## Invoke
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -44,7 +56,7 @@ src/aipass/devpulse/
|
|||||||
│ │ └── watchdog/ # Agent, timer, schedule, registry
|
│ │ └── watchdog/ # Agent, timer, schedule, registry
|
||||||
│ └── plugins/ # Plugin extension point
|
│ └── plugins/ # Plugin extension point
|
||||||
├── devpulse_json/ # JSON handler storage (config, data, logs per module)
|
├── devpulse_json/ # JSON handler storage (config, data, logs per module)
|
||||||
├── tests/ # 309 tests
|
├── tests/ # 348 tests
|
||||||
├── artifacts/ # Birth certificate, reports
|
├── artifacts/ # Birth certificate, reports
|
||||||
├── dropbox/ # Received files, archived plans, install audit
|
├── dropbox/ # Received files, archived plans, install audit
|
||||||
├── docs/ # Transition notes
|
├── docs/ # Transition notes
|
||||||
|
|||||||
@@ -89,11 +89,14 @@ def print_introspection():
|
|||||||
"""Print branch introspection — discovered modules and capabilities."""
|
"""Print branch introspection — discovered modules and capabilities."""
|
||||||
modules = discover_modules()
|
modules = discover_modules()
|
||||||
console.print("[bold cyan]DEVPULSE[/bold cyan] — Orchestration Hub")
|
console.print("[bold cyan]DEVPULSE[/bold cyan] — Orchestration Hub")
|
||||||
|
console.print("[dim]The user's primary collaborator — design, plan, dispatch, track[/dim]")
|
||||||
console.print(f" Modules discovered: {len(modules)}")
|
console.print(f" Modules discovered: {len(modules)}")
|
||||||
for module in modules:
|
for module in modules:
|
||||||
name = module.__name__.split(".")[-1]
|
name = module.__name__.split(".")[-1]
|
||||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||||
console.print(f" {name:20} {desc}")
|
console.print(f" {name:20} {desc}")
|
||||||
|
console.print()
|
||||||
|
console.print("Run 'drone @devpulse --help' for usage information")
|
||||||
|
|
||||||
|
|
||||||
def print_help():
|
def print_help():
|
||||||
@@ -112,6 +115,11 @@ def print_help():
|
|||||||
console.print("[bold]FLAGS:[/bold]")
|
console.print("[bold]FLAGS:[/bold]")
|
||||||
console.print(" --help, -h Show this help message")
|
console.print(" --help, -h Show this help message")
|
||||||
console.print(" --version, -V Show version")
|
console.print(" --version, -V Show version")
|
||||||
|
console.print()
|
||||||
|
console.print("[bold]EXAMPLES:[/bold]")
|
||||||
|
console.print(' drone @devpulse compass query "registry" Search rated decisions')
|
||||||
|
console.print(" drone @devpulse watchdog agent @flow Watch a dispatched agent")
|
||||||
|
console.print(" drone @devpulse feedback inbox Check cross-project feedback")
|
||||||
|
|
||||||
|
|
||||||
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ from aipass.devpulse.apps.handlers.compass.store import (
|
|||||||
VALID_STATUSES,
|
VALID_STATUSES,
|
||||||
add_decision,
|
add_decision,
|
||||||
archive,
|
archive,
|
||||||
|
find_conflicts,
|
||||||
|
mark_surfaced,
|
||||||
query_decisions,
|
query_decisions,
|
||||||
rate,
|
rate,
|
||||||
|
recall_decisions,
|
||||||
review,
|
review,
|
||||||
|
set_note,
|
||||||
stats,
|
stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,8 +40,12 @@ __all__ = [
|
|||||||
"VALID_STATUSES",
|
"VALID_STATUSES",
|
||||||
"add_decision",
|
"add_decision",
|
||||||
"archive",
|
"archive",
|
||||||
|
"find_conflicts",
|
||||||
|
"mark_surfaced",
|
||||||
"query_decisions",
|
"query_decisions",
|
||||||
"rate",
|
"rate",
|
||||||
|
"recall_decisions",
|
||||||
"review",
|
"review",
|
||||||
|
"set_note",
|
||||||
"stats",
|
"stats",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ command, and maintenance UX are later phases.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -57,6 +58,9 @@ VALID_SOURCES = ("devpulse", "user")
|
|||||||
VALID_STATUSES = ("active", "archived")
|
VALID_STATUSES = ("active", "archived")
|
||||||
|
|
||||||
# Columns we return / surface from the decisions table (everything useful).
|
# Columns we return / surface from the decisions table (everything useful).
|
||||||
|
# NOTE: ``score`` is deliberately absent — it is code-invisible (DPLAN-0246
|
||||||
|
# seedgo ruling). The column stays physically on disk as inert NULL, but no
|
||||||
|
# Python surface (SELECTs, returned dicts) touches it.
|
||||||
_DECISION_COLUMNS = (
|
_DECISION_COLUMNS = (
|
||||||
"id",
|
"id",
|
||||||
"created",
|
"created",
|
||||||
@@ -66,10 +70,10 @@ _DECISION_COLUMNS = (
|
|||||||
"note",
|
"note",
|
||||||
"tags",
|
"tags",
|
||||||
"source",
|
"source",
|
||||||
"score",
|
|
||||||
"status",
|
"status",
|
||||||
"last_reviewed",
|
"last_reviewed",
|
||||||
"times_surfaced",
|
"times_surfaced",
|
||||||
|
"supersedes",
|
||||||
)
|
)
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA = """
|
||||||
@@ -85,7 +89,8 @@ CREATE TABLE IF NOT EXISTS decisions (
|
|||||||
score INTEGER,
|
score INTEGER,
|
||||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')),
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')),
|
||||||
last_reviewed TEXT,
|
last_reviewed TEXT,
|
||||||
times_surfaced INTEGER NOT NULL DEFAULT 0
|
times_surfaced INTEGER NOT NULL DEFAULT 0,
|
||||||
|
supersedes INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts USING fts5(
|
||||||
@@ -133,6 +138,42 @@ def _verify_fts5(conn: sqlite3.Connection) -> None:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Apply idempotent schema migrations to an already-open DB.
|
||||||
|
|
||||||
|
Adds the ``supersedes`` column to DBs created before it existed. Guarded by
|
||||||
|
a ``PRAGMA table_info`` pre-check so it is safe to run on every connect —
|
||||||
|
never a blind ``ALTER`` (DPLAN-0246 seedgo ruling: idempotent migration).
|
||||||
|
Fresh DBs already carry the column from ``_SCHEMA``; the pre-check makes
|
||||||
|
this a no-op for them.
|
||||||
|
"""
|
||||||
|
cols = {row["name"] for row in conn.execute("PRAGMA table_info(decisions)")}
|
||||||
|
if "supersedes" not in cols:
|
||||||
|
conn.execute("ALTER TABLE decisions ADD COLUMN supersedes INTEGER")
|
||||||
|
conn.commit()
|
||||||
|
logger.info("[compass] migration: added supersedes column")
|
||||||
|
|
||||||
|
|
||||||
|
# FTS5 MATCH treats characters like " * ( ) : - ^ and the words AND/OR/NOT as
|
||||||
|
# syntax. Untrusted text (a decision's own words) can therefore crash MATCH.
|
||||||
|
# We defuse it by extracting bare word tokens and OR-ing them as quoted string
|
||||||
|
# literals — no operator can survive, and quoting a bareword is exact.
|
||||||
|
_FTS_WORD = re.compile(r"\w+", re.UNICODE)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_fts_query(text: Optional[str]) -> Optional[str]:
|
||||||
|
"""Turn arbitrary text into a safe FTS5 MATCH expression, or None.
|
||||||
|
|
||||||
|
Returns an ``OR`` of the text's word tokens, each quoted as a string
|
||||||
|
literal so FTS5 syntax characters can never reach the parser. Returns None
|
||||||
|
when there are no usable tokens (caller should skip the search).
|
||||||
|
"""
|
||||||
|
tokens = _FTS_WORD.findall(text or "")
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
return " OR ".join(f'"{t}"' for t in tokens)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_db_path(db_path: Optional[Path | str]) -> Path:
|
def _resolve_db_path(db_path: Optional[Path | str]) -> Path:
|
||||||
"""Resolve the effective DB path, defaulting to the branch-root location."""
|
"""Resolve the effective DB path, defaulting to the branch-root location."""
|
||||||
return Path(db_path) if db_path is not None else DEFAULT_DB_PATH
|
return Path(db_path) if db_path is not None else DEFAULT_DB_PATH
|
||||||
@@ -141,8 +182,9 @@ def _resolve_db_path(db_path: Optional[Path | str]) -> Path:
|
|||||||
def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection:
|
def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection:
|
||||||
"""Open (and lazily initialise) the compass DB.
|
"""Open (and lazily initialise) the compass DB.
|
||||||
|
|
||||||
Creates parent directories on first use, verifies FTS5, ensures schema.
|
Creates parent directories on first use, verifies FTS5, ensures schema, and
|
||||||
Rows come back as ``sqlite3.Row`` so we can build clean dicts.
|
runs idempotent migrations. Rows come back as ``sqlite3.Row`` so we can
|
||||||
|
build clean dicts.
|
||||||
"""
|
"""
|
||||||
path = _resolve_db_path(db_path)
|
path = _resolve_db_path(db_path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -151,6 +193,7 @@ def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection:
|
|||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
_verify_fts5(conn)
|
_verify_fts5(conn)
|
||||||
conn.executescript(_SCHEMA)
|
conn.executescript(_SCHEMA)
|
||||||
|
_migrate(conn)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +211,7 @@ def add_decision(
|
|||||||
source: str = "devpulse",
|
source: str = "devpulse",
|
||||||
db_path: Optional[Path | str] = None,
|
db_path: Optional[Path | str] = None,
|
||||||
created: Optional[str] = None,
|
created: Optional[str] = None,
|
||||||
|
supersedes: Optional[int] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Add a rated decision and return its new id.
|
"""Add a rated decision and return its new id.
|
||||||
|
|
||||||
@@ -181,12 +225,16 @@ def add_decision(
|
|||||||
db_path: Optional DB path override (tests pass a temp path).
|
db_path: Optional DB path override (tests pass a temp path).
|
||||||
created: Optional ISO date override; defaults to today. This is the
|
created: Optional ISO date override; defaults to today. This is the
|
||||||
ONLY place a "today" date is stamped.
|
ONLY place a "today" date is stamped.
|
||||||
|
supersedes: Optional id of the decision this entry corrects. When set,
|
||||||
|
the new entry links to it AND that entry is archived — atomically,
|
||||||
|
in one transaction. Errors cleanly (no write) if the id is unknown.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The new row's integer id.
|
The new row's integer id.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: On empty context/decision or invalid rating/source.
|
ValueError: On empty context/decision, invalid rating/source, or a
|
||||||
|
``supersedes`` target that does not exist.
|
||||||
"""
|
"""
|
||||||
if not context or not context.strip():
|
if not context or not context.strip():
|
||||||
raise ValueError("context must be a non-empty string")
|
raise ValueError("context must be a non-empty string")
|
||||||
@@ -201,22 +249,46 @@ def add_decision(
|
|||||||
|
|
||||||
conn = _connect(db_path)
|
conn = _connect(db_path)
|
||||||
try:
|
try:
|
||||||
|
# Validate the supersede target BEFORE any write so a bad id never
|
||||||
|
# leaves a partial insert behind.
|
||||||
|
if supersedes is not None:
|
||||||
|
target = conn.execute("SELECT 1 FROM decisions WHERE id = ?", (supersedes,)).fetchone()
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(f"cannot supersede #{supersedes}: no decision with that id")
|
||||||
|
|
||||||
|
# Insert + archive-the-target in ONE transaction (commit once at the
|
||||||
|
# end); any failure rolls the whole thing back — never half-applied.
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO decisions (created, context, decision, rating, note, tags, source)
|
INSERT INTO decisions (created, context, decision, rating, note, tags, source, supersedes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(stamp, context.strip(), decision.strip(), rating, note, tags, source),
|
(stamp, context.strip(), decision.strip(), rating, note, tags, source, supersedes),
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
if cur.lastrowid is None: # pragma: no cover - sqlite always sets this on INSERT
|
if cur.lastrowid is None: # pragma: no cover - sqlite always sets this on INSERT
|
||||||
raise RuntimeError("compass: INSERT did not return a rowid")
|
raise RuntimeError("compass: INSERT did not return a rowid")
|
||||||
new_id = int(cur.lastrowid)
|
new_id = int(cur.lastrowid)
|
||||||
|
|
||||||
|
if supersedes is not None:
|
||||||
|
conn.execute("UPDATE decisions SET status = 'archived' WHERE id = ?", (supersedes,))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
logger.info("[compass] added decision id=%s rating=%s source=%s", new_id, rating, source)
|
logger.info(
|
||||||
json_handler.log_operation("compass_add", {"id": new_id, "rating": rating, "source": source})
|
"[compass] added decision id=%s rating=%s source=%s supersedes=%s",
|
||||||
|
new_id,
|
||||||
|
rating,
|
||||||
|
source,
|
||||||
|
supersedes,
|
||||||
|
)
|
||||||
|
json_handler.log_operation(
|
||||||
|
"compass_add", {"id": new_id, "rating": rating, "source": source, "supersedes": supersedes}
|
||||||
|
)
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
|
|
||||||
@@ -224,21 +296,28 @@ def query_decisions(
|
|||||||
query: str,
|
query: str,
|
||||||
rating: Optional[str] = None,
|
rating: Optional[str] = None,
|
||||||
limit: int = 5,
|
limit: int = 5,
|
||||||
|
include_archived: bool = False,
|
||||||
db_path: Optional[Path | str] = None,
|
db_path: Optional[Path | str] = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Search active decisions, ranked by FTS5 BM25 relevance.
|
"""Search decisions, ranked by FTS5 BM25 relevance.
|
||||||
|
|
||||||
Increments ``times_surfaced`` for every returned row.
|
Increments ``times_surfaced`` for every returned row. Each result dict
|
||||||
|
carries a computed ``superseded_by`` field: the id of the row that
|
||||||
|
supersedes this one, or None. Combined with the ``supersedes`` column this
|
||||||
|
lets callers render both pointer directions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: FTS5 match query (keywords).
|
query: FTS5 match query (keywords).
|
||||||
rating: Optional exact rating filter (one of VALID_RATINGS).
|
rating: Optional exact rating filter (one of VALID_RATINGS).
|
||||||
limit: Max rows to return (default 5).
|
limit: Max rows to return (default 5).
|
||||||
|
include_archived: When True, lift the ``status = 'active'`` filter so
|
||||||
|
archived rows (the avoid-list) are searchable too. Default False —
|
||||||
|
unchanged active-only behaviour.
|
||||||
db_path: Optional DB path override.
|
db_path: Optional DB path override.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of decision dicts (most relevant first). Each dict includes the
|
A list of decision dicts (most relevant first). Each dict includes the
|
||||||
rating and all useful fields.
|
rating, all useful fields, and the computed ``superseded_by`` pointer.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: On empty query, bad rating filter, or non-positive limit.
|
ValueError: On empty query, bad rating filter, or non-positive limit.
|
||||||
@@ -256,9 +335,10 @@ def query_decisions(
|
|||||||
FROM decisions_fts f
|
FROM decisions_fts f
|
||||||
JOIN decisions d ON d.id = f.rowid
|
JOIN decisions d ON d.id = f.rowid
|
||||||
WHERE decisions_fts MATCH ?
|
WHERE decisions_fts MATCH ?
|
||||||
AND d.status = 'active'
|
|
||||||
"""
|
"""
|
||||||
params: list = [query.strip()]
|
params: list = [query.strip()]
|
||||||
|
if not include_archived:
|
||||||
|
sql += " AND d.status = 'active'"
|
||||||
if rating is not None:
|
if rating is not None:
|
||||||
sql += " AND d.rating = ?"
|
sql += " AND d.rating = ?"
|
||||||
params.append(rating)
|
params.append(rating)
|
||||||
@@ -269,24 +349,119 @@ def query_decisions(
|
|||||||
try:
|
try:
|
||||||
rows = conn.execute(sql, params).fetchall()
|
rows = conn.execute(sql, params).fetchall()
|
||||||
results = [_row_to_dict(r) for r in rows]
|
results = [_row_to_dict(r) for r in rows]
|
||||||
|
for r in results:
|
||||||
|
r["superseded_by"] = None
|
||||||
ids = [r["id"] for r in results]
|
ids = [r["id"] for r in results]
|
||||||
if ids:
|
if ids:
|
||||||
placeholders = ",".join("?" for _ in ids)
|
placeholders = ",".join("?" for _ in ids)
|
||||||
|
# Reverse-lookup: which returned rows are pointed AT by a superseder?
|
||||||
|
successors: dict = {}
|
||||||
|
for row in conn.execute(
|
||||||
|
f"SELECT id, supersedes FROM decisions WHERE supersedes IN ({placeholders})",
|
||||||
|
ids,
|
||||||
|
):
|
||||||
|
successors[row["supersedes"]] = row["id"]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"UPDATE decisions SET times_surfaced = times_surfaced + 1 WHERE id IN ({placeholders})",
|
f"UPDATE decisions SET times_surfaced = times_surfaced + 1 WHERE id IN ({placeholders})",
|
||||||
ids,
|
ids,
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
# Reflect the increment in the returned dicts without a re-query.
|
# Reflect the increment + attach the reverse pointer without re-query.
|
||||||
for r in results:
|
for r in results:
|
||||||
r["times_surfaced"] = (r["times_surfaced"] or 0) + 1
|
r["times_surfaced"] = (r["times_surfaced"] or 0) + 1
|
||||||
|
r["superseded_by"] = successors.get(r["id"])
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
logger.info("[compass] query %r rating=%s -> %d hit(s)", query, rating, len(results))
|
logger.info(
|
||||||
|
"[compass] query %r rating=%s include_archived=%s -> %d hit(s)",
|
||||||
|
query,
|
||||||
|
rating,
|
||||||
|
include_archived,
|
||||||
|
len(results),
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def find_conflicts(
|
||||||
|
context: str,
|
||||||
|
decision: str,
|
||||||
|
limit: int = 3,
|
||||||
|
db_path: Optional[Path | str] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return ACTIVE decisions whose text overlaps a would-be new entry.
|
||||||
|
|
||||||
|
A write-time, side-effect-free advisory helper: it does NOT increment
|
||||||
|
``times_surfaced`` and never writes. The combined ``context + decision``
|
||||||
|
text is sanitised (:func:`_sanitize_fts_query`) so no FTS5 syntax character
|
||||||
|
can crash the MATCH. Returns up to ``limit`` active hits by BM25 relevance,
|
||||||
|
or an empty list when the text has no usable tokens / nothing overlaps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: The would-be new entry's context.
|
||||||
|
decision: The would-be new entry's decision.
|
||||||
|
limit: Max advisory hits to return (default 3).
|
||||||
|
db_path: Optional DB path override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of active decision dicts (most relevant first), possibly empty.
|
||||||
|
"""
|
||||||
|
match = _sanitize_fts_query(f"{context or ''} {decision or ''}")
|
||||||
|
if match is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
select_cols = ", ".join(f"d.{c}" for c in _DECISION_COLUMNS)
|
||||||
|
sql = f"""
|
||||||
|
SELECT {select_cols}
|
||||||
|
FROM decisions_fts f
|
||||||
|
JOIN decisions d ON d.id = f.rowid
|
||||||
|
WHERE decisions_fts MATCH ?
|
||||||
|
AND d.status = 'active'
|
||||||
|
ORDER BY bm25(decisions_fts) ASC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(sql, (match, limit)).fetchall()
|
||||||
|
results = [_row_to_dict(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("[compass] conflict-check -> %d active hit(s)", len(results))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def set_note(
|
||||||
|
decision_id: int,
|
||||||
|
note: Optional[str],
|
||||||
|
db_path: Optional[Path | str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Set (replace) the note on an existing decision.
|
||||||
|
|
||||||
|
The FTS5 external-content ``decisions_au`` trigger re-indexes the row on
|
||||||
|
UPDATE, so the new note is immediately searchable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_id: Target decision id.
|
||||||
|
note: The note text to store (may be empty to clear).
|
||||||
|
db_path: Optional DB path override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was updated, False if no such id.
|
||||||
|
"""
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
cur = conn.execute("UPDATE decisions SET note = ? WHERE id = ?", (note, decision_id))
|
||||||
|
conn.commit()
|
||||||
|
changed = cur.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("[compass] note id=%s (changed=%s)", decision_id, changed)
|
||||||
|
json_handler.log_operation("compass_note", {"id": decision_id, "changed": changed})
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def stats(db_path: Optional[Path | str] = None) -> dict:
|
def stats(db_path: Optional[Path | str] = None) -> dict:
|
||||||
"""Return decision counts by rating, by status, and the total.
|
"""Return decision counts by rating, by status, and the total.
|
||||||
|
|
||||||
@@ -414,3 +589,174 @@ def review(
|
|||||||
logger.info("[compass] review surfaced id=%s stamped=%s", result["id"], stamp)
|
logger.info("[compass] review surfaced id=%s stamped=%s", result["id"], stamp)
|
||||||
json_handler.log_operation("compass_review", {"id": result["id"], "last_reviewed": stamp})
|
json_handler.log_operation("compass_review", {"id": result["id"], "last_reviewed": stamp})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Ambient recall caps the OR-expansion of a raw prompt: beyond this many unique
|
||||||
|
# tokens the extra words add noise, not recall, and the MATCH string balloons.
|
||||||
|
_RECALL_MAX_TOKENS = 64
|
||||||
|
|
||||||
|
# Stopwords never reach the MATCH: in an OR-of-tokens query, high-frequency
|
||||||
|
# filler ("lets keep working on the...") outweighs topic words in BM25 and
|
||||||
|
# surfaces unrelated entries — proven live in the FPLAN-0332 acceptance run.
|
||||||
|
# Three categories, all query-side only (entry text is never filtered):
|
||||||
|
# grammatical stopwords; conversational filler verbs that open most prompts
|
||||||
|
# ("lets keep working on / need to fix"); greeting/small-talk words ("good
|
||||||
|
# morning", "how did it go last night"). In a technical store casual words are
|
||||||
|
# RARE (df 1-2), so rarity scoring alone cannot reject them — they must never
|
||||||
|
# become query tokens at all. The topic space is open; this filler set is
|
||||||
|
# closed and small, which is why filtering here works.
|
||||||
|
_RECALL_STOPWORDS = frozenset(
|
||||||
|
"""a an and about again also are as at back be bit but by can could did do
|
||||||
|
does for from had has have how i if in is it its just me my no not of on
|
||||||
|
or our so still sure than thanks thank that the their then there these
|
||||||
|
they this to too u ur us was way we well were what when where which who
|
||||||
|
why will with would yes you your
|
||||||
|
add check doing done fix get go going keep lets look make need now see
|
||||||
|
should try use want work working write
|
||||||
|
day days good hello hey hi im ive last morning night ok okay please right
|
||||||
|
today tomorrow tonight week yesterday""".split()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recall_decisions(
|
||||||
|
prompt_text: str,
|
||||||
|
limit: int = 3,
|
||||||
|
db_path: Optional[Path | str] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return scored ambient-recall candidates for raw prompt text.
|
||||||
|
|
||||||
|
The Track 2 read path (DPLAN-0246): a hooks handler passes the raw user
|
||||||
|
prompt; governance (@memory's ``should_surface``) judges the candidates.
|
||||||
|
Side-effect-free — ``times_surfaced`` is NOT incremented here, because a
|
||||||
|
candidate is not yet surfaced; the caller reports actual injections via
|
||||||
|
:func:`mark_surfaced` so the counter stays honest.
|
||||||
|
|
||||||
|
The prompt is arbitrary text, never FTS5 syntax: unique word tokens (first
|
||||||
|
``_RECALL_MAX_TOKENS``) are OR-ed as quoted literals, ACTIVE rows only,
|
||||||
|
ranked by BM25. Each result dict carries ``relevance`` — the BM25 magnitude
|
||||||
|
mapped to (0, 1) via ``m / (1 + m)``, higher = more relevant — so
|
||||||
|
governance thresholds live on a bounded scale.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt_text: Raw prompt text (any content, any length).
|
||||||
|
limit: Max candidates to return (default 3).
|
||||||
|
db_path: Optional DB path override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of active decision dicts (most relevant first), each with a
|
||||||
|
``relevance`` float in (0, 1); empty when the prompt has no usable
|
||||||
|
tokens or nothing matches.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: On non-positive limit.
|
||||||
|
"""
|
||||||
|
if limit <= 0:
|
||||||
|
raise ValueError(f"limit must be a positive integer, got {limit!r}")
|
||||||
|
|
||||||
|
tokens = _FTS_WORD.findall(prompt_text or "")
|
||||||
|
unique: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for t in tokens:
|
||||||
|
lowered = t.lower()
|
||||||
|
if lowered not in seen and lowered not in _RECALL_STOPWORDS:
|
||||||
|
seen.add(lowered)
|
||||||
|
unique.append(t)
|
||||||
|
if len(unique) >= _RECALL_MAX_TOKENS:
|
||||||
|
break
|
||||||
|
if not unique:
|
||||||
|
return []
|
||||||
|
match = " OR ".join(f'"{t}"' for t in unique)
|
||||||
|
|
||||||
|
select_cols = ", ".join(f"d.{c}" for c in _DECISION_COLUMNS)
|
||||||
|
sql = f"""
|
||||||
|
SELECT {select_cols}, bm25(decisions_fts) AS rank,
|
||||||
|
(d.context || ' ' || d.decision || ' ' ||
|
||||||
|
COALESCE(d.note, '') || ' ' || COALESCE(d.tags, '')) AS _text
|
||||||
|
FROM decisions_fts f
|
||||||
|
JOIN decisions d ON d.id = f.rowid
|
||||||
|
WHERE decisions_fts MATCH ?
|
||||||
|
AND d.status = 'active'
|
||||||
|
ORDER BY bm25(decisions_fts) ASC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
# Over-fetch: rare-token scoring below reorders, so BM25's top-N alone
|
||||||
|
# would let a filler-heavy row crowd out a topical one.
|
||||||
|
rows = conn.execute(sql, (match, max(limit * 3, 10))).fetchall()
|
||||||
|
|
||||||
|
# Rarity cutoff: a token is evidence only if few entries contain it.
|
||||||
|
active_total = conn.execute("SELECT count(*) FROM decisions WHERE status = 'active'").fetchone()[0]
|
||||||
|
rare_cutoff = max(3, active_total // 10)
|
||||||
|
|
||||||
|
# Document frequency per prompt token, ONE query against the FTS index.
|
||||||
|
df: dict[str, int] = {}
|
||||||
|
for t in unique:
|
||||||
|
df[t.lower()] = conn.execute(
|
||||||
|
"SELECT count(*) FROM decisions_fts f JOIN decisions d ON d.id = f.rowid "
|
||||||
|
"WHERE decisions_fts MATCH ? AND d.status = 'active'",
|
||||||
|
(f'"{t}"',),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
item = _row_to_dict(row)
|
||||||
|
text = row["_text"].lower()
|
||||||
|
# Rare-token evidence: how many DISTINCTIVE prompt words this entry
|
||||||
|
# actually contains. Filler matches score zero — an entry with no
|
||||||
|
# rare-token overlap must not surface (FPLAN-0332 acceptance: a
|
||||||
|
# haiku prompt surfaced an unrelated ruling on BM25 alone).
|
||||||
|
matched_rare = sum(
|
||||||
|
1
|
||||||
|
for t in unique
|
||||||
|
if df[t.lower()] <= rare_cutoff and re.search(rf"\b{re.escape(t)}", text, re.IGNORECASE)
|
||||||
|
)
|
||||||
|
item["relevance"] = matched_rare / (1.0 + matched_rare)
|
||||||
|
item["_bm25"] = row["rank"]
|
||||||
|
results.append(item)
|
||||||
|
|
||||||
|
results.sort(key=lambda r: (-r["relevance"], r["_bm25"]))
|
||||||
|
results = results[:limit]
|
||||||
|
for r in results:
|
||||||
|
del r["_bm25"]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("[compass] recall -> %d candidate(s)", len(results))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def mark_surfaced(
|
||||||
|
decision_ids: list[int],
|
||||||
|
db_path: Optional[Path | str] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Increment ``times_surfaced`` for decisions actually injected.
|
||||||
|
|
||||||
|
The write half of the recall contract: :func:`recall_decisions` returns
|
||||||
|
candidates without side effects; whatever governance approves and the
|
||||||
|
caller truly injects gets counted here — never the merely-considered.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_ids: Ids of the decisions that were injected.
|
||||||
|
db_path: Optional DB path override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rows updated (0 for an empty list).
|
||||||
|
"""
|
||||||
|
if not decision_ids:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
placeholders = ",".join("?" for _ in decision_ids)
|
||||||
|
cur = conn.execute(
|
||||||
|
f"UPDATE decisions SET times_surfaced = times_surfaced + 1 WHERE id IN ({placeholders})",
|
||||||
|
list(decision_ids),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
updated = cur.rowcount
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("[compass] mark_surfaced -> %d row(s)", updated)
|
||||||
|
return updated
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ This module is the thin command layer (FPLAN P2). It parses args, calls the
|
|||||||
No business logic lives here — that's the handler's job.
|
No business logic lives here — that's the handler's job.
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]
|
add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..] [--supersedes N]
|
||||||
query "question" [--rating R] [--limit N]
|
query "question" [--rating R] [--limit N] [--include-archived]
|
||||||
stats
|
stats
|
||||||
rate <id> <rating>
|
rate <id> <rating>
|
||||||
archive <id>
|
archive <id>
|
||||||
|
note <id> "text"
|
||||||
review
|
review
|
||||||
|
|
||||||
Every subcommand accepts ``--db PATH`` (passed through as ``db_path=``) for
|
Every subcommand accepts ``--db PATH`` (passed through as ``db_path=``) for
|
||||||
@@ -36,11 +37,19 @@ from typing import List, Optional
|
|||||||
from aipass.prax import logger
|
from aipass.prax import logger
|
||||||
from aipass.cli.apps.modules import err_console, error, warning
|
from aipass.cli.apps.modules import err_console, error, warning
|
||||||
from aipass.devpulse.apps.handlers import compass
|
from aipass.devpulse.apps.handlers import compass
|
||||||
|
from aipass.devpulse.apps.handlers.compass import mark_surfaced, recall_decisions
|
||||||
from aipass.devpulse.apps.handlers.json import json_handler
|
from aipass.devpulse.apps.handlers.json import json_handler
|
||||||
|
|
||||||
|
# Public cross-branch recall API (DPLAN-0246 Track 2). Other branches import
|
||||||
|
# at the modules/ boundary ONLY (seedgo boardroom ruling):
|
||||||
|
# from aipass.devpulse.apps.modules.compass import recall_decisions, mark_surfaced
|
||||||
|
# recall_decisions(prompt_text, limit) -> scored candidates, side-effect-free;
|
||||||
|
# mark_surfaced(ids) counts only what the caller actually injected.
|
||||||
|
__all__ = ["handle_command", "mark_surfaced", "recall_decisions"]
|
||||||
|
|
||||||
console = err_console
|
console = err_console
|
||||||
|
|
||||||
_VALID_SUBCOMMANDS = ("add", "query", "stats", "rate", "archive", "review")
|
_VALID_SUBCOMMANDS = ("add", "query", "stats", "rate", "archive", "note", "review")
|
||||||
|
|
||||||
# Console colour per rating — the rating is the signal, so make it pop.
|
# Console colour per rating — the rating is the signal, so make it pop.
|
||||||
_RATING_STYLE = {
|
_RATING_STYLE = {
|
||||||
@@ -59,6 +68,7 @@ HELP_TEXT = """\
|
|||||||
compass stats Counts by rating/status
|
compass stats Counts by rating/status
|
||||||
compass rate <id> <rating> Re-rate a decision
|
compass rate <id> <rating> Re-rate a decision
|
||||||
compass archive <id> Archive a decision
|
compass archive <id> Archive a decision
|
||||||
|
compass note <id> "text" Set a decision's note
|
||||||
compass review Surface one to review
|
compass review Surface one to review
|
||||||
compass --help Show this help
|
compass --help Show this help
|
||||||
|
|
||||||
@@ -70,19 +80,43 @@ HELP_TEXT = """\
|
|||||||
--note "..." Optional human observation.
|
--note "..." Optional human observation.
|
||||||
--tags a,b,c Optional comma-separated tags.
|
--tags a,b,c Optional comma-separated tags.
|
||||||
--source S Optional. devpulse (default) or user.
|
--source S Optional. devpulse (default) or user.
|
||||||
|
--supersedes N Optional. Archive decision #N and link this entry as its
|
||||||
|
correction (atomic). At add time, overlapping active
|
||||||
|
entries are shown as a non-blocking advisory.
|
||||||
|
|
||||||
|
[bold]Options (query):[/bold]
|
||||||
|
--rating R Optional exact-rating filter.
|
||||||
|
--limit N Optional max results (default 5).
|
||||||
|
--include-archived Also search archived (avoid-list) entries; archived hits
|
||||||
|
show their status + supersession pointer.
|
||||||
|
|
||||||
[bold]Options (all subcommands):[/bold]
|
[bold]Options (all subcommands):[/bold]
|
||||||
--db PATH Use an alternate SQLite store (testing / power use).
|
--db PATH Use an alternate SQLite store (testing / power use).
|
||||||
|
|
||||||
[bold]Examples:[/bold]
|
[bold]Examples:[/bold]
|
||||||
drone @devpulse compass add "auth fork" "chose JWT over sessions" --rating good
|
drone @devpulse compass add "auth fork" "chose JWT over sessions" --rating good
|
||||||
|
drone @devpulse compass add "auth fork" "switch to sessions" --rating good --supersedes 4
|
||||||
drone @devpulse compass query "auth" --rating good --limit 3
|
drone @devpulse compass query "auth" --rating good --limit 3
|
||||||
|
drone @devpulse compass query "auth" --include-archived
|
||||||
drone @devpulse compass stats
|
drone @devpulse compass stats
|
||||||
drone @devpulse compass rate 4 bad
|
drone @devpulse compass rate 4 bad
|
||||||
drone @devpulse compass archive 4
|
drone @devpulse compass archive 4
|
||||||
|
drone @devpulse compass note 4 "revisited — this held up"
|
||||||
drone @devpulse compass review
|
drone @devpulse compass review
|
||||||
|
|
||||||
See DPLAN-0212 (design) and the compass handler (apps/handlers/compass/).
|
See DPLAN-0212 / DPLAN-0246 (design) and the compass handler (apps/handlers/compass/).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_NOTE_HELP_TEXT = """\
|
||||||
|
[bold]compass note[/bold] — set (replace) a decision's note
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
compass note <id> "text" Set the note on decision #<id>
|
||||||
|
compass note --help Show this help
|
||||||
|
|
||||||
|
The note is re-indexed for search immediately — the FTS5 mirror stays in sync,
|
||||||
|
so the new note text is findable by 'compass query' right away.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +127,7 @@ def print_introspection() -> None:
|
|||||||
console.print("[dim]Devpulse rated decision store. The truth-store of choices —[/dim]")
|
console.print("[dim]Devpulse rated decision store. The truth-store of choices —[/dim]")
|
||||||
console.print("[dim]each decision rated; the rating is the signal at a fork.[/dim]")
|
console.print("[dim]each decision rated; the rating is the signal at a fork.[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[yellow]Subcommands:[/yellow] [cyan]add, query, stats, rate, archive, review[/cyan]")
|
console.print("[yellow]Subcommands:[/yellow] [cyan]add, query, stats, rate, archive, note, review[/cyan]")
|
||||||
console.print("[dim]Run 'compass --help' for full usage.[/dim]")
|
console.print("[dim]Run 'compass --help' for full usage.[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
@@ -141,6 +175,8 @@ def handle_command(command: str, args: List[str]) -> bool:
|
|||||||
return _handle_rate(sub_args)
|
return _handle_rate(sub_args)
|
||||||
if subcommand == "archive":
|
if subcommand == "archive":
|
||||||
return _handle_archive(sub_args)
|
return _handle_archive(sub_args)
|
||||||
|
if subcommand == "note":
|
||||||
|
return _handle_note(sub_args)
|
||||||
if subcommand == "review":
|
if subcommand == "review":
|
||||||
return _handle_review(sub_args)
|
return _handle_review(sub_args)
|
||||||
|
|
||||||
@@ -179,6 +215,18 @@ def _extract_db_path(args: List[str]) -> tuple[List[str], Optional[str]]:
|
|||||||
return _extract_flag(args, "--db")
|
return _extract_flag(args, "--db")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bool_flag(args: List[str], flag: str) -> tuple[List[str], bool]:
|
||||||
|
"""Pull a valueless boolean ``--flag`` out of args.
|
||||||
|
|
||||||
|
Returns the remaining args (every occurrence of the flag removed) and True
|
||||||
|
if the flag was present, else False. Unlike ``_extract_flag`` this consumes
|
||||||
|
no following value.
|
||||||
|
"""
|
||||||
|
if flag in args:
|
||||||
|
return [a for a in args if a != flag], True
|
||||||
|
return args, False
|
||||||
|
|
||||||
|
|
||||||
def _rating_tag(rating: str) -> str:
|
def _rating_tag(rating: str) -> str:
|
||||||
"""Render a coloured ``[RATING]`` tag for query/review output."""
|
"""Render a coloured ``[RATING]`` tag for query/review output."""
|
||||||
style = _RATING_STYLE.get(rating, "bold white")
|
style = _RATING_STYLE.get(rating, "bold white")
|
||||||
@@ -198,13 +246,16 @@ def _handle_add(sub_args: List[str]) -> bool:
|
|||||||
rest, note = _extract_flag(rest, "--note")
|
rest, note = _extract_flag(rest, "--note")
|
||||||
rest, tags = _extract_flag(rest, "--tags")
|
rest, tags = _extract_flag(rest, "--tags")
|
||||||
rest, source = _extract_flag(rest, "--source")
|
rest, source = _extract_flag(rest, "--source")
|
||||||
|
rest, supersedes_raw = _extract_flag(rest, "--supersedes")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("[compass] add arg-parse error: %s", exc)
|
logger.warning("[compass] add arg-parse error: %s", exc)
|
||||||
error(str(exc), suggestion="Use 'compass --help' for usage")
|
error(str(exc), suggestion="Use 'compass --help' for usage")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if len(rest) < 2:
|
if len(rest) < 2:
|
||||||
error('Usage: compass add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]')
|
error(
|
||||||
|
'Usage: compass add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..] [--supersedes N]'
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
if rating is None:
|
if rating is None:
|
||||||
error("compass add requires --rating", suggestion="One of: good | bad | impressive | interesting")
|
error("compass add requires --rating", suggestion="One of: good | bad | impressive | interesting")
|
||||||
@@ -213,6 +264,34 @@ def _handle_add(sub_args: List[str]) -> bool:
|
|||||||
context = rest[0]
|
context = rest[0]
|
||||||
decision = rest[1]
|
decision = rest[1]
|
||||||
|
|
||||||
|
supersedes: Optional[int] = None
|
||||||
|
if supersedes_raw is not None:
|
||||||
|
try:
|
||||||
|
supersedes = int(supersedes_raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning("[compass] add bad --supersedes %r: %s", supersedes_raw, exc)
|
||||||
|
error(f"--supersedes must be an integer, got {supersedes_raw!r}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Write-time conflict check — a NON-BLOCKING advisory (DPLAN-0246). Skipped
|
||||||
|
# when the writer already chose to supersede, and never allowed to block or
|
||||||
|
# crash the add. Only shown when NOT already superseding.
|
||||||
|
if supersedes is None:
|
||||||
|
try:
|
||||||
|
conflicts = compass.find_conflicts(context, decision, db_path=db_path)
|
||||||
|
except Exception as exc: # advisory must never break a write
|
||||||
|
logger.warning("[compass] conflict-check failed (non-blocking): %s", exc)
|
||||||
|
conflicts = []
|
||||||
|
for c in conflicts:
|
||||||
|
cid = c.get("id")
|
||||||
|
excerpt = (c.get("context") or "").strip()
|
||||||
|
if len(excerpt) > 80:
|
||||||
|
excerpt = excerpt[:77] + "..."
|
||||||
|
console.print(
|
||||||
|
f"[yellow]possible conflict with #{cid}[/yellow]: {excerpt} "
|
||||||
|
f"[dim]— supersede? (--supersedes {cid})[/dim]"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
new_id = compass.add_decision(
|
new_id = compass.add_decision(
|
||||||
context,
|
context,
|
||||||
@@ -222,6 +301,7 @@ def _handle_add(sub_args: List[str]) -> bool:
|
|||||||
tags=tags,
|
tags=tags,
|
||||||
source=source if source is not None else "devpulse",
|
source=source if source is not None else "devpulse",
|
||||||
db_path=db_path,
|
db_path=db_path,
|
||||||
|
supersedes=supersedes,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("[compass] add rejected: %s", exc)
|
logger.warning("[compass] add rejected: %s", exc)
|
||||||
@@ -235,6 +315,8 @@ def _handle_add(sub_args: List[str]) -> bool:
|
|||||||
console.print(f" [cyan]note:[/cyan] {note}")
|
console.print(f" [cyan]note:[/cyan] {note}")
|
||||||
if tags:
|
if tags:
|
||||||
console.print(f" [cyan]tags:[/cyan] {tags}")
|
console.print(f" [cyan]tags:[/cyan] {tags}")
|
||||||
|
if supersedes is not None:
|
||||||
|
console.print(f" [magenta]supersedes #{supersedes}[/magenta] [dim](archived)[/dim]")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -242,6 +324,7 @@ def _handle_query(sub_args: List[str]) -> bool:
|
|||||||
"""Parse and dispatch ``compass query "question" [--rating R] [--limit N]``."""
|
"""Parse and dispatch ``compass query "question" [--rating R] [--limit N]``."""
|
||||||
try:
|
try:
|
||||||
rest, db_path = _extract_db_path(sub_args)
|
rest, db_path = _extract_db_path(sub_args)
|
||||||
|
rest, include_archived = _extract_bool_flag(rest, "--include-archived")
|
||||||
rest, rating = _extract_flag(rest, "--rating")
|
rest, rating = _extract_flag(rest, "--rating")
|
||||||
rest, limit_raw = _extract_flag(rest, "--limit")
|
rest, limit_raw = _extract_flag(rest, "--limit")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -250,7 +333,7 @@ def _handle_query(sub_args: List[str]) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
if not rest:
|
if not rest:
|
||||||
error('Usage: compass query "question" [--rating R] [--limit N]')
|
error('Usage: compass query "question" [--rating R] [--limit N] [--include-archived]')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
query_text = rest[0]
|
query_text = rest[0]
|
||||||
@@ -265,7 +348,13 @@ def _handle_query(sub_args: List[str]) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = compass.query_decisions(query_text, rating=rating, limit=limit, db_path=db_path)
|
results = compass.query_decisions(
|
||||||
|
query_text,
|
||||||
|
rating=rating,
|
||||||
|
limit=limit,
|
||||||
|
include_archived=include_archived,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("[compass] query rejected: %s", exc)
|
logger.warning("[compass] query rejected: %s", exc)
|
||||||
error(str(exc))
|
error(str(exc))
|
||||||
@@ -294,6 +383,16 @@ def _render_query_results(query_text: str, rating: Optional[str], results: List[
|
|||||||
console.print(f" [cyan]note:[/cyan] {r['note']}")
|
console.print(f" [cyan]note:[/cyan] {r['note']}")
|
||||||
if r.get("tags"):
|
if r.get("tags"):
|
||||||
console.print(f" [cyan]tags:[/cyan] {r['tags']}")
|
console.print(f" [cyan]tags:[/cyan] {r['tags']}")
|
||||||
|
# Supersession pointers — an archived hit must never masquerade as
|
||||||
|
# current truth, so flag its status + who replaced it (DPLAN-0246).
|
||||||
|
if r.get("status") == "archived":
|
||||||
|
superseded_by = r.get("superseded_by")
|
||||||
|
if superseded_by:
|
||||||
|
console.print(f" [bold yellow]ARCHIVED[/bold yellow] — superseded by #{superseded_by}")
|
||||||
|
else:
|
||||||
|
console.print(" [bold yellow]ARCHIVED[/bold yellow] (avoid-list)")
|
||||||
|
if r.get("supersedes"):
|
||||||
|
console.print(f" [magenta]supersedes #{r['supersedes']}[/magenta]")
|
||||||
meta = f"source={r.get('source', '?')} status={r.get('status', '?')} surfaced={r.get('times_surfaced', 0)}"
|
meta = f"source={r.get('source', '?')} status={r.get('status', '?')} surfaced={r.get('times_surfaced', 0)}"
|
||||||
console.print(f" [dim]{meta}[/dim]")
|
console.print(f" [dim]{meta}[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
@@ -389,6 +488,44 @@ def _handle_archive(sub_args: List[str]) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_note(sub_args: List[str]) -> bool:
|
||||||
|
"""Dispatch ``compass note <id> "text"`` — set a decision's note.
|
||||||
|
|
||||||
|
Follows the subcommand-help convention: ``compass note --help`` prints the
|
||||||
|
per-subcommand help block; malformed input shows the Usage line.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rest, db_path = _extract_db_path(sub_args)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning("[compass] note arg-parse error: %s", exc)
|
||||||
|
error(str(exc))
|
||||||
|
return True
|
||||||
|
|
||||||
|
if rest and rest[0] in ("--help", "-h", "help"):
|
||||||
|
console.print(_NOTE_HELP_TEXT)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if len(rest) < 2:
|
||||||
|
error('Usage: compass note <id> "text"')
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
decision_id = int(rest[0])
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning("[compass] note bad id %r: %s", rest[0], exc)
|
||||||
|
error(f"<id> must be an integer, got {rest[0]!r}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
note_text = rest[1]
|
||||||
|
changed = compass.set_note(decision_id, note_text, db_path=db_path)
|
||||||
|
if changed:
|
||||||
|
console.print(f"[green]Note set[/green] on [bold]#{decision_id}[/bold]")
|
||||||
|
console.print(f" [cyan]note:[/cyan] {note_text}")
|
||||||
|
else:
|
||||||
|
warning(f"No decision with id {decision_id} — nothing changed.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _handle_review(sub_args: List[str]) -> bool:
|
def _handle_review(sub_args: List[str]) -> bool:
|
||||||
"""Dispatch ``compass review`` — surface one active decision to review."""
|
"""Dispatch ``compass review`` — surface one active decision to review."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -275,3 +275,176 @@ def test_flag_without_value_errors(capsys, db):
|
|||||||
assert compass_cmd.handle_command("compass", ["query", "x", "--rating"]) is True
|
assert compass_cmd.handle_command("compass", ["query", "x", "--rating"]) is True
|
||||||
out = _output(capsys).lower()
|
out = _output(capsys).lower()
|
||||||
assert "rating" in out and "value" in out
|
assert "rating" in out and "value" in out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# supersedes — atomic archive + link, both pointer directions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_supersedes_archives_and_links(capsys, db):
|
||||||
|
"""add --supersedes N archives #N, links the new row, shows 'supersedes #N'."""
|
||||||
|
old = _add(capsys, db, "old ctx sessions", "use sessions", "good")
|
||||||
|
capsys.readouterr()
|
||||||
|
assert (
|
||||||
|
compass_cmd.handle_command(
|
||||||
|
"compass",
|
||||||
|
[
|
||||||
|
"add",
|
||||||
|
"new ctx jwt",
|
||||||
|
"switch to jwt",
|
||||||
|
"--rating",
|
||||||
|
"good",
|
||||||
|
"--supersedes",
|
||||||
|
str(old),
|
||||||
|
"--db",
|
||||||
|
db,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
out = _output(capsys)
|
||||||
|
assert f"supersedes #{old}" in out
|
||||||
|
|
||||||
|
# The archived row is gone from the default (active-only) query...
|
||||||
|
q = _query_out(capsys, db, "sessions")
|
||||||
|
assert "0 result(s)" in q
|
||||||
|
|
||||||
|
# ...but --include-archived surfaces it WITH its status + forward pointer.
|
||||||
|
q2 = _query_out(capsys, db, "sessions", "--include-archived")
|
||||||
|
assert "ARCHIVED" in q2.upper()
|
||||||
|
assert "superseded by #" in q2.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_supersedes_bad_id_errors_no_write(capsys, db):
|
||||||
|
"""add --supersedes to a missing id errors and writes nothing."""
|
||||||
|
capsys.readouterr()
|
||||||
|
compass_cmd.handle_command(
|
||||||
|
"compass",
|
||||||
|
["add", "ctx", "dec", "--rating", "good", "--supersedes", "9999", "--db", db],
|
||||||
|
)
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "9999" in out
|
||||||
|
assert "total decisions: 0" in _stats_out(capsys, db).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_supersedes_non_integer_errors(capsys, db):
|
||||||
|
"""A non-integer --supersedes fails loud."""
|
||||||
|
assert (
|
||||||
|
compass_cmd.handle_command(
|
||||||
|
"compass",
|
||||||
|
["add", "ctx", "dec", "--rating", "good", "--supersedes", "abc", "--db", db],
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "supersedes" in out and "integer" in out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# write-time conflict advisory — non-blocking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_conflict_advisory_prints_but_does_not_block(capsys, db):
|
||||||
|
"""An overlapping active row triggers an advisory; the add still succeeds."""
|
||||||
|
_add(capsys, db, "caching layer strategy", "add redis caching", "good")
|
||||||
|
capsys.readouterr()
|
||||||
|
compass_cmd.handle_command(
|
||||||
|
"compass",
|
||||||
|
["add", "caching approach again", "another caching layer", "--rating", "good", "--db", db],
|
||||||
|
)
|
||||||
|
out = _output(capsys)
|
||||||
|
assert "possible conflict" in out.lower()
|
||||||
|
assert "--supersedes" in out # advisory hints the fix
|
||||||
|
assert "Added decision" in out # NON-BLOCKING: still added
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_no_conflict_on_empty_store(capsys, db):
|
||||||
|
"""First add on an empty store prints no advisory."""
|
||||||
|
capsys.readouterr()
|
||||||
|
compass_cmd.handle_command("compass", ["add", "unique ctx", "unique dec", "--rating", "good", "--db", db])
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "possible conflict" not in out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# note — set a note, prove it is immediately searchable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_command_sets_and_is_searchable(capsys, db):
|
||||||
|
"""note <id> "text" sets the note; a later query finds the new note text."""
|
||||||
|
did = _add(capsys, db, "note cmd ctx", "note cmd dec", "good")
|
||||||
|
capsys.readouterr()
|
||||||
|
assert compass_cmd.handle_command("compass", ["note", str(did), "findme pterodactyl", "--db", db]) is True
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "note set" in out
|
||||||
|
|
||||||
|
q = _query_out(capsys, db, "pterodactyl")
|
||||||
|
assert "1 result(s)" in q
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_help(capsys):
|
||||||
|
"""compass note --help prints per-subcommand usage."""
|
||||||
|
assert compass_cmd.handle_command("compass", ["note", "--help"]) is True
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "note" in out and "usage" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_missing_id_warns(capsys, db):
|
||||||
|
"""note on a non-existent id reports nothing changed, does not crash."""
|
||||||
|
assert compass_cmd.handle_command("compass", ["note", "999", "text", "--db", db]) is True
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "999" in out and ("nothing changed" in out or "no decision" in out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_missing_args_shows_usage(capsys, db):
|
||||||
|
"""note with too few args shows usage."""
|
||||||
|
assert compass_cmd.handle_command("compass", ["note", "5", "--db", db]) is True
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "usage" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_and_introspection_list_note(capsys):
|
||||||
|
"""Both --help and bare introspection advertise the note subcommand."""
|
||||||
|
compass_cmd.handle_command("compass", ["--help"])
|
||||||
|
assert "note" in _output(capsys).lower()
|
||||||
|
compass_cmd.handle_command("compass", [])
|
||||||
|
assert "note" in _output(capsys).lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --include-archived — archived hits must show status + supersession pointer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_include_archived_shows_archived_pointer(capsys, db):
|
||||||
|
"""--include-archived surfaces an archived row flagged with its successor."""
|
||||||
|
old = _add(capsys, db, "archived-visible ctx", "the old choice", "bad")
|
||||||
|
capsys.readouterr()
|
||||||
|
compass_cmd.handle_command(
|
||||||
|
"compass",
|
||||||
|
[
|
||||||
|
"add",
|
||||||
|
"replacement ctx",
|
||||||
|
"the new choice",
|
||||||
|
"--rating",
|
||||||
|
"good",
|
||||||
|
"--supersedes",
|
||||||
|
str(old),
|
||||||
|
"--db",
|
||||||
|
db,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
capsys.readouterr()
|
||||||
|
|
||||||
|
# Default query hides the archived row.
|
||||||
|
q = _query_out(capsys, db, "old choice")
|
||||||
|
assert "0 result(s)" in q
|
||||||
|
|
||||||
|
# With the flag it appears, unmistakably marked archived + superseded.
|
||||||
|
q2 = _query_out(capsys, db, "old choice", "--include-archived")
|
||||||
|
assert "1 result(s)" in q2
|
||||||
|
assert "archived" in q2.lower()
|
||||||
|
assert "superseded by #" in q2.lower()
|
||||||
|
|||||||
@@ -283,3 +283,252 @@ class TestInputValidation:
|
|||||||
compass.add_decision("ctx", "dec", "good", db_path=db)
|
compass.add_decision("ctx", "dec", "good", db_path=db)
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
compass.query_decisions("ctx", limit=0, db_path=db)
|
compass.query_decisions("ctx", limit=0, db_path=db)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSupersedes:
|
||||||
|
"""supersedes column, atomic archive+link, idempotent migration (DPLAN-0246)."""
|
||||||
|
|
||||||
|
def test_add_with_supersedes_archives_and_links(self, db):
|
||||||
|
"""--supersedes links the corrector AND archives the target, atomically."""
|
||||||
|
old = compass.add_decision("old auth ctx", "use sessions", "good", db_path=db)
|
||||||
|
new = compass.add_decision("new auth ctx", "switch to JWT", "good", db_path=db, supersedes=old)
|
||||||
|
# The corrector row links back to what it replaced.
|
||||||
|
hit = compass.query_decisions("JWT", db_path=db)[0]
|
||||||
|
assert hit["supersedes"] == old
|
||||||
|
# The old entry is archived → gone from the active query.
|
||||||
|
assert compass.query_decisions("sessions", db_path=db) == []
|
||||||
|
# With include_archived it reappears, pointing FORWARD to its successor.
|
||||||
|
arch = compass.query_decisions("sessions", include_archived=True, db_path=db)[0]
|
||||||
|
assert arch["status"] == "archived"
|
||||||
|
assert arch["superseded_by"] == new
|
||||||
|
|
||||||
|
def test_supersedes_nonexistent_raises_no_partial_write(self, db):
|
||||||
|
"""A bad --supersedes id errors cleanly and leaves NO partial write."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
compass.add_decision("ctx", "dec", "good", db_path=db, supersedes=9999)
|
||||||
|
assert compass.stats(db_path=db)["total"] == 0
|
||||||
|
|
||||||
|
def test_supersedes_defaults_none(self, db):
|
||||||
|
"""A plain add has supersedes=None and superseded_by=None."""
|
||||||
|
compass.add_decision("plain ctx", "plain dec", "good", db_path=db)
|
||||||
|
hit = compass.query_decisions("plain", db_path=db)[0]
|
||||||
|
assert hit["supersedes"] is None
|
||||||
|
assert hit["superseded_by"] is None
|
||||||
|
|
||||||
|
def test_active_hit_shows_its_supersedes_pointer(self, db):
|
||||||
|
"""An ACTIVE corrector still exposes its supersedes pointer on query."""
|
||||||
|
old = compass.add_decision("legacy topic zzz", "old way", "bad", db_path=db)
|
||||||
|
compass.add_decision("current topic zzz", "new way", "good", db_path=db, supersedes=old)
|
||||||
|
hit = compass.query_decisions("current", db_path=db)[0]
|
||||||
|
assert hit["supersedes"] == old
|
||||||
|
assert hit["superseded_by"] is None # nothing supersedes the corrector
|
||||||
|
|
||||||
|
def test_migration_idempotent_repeated_connects(self, db):
|
||||||
|
"""Re-opening the DB re-runs migration harmlessly; column stays present."""
|
||||||
|
compass.add_decision("ctx one", "dec one", "good", db_path=db)
|
||||||
|
for _ in range(3):
|
||||||
|
conn = store._connect(db)
|
||||||
|
try:
|
||||||
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert "supersedes" in cols
|
||||||
|
|
||||||
|
def test_migration_adds_column_to_legacy_db(self, db):
|
||||||
|
"""A pre-supersedes DB gets the column added in via _connect migration."""
|
||||||
|
# Build a legacy `decisions` table WITHOUT supersedes, as older builds had.
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE decisions ("
|
||||||
|
"id INTEGER PRIMARY KEY, created TEXT, context TEXT NOT NULL, "
|
||||||
|
"decision TEXT NOT NULL, rating TEXT NOT NULL, note TEXT, tags TEXT, "
|
||||||
|
"source TEXT, score INTEGER, status TEXT DEFAULT 'active', "
|
||||||
|
"last_reviewed TEXT, times_surfaced INTEGER DEFAULT 0)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
# Legacy table lacks the column...
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
pre = {r[1] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||||
|
conn.close()
|
||||||
|
assert "supersedes" not in pre
|
||||||
|
# ...a store connect migrates it in (CREATE IF NOT EXISTS is a no-op here).
|
||||||
|
conn = store._connect(db)
|
||||||
|
try:
|
||||||
|
post = {r["name"] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert "supersedes" in post
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindConflicts:
|
||||||
|
"""Write-time conflict check: FTS over ACTIVE rows, sanitized, side-effect-free."""
|
||||||
|
|
||||||
|
def test_finds_overlapping_active_row(self, db):
|
||||||
|
"""A would-be entry surfaces an existing active row it overlaps."""
|
||||||
|
compass.add_decision("caching strategy for the API", "add a redis caching layer", "good", db_path=db)
|
||||||
|
hits = compass.find_conflicts("caching approach", "use a caching layer", db_path=db)
|
||||||
|
assert len(hits) >= 1
|
||||||
|
assert any("caching" in h["context"] for h in hits)
|
||||||
|
|
||||||
|
def test_ignores_archived_rows(self, db):
|
||||||
|
"""Conflict check searches active rows only — archived never surfaces."""
|
||||||
|
did = compass.add_decision("archived topic xyzzy", "some decision", "good", db_path=db)
|
||||||
|
compass.archive(did, db_path=db)
|
||||||
|
assert compass.find_conflicts("xyzzy topic", "another decision", db_path=db) == []
|
||||||
|
|
||||||
|
def test_no_side_effects_on_times_surfaced(self, db):
|
||||||
|
"""The advisory must NOT bump times_surfaced — it is not a real surface."""
|
||||||
|
compass.add_decision("surfacing guard ctx", "a decision here", "good", db_path=db)
|
||||||
|
compass.find_conflicts("surfacing guard", "a decision", db_path=db)
|
||||||
|
hit = compass.query_decisions("surfacing", db_path=db)[0]
|
||||||
|
assert hit["times_surfaced"] == 1 # only the query above counted
|
||||||
|
|
||||||
|
def test_sanitizes_fts_special_chars(self, db):
|
||||||
|
"""Raw FTS5 syntax characters must not crash MATCH — sanitized to literals."""
|
||||||
|
compass.add_decision("special ctx", "a normal decision", "good", db_path=db)
|
||||||
|
weird = 'broken " ( ) * : query -term AND OR NOT'
|
||||||
|
result = compass.find_conflicts(weird, "more * (text) ^caret", db_path=db)
|
||||||
|
assert isinstance(result, list) # no exception raised
|
||||||
|
|
||||||
|
def test_empty_text_returns_empty(self, db):
|
||||||
|
"""Text with no usable tokens yields no conflicts (and no crash)."""
|
||||||
|
assert compass.find_conflicts(" ", " ", db_path=db) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetNote:
|
||||||
|
"""note edits persist AND re-index immediately via the FTS5 UPDATE trigger."""
|
||||||
|
|
||||||
|
def test_set_note_updates_and_returns_true(self, db):
|
||||||
|
"""set_note stores the note and reports the row was changed."""
|
||||||
|
did = compass.add_decision("note ctx", "note dec", "good", db_path=db)
|
||||||
|
assert compass.set_note(did, "a fresh observation", db_path=db) is True
|
||||||
|
hit = compass.query_decisions("note ctx", db_path=db)[0]
|
||||||
|
assert hit["note"] == "a fresh observation"
|
||||||
|
|
||||||
|
def test_note_edit_is_immediately_fts_searchable(self, db):
|
||||||
|
"""PROOF: the decisions_au trigger re-indexes a note UPDATE for FTS."""
|
||||||
|
did = compass.add_decision("indexing ctx", "indexing dec", "good", db_path=db)
|
||||||
|
# 'zebra' appears nowhere yet.
|
||||||
|
assert compass.query_decisions("zebra", db_path=db) == []
|
||||||
|
compass.set_note(did, "mentions zebra now", db_path=db)
|
||||||
|
# Immediately findable through the freshly re-indexed note column.
|
||||||
|
found = compass.query_decisions("zebra", db_path=db)
|
||||||
|
assert len(found) == 1
|
||||||
|
assert found[0]["id"] == did
|
||||||
|
|
||||||
|
def test_set_note_missing_id_returns_false(self, db):
|
||||||
|
"""set_note on a non-existent id returns False (no silent create)."""
|
||||||
|
assert compass.set_note(9999, "nope", db_path=db) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreRemoved:
|
||||||
|
"""score is gone from every Python surface (DPLAN-0246 seedgo ruling)."""
|
||||||
|
|
||||||
|
def test_score_not_in_decision_columns(self):
|
||||||
|
"""The code-level column list no longer names score."""
|
||||||
|
assert "score" not in store._DECISION_COLUMNS
|
||||||
|
|
||||||
|
def test_score_absent_from_query_dict(self, db):
|
||||||
|
"""Query result dicts carry no score key."""
|
||||||
|
compass.add_decision("score ctx", "score dec", "good", db_path=db)
|
||||||
|
hit = compass.query_decisions("score", db_path=db)[0]
|
||||||
|
assert "score" not in hit
|
||||||
|
|
||||||
|
def test_score_absent_from_review_dict(self, db):
|
||||||
|
"""Review result dicts carry no score key."""
|
||||||
|
compass.add_decision("review score ctx", "dec", "good", db_path=db)
|
||||||
|
result = compass.review(db_path=db)
|
||||||
|
assert result is not None
|
||||||
|
assert "score" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecall:
|
||||||
|
"""recall_decisions/mark_surfaced — the Track 2 ambient-recall read path."""
|
||||||
|
|
||||||
|
def test_recall_returns_scored_active_candidates(self, db):
|
||||||
|
"""Raw prompt text yields active hits, each with relevance in (0, 1)."""
|
||||||
|
compass.add_decision("vectorization pipeline ctx", "salt vector ids", "good", db_path=db)
|
||||||
|
hits = compass.recall_decisions("we are working on the vectorization pipeline", db_path=db)
|
||||||
|
assert hits and hits[0]["decision"] == "salt vector ids"
|
||||||
|
assert 0.0 < hits[0]["relevance"] < 1.0
|
||||||
|
|
||||||
|
def test_recall_is_side_effect_free(self, db):
|
||||||
|
"""A recall does NOT bump times_surfaced — candidates are not surfacings."""
|
||||||
|
compass.add_decision("recall counter ctx", "stay untouched", "good", db_path=db)
|
||||||
|
rid = compass.recall_decisions("recall counter", db_path=db)[0]["id"]
|
||||||
|
compass.recall_decisions("recall counter", db_path=db)
|
||||||
|
hit = compass.query_decisions("untouched", db_path=db)[0]
|
||||||
|
assert hit["id"] == rid
|
||||||
|
# query_decisions itself increments once; recalls added nothing.
|
||||||
|
assert hit["times_surfaced"] == 1
|
||||||
|
|
||||||
|
def test_recall_excludes_archived(self, db):
|
||||||
|
"""Archived rows never come back as ambient candidates."""
|
||||||
|
rid = compass.add_decision("archived recall ctx", "dead ruling", "bad", db_path=db)
|
||||||
|
compass.archive(rid, db_path=db)
|
||||||
|
assert compass.recall_decisions("archived recall dead ruling", db_path=db) == []
|
||||||
|
|
||||||
|
def test_recall_survives_fts_syntax_in_prompt(self, db):
|
||||||
|
"""FTS5 syntax characters in a prompt cannot crash the MATCH."""
|
||||||
|
compass.add_decision("syntax safety ctx", "quote all tokens", "good", db_path=db)
|
||||||
|
hits = compass.recall_decisions('safety AND (tokens) OR "quote" NEAR *:^-', db_path=db)
|
||||||
|
assert hits and hits[0]["decision"] == "quote all tokens"
|
||||||
|
|
||||||
|
def test_recall_empty_prompt_returns_empty(self, db):
|
||||||
|
"""No usable tokens → empty list, no error."""
|
||||||
|
assert compass.recall_decisions("", db_path=db) == []
|
||||||
|
assert compass.recall_decisions("()!@#$", db_path=db) == []
|
||||||
|
|
||||||
|
def test_recall_ranks_most_relevant_first(self, db):
|
||||||
|
"""Denser overlap outranks a single shared token."""
|
||||||
|
compass.add_decision("alpha beta gamma delta", "dense match", "good", db_path=db)
|
||||||
|
compass.add_decision("alpha unrelated topic here", "sparse match", "good", db_path=db)
|
||||||
|
hits = compass.recall_decisions("alpha beta gamma delta", limit=2, db_path=db)
|
||||||
|
assert hits[0]["decision"] == "dense match"
|
||||||
|
assert hits[0]["relevance"] > hits[1]["relevance"]
|
||||||
|
|
||||||
|
def test_recall_invalid_limit_raises(self, db):
|
||||||
|
"""Non-positive limit fails honestly."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
compass.recall_decisions("anything", limit=0, db_path=db)
|
||||||
|
|
||||||
|
def test_mark_surfaced_counts_only_injected(self, db):
|
||||||
|
"""mark_surfaced increments exactly the ids the caller reports."""
|
||||||
|
a = compass.add_decision("mark ctx one", "dec one xyzzy", "good", db_path=db)
|
||||||
|
b = compass.add_decision("mark ctx two", "dec two xyzzy", "good", db_path=db)
|
||||||
|
assert compass.mark_surfaced([a], db_path=db) == 1
|
||||||
|
hits = {h["id"]: h for h in compass.query_decisions("xyzzy", limit=5, db_path=db)}
|
||||||
|
# query bumps both by 1; only a carries the extra mark_surfaced bump.
|
||||||
|
assert hits[a]["times_surfaced"] == 2
|
||||||
|
assert hits[b]["times_surfaced"] == 1
|
||||||
|
|
||||||
|
def test_mark_surfaced_empty_list_is_noop(self, db):
|
||||||
|
"""An empty id list returns 0 and touches nothing."""
|
||||||
|
assert compass.mark_surfaced([], db_path=db) == 0
|
||||||
|
|
||||||
|
def test_recall_stopwords_do_not_drive_ranking(self, db):
|
||||||
|
"""Filler words in the prompt cannot outrank topic words (FPLAN-0332)."""
|
||||||
|
compass.add_decision(
|
||||||
|
"docker sop ctx with the and on for filler heavy text",
|
||||||
|
"push dev first clone in container",
|
||||||
|
"good",
|
||||||
|
db_path=db,
|
||||||
|
)
|
||||||
|
compass.add_decision(
|
||||||
|
"compass curation ctx",
|
||||||
|
"supersedes links archive corrections",
|
||||||
|
"good",
|
||||||
|
db_path=db,
|
||||||
|
)
|
||||||
|
hits = compass.recall_decisions(
|
||||||
|
"lets keep working on the compass supersedes links and curation",
|
||||||
|
limit=2,
|
||||||
|
db_path=db,
|
||||||
|
)
|
||||||
|
assert hits[0]["decision"] == "supersedes links archive corrections"
|
||||||
|
|
||||||
|
def test_recall_all_stopword_prompt_returns_empty(self, db):
|
||||||
|
"""A prompt made only of stopwords yields no candidates, no error."""
|
||||||
|
compass.add_decision("some ctx", "some dec", "good", db_path=db)
|
||||||
|
assert compass.recall_decisions("what is it and how do we", db_path=db) == []
|
||||||
|
|||||||
@@ -21,6 +21,17 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone systems # See all registered branches
|
||||||
|
drone @seedgo audit aipass # Route a command to a branch
|
||||||
|
drone @flow --help # Show help for any branch
|
||||||
|
drone scan @memory # Discover available commands
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Commands / Usage
|
## Commands / Usage
|
||||||
|
|
||||||
Drone provides a CLI for terminal use and a Python API for programmatic access.
|
Drone provides a CLI for terminal use and a Python API for programmatic access.
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ INTERACTIVE_COMMANDS = ("monitor", "audit", "watchdog", "status")
|
|||||||
INTERACTIVE_BRANCHES = ("cli", "backup")
|
INTERACTIVE_BRANCHES = ("cli", "backup")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_timeout(args: list[str]) -> tuple[list[str], int | None]:
|
||||||
|
"""Extract --drone-timeout N from an arg list. Returns (cleaned_args, timeout_or_None)."""
|
||||||
|
if "--drone-timeout" not in args:
|
||||||
|
return args, None
|
||||||
|
idx = args.index("--drone-timeout")
|
||||||
|
if idx + 1 >= len(args):
|
||||||
|
return args, None
|
||||||
|
try:
|
||||||
|
timeout = int(args[idx + 1])
|
||||||
|
except ValueError:
|
||||||
|
logger.info("--drone-timeout value %r is not an integer, ignoring", args[idx + 1])
|
||||||
|
return args, None
|
||||||
|
return args[:idx] + args[idx + 2 :], timeout
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# AUTO-DISCOVERY
|
# AUTO-DISCOVERY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -78,7 +93,7 @@ def _discover_modules() -> list[tuple[str, str]]:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def show_help() -> None:
|
def print_help() -> None:
|
||||||
"""Display drone help with Rich formatting."""
|
"""Display drone help with Rich formatting."""
|
||||||
table = Table(show_header=False, box=None, pad_edge=False, show_edge=False)
|
table = Table(show_header=False, box=None, pad_edge=False, show_edge=False)
|
||||||
table.add_column(style="cyan", no_wrap=True)
|
table.add_column(style="cyan", no_wrap=True)
|
||||||
@@ -92,6 +107,7 @@ def show_help() -> None:
|
|||||||
table.add_row("list", "List registered custom commands")
|
table.add_row("list", "List registered custom commands")
|
||||||
table.add_row("remove <name>", "Remove a custom command")
|
table.add_row("remove <name>", "Remove a custom command")
|
||||||
table.add_row("rm <path> [<path>...]", "Contained safe-delete (project + tmp)")
|
table.add_row("rm <path> [<path>...]", "Contained safe-delete (project + tmp)")
|
||||||
|
table.add_row("--drone-timeout <seconds>", "Override subprocess timeout (default 30s)")
|
||||||
table.add_row("--help", "Show this help")
|
table.add_row("--help", "Show this help")
|
||||||
table.add_row("--version", "Show version")
|
table.add_row("--version", "Show version")
|
||||||
|
|
||||||
@@ -100,22 +116,22 @@ def show_help() -> None:
|
|||||||
console.print()
|
console.print()
|
||||||
console.print("[dim]Routes commands to registered AIPass branches and modules.[/dim]")
|
console.print("[dim]Routes commands to registered AIPass branches and modules.[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
|
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [dim]drone @<target> <command> [args...][/dim]")
|
||||||
|
console.print(" [dim]drone <built-in> [args...][/dim]")
|
||||||
|
console.print(" [dim]drone --help[/dim]")
|
||||||
|
console.print()
|
||||||
console.print(table)
|
console.print(table)
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[bold]Examples:[/bold]")
|
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
console.print(" [green]drone @seedgo audit aipass[/green]")
|
console.print(" [green]drone @seedgo audit aipass[/green]")
|
||||||
console.print(" [green]drone @flow status[/green]")
|
console.print(" [green]drone @flow status[/green]")
|
||||||
console.print(" [green]drone systems[/green]")
|
console.print(" [green]drone systems[/green]")
|
||||||
console.print(" [green]drone activate @seedgo[/green]")
|
|
||||||
console.print(" [green]drone audit[/green] [dim](custom shortcut)[/dim]")
|
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def print_help() -> None:
|
|
||||||
"""Alias for seedgo standard compliance (audit expects print_help)."""
|
|
||||||
show_help()
|
|
||||||
|
|
||||||
|
|
||||||
def print_introspection() -> None:
|
def print_introspection() -> None:
|
||||||
"""Display branch overview — auto-discovers modules."""
|
"""Display branch overview — auto-discovers modules."""
|
||||||
console.print()
|
console.print()
|
||||||
@@ -340,6 +356,7 @@ def _handle_custom_command(args: list[str]) -> int:
|
|||||||
target = cmd_data["target"]
|
target = cmd_data["target"]
|
||||||
command = cmd_data["command"]
|
command = cmd_data["command"]
|
||||||
cmd_args = list(cmd_data.get("args", [])) + remaining_args
|
cmd_args = list(cmd_data.get("args", [])) + remaining_args
|
||||||
|
cmd_args, explicit_timeout = _extract_timeout(cmd_args)
|
||||||
module_name = target.lstrip("@").lower()
|
module_name = target.lstrip("@").lower()
|
||||||
|
|
||||||
interactive = command in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES
|
interactive = command in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES
|
||||||
@@ -349,6 +366,7 @@ def _handle_custom_command(args: list[str]) -> int:
|
|||||||
target,
|
target,
|
||||||
command,
|
command,
|
||||||
args=cmd_args if cmd_args else None,
|
args=cmd_args if cmd_args else None,
|
||||||
|
timeout=explicit_timeout,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
)
|
)
|
||||||
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
||||||
@@ -412,6 +430,7 @@ def _handle_target(args: List[str]) -> int:
|
|||||||
"""Handle `drone @target command [args]` or `drone @target --help`."""
|
"""Handle `drone @target command [args]` or `drone @target --help`."""
|
||||||
target = args[0]
|
target = args[0]
|
||||||
rest = args[1:]
|
rest = args[1:]
|
||||||
|
rest, explicit_timeout = _extract_timeout(rest)
|
||||||
module_name = target.lstrip("@").lower()
|
module_name = target.lstrip("@").lower()
|
||||||
|
|
||||||
first_cmd = rest[0] if rest and rest[0] not in ("--help", "-h") else None
|
first_cmd = rest[0] if rest and rest[0] not in ("--help", "-h") else None
|
||||||
@@ -470,6 +489,7 @@ def _handle_target(args: List[str]) -> int:
|
|||||||
target,
|
target,
|
||||||
command,
|
command,
|
||||||
args=cmd_args if cmd_args else None,
|
args=cmd_args if cmd_args else None,
|
||||||
|
timeout=explicit_timeout,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
)
|
)
|
||||||
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
||||||
|
|||||||
@@ -21,6 +21,29 @@ from .exceptions import CommandExecutionError
|
|||||||
from aipass.drone.apps.handlers.json import json_handler
|
from aipass.drone.apps.handlers.json import json_handler
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
TIMEOUT_OVERRIDES: dict[str, dict[str, int]] = {
|
||||||
|
"memory": {"process-plans": 120},
|
||||||
|
"flow": {"close": 90},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_timeout(branch: str, command: str | None, explicit: int | None = None) -> int:
|
||||||
|
"""Resolve subprocess timeout for a branch command.
|
||||||
|
|
||||||
|
Priority: explicit flag > per-command policy > DEFAULT_TIMEOUT.
|
||||||
|
"""
|
||||||
|
if explicit is not None:
|
||||||
|
return explicit
|
||||||
|
branch_key = branch.lstrip("@").lower()
|
||||||
|
if command and branch_key in TIMEOUT_OVERRIDES:
|
||||||
|
cmd_timeout = TIMEOUT_OVERRIDES[branch_key].get(command)
|
||||||
|
if cmd_timeout is not None:
|
||||||
|
return cmd_timeout
|
||||||
|
return DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandResult:
|
class CommandResult:
|
||||||
"""Result of a routed command execution."""
|
"""Result of a routed command execution."""
|
||||||
@@ -82,7 +105,10 @@ def execute_command(
|
|||||||
return CommandResult(stdout="", stderr="", exit_code=130, branch="", command="")
|
return CommandResult(stdout="", stderr="", exit_code=130, branch="", command="")
|
||||||
raise
|
raise
|
||||||
except subprocess.TimeoutExpired as e:
|
except subprocess.TimeoutExpired as e:
|
||||||
raise CommandExecutionError(f"Command timed out after {timeout}s: {' '.join(full_cmd)}") from e
|
raise CommandExecutionError(
|
||||||
|
f"Command timed out after {timeout}s: {' '.join(full_cmd)}\n"
|
||||||
|
f" Override with: drone @<target> <command> --drone-timeout <seconds>"
|
||||||
|
) from e
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise CommandExecutionError(f"Executable not found: {executable!r}") from e
|
raise CommandExecutionError(f"Executable not found: {executable!r}") from e
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from aipass.prax.apps.modules.logger import system_logger
|
from aipass.prax.apps.modules.logger import system_logger
|
||||||
from aipass.cli.apps.modules import console
|
from aipass.cli.apps.modules import console
|
||||||
from aipass.drone.apps.handlers.executor import CommandResult
|
from aipass.drone.apps.handlers.executor import CommandResult, resolve_timeout
|
||||||
from aipass.drone.apps.handlers.json import json_handler
|
from aipass.drone.apps.handlers.json import json_handler
|
||||||
from aipass.drone.apps.handlers.router_handler import (
|
from aipass.drone.apps.handlers.router_handler import (
|
||||||
detect_caller_branch_name,
|
detect_caller_branch_name,
|
||||||
@@ -90,28 +90,38 @@ def route_command(
|
|||||||
target: str,
|
target: str,
|
||||||
command: Optional[str] = None,
|
command: Optional[str] = None,
|
||||||
args: Optional[List[str]] = None,
|
args: Optional[List[str]] = None,
|
||||||
timeout: int = 30,
|
timeout: int | None = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Route a command to a branch's entry point.
|
"""Route a command to a branch's entry point.
|
||||||
|
|
||||||
Resolves @target to a path, then delegates to the handler for execution.
|
Resolves @target to a path, then delegates to the handler for execution.
|
||||||
When command is None, runs the branch with no args (introspection).
|
When command is None, runs the branch with no args (introspection).
|
||||||
|
|
||||||
|
Timeout resolution: explicit value > per-command policy > DEFAULT_TIMEOUT.
|
||||||
"""
|
"""
|
||||||
branch_path = resolve_branch(target)
|
branch_path = resolve_branch(target)
|
||||||
branch_name = target.lstrip("@").lower()
|
branch_name = target.lstrip("@").lower()
|
||||||
|
resolved_timeout = resolve_timeout(branch_name, command, timeout)
|
||||||
|
|
||||||
caller = detect_caller_branch_name(Path.cwd())
|
caller = detect_caller_branch_name(Path.cwd())
|
||||||
if not caller:
|
if not caller:
|
||||||
caller = os.environ.get("AIPASS_BRANCH_NAME")
|
caller = os.environ.get("AIPASS_BRANCH_NAME")
|
||||||
caller_tag = f" [CALLER:{caller.upper()}]" if caller else ""
|
caller_tag = f" [CALLER:{caller.upper()}]" if caller else ""
|
||||||
logger.info("Routing @%s%s → %s %s", branch_name, caller_tag, command or "(introspection)", args or [])
|
logger.info(
|
||||||
|
"Routing @%s%s → %s %s (timeout=%ds)",
|
||||||
|
branch_name,
|
||||||
|
caller_tag,
|
||||||
|
command or "(introspection)",
|
||||||
|
args or [],
|
||||||
|
resolved_timeout,
|
||||||
|
)
|
||||||
return execute_branch_command(
|
return execute_branch_command(
|
||||||
branch_path=branch_path,
|
branch_path=branch_path,
|
||||||
branch_name=branch_name,
|
branch_name=branch_name,
|
||||||
command=command,
|
command=command,
|
||||||
args=args,
|
args=args,
|
||||||
timeout=timeout,
|
timeout=resolved_timeout,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -147,7 +157,7 @@ def print_introspection():
|
|||||||
def route_all(
|
def route_all(
|
||||||
command: str,
|
command: str,
|
||||||
args: Optional[List[str]] = None,
|
args: Optional[List[str]] = None,
|
||||||
timeout: int = 30,
|
timeout: int | None = None,
|
||||||
) -> Dict[str, CommandResult]:
|
) -> Dict[str, CommandResult]:
|
||||||
"""Route the same command to ALL active branches in the registry."""
|
"""Route the same command to ALL active branches in the registry."""
|
||||||
if args is None:
|
if args is None:
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ class TestHandleCustomCommand:
|
|||||||
"@seedgo",
|
"@seedgo",
|
||||||
"audit",
|
"audit",
|
||||||
args=["aipass"],
|
args=["aipass"],
|
||||||
|
timeout=None,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -380,6 +381,7 @@ class TestHandleCustomCommand:
|
|||||||
"@seedgo",
|
"@seedgo",
|
||||||
"audit",
|
"audit",
|
||||||
args=["aipass", "@drone"],
|
args=["aipass", "@drone"],
|
||||||
|
timeout=None,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -616,6 +618,7 @@ class TestMainIntegration:
|
|||||||
"@seedgo",
|
"@seedgo",
|
||||||
"audit",
|
"audit",
|
||||||
args=["aipass"],
|
args=["aipass"],
|
||||||
|
timeout=None,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -642,6 +645,7 @@ class TestMainIntegration:
|
|||||||
"@seedgo",
|
"@seedgo",
|
||||||
"audit",
|
"audit",
|
||||||
args=["aipass", "@drone"],
|
args=["aipass", "@drone"],
|
||||||
|
timeout=None,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -714,5 +718,6 @@ class TestMatchCommandIntegration:
|
|||||||
"@flow",
|
"@flow",
|
||||||
"create",
|
"create",
|
||||||
args=["--type=plan", "my-plan"],
|
args=["--type=plan", "my-plan"],
|
||||||
|
timeout=None,
|
||||||
interactive=False,
|
interactive=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -903,3 +903,80 @@ class TestAipassIntercept:
|
|||||||
):
|
):
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _extract_timeout — --timeout flag parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractTimeout:
|
||||||
|
"""Tests for --drone-timeout flag extraction from arg lists."""
|
||||||
|
|
||||||
|
def test_no_flag(self) -> None:
|
||||||
|
"""Args without --drone-timeout pass through unchanged."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
args = ["close", "FPLAN-0313"]
|
||||||
|
cleaned, timeout = _extract_timeout(args)
|
||||||
|
assert cleaned == ["close", "FPLAN-0313"]
|
||||||
|
assert timeout is None
|
||||||
|
|
||||||
|
def test_flag_at_end(self) -> None:
|
||||||
|
"""--drone-timeout N at end of args is extracted."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
cleaned, timeout = _extract_timeout(["process-plans", "--drone-timeout", "120"])
|
||||||
|
assert cleaned == ["process-plans"]
|
||||||
|
assert timeout == 120
|
||||||
|
|
||||||
|
def test_flag_at_start(self) -> None:
|
||||||
|
"""--drone-timeout N at start of args is extracted."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
cleaned, timeout = _extract_timeout(["--drone-timeout", "90", "close", "FPLAN-0313"])
|
||||||
|
assert cleaned == ["close", "FPLAN-0313"]
|
||||||
|
assert timeout == 90
|
||||||
|
|
||||||
|
def test_flag_in_middle(self) -> None:
|
||||||
|
"""--drone-timeout N in the middle of args is extracted."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
cleaned, timeout = _extract_timeout(["close", "--drone-timeout", "60", "FPLAN-0313"])
|
||||||
|
assert cleaned == ["close", "FPLAN-0313"]
|
||||||
|
assert timeout == 60
|
||||||
|
|
||||||
|
def test_flag_without_value(self) -> None:
|
||||||
|
"""--drone-timeout at end with no value returns None and leaves args."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
args = ["close", "--drone-timeout"]
|
||||||
|
cleaned, timeout = _extract_timeout(args)
|
||||||
|
assert cleaned == args
|
||||||
|
assert timeout is None
|
||||||
|
|
||||||
|
def test_flag_non_integer_value(self) -> None:
|
||||||
|
"""--drone-timeout with non-integer value returns None and leaves args."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
args = ["close", "--drone-timeout", "abc"]
|
||||||
|
cleaned, timeout = _extract_timeout(args)
|
||||||
|
assert cleaned == args
|
||||||
|
assert timeout is None
|
||||||
|
|
||||||
|
def test_empty_args(self) -> None:
|
||||||
|
"""Empty arg list returns empty with None timeout."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
cleaned, timeout = _extract_timeout([])
|
||||||
|
assert cleaned == []
|
||||||
|
assert timeout is None
|
||||||
|
|
||||||
|
def test_plain_timeout_passes_through(self) -> None:
|
||||||
|
"""--timeout (without drone- prefix) is NOT consumed — passes to target."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
args = ["watchdog", "agent", "@memory", "--timeout", "1800"]
|
||||||
|
cleaned, timeout = _extract_timeout(args)
|
||||||
|
assert cleaned == args
|
||||||
|
assert timeout is None
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aipass.drone.apps.handlers.exceptions import CommandExecutionError
|
from aipass.drone.apps.handlers.exceptions import CommandExecutionError
|
||||||
from aipass.drone.apps.handlers.executor import execute_command
|
from aipass.drone.apps.handlers.executor import (
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
TIMEOUT_OVERRIDES,
|
||||||
|
execute_command,
|
||||||
|
resolve_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -389,3 +394,63 @@ class TestShellSecurity:
|
|||||||
# The semicolon is treated as literal text, not a shell separator
|
# The semicolon is treated as literal text, not a shell separator
|
||||||
assert result.stdout.strip() == "hello; echo pwned"
|
assert result.stdout.strip() == "hello; echo pwned"
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 11. resolve_timeout — policy resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveTimeout:
|
||||||
|
"""Timeout resolution: explicit > policy > default."""
|
||||||
|
|
||||||
|
def test_default_timeout(self):
|
||||||
|
"""Unknown branch+command returns DEFAULT_TIMEOUT."""
|
||||||
|
assert resolve_timeout("unknown", "whatever") == DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
def test_policy_override(self):
|
||||||
|
"""Known branch+command returns the policy value."""
|
||||||
|
for branch, cmds in TIMEOUT_OVERRIDES.items():
|
||||||
|
for cmd, expected in cmds.items():
|
||||||
|
assert resolve_timeout(branch, cmd) == expected
|
||||||
|
|
||||||
|
def test_explicit_wins_over_policy(self):
|
||||||
|
"""Explicit timeout overrides the policy map."""
|
||||||
|
branch = next(iter(TIMEOUT_OVERRIDES))
|
||||||
|
cmd = next(iter(TIMEOUT_OVERRIDES[branch]))
|
||||||
|
assert resolve_timeout(branch, cmd, explicit=999) == 999
|
||||||
|
|
||||||
|
def test_explicit_wins_over_default(self):
|
||||||
|
"""Explicit timeout overrides the default."""
|
||||||
|
assert resolve_timeout("unknown", "whatever", explicit=42) == 42
|
||||||
|
|
||||||
|
def test_none_command_returns_default(self):
|
||||||
|
"""None command (introspection) returns default."""
|
||||||
|
assert resolve_timeout("memory", None) == DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
def test_at_prefix_stripped(self):
|
||||||
|
"""Leading @ on branch name is stripped before lookup."""
|
||||||
|
for branch in TIMEOUT_OVERRIDES:
|
||||||
|
cmd = next(iter(TIMEOUT_OVERRIDES[branch]))
|
||||||
|
expected = TIMEOUT_OVERRIDES[branch][cmd]
|
||||||
|
assert resolve_timeout(f"@{branch}", cmd) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12. Timeout error message includes --timeout hint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimeoutErrorMessage:
|
||||||
|
"""Timeout error tells the caller how to override."""
|
||||||
|
|
||||||
|
def test_timeout_error_includes_override_hint(self, temp_test_dir: Path):
|
||||||
|
"""The timeout error message mentions --timeout."""
|
||||||
|
with pytest.raises(CommandExecutionError, match="--drone-timeout") as exc_info:
|
||||||
|
execute_command(
|
||||||
|
sys.executable,
|
||||||
|
["-c", "import time; time.sleep(10)"],
|
||||||
|
cwd=str(temp_test_dir),
|
||||||
|
timeout=1,
|
||||||
|
)
|
||||||
|
assert "--drone-timeout" in str(exc_info.value)
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ Flow is AIPass's plan management system. Every branch uses flow to create, track
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone @flow create . "My task description" # Create a plan in the current directory
|
||||||
|
drone @flow list open # See all open plans
|
||||||
|
drone @flow close FPLAN-0042 # Close a completed plan
|
||||||
|
drone @flow create . "Design topic" dplan # Create a design plan (DPLAN)
|
||||||
|
drone @flow templates # List available plan types
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ Key Functions:
|
|||||||
- verify_and_heal_orphaned_plans() - Orphan healing logic
|
- verify_and_heal_orphaned_plans() - Orphan healing logic
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa: E402
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_PKG_ROOT = Path(__file__).resolve().parents[4]
|
_PKG_ROOT = Path(__file__).resolve().parents[4]
|
||||||
|
|
||||||
# Standard imports
|
# Standard imports
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
@@ -42,6 +45,35 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
|||||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||||
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
||||||
|
|
||||||
|
MODULE_NAME = "mbank_process"
|
||||||
|
_LOCK_RETRIES = 10
|
||||||
|
_LOCK_BACKOFF_BASE = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_lock(lock_path: Path) -> bool:
|
||||||
|
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
||||||
|
for attempt in range(_LOCK_RETRIES):
|
||||||
|
try:
|
||||||
|
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.write(fd, str(os.getpid()).encode())
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
||||||
|
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Lock creation failed for %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _release_lock(lock_path: Path) -> None:
|
||||||
|
"""Remove lockfile, tolerating already-removed."""
|
||||||
|
try:
|
||||||
|
lock_path.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
|
||||||
|
|
||||||
def _find_repo_root() -> Path:
|
def _find_repo_root() -> Path:
|
||||||
"""Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json)."""
|
"""Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json)."""
|
||||||
@@ -97,12 +129,22 @@ def load_flow_registry(registry_file: str | None = None) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def save_flow_registry(registry: Dict[str, Any], registry_file: str | None = None) -> None:
|
def save_flow_registry(registry: Dict[str, Any], registry_file: str | None = None) -> None:
|
||||||
"""Save a plan registry."""
|
"""Save a plan registry with lockfile + atomic write."""
|
||||||
target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE
|
target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE
|
||||||
|
lock_path = target.with_suffix(".lock")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
if not _acquire_lock(lock_path):
|
||||||
with open(target, "w", encoding="utf-8") as f:
|
raise OSError(f"Could not acquire lock for {target}")
|
||||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
|
||||||
|
try:
|
||||||
|
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
tmp_path = target.with_suffix(".tmp")
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(str(tmp_path), str(target))
|
||||||
|
finally:
|
||||||
|
_release_lock(lock_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Failed to save flow registry: {e}")
|
raise Exception(f"Failed to save flow registry: {e}")
|
||||||
|
|
||||||
@@ -361,7 +403,10 @@ def is_template_content(content: str) -> bool:
|
|||||||
# today = datetime.now().strftime("%Y%m%d")
|
# today = datetime.now().strftime("%Y%m%d")
|
||||||
# plan_num = plan_path.stem.replace("FPLAN-", "")
|
# plan_num = plan_path.stem.replace("FPLAN-", "")
|
||||||
# template_suffix = "-TEMP" if is_template else ""
|
# template_suffix = "-TEMP" if is_template else ""
|
||||||
# filename = f"{folder_context}-{analysis['type']}-{analysis['category']}-{analysis['action']}-FPLAN-{plan_num}{template_suffix}-{today}.md"
|
# filename = (
|
||||||
|
# f"{folder_context}-{analysis['type']}-{analysis['category']}"
|
||||||
|
# f"-{analysis['action']}-FPLAN-{plan_num}{template_suffix}-{today}.md"
|
||||||
|
# )
|
||||||
#
|
#
|
||||||
# filename = re.sub(r'[<>:"|?*]', '-', filename)
|
# filename = re.sub(r'[<>:"|?*]', '-', filename)
|
||||||
# filename = re.sub(r'-+', '-', filename)
|
# filename = re.sub(r'-+', '-', filename)
|
||||||
@@ -627,7 +672,7 @@ def process_closed_plans() -> Dict[str, Any]:
|
|||||||
|
|
||||||
if archive_success:
|
if archive_success:
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
# Vector intake handled by close_ops.py via drone @memory process-plans
|
# Vector intake triggered by post_close_runner via direct import
|
||||||
results.append({"plan": plan_label, "status": "archived", "correlation_id": correlation_id})
|
results.append({"plan": plan_label, "status": "archived", "correlation_id": correlation_id})
|
||||||
else:
|
else:
|
||||||
error_count += 1
|
error_count += 1
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Description: Closed Plans Local Registry Handler
|
# Description: Closed Plans Local Registry Handler
|
||||||
# Version: 0.1.0
|
# Version: 0.1.0
|
||||||
# Created: 2026-03-03
|
# Created: 2026-03-03
|
||||||
# Modified: 2026-03-03
|
# Modified: 2026-07-15
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -13,8 +13,11 @@ Appends a closed plan entry to the branch's CLOSED_PLANS.local.json file.
|
|||||||
Creates the file if it doesn't exist.
|
Creates the file if it doesn't exist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa: E402
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# INFRASTRUCTURE IMPORT PATTERN
|
# INFRASTRUCTURE IMPORT PATTERN
|
||||||
@@ -27,6 +30,31 @@ from aipass.flow.apps.handlers.json import json_handler
|
|||||||
MODULE_NAME = "append_closed_plan"
|
MODULE_NAME = "append_closed_plan"
|
||||||
CLOSED_PLANS_FILE = "CLOSED_PLANS.local.json"
|
CLOSED_PLANS_FILE = "CLOSED_PLANS.local.json"
|
||||||
|
|
||||||
|
_LOCK_RETRIES = 10
|
||||||
|
_LOCK_BACKOFF_BASE = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_append_lock(lock_path: Path) -> bool:
|
||||||
|
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
||||||
|
for attempt in range(_LOCK_RETRIES):
|
||||||
|
try:
|
||||||
|
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.write(fd, str(os.getpid()).encode())
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
||||||
|
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _release_append_lock(lock_path: Path) -> None:
|
||||||
|
"""Remove lockfile, tolerating already-removed."""
|
||||||
|
try:
|
||||||
|
lock_path.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
|
||||||
|
|
||||||
def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path) -> bool:
|
def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -61,27 +89,35 @@ def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path)
|
|||||||
"location": plan_info.get("relative_path", ""),
|
"location": plan_info.get("relative_path", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Read existing file or create new structure
|
# Locked read-modify-write to prevent lost updates under concurrent close
|
||||||
closed_plans_path = plan_location / CLOSED_PLANS_FILE
|
closed_plans_path = plan_location / CLOSED_PLANS_FILE
|
||||||
|
lock_path = closed_plans_path.with_suffix(".lock")
|
||||||
|
|
||||||
if closed_plans_path.exists():
|
if not _acquire_append_lock(lock_path):
|
||||||
with open(closed_plans_path, "r", encoding="utf-8") as f:
|
logger.error(
|
||||||
data = json.load(f)
|
f"[{MODULE_NAME}] Could not acquire lock for {closed_plans_path} after {_LOCK_RETRIES} retries"
|
||||||
else:
|
)
|
||||||
data = {"closed_plans": []}
|
return False
|
||||||
|
|
||||||
# Check for duplicate plan_id before appending
|
try:
|
||||||
existing_ids = {p.get("plan_id") for p in data.get("closed_plans", [])}
|
if closed_plans_path.exists():
|
||||||
if plan_id in existing_ids:
|
with open(closed_plans_path, "r", encoding="utf-8") as f:
|
||||||
logger.info(f"[{MODULE_NAME}] {plan_id} already in {CLOSED_PLANS_FILE} at {plan_location}, skipping")
|
data = json.load(f)
|
||||||
return True
|
else:
|
||||||
|
data = {"closed_plans": []}
|
||||||
|
|
||||||
# Append and write
|
existing_ids = {p.get("plan_id") for p in data.get("closed_plans", [])}
|
||||||
data["closed_plans"].append(entry)
|
if plan_id in existing_ids:
|
||||||
|
logger.info(f"[{MODULE_NAME}] {plan_id} already in {CLOSED_PLANS_FILE} at {plan_location}, skipping")
|
||||||
|
return True
|
||||||
|
|
||||||
with open(closed_plans_path, "w", encoding="utf-8") as f:
|
data["closed_plans"].append(entry)
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
f.write("\n")
|
with open(closed_plans_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
finally:
|
||||||
|
_release_append_lock(lock_path)
|
||||||
|
|
||||||
logger.info(f"[{MODULE_NAME}] Appended {plan_id} to {closed_plans_path}")
|
logger.info(f"[{MODULE_NAME}] Appended {plan_id} to {closed_plans_path}")
|
||||||
json_handler.log_operation(
|
json_handler.log_operation(
|
||||||
|
|||||||
@@ -379,9 +379,22 @@ def close_plan_impl(
|
|||||||
try:
|
try:
|
||||||
from aipass.flow.apps.handlers.plan.append_closed_plan import append_to_closed_plans
|
from aipass.flow.apps.handlers.plan.append_closed_plan import append_to_closed_plans
|
||||||
|
|
||||||
append_to_closed_plans(plan_key, plan_info, plan_file.parent)
|
if not append_to_closed_plans(plan_key, plan_info, plan_file.parent):
|
||||||
|
logger.error(f"[{MODULE_NAME}] CLOSED_PLANS append failed for {plan_prefix}-{plan_key}")
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"type": "warning",
|
||||||
|
"text": f" CLOSED_PLANS append failed for {plan_prefix}-{plan_key}",
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[{MODULE_NAME}] CLOSED_PLANS update failed (non-critical): {e}")
|
logger.error(f"[{MODULE_NAME}] CLOSED_PLANS update failed: {e}")
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"type": "warning",
|
||||||
|
"text": f" CLOSED_PLANS update failed: {e}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Fire trigger event for plan closure
|
# Fire trigger event for plan closure
|
||||||
if trigger_fire_fn is not None:
|
if trigger_fire_fn is not None:
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
@@ -44,6 +46,35 @@ MODULE_NAME = "save_registry"
|
|||||||
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
||||||
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
|
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
|
||||||
|
|
||||||
|
_LOCK_RETRIES = 10
|
||||||
|
_LOCK_BACKOFF_BASE = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_lock(lock_path: Path) -> bool:
|
||||||
|
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
||||||
|
for attempt in range(_LOCK_RETRIES):
|
||||||
|
try:
|
||||||
|
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.write(fd, str(os.getpid()).encode())
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
||||||
|
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Lock creation failed for %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _release_lock(lock_path: Path) -> None:
|
||||||
|
"""Remove lockfile, tolerating already-removed."""
|
||||||
|
try:
|
||||||
|
lock_path.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
|
||||||
|
|
||||||
# =============================================
|
# =============================================
|
||||||
# HANDLER FUNCTION
|
# HANDLER FUNCTION
|
||||||
# =============================================
|
# =============================================
|
||||||
@@ -64,15 +95,30 @@ def save_registry(registry: Dict[str, Any], registry_file: str | None = None) ->
|
|||||||
|
|
||||||
Automatically updates the last_updated timestamp before saving.
|
Automatically updates the last_updated timestamp before saving.
|
||||||
Creates the flow_json directory if it doesn't exist.
|
Creates the flow_json directory if it doesn't exist.
|
||||||
|
Uses a lockfile to serialize concurrent writes and atomic
|
||||||
|
tempfile+rename to prevent torn reads.
|
||||||
"""
|
"""
|
||||||
target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE
|
target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE
|
||||||
|
lock_path = target.with_suffix(".lock")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
registry["_notice"] = "DO NOT MANUALLY EDIT — managed by flow close pipeline"
|
|
||||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
if not _acquire_lock(lock_path):
|
||||||
with open(target, "w", encoding="utf-8") as f:
|
logger.error("[%s] Could not acquire lock for %s after %d retries", MODULE_NAME, target, _LOCK_RETRIES)
|
||||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
registry["_notice"] = "DO NOT MANUALLY EDIT — managed by flow close pipeline"
|
||||||
|
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
tmp_path = target.with_suffix(".tmp")
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(str(tmp_path), str(target))
|
||||||
|
finally:
|
||||||
|
_release_lock(lock_path)
|
||||||
|
|
||||||
json_handler.log_operation(
|
json_handler.log_operation(
|
||||||
"registry_saved",
|
"registry_saved",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ if sys.platform == "win32":
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aipass.cli.apps.modules import console, error, warning
|
from aipass.cli.apps.modules import console, error, success, warning
|
||||||
from aipass.flow.apps.handlers.json import json_handler
|
from aipass.flow.apps.handlers.json import json_handler
|
||||||
from aipass.flow.apps.handlers.mbank.process import process_closed_plans
|
from aipass.flow.apps.handlers.mbank.process import process_closed_plans
|
||||||
from aipass.flow.apps.handlers.runner.lock_ops import acquire_lock, release_lock
|
from aipass.flow.apps.handlers.runner.lock_ops import acquire_lock, release_lock
|
||||||
@@ -80,7 +80,25 @@ def handle_command(command: str, args: list) -> bool:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
process_closed_plans()
|
process_closed_plans()
|
||||||
console.print("[green]Processing complete[/green]")
|
|
||||||
|
try:
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
_plans_mod = importlib.import_module("aipass.memory.apps.handlers.intake.plans_processor")
|
||||||
|
result = _plans_mod.process_plans()
|
||||||
|
if result.get("success"):
|
||||||
|
count = result.get("files_processed", 0)
|
||||||
|
chunks = result.get("total_chunks", 0)
|
||||||
|
if count > 0:
|
||||||
|
success(f"Vectorized {count} plan(s) ({chunks} chunks)")
|
||||||
|
logger.info("[%s] Plan vectorization: %s", MODULE_NAME, result)
|
||||||
|
else:
|
||||||
|
logger.error("[%s] Plan vectorization failed: %s", MODULE_NAME, result.get("error", "unknown"))
|
||||||
|
error(f"Vectorization failed: {result.get('error', 'unknown')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[%s] Plan vectorization error: %s", MODULE_NAME, e)
|
||||||
|
error(f"Vectorization error: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{MODULE_NAME}] Background processing failed: {e}")
|
logger.error(f"[{MODULE_NAME}] Background processing failed: {e}")
|
||||||
error(f"Processing failed: {e}")
|
error(f"Processing failed: {e}")
|
||||||
@@ -130,6 +148,19 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
process_closed_plans()
|
process_closed_plans()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
_plans_mod = importlib.import_module("aipass.memory.apps.handlers.intake.plans_processor")
|
||||||
|
result = _plans_mod.process_plans()
|
||||||
|
if result.get("success"):
|
||||||
|
logger.info("[%s] Plan vectorization: %s", MODULE_NAME, result)
|
||||||
|
else:
|
||||||
|
logger.error("[%s] Plan vectorization failed: %s", MODULE_NAME, result.get("error", "unknown"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[%s] Plan vectorization error: %s", MODULE_NAME, e)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{MODULE_NAME}] Background processing failed: {e}")
|
logger.error(f"[{MODULE_NAME}] Background processing failed: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# {plan_number} - {subject} (MERGE)
|
# {plan_number} - {subject} (MERGE)
|
||||||
|
|
||||||
|
> **Create:** `drone @flow create . "Merge summary" merge pplan` (template name before type)
|
||||||
|
|
||||||
**Created**: {today}
|
**Created**: {today}
|
||||||
**Branch**: {location}
|
**Branch**: {location}
|
||||||
**Status**: Active
|
**Status**: Active
|
||||||
@@ -59,10 +61,9 @@ just that one merge commit — **cosmetic and trivially resolved**.
|
|||||||
WORKS** — a clean fast-forward realign, no merge commit created, no history rewrite.
|
WORKS** — a clean fast-forward realign, no merge commit created, no history rewrite.
|
||||||
- **Realign dev to even** (recommended): `drone @git sync` from dev (clean FF), or
|
- **Realign dev to even** (recommended): `drone @git sync` from dev (clean FF), or
|
||||||
manually `git merge --ff-only origin/main` on dev. No force-push, no rebase needed.
|
manually `git merge --ff-only origin/main` on dev. No force-push, no rebase needed.
|
||||||
- **Sync local `main` ref WITHOUT checkout**: `git fetch origin main:main` — updates the
|
- **Local main behind?** `drone @git sync` from dev handles it (stays on dev, clean FF).
|
||||||
local main ref to match origin with **zero working-tree touch, no checkout**. This is the
|
If the IDE shows "switch to main → your local changes would be overwritten by checkout" —
|
||||||
answer to the IDE "switch to main → your local changes would be overwritten by checkout"
|
that dialog is git SAFETY working — **Cancel, never Force Checkout**. You never
|
||||||
dialog: that dialog is git SAFETY working — **Cancel, never Force Checkout**. You never
|
|
||||||
need to stand on main.
|
need to stand on main.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -73,7 +74,7 @@ just that one merge commit — **cosmetic and trivially resolved**.
|
|||||||
- [ ] Confirm what's shipping — scan uncommitted changes + already-pushed dev commits ahead of main: `git rev-list --count main..dev` (read git, raw ok)
|
- [ ] Confirm what's shipping — scan uncommitted changes + already-pushed dev commits ahead of main: `git rev-list --count main..dev` (read git, raw ok)
|
||||||
- [ ] No surprise files (stray `/tmp` artifacts, test pollution, `.recovery`/`.archive` churn). Clean = archive, never delete.
|
- [ ] No surprise files (stray `/tmp` artifacts, test pollution, `.recovery`/`.archive` churn). Clean = archive, never delete.
|
||||||
- [ ] **Version state check** (informs the bump decision): read the **two** release-tied versions — `grep '^version' pyproject.toml` and `grep __version__ src/aipass/__init__.py` (they should match; if drifted, note it) — and what PyPI already has: `curl -s https://pypi.org/pypi/aipass/json | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])"`. PyPI rejects a duplicate, so the target must be > published.
|
- [ ] **Version state check** (informs the bump decision): read the **two** release-tied versions — `grep '^version' pyproject.toml` and `grep __version__ src/aipass/__init__.py` (they should match; if drifted, note it) — and what PyPI already has: `curl -s https://pypi.org/pypi/aipass/json | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])"`. PyPI rejects a duplicate, so the target must be > published.
|
||||||
- [ ] Decide: **release tag this merge?** (tag = PyPI publish + GitHub Release). If yes, note target version. (Significance call is the user's — the PATCH-default rule below is guidance, and the actual release history is a useful tie-breaker.)
|
- [ ] **Release tag: default YES with PATCH bump.** Every dev-to-main merge ships a PATCH bump + tag so PyPI always tracks main (Patrick ruling S318, 2026-07-17 — version numbers carry no significance during beta). Override to MINOR/MAJOR only when warranted; skip only if explicitly told.
|
||||||
|
|
||||||
## 2. Verify, commit, CHANGELOG
|
## 2. Verify, commit, CHANGELOG
|
||||||
|
|
||||||
@@ -112,14 +113,16 @@ The PR gate (verified against `.github/workflows/`):
|
|||||||
|
|
||||||
- [ ] **Expect `dev` to show "1 behind main" — that's the merge commit, it's cosmetic + fast-forwardable.** See "Why dev shows behind main" up top.
|
- [ ] **Expect `dev` to show "1 behind main" — that's the merge commit, it's cosmetic + fast-forwardable.** See "Why dev shows behind main" up top.
|
||||||
- [ ] **Realign dev** (recommended): `drone @git sync` from dev, or `git merge --ff-only origin/main` on dev. Clean FF, no merge commit, no rewrite.
|
- [ ] **Realign dev** (recommended): `drone @git sync` from dev, or `git merge --ff-only origin/main` on dev. Clean FF, no merge commit, no rewrite.
|
||||||
- [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine — sync it without checkout: `git fetch origin main:main` (zero working-tree touch).
|
- [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine — `drone @git sync` from dev covers it.
|
||||||
- [ ] Never rebase, never reset, never checkout main.
|
- [ ] Never rebase, never reset, never checkout main.
|
||||||
- [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push
|
- [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push
|
||||||
|
|
||||||
## 7. Release tag (only if cutting a release)
|
## 7. Release tag
|
||||||
|
|
||||||
**Versioning rule — bump by SIGNIFICANCE, not cadence:**
|
**Standing default: PATCH bump every merge** (Patrick ruling S318, 2026-07-17). PyPI should always track main; version numbers carry no significance during beta. Big jump reserved for beta exit.
|
||||||
- **PATCH** (`x.y.Z+1`) = fix / internal / standards / UX only → the default for most merges
|
|
||||||
|
Reference (SemVer — for when significance matters post-beta):
|
||||||
|
- **PATCH** (`x.y.Z+1`) = fix / internal / standards / UX only
|
||||||
- **MINOR** (`x.Y+1.0`) = a new backward-compatible user-facing feature shipped
|
- **MINOR** (`x.Y+1.0`) = a new backward-compatible user-facing feature shipped
|
||||||
- **MAJOR** (`X+1.0.0`) = breaking public-API change
|
- **MAJOR** (`X+1.0.0`) = breaking public-API change
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,11 @@
|
|||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
|
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/handlers/config/trust_registry.py",
|
||||||
|
"standard": "unused_function",
|
||||||
|
"reason": "enroll() and revoke() are the public API consumed CROSS-BRANCH by @aipass CLI (init/trust/revoke commands, DPLAN-0244 phase 2). seedgo's intra-branch static analysis cannot see cross-branch callers. read_registry() also exported for @aipass CLI use."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/handlers/config/loader.py",
|
"file": "apps/handlers/config/loader.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
@@ -441,6 +446,11 @@
|
|||||||
"standard": "trigger",
|
"standard": "trigger",
|
||||||
"reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle \u2014 not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'."
|
"reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle \u2014 not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/feedback.py",
|
||||||
|
"standard": "trigger",
|
||||||
|
"reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file \u2014 not a tracked resource. Deliberate user action via 'drone @hooks feedback on'."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/hookstatus.py",
|
"file": "apps/modules/hookstatus.py",
|
||||||
"standard": "json_structure",
|
"standard": "json_structure",
|
||||||
|
|||||||
@@ -2,15 +2,25 @@
|
|||||||
|
|
||||||
# Hooks
|
# Hooks
|
||||||
|
|
||||||
> Hook infrastructure for AIPass. Single engine dispatches all hooks across platforms (Claude, Codex) with per-project config, full logging, and crash isolation. The 13th citizen.
|
> Hook infrastructure for AIPass. A single dispatch engine routes hook events across platforms (Claude Code, Codex) with per-project configuration, full logging, and crash isolation.
|
||||||
|
|
||||||
Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (`.aipass/hooks.json`), dispatches matching handlers, and logs everything to prax + JSONL.
|
Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (`.aipass/hooks.json`), dispatches matching handlers, and logs everything to JSONL diagnostics.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone @hooks status # Show hook config for current project
|
||||||
|
drone @hooks log # Tail recent hook activity
|
||||||
|
drone @hooks engine # Show connected handlers
|
||||||
|
drone @hooks verify # Cross-check provider ↔ project wiring
|
||||||
|
drone @hooks --help # Full help reference
|
||||||
|
```
|
||||||
|
|
||||||
## Start here
|
## Start here
|
||||||
|
|
||||||
| You want to | Read |
|
| You want to | Read |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Identity, memory, session history | [`.trinity/`](.trinity/) |
|
| Identity, session history | [`.trinity/`](.trinity/) |
|
||||||
| Hook engine design | `DPLAN-0184` |
|
| Hook engine design | `DPLAN-0184` |
|
||||||
| Per-project config | `.aipass/hooks.json` |
|
| Per-project config | `.aipass/hooks.json` |
|
||||||
|
|
||||||
@@ -25,6 +35,9 @@ Every hook event flows through one engine. Platform bridges normalize the event
|
|||||||
| `drone @hooks hooksound` | Show current sound mute status |
|
| `drone @hooks hooksound` | Show current sound mute status |
|
||||||
| `drone @hooks hooksound off` | Mute all hook sounds |
|
| `drone @hooks hooksound off` | Mute all hook sounds |
|
||||||
| `drone @hooks hooksound on` | Unmute all hook sounds |
|
| `drone @hooks hooksound on` | Unmute all hook sounds |
|
||||||
|
| `drone @hooks feedback` | Show feedback pulse status (enabled/disabled) |
|
||||||
|
| `drone @hooks feedback off` | Disable feedback pulse for this project |
|
||||||
|
| `drone @hooks feedback on` | Enable feedback pulse for this project |
|
||||||
| `drone @hooks dismiss <alert-id>` | Remove an alert from `.aipass/alerts.json` |
|
| `drone @hooks dismiss <alert-id>` | Remove an alert from `.aipass/alerts.json` |
|
||||||
| `drone @hooks cadence` | Show prompt injection cadence config and state |
|
| `drone @hooks cadence` | Show prompt injection cadence config and state |
|
||||||
| `drone @hooks verify` | Cross-check provider settings vs project hook config |
|
| `drone @hooks verify` | Cross-check provider settings vs project hook config |
|
||||||
@@ -56,6 +69,7 @@ src/aipass/hooks/
|
|||||||
│ │ ├── hook_test.py # Portable test runner (drone @hooks test)
|
│ │ ├── hook_test.py # Portable test runner (drone @hooks test)
|
||||||
│ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json)
|
│ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json)
|
||||||
│ │ ├── engine.py # Core dispatch — routes events to handlers
|
│ │ ├── engine.py # Core dispatch — routes events to handlers
|
||||||
|
│ │ ├── feedback.py # Feedback pulse toggle (drone @hooks feedback on/off)
|
||||||
│ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off)
|
│ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off)
|
||||||
│ │ ├── hookstatus.py # Config viewer (drone @hooks status)
|
│ │ ├── hookstatus.py # Config viewer (drone @hooks status)
|
||||||
│ │ ├── alert_dismiss.py # Dismiss alerts (drone @hooks dismiss <id>)
|
│ │ ├── alert_dismiss.py # Dismiss alerts (drone @hooks dismiss <id>)
|
||||||
@@ -71,6 +85,7 @@ src/aipass/hooks/
|
|||||||
│ │ │ ├── tier0_kernel.py # Injects tier0 kernel prompt (every turn)
|
│ │ │ ├── tier0_kernel.py # Injects tier0 kernel prompt (every turn)
|
||||||
│ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic)
|
│ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic)
|
||||||
│ │ │ ├── identity.py # Injects passport identity block
|
│ │ │ ├── identity.py # Injects passport identity block
|
||||||
|
│ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable)
|
||||||
│ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json
|
│ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json
|
||||||
│ │ ├── security/ # Enforcement hooks
|
│ │ ├── security/ # Enforcement hooks
|
||||||
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
|
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
|
||||||
@@ -117,7 +132,7 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
|
|||||||
|
|
||||||
| Event | Hooks | Description |
|
| Event | Hooks | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + auto-process + TG mirror |
|
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + auto-process + TG mirror |
|
||||||
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound |
|
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound |
|
||||||
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
|
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
|
||||||
| SubagentStop | subagent_gate | Seedgo validation |
|
| SubagentStop | subagent_gate | Seedgo validation |
|
||||||
|
|||||||
@@ -20,19 +20,39 @@ AIPASS_HOME = os.environ.get("AIPASS_HOME", "")
|
|||||||
|
|
||||||
|
|
||||||
def find_project_config() -> dict | None:
|
def find_project_config() -> dict | None:
|
||||||
"""Walk up from CWD looking for .aipass/hooks.json."""
|
"""Walk up from CWD looking for .aipass/hooks.json, with trust verification."""
|
||||||
|
from aipass.hooks.apps.handlers.config.trust_registry import (
|
||||||
|
REGISTRY_PATH,
|
||||||
|
bootstrap,
|
||||||
|
is_trusted,
|
||||||
|
)
|
||||||
|
|
||||||
search = Path.cwd()
|
search = Path.cwd()
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
while search != home and search.parent != search:
|
while search != home and search.parent != search:
|
||||||
config = search / ".aipass" / "hooks.json"
|
config_file = search / ".aipass" / "hooks.json"
|
||||||
if config.exists():
|
if config_file.exists():
|
||||||
|
project_dir = str(search)
|
||||||
|
|
||||||
|
if not REGISTRY_PATH.exists():
|
||||||
|
bootstrap()
|
||||||
|
|
||||||
|
if not is_trusted(project_dir):
|
||||||
|
logger.warning(
|
||||||
|
"[HOOKS] project not enrolled in trust registry: %s (run: aipass init update)",
|
||||||
|
project_dir,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = config.read_text(encoding="utf-8")
|
raw = config_file.read_text(encoding="utf-8")
|
||||||
if AIPASS_HOME:
|
if AIPASS_HOME:
|
||||||
raw = raw.replace("$AIPASS_HOME", AIPASS_HOME)
|
raw = raw.replace("$AIPASS_HOME", AIPASS_HOME)
|
||||||
return json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
|
parsed["_source"] = "project"
|
||||||
|
return parsed
|
||||||
except (json.JSONDecodeError, OSError) as exc:
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
logger.error("[HOOKS] bad config %s: %s", config, exc)
|
logger.error("[HOOKS] bad config %s: %s", config_file, exc)
|
||||||
return None
|
return None
|
||||||
search = search.parent
|
search = search.parent
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: trust_registry.py
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Description: Trusted-project registry — DPLAN-0244 Layer B
|
||||||
|
# Branch: hooks
|
||||||
|
# Layer: apps/handlers/config
|
||||||
|
# Created: 2026-07-15
|
||||||
|
# Modified: 2026-07-15
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Trusted-project registry for hook config loading.
|
||||||
|
|
||||||
|
Single source of truth for which projects are trusted to have their
|
||||||
|
.aipass/hooks.json loaded by the hook engine. Registry lives at
|
||||||
|
~/.aipass/trusted_projects.json. @aipass CLI (init/trust/revoke)
|
||||||
|
imports this module for enrollment operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.hooks.apps.handlers.json import json_handler
|
||||||
|
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||||
|
|
||||||
|
REGISTRY_PATH = Path.home() / ".aipass" / "trusted_projects.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_file(path: Path) -> str:
|
||||||
|
"""Compute sha256 of a file's contents."""
|
||||||
|
data = path.read_bytes()
|
||||||
|
return f"sha256:{hashlib.sha256(data).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
def read_registry() -> dict:
|
||||||
|
"""Read the trusted-project registry. Returns empty registry if absent or corrupt."""
|
||||||
|
if not REGISTRY_PATH.exists():
|
||||||
|
return {"version": 1, "projects": {}}
|
||||||
|
try:
|
||||||
|
data = json_handler.read_json_file(REGISTRY_PATH)
|
||||||
|
if not isinstance(data.get("projects"), dict):
|
||||||
|
return {"version": 1, "projects": {}}
|
||||||
|
return data
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
logger.error("[HOOKS] bad trust registry %s: %s", REGISTRY_PATH, exc)
|
||||||
|
return {"version": 1, "projects": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_registry(registry: dict) -> None:
|
||||||
|
"""Write the registry to disk, creating parent dirs if needed."""
|
||||||
|
REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
json_handler.write_json_file(REGISTRY_PATH, registry)
|
||||||
|
|
||||||
|
|
||||||
|
def enroll(project_dir: str) -> bool:
|
||||||
|
"""Enroll a project in the trusted registry. Returns True on success."""
|
||||||
|
project_path = Path(project_dir).resolve()
|
||||||
|
config_path = project_path / ".aipass" / "hooks.json"
|
||||||
|
if not config_path.exists():
|
||||||
|
logger.warning("[HOOKS] cannot enroll %s: no .aipass/hooks.json", project_path)
|
||||||
|
return False
|
||||||
|
config_hash = _hash_file(config_path)
|
||||||
|
registry = read_registry()
|
||||||
|
registry["projects"][str(project_path)] = {
|
||||||
|
"enrolled": _isoformat_now(),
|
||||||
|
"config_hash": config_hash,
|
||||||
|
"config_path": str(config_path),
|
||||||
|
}
|
||||||
|
_write_registry(registry)
|
||||||
|
json_handler.log_operation("enroll", {"project": str(project_path)}, module_name="trust_registry")
|
||||||
|
logger.info("[HOOKS] enrolled %s (hash=%s)", project_path, config_hash)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(project_dir: str) -> bool:
|
||||||
|
"""Remove a project from the trusted registry. Returns True if it was present."""
|
||||||
|
project_path = str(Path(project_dir).resolve())
|
||||||
|
registry = read_registry()
|
||||||
|
if project_path not in registry["projects"]:
|
||||||
|
return False
|
||||||
|
del registry["projects"][project_path]
|
||||||
|
_write_registry(registry)
|
||||||
|
json_handler.log_operation("revoke", {"project": project_path}, module_name="trust_registry")
|
||||||
|
logger.info("[HOOKS] revoked %s", project_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_trusted(project_dir: str) -> bool:
|
||||||
|
"""Check if a project is enrolled with a matching config hash."""
|
||||||
|
project_path = str(Path(project_dir).resolve())
|
||||||
|
registry = read_registry()
|
||||||
|
entry = registry["projects"].get(project_path)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
config_path = Path(project_dir).resolve() / ".aipass" / "hooks.json"
|
||||||
|
if not config_path.exists():
|
||||||
|
return False
|
||||||
|
current_hash = _hash_file(config_path)
|
||||||
|
return current_hash == entry.get("config_hash", "")
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap() -> bool:
|
||||||
|
"""Bootstrap the registry with ONLY the AIPass install. Returns True on success.
|
||||||
|
|
||||||
|
Called when the registry file does not exist. Enrolls the AIPass
|
||||||
|
install identified by $AIPASS_HOME — never the current CWD.
|
||||||
|
"""
|
||||||
|
aipass_home = os.environ.get("AIPASS_HOME", "")
|
||||||
|
if not aipass_home:
|
||||||
|
logger.warning("[HOOKS] registry absent and AIPASS_HOME not set — cannot bootstrap")
|
||||||
|
return False
|
||||||
|
aipass_path = Path(aipass_home).resolve()
|
||||||
|
config_path = aipass_path / ".aipass" / "hooks.json"
|
||||||
|
if not config_path.exists():
|
||||||
|
logger.warning(
|
||||||
|
"[HOOKS] registry absent and AIPass hooks.json not found at %s",
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
config_hash = _hash_file(config_path)
|
||||||
|
registry = {"version": 1, "projects": {}}
|
||||||
|
registry["projects"][str(aipass_path)] = {
|
||||||
|
"enrolled": _isoformat_now(),
|
||||||
|
"config_hash": config_hash,
|
||||||
|
"config_path": str(config_path),
|
||||||
|
}
|
||||||
|
_write_registry(registry)
|
||||||
|
json_handler.log_operation("bootstrap", {"aipass_home": str(aipass_path)}, module_name="trust_registry")
|
||||||
|
logger.info("[HOOKS] registry bootstrapped, enrolled AIPass install: %s", aipass_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _isoformat_now() -> str:
|
||||||
|
"""Return current UTC time as ISO string."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: compass_recall.py
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Description: Ambient compass recall — surfaces rated decisions on relevant prompts
|
||||||
|
# Branch: hooks
|
||||||
|
# Layer: apps/handlers/prompt
|
||||||
|
# Created: 2026-07-16
|
||||||
|
# Modified: 2026-07-16
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Queries compass FTS against the user's prompt and injects matching decisions
|
||||||
|
under governance rules. Never blocks the prompt on error."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||||
|
from aipass.hooks.apps.handlers.json import json_handler
|
||||||
|
|
||||||
|
_STATE_DIR = Path(tempfile.gettempdir())
|
||||||
|
|
||||||
|
|
||||||
|
def _state_path(hook_data: dict | None = None) -> Path | None:
|
||||||
|
session_id = ""
|
||||||
|
if hook_data:
|
||||||
|
session_id = hook_data.get("session_id", "")
|
||||||
|
if not session_id:
|
||||||
|
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
return _STATE_DIR / f"aipass-compass-recall-{session_id}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state(hook_data: dict | None = None) -> dict:
|
||||||
|
path = _state_path(hook_data)
|
||||||
|
if path is None or not path.exists():
|
||||||
|
return _fresh_state()
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
logger.info("[HOOKS] compass_recall: state read failed: %s", exc)
|
||||||
|
return _fresh_state()
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_state() -> dict:
|
||||||
|
try:
|
||||||
|
from aipass.memory.apps.modules.governance import new_state
|
||||||
|
|
||||||
|
return new_state()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("[HOOKS] compass_recall: governance import failed: %s", exc)
|
||||||
|
return {"surfaces_count": 0, "messages_since_last": 0, "last_surface_time": 0.0, "surfaced_ids": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(state: dict, hook_data: dict | None = None) -> None:
|
||||||
|
path = _state_path(hook_data)
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
path.write_text(json.dumps(state), encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
logger.info("[HOOKS] compass_recall: state write failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def handle(hook_data: dict) -> dict:
|
||||||
|
"""Surface relevant compass decisions into the prompt context."""
|
||||||
|
try:
|
||||||
|
if not _state_path(hook_data):
|
||||||
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|
||||||
|
state = _load_state(hook_data)
|
||||||
|
|
||||||
|
from aipass.memory.apps.modules.governance import should_surface, record_message
|
||||||
|
|
||||||
|
state = record_message(state)
|
||||||
|
|
||||||
|
prompt_text = hook_data.get("prompt", "")
|
||||||
|
if not prompt_text or len(prompt_text) < 10:
|
||||||
|
_save_state(state, hook_data)
|
||||||
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|
||||||
|
from aipass.devpulse.apps.modules.compass import recall_decisions, mark_surfaced
|
||||||
|
|
||||||
|
candidates = recall_decisions(prompt_text, limit=3)
|
||||||
|
if not candidates:
|
||||||
|
_save_state(state, hook_data)
|
||||||
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|
||||||
|
approved = []
|
||||||
|
for c in candidates:
|
||||||
|
item_id = str(c["id"])
|
||||||
|
relevance = c.get("relevance", 0.0)
|
||||||
|
surface, reason, new_st = should_surface(item_id, relevance, state)
|
||||||
|
if surface:
|
||||||
|
approved.append(c)
|
||||||
|
state = new_st
|
||||||
|
|
||||||
|
_save_state(state, hook_data)
|
||||||
|
|
||||||
|
if not approved:
|
||||||
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for c in approved:
|
||||||
|
rating = c.get("rating", "good").upper()
|
||||||
|
lines.append(f"[{rating}] #{c['id']}: {c['decision']}")
|
||||||
|
|
||||||
|
mark_surfaced([c["id"] for c in approved])
|
||||||
|
json_handler.log_operation("compass_recall", {"count": len(approved)})
|
||||||
|
|
||||||
|
return {"stdout": "\n".join(lines), "exit_code": 0}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("[HOOKS] compass_recall_unreachable: %s", exc)
|
||||||
|
return {"stdout": "", "exit_code": 0}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: feedback_pulse.py
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Description: Periodic feedback ask — one ignorable line every ~10 turns
|
||||||
|
# Branch: hooks
|
||||||
|
# Layer: apps/handlers/prompt
|
||||||
|
# Created: 2026-07-18
|
||||||
|
# Modified: 2026-07-18
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Periodic feedback pulse — surfaces a one-line feedback ask every ~10 turns.
|
||||||
|
|
||||||
|
Scoped to external user projects (not the AIPass host). Toggle via
|
||||||
|
drone @hooks feedback on/off. State persists across session restarts
|
||||||
|
as a .aipass/feedback_off sentinel file per project."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||||
|
from aipass.hooks.apps.handlers.json import json_handler
|
||||||
|
|
||||||
|
_STATE_DIR = Path(tempfile.gettempdir())
|
||||||
|
_PERIOD = 10
|
||||||
|
_FEEDBACK_URL = "https://github.com/AIOSAI/AIPass/issues"
|
||||||
|
_FEEDBACK_LINE = f"How are we doing? Your feedback is hugely appreciated → {_FEEDBACK_URL}"
|
||||||
|
|
||||||
|
|
||||||
|
def _state_path(hook_data: dict) -> Path | None:
|
||||||
|
session_id = hook_data.get("session_id", "")
|
||||||
|
if not session_id:
|
||||||
|
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
return _STATE_DIR / f"aipass-feedback-pulse-{session_id}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_and_increment(path: Path) -> int:
|
||||||
|
"""Load turn counter, increment, and persist. Returns the new turn number."""
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
turn = data.get("turn", 0) + 1
|
||||||
|
else:
|
||||||
|
turn = 0
|
||||||
|
path.write_text(json.dumps({"turn": turn}), encoding="utf-8")
|
||||||
|
return turn
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
logger.info("[HOOKS] feedback_pulse: state access failed: %s", exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _find_aipass_dir(cwd: str | None = None) -> Path | None:
|
||||||
|
"""Walk up from CWD to find the nearest .aipass/ directory."""
|
||||||
|
start = Path(cwd) if cwd else Path.cwd()
|
||||||
|
for parent in [start, *start.parents]:
|
||||||
|
candidate = parent / ".aipass"
|
||||||
|
if candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
if parent == parent.parent:
|
||||||
|
break
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_disabled(cwd: str | None = None) -> bool:
|
||||||
|
"""Check if feedback pulse is toggled off for this project."""
|
||||||
|
aipass_dir = _find_aipass_dir(cwd)
|
||||||
|
if aipass_dir is None:
|
||||||
|
return True
|
||||||
|
sentinel = aipass_dir / "feedback_off"
|
||||||
|
return sentinel.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def handle(hook_data: dict) -> dict:
|
||||||
|
"""Inject feedback pulse line on cadence (~every 10 turns, skipping early turns)."""
|
||||||
|
try:
|
||||||
|
path = _state_path(hook_data)
|
||||||
|
if path is None:
|
||||||
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|
||||||
|
turn = _load_and_increment(path)
|
||||||
|
|
||||||
|
if turn < _PERIOD or turn % _PERIOD != 0:
|
||||||
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|
||||||
|
cwd = hook_data.get("cwd", "")
|
||||||
|
if _is_disabled(cwd or None):
|
||||||
|
return {"stdout": "", "exit_code": 0}
|
||||||
|
|
||||||
|
json_handler.log_operation("feedback_pulse", {"turn": turn})
|
||||||
|
return {"stdout": _FEEDBACK_LINE, "exit_code": 0}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("[HOOKS] feedback_pulse: unexpected error: %s", exc)
|
||||||
|
return {"stdout": "", "exit_code": 0}
|
||||||
@@ -33,9 +33,7 @@ if sys.platform == "win32":
|
|||||||
_reconfigure(encoding="utf-8", errors="replace")
|
_reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402
|
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402
|
||||||
from aipass.cli.apps.modules import err_console # noqa: E402
|
from aipass.cli.apps.modules import console # noqa: E402
|
||||||
|
|
||||||
CONSOLE = err_console
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MODULE DISCOVERY
|
# MODULE DISCOVERY
|
||||||
@@ -85,86 +83,86 @@ def discover_modules() -> list[Any]:
|
|||||||
def print_introspection():
|
def print_introspection():
|
||||||
"""Print branch introspection — discovered modules and capabilities."""
|
"""Print branch introspection — discovered modules and capabilities."""
|
||||||
modules = discover_modules()
|
modules = discover_modules()
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("[bold cyan]HOOKS — Hook Infrastructure for AIPass[/bold cyan]")
|
console.print("[bold cyan]HOOKS — Hook Infrastructure for AIPass[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
|
console.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
for module in modules:
|
for module in modules:
|
||||||
name = module.__name__.split(".")[-1]
|
name = module.__name__.split(".")[-1]
|
||||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||||
CONSOLE.print(f" [cyan]•[/cyan] {name:20} [dim]{desc}[/dim]")
|
console.print(f" [cyan]•[/cyan] {name:20} [dim]{desc}[/dim]")
|
||||||
|
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("Run [green]'drone @hooks --help'[/green] for usage information")
|
console.print("Run [green]'drone @hooks --help'[/green] for usage information")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def print_help():
|
def print_help():
|
||||||
"""Print CLI help — usage instructions and available commands."""
|
"""Print CLI help — usage instructions and available commands."""
|
||||||
modules = discover_modules()
|
modules = discover_modules()
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("[bold cyan]HOOKS[/bold cyan] [dim]v1.1.0[/dim] — Hook Infrastructure for AIPass")
|
console.print("[bold cyan]HOOKS[/bold cyan] [dim]v1.1.0[/dim] — Hook Infrastructure for AIPass")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
|
console.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("─" * 70)
|
console.print("─" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[bold cyan]USAGE:[/bold cyan]")
|
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print(" [dim]drone @hooks <command> [args...][/dim]")
|
console.print(" [dim]drone @hooks <command> [args...][/dim]")
|
||||||
CONSOLE.print(" [dim]drone @hooks --help[/dim]")
|
console.print(" [dim]drone @hooks --help[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("─" * 70)
|
console.print("─" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[bold cyan]COMMANDS:[/bold cyan]")
|
console.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
for module in modules:
|
for module in modules:
|
||||||
commands = getattr(module, "HELP_COMMANDS", None)
|
commands = getattr(module, "HELP_COMMANDS", None)
|
||||||
if commands:
|
if commands:
|
||||||
for cmd, desc in commands:
|
for cmd, desc in commands:
|
||||||
CONSOLE.print(f" [green]{cmd:26}[/green] [dim]{desc}[/dim]")
|
console.print(f" [green]{cmd:26}[/green] [dim]{desc}[/dim]")
|
||||||
else:
|
else:
|
||||||
name = module.__name__.split(".")[-1]
|
name = module.__name__.split(".")[-1]
|
||||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||||
CONSOLE.print(f" [green]{name:26}[/green] [dim]{desc}[/dim]")
|
console.print(f" [green]{name:26}[/green] [dim]{desc}[/dim]")
|
||||||
|
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("─" * 70)
|
console.print("─" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[bold cyan]BRIDGES:[/bold cyan]")
|
console.print("[bold cyan]BRIDGES:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print(
|
console.print(
|
||||||
" [green]claude[/green] [dim]Claude Code bridge (provider settings entry point)[/dim]"
|
" [green]claude[/green] [dim]Claude Code bridge (provider settings entry point)[/dim]"
|
||||||
)
|
)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("─" * 70)
|
console.print("─" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print(" [dim]drone @hooks status[/dim] [dim]# Show hook config for current project[/dim]")
|
console.print(" [dim]drone @hooks status[/dim] [dim]# Show hook config for current project[/dim]")
|
||||||
CONSOLE.print(" [dim]drone @hooks log[/dim] [dim]# Tail recent hook activity[/dim]")
|
console.print(" [dim]drone @hooks log[/dim] [dim]# Tail recent hook activity[/dim]")
|
||||||
CONSOLE.print(" [dim]drone @hooks hooksound off[/dim] [dim]# Mute all hook sounds[/dim]")
|
console.print(" [dim]drone @hooks hooksound off[/dim] [dim]# Mute all hook sounds[/dim]")
|
||||||
CONSOLE.print(" [dim]drone @hooks hooksound on[/dim] [dim]# Unmute all hook sounds[/dim]")
|
console.print(" [dim]drone @hooks hooksound on[/dim] [dim]# Unmute all hook sounds[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("─" * 70)
|
console.print("─" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[bold cyan]FLAGS:[/bold cyan]")
|
console.print("[bold cyan]FLAGS:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print(" [green]--help, -h[/green] [dim]Show this help message[/dim]")
|
console.print(" [green]--help, -h[/green] [dim]Show this help message[/dim]")
|
||||||
CONSOLE.print(" [green]--version, -V[/green] [dim]Show version[/dim]")
|
console.print(" [green]--version, -V[/green] [dim]Show version[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("[bold]TIP:[/bold] For command-specific help:")
|
console.print("[bold]TIP:[/bold] For command-specific help:")
|
||||||
CONSOLE.print(" [dim]drone @hooks <command> --help[/dim]")
|
console.print(" [dim]drone @hooks <command> --help[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
||||||
@@ -192,7 +190,7 @@ def handle_command(command: str, args: list) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
if command in ["--version", "-V"]:
|
if command in ["--version", "-V"]:
|
||||||
CONSOLE.print("hooks 1.1.0")
|
console.print("hooks 1.1.0")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return route_command(command, args, modules)
|
return route_command(command, args, modules)
|
||||||
@@ -209,7 +207,7 @@ def main() -> int:
|
|||||||
if handle_command(args[0], args[1:]):
|
if handle_command(args[0], args[1:]):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
CONSOLE.print(f"Unknown command: {args[0]}. Try: drone @hooks --help")
|
console.print(f"Unknown command: {args[0]}. Try: drone @hooks --help")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import importlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -65,6 +66,18 @@ def _run_handler(handler_path: str, hook_data: dict) -> dict:
|
|||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
module_path, func_name = handler_path.rsplit(".", 1)
|
module_path, func_name = handler_path.rsplit(".", 1)
|
||||||
|
if not module_path.startswith("aipass."):
|
||||||
|
elapsed_ms = (time.monotonic() - start) * 1000
|
||||||
|
logger.warning(
|
||||||
|
"[HOOKS] handler path refused (not in aipass.* namespace): %s",
|
||||||
|
handler_path,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"exit_code": -1,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": f"handler namespace refused: {handler_path}",
|
||||||
|
"elapsed_ms": round(elapsed_ms, 1),
|
||||||
|
}
|
||||||
module = importlib.import_module(module_path)
|
module = importlib.import_module(module_path)
|
||||||
handler_func = getattr(module, func_name)
|
handler_func = getattr(module, func_name)
|
||||||
result = handler_func(hook_data)
|
result = handler_func(hook_data)
|
||||||
@@ -89,6 +102,63 @@ def _matches(matcher: str, value: str) -> bool:
|
|||||||
return value in matcher.split("|")
|
return value in matcher.split("|")
|
||||||
|
|
||||||
|
|
||||||
|
_BUDGET_KEYS = ("max_per_session", "min_spacing_turns", "cooldown_seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_state_path(session_id: str = "") -> Path | None:
|
||||||
|
if not session_id:
|
||||||
|
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
return Path(tempfile.gettempdir()) / f"aipass-handler-budget-{session_id}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_budget_state(session_id: str = "") -> dict:
|
||||||
|
path = _budget_state_path(session_id)
|
||||||
|
if path is None or not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
logger.info("[HOOKS] budget: state read failed: %s", exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_budget_state(state: dict, session_id: str = "") -> None:
|
||||||
|
path = _budget_state_path(session_id)
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
path.write_text(json.dumps(state), encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
logger.info("[HOOKS] budget: state write failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_budget(hook_name: str, budget_cfg: dict, budget_state: dict) -> tuple[bool, str]:
|
||||||
|
"""Check if handler is within its per-session budget."""
|
||||||
|
hs = budget_state.get(hook_name, {})
|
||||||
|
fire_count = hs.get("fire_count", 0)
|
||||||
|
|
||||||
|
max_fires = budget_cfg.get("max_per_session")
|
||||||
|
if max_fires is not None and fire_count >= max_fires:
|
||||||
|
return False, f"budget exhausted ({fire_count}/{max_fires})"
|
||||||
|
|
||||||
|
if fire_count > 0:
|
||||||
|
min_spacing = budget_cfg.get("min_spacing_turns")
|
||||||
|
if min_spacing is not None:
|
||||||
|
turns_since = hs.get("turns_since_fire", 0)
|
||||||
|
if turns_since < min_spacing:
|
||||||
|
return False, f"spacing ({turns_since}/{min_spacing})"
|
||||||
|
|
||||||
|
cooldown = budget_cfg.get("cooldown_seconds")
|
||||||
|
if cooldown is not None:
|
||||||
|
elapsed = time.time() - hs.get("last_fire_time", 0.0)
|
||||||
|
if elapsed < cooldown:
|
||||||
|
return False, f"cooldown ({int(cooldown - elapsed)}s)"
|
||||||
|
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
|
||||||
def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
|
def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
|
||||||
"""Core dispatch — run hooks for event, return (merged_stdout, exit_code)."""
|
"""Core dispatch — run hooks for event, return (merged_stdout, exit_code)."""
|
||||||
if not config.get("hooks_enabled", True):
|
if not config.get("hooks_enabled", True):
|
||||||
@@ -111,6 +181,9 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
|
|||||||
|
|
||||||
outputs = []
|
outputs = []
|
||||||
total_start = time.monotonic()
|
total_start = time.monotonic()
|
||||||
|
budget_state = None
|
||||||
|
budget_dirty = False
|
||||||
|
payload_session_id = parsed.get("session_id", "")
|
||||||
|
|
||||||
for hook_name, hook_def in event_hooks.items():
|
for hook_name, hook_def in event_hooks.items():
|
||||||
if not hook_def.get("enabled", True):
|
if not hook_def.get("enabled", True):
|
||||||
@@ -137,6 +210,43 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if command and not handler and config.get("_source") == "project":
|
||||||
|
logger.warning(
|
||||||
|
"[HOOKS] %s.%s REFUSED: command-type not allowed in per-project config",
|
||||||
|
event_type,
|
||||||
|
hook_name,
|
||||||
|
)
|
||||||
|
_log(
|
||||||
|
{
|
||||||
|
"ts": time.time(),
|
||||||
|
"event": event_type,
|
||||||
|
"hook": hook_name,
|
||||||
|
"action": "refused_command_type",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
budget_cfg = {k: hook_def[k] for k in _BUDGET_KEYS if k in hook_def}
|
||||||
|
if budget_cfg:
|
||||||
|
if budget_state is None:
|
||||||
|
budget_state = _load_budget_state(payload_session_id)
|
||||||
|
hs = budget_state.setdefault(hook_name, {})
|
||||||
|
hs["turns_since_fire"] = hs.get("turns_since_fire", 0) + 1
|
||||||
|
budget_dirty = True
|
||||||
|
allowed, reason = _check_budget(hook_name, budget_cfg, budget_state)
|
||||||
|
if not allowed:
|
||||||
|
logger.info("[HOOKS] %s.%s budget: %s", event_type, hook_name, reason)
|
||||||
|
_log(
|
||||||
|
{
|
||||||
|
"ts": time.time(),
|
||||||
|
"event": event_type,
|
||||||
|
"hook": hook_name,
|
||||||
|
"action": "budget_suppressed",
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
if handler:
|
if handler:
|
||||||
result = _run_handler(handler, parsed)
|
result = _run_handler(handler, parsed)
|
||||||
else:
|
else:
|
||||||
@@ -216,6 +326,15 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]:
|
|||||||
|
|
||||||
if result["stdout"]:
|
if result["stdout"]:
|
||||||
outputs.append(result["stdout"])
|
outputs.append(result["stdout"])
|
||||||
|
if budget_cfg and budget_state is not None:
|
||||||
|
hs = budget_state.setdefault(hook_name, {})
|
||||||
|
hs["fire_count"] = hs.get("fire_count", 0) + 1
|
||||||
|
hs["last_fire_time"] = time.time()
|
||||||
|
hs["turns_since_fire"] = 0
|
||||||
|
budget_dirty = True
|
||||||
|
|
||||||
|
if budget_dirty and budget_state is not None:
|
||||||
|
_save_budget_state(budget_state, payload_session_id)
|
||||||
|
|
||||||
total_ms = (time.monotonic() - total_start) * 1000
|
total_ms = (time.monotonic() - total_start) * 1000
|
||||||
logger.info("[HOOKS] %s complete: %d hooks %dms", event_type, len(outputs), total_ms)
|
logger.info("[HOOKS] %s complete: %d hooks %dms", event_type, len(outputs), total_ms)
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: feedback.py
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Description: Feedback pulse toggle — on/off control for periodic feedback ask
|
||||||
|
# Branch: hooks
|
||||||
|
# Layer: apps/modules
|
||||||
|
# Created: 2026-07-18
|
||||||
|
# Modified: 2026-07-18
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Feedback pulse toggle — on/off control for the periodic feedback ask via drone @hooks feedback."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.cli.apps.modules import err_console
|
||||||
|
from aipass.hooks.apps.handlers.json import json_handler
|
||||||
|
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: F401
|
||||||
|
|
||||||
|
CONSOLE = err_console
|
||||||
|
|
||||||
|
HELP_COMMANDS = [
|
||||||
|
("feedback on", "Enable feedback pulse (default)"),
|
||||||
|
("feedback off", "Disable feedback pulse"),
|
||||||
|
("feedback", "Show current feedback pulse status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_aipass_dir() -> Path | None:
|
||||||
|
"""Walk up from CWD to find the nearest .aipass/ directory."""
|
||||||
|
cwd = Path.cwd()
|
||||||
|
for parent in [cwd, *cwd.parents]:
|
||||||
|
candidate = parent / ".aipass"
|
||||||
|
if candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
if parent == parent.parent:
|
||||||
|
break
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sentinel() -> Path | None:
|
||||||
|
"""Return the sentinel file path, or None if no .aipass/ dir found."""
|
||||||
|
aipass_dir = _find_aipass_dir()
|
||||||
|
if aipass_dir is None:
|
||||||
|
return None
|
||||||
|
return aipass_dir / "feedback_off"
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection() -> None:
|
||||||
|
"""Print module structure for drone routing."""
|
||||||
|
sentinel = _sentinel()
|
||||||
|
if sentinel is None:
|
||||||
|
status = "NO PROJECT"
|
||||||
|
else:
|
||||||
|
status = "DISABLED" if sentinel.exists() else "ENABLED"
|
||||||
|
CONSOLE.print(f"[bold cyan]feedback[/bold cyan] — Feedback pulse ({status})")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_command(command: str, args: list) -> bool:
|
||||||
|
"""Route feedback commands from drone @hooks."""
|
||||||
|
if command == "feedback":
|
||||||
|
if not args:
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
|
||||||
|
sub = args[0]
|
||||||
|
|
||||||
|
if sub in ("--help", "-h", "help"):
|
||||||
|
CONSOLE.print("[bold cyan]feedback[/bold cyan] — Toggle the periodic feedback pulse")
|
||||||
|
CONSOLE.print()
|
||||||
|
CONSOLE.print(" drone @hooks feedback Show current status")
|
||||||
|
CONSOLE.print(" drone @hooks feedback on Enable feedback pulse (default)")
|
||||||
|
CONSOLE.print(" drone @hooks feedback off Disable feedback pulse")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if sub == "off":
|
||||||
|
sentinel = _sentinel()
|
||||||
|
if sentinel is None:
|
||||||
|
CONSOLE.print("[yellow]No .aipass/ directory found[/yellow]")
|
||||||
|
return True
|
||||||
|
sentinel.touch()
|
||||||
|
json_handler.log_operation("feedback_toggle", {"state": "off"})
|
||||||
|
CONSOLE.print("[yellow]Feedback pulse DISABLED[/yellow]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if sub == "on":
|
||||||
|
sentinel = _sentinel()
|
||||||
|
if sentinel is None:
|
||||||
|
CONSOLE.print("[yellow]No .aipass/ directory found[/yellow]")
|
||||||
|
return True
|
||||||
|
if sentinel.exists():
|
||||||
|
sentinel.unlink()
|
||||||
|
json_handler.log_operation("feedback_toggle", {"state": "on"})
|
||||||
|
CONSOLE.print("[green]Feedback pulse ENABLED[/green]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: test_compass_recall.py
|
||||||
|
# Version: 1.1.0
|
||||||
|
# Description: Tests for compass recall prompt handler
|
||||||
|
# Branch: hooks
|
||||||
|
# Created: 2026-07-16
|
||||||
|
# Modified: 2026-07-16
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Tests for handlers/prompt/compass_recall.py."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
CANDIDATE_GOOD = {
|
||||||
|
"id": 56,
|
||||||
|
"rating": "good",
|
||||||
|
"decision": "Never hardcode config in prompts",
|
||||||
|
"context": "Prompt config management",
|
||||||
|
"note": "",
|
||||||
|
"tags": "config,prompts",
|
||||||
|
"relevance": 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
CANDIDATE_BAD = {
|
||||||
|
"id": 84,
|
||||||
|
"rating": "bad",
|
||||||
|
"decision": "Usage gap is not a bug",
|
||||||
|
"context": "Compass audit",
|
||||||
|
"note": "",
|
||||||
|
"tags": "compass",
|
||||||
|
"relevance": 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
CANDIDATE_LOW_RELEVANCE = {
|
||||||
|
"id": 99,
|
||||||
|
"rating": "good",
|
||||||
|
"decision": "Some low relevance decision",
|
||||||
|
"context": "Testing",
|
||||||
|
"note": "",
|
||||||
|
"tags": "test",
|
||||||
|
"relevance": 0.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
REAL_PAYLOAD = {
|
||||||
|
"session_id": "abc-123-def",
|
||||||
|
"transcript_path": "/tmp/transcript.jsonl",
|
||||||
|
"cwd": "/home/user/project",
|
||||||
|
"permission_mode": "default",
|
||||||
|
"hook_event_name": "UserPromptSubmit",
|
||||||
|
"prompt": "How should we handle prompt config?",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(prompt, session_id="test-session"):
|
||||||
|
"""Build a realistic hook payload with documented keys."""
|
||||||
|
return {"session_id": session_id, "prompt": prompt, "cwd": "/tmp"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompassRecallHandler:
|
||||||
|
def test_surfaces_relevant_decision(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.recall_decisions",
|
||||||
|
return_value=[CANDIDATE_GOOD],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.mark_surfaced",
|
||||||
|
return_value=1,
|
||||||
|
) as mock_mark,
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.should_surface",
|
||||||
|
return_value=(
|
||||||
|
True,
|
||||||
|
"Ready to surface",
|
||||||
|
{
|
||||||
|
"surfaces_count": 1,
|
||||||
|
"messages_since_last": 0,
|
||||||
|
"last_surface_time": 1000.0,
|
||||||
|
"surfaced_ids": ["56"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.record_message",
|
||||||
|
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
result = handle(_payload("How should we handle prompt config?"))
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert "[GOOD] #56:" in result["stdout"]
|
||||||
|
assert "Never hardcode config in prompts" in result["stdout"]
|
||||||
|
mock_mark.assert_called_once_with([56])
|
||||||
|
|
||||||
|
def test_formats_bad_rating(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.recall_decisions",
|
||||||
|
return_value=[CANDIDATE_BAD],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.mark_surfaced",
|
||||||
|
return_value=1,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.should_surface",
|
||||||
|
return_value=(
|
||||||
|
True,
|
||||||
|
"Ready",
|
||||||
|
{
|
||||||
|
"surfaces_count": 1,
|
||||||
|
"messages_since_last": 0,
|
||||||
|
"last_surface_time": 1000.0,
|
||||||
|
"surfaced_ids": ["84"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.record_message",
|
||||||
|
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
result = handle(_payload("Is the usage gap a real bug?"))
|
||||||
|
|
||||||
|
assert "[BAD] #84:" in result["stdout"]
|
||||||
|
|
||||||
|
def test_empty_when_no_candidates(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.recall_decisions",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.record_message",
|
||||||
|
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
result = handle(_payload("Some prompt about something"))
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert result["stdout"] == ""
|
||||||
|
|
||||||
|
def test_empty_when_governance_suppresses(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.recall_decisions",
|
||||||
|
return_value=[CANDIDATE_GOOD],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.should_surface",
|
||||||
|
return_value=(
|
||||||
|
False,
|
||||||
|
"Spacing not met",
|
||||||
|
{
|
||||||
|
"surfaces_count": 0,
|
||||||
|
"messages_since_last": 1,
|
||||||
|
"last_surface_time": 0.0,
|
||||||
|
"surfaced_ids": [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.record_message",
|
||||||
|
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
result = handle(_payload("How should we handle prompt config?"))
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert result["stdout"] == ""
|
||||||
|
|
||||||
|
def test_empty_when_prompt_too_short(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.record_message",
|
||||||
|
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
result = handle(_payload("Hi"))
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert result["stdout"] == ""
|
||||||
|
|
||||||
|
def test_never_blocks_on_import_error(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._state_path",
|
||||||
|
return_value=tmp_path / "state.json",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._load_state",
|
||||||
|
side_effect=Exception("DB locked"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
result = handle(_payload("Some prompt about something important"))
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert result["stdout"] == ""
|
||||||
|
|
||||||
|
def test_persists_governance_state(self, tmp_path):
|
||||||
|
updated_state = {
|
||||||
|
"surfaces_count": 1,
|
||||||
|
"messages_since_last": 0,
|
||||||
|
"last_surface_time": 1000.0,
|
||||||
|
"surfaced_ids": ["56"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.recall_decisions",
|
||||||
|
return_value=[CANDIDATE_GOOD],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.mark_surfaced",
|
||||||
|
return_value=1,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.should_surface",
|
||||||
|
return_value=(True, "Ready", updated_state),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.record_message",
|
||||||
|
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
handle(_payload("How should we handle prompt config?", session_id="test-persist"))
|
||||||
|
|
||||||
|
state_file = tmp_path / "aipass-compass-recall-test-persist.json"
|
||||||
|
assert state_file.exists()
|
||||||
|
saved = json.loads(state_file.read_text())
|
||||||
|
assert saved["surfaces_count"] == 1
|
||||||
|
assert "56" in saved["surfaced_ids"]
|
||||||
|
|
||||||
|
def test_multiple_candidates_partial_approval(self, tmp_path):
|
||||||
|
def mock_should_surface(item_id, relevance, state, config=None, *, current_time=None):
|
||||||
|
if item_id == "56":
|
||||||
|
new_st = {**state, "surfaces_count": 1, "surfaced_ids": list(state.get("surfaced_ids", [])) + ["56"]}
|
||||||
|
return True, "Ready", new_st
|
||||||
|
return False, "Below threshold", state
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.recall_decisions",
|
||||||
|
return_value=[CANDIDATE_GOOD, CANDIDATE_LOW_RELEVANCE],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.mark_surfaced",
|
||||||
|
return_value=1,
|
||||||
|
) as mock_mark,
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.should_surface",
|
||||||
|
side_effect=mock_should_surface,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.record_message",
|
||||||
|
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
result = handle(_payload("How should we handle prompt config?"))
|
||||||
|
|
||||||
|
assert "[GOOD] #56:" in result["stdout"]
|
||||||
|
assert "#99" not in result["stdout"]
|
||||||
|
mock_mark.assert_called_once_with([56])
|
||||||
|
|
||||||
|
def test_empty_prompt_no_cross_branch_import(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.record_message",
|
||||||
|
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
result = handle(_payload(""))
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert result["stdout"] == ""
|
||||||
|
|
||||||
|
def test_no_session_id_degrades_safe(self):
|
||||||
|
"""No session_id in payload or env = no injection, no crash."""
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
|
os.environ.pop("CLAUDE_CODE_SESSION_ID", None)
|
||||||
|
result = handle({"prompt": "How should we handle prompt config?"})
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert result["stdout"] == ""
|
||||||
|
|
||||||
|
def test_real_documented_payload_shape(self, tmp_path):
|
||||||
|
"""Surfaces from a payload using the official Claude Code hook keys."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.recall_decisions",
|
||||||
|
return_value=[CANDIDATE_GOOD],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.mark_surfaced",
|
||||||
|
return_value=1,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.should_surface",
|
||||||
|
return_value=(
|
||||||
|
True,
|
||||||
|
"Ready",
|
||||||
|
{
|
||||||
|
"surfaces_count": 1,
|
||||||
|
"messages_since_last": 0,
|
||||||
|
"last_surface_time": 1000.0,
|
||||||
|
"surfaced_ids": ["56"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.record_message",
|
||||||
|
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
result = handle(REAL_PAYLOAD)
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert "[GOOD] #56:" in result["stdout"]
|
||||||
|
|
||||||
|
def test_env_var_fallback_for_session_id(self, tmp_path):
|
||||||
|
"""Falls back to CLAUDE_CODE_SESSION_ID env var if payload has no session_id."""
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"CLAUDE_CODE_SESSION_ID": "env-fallback"}),
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.compass_recall._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.recall_decisions",
|
||||||
|
return_value=[CANDIDATE_GOOD],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.devpulse.apps.modules.compass.mark_surfaced",
|
||||||
|
return_value=1,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.should_surface",
|
||||||
|
return_value=(
|
||||||
|
True,
|
||||||
|
"Ready",
|
||||||
|
{
|
||||||
|
"surfaces_count": 1,
|
||||||
|
"messages_since_last": 0,
|
||||||
|
"last_surface_time": 1000.0,
|
||||||
|
"surfaced_ids": ["56"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.memory.apps.modules.governance.record_message",
|
||||||
|
side_effect=lambda s: {**s, "messages_since_last": s.get("messages_since_last", 0) + 1},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.compass_recall import handle
|
||||||
|
|
||||||
|
result = handle({"prompt": "How should we handle prompt config?"})
|
||||||
|
|
||||||
|
assert "[GOOD] #56:" in result["stdout"]
|
||||||
|
state_file = tmp_path / "aipass-compass-recall-env-fallback.json"
|
||||||
|
assert state_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEngineBudget:
|
||||||
|
def test_check_budget_allows_first_fire(self):
|
||||||
|
from aipass.hooks.apps.modules.engine import _check_budget
|
||||||
|
|
||||||
|
allowed, reason = _check_budget("test_hook", {"max_per_session": 5}, {})
|
||||||
|
assert allowed is True
|
||||||
|
|
||||||
|
def test_check_budget_blocks_when_exhausted(self):
|
||||||
|
from aipass.hooks.apps.modules.engine import _check_budget
|
||||||
|
|
||||||
|
state = {"test_hook": {"fire_count": 5}}
|
||||||
|
allowed, reason = _check_budget("test_hook", {"max_per_session": 5}, state)
|
||||||
|
assert allowed is False
|
||||||
|
assert "exhausted" in reason
|
||||||
|
|
||||||
|
def test_check_budget_spacing_skipped_on_first_fire(self):
|
||||||
|
from aipass.hooks.apps.modules.engine import _check_budget
|
||||||
|
|
||||||
|
state = {"test_hook": {"fire_count": 0, "turns_since_fire": 0}}
|
||||||
|
allowed, reason = _check_budget("test_hook", {"min_spacing_turns": 10}, state)
|
||||||
|
assert allowed is True
|
||||||
|
|
||||||
|
def test_check_budget_spacing_enforced_after_fire(self):
|
||||||
|
from aipass.hooks.apps.modules.engine import _check_budget
|
||||||
|
|
||||||
|
state = {"test_hook": {"fire_count": 1, "turns_since_fire": 3}}
|
||||||
|
allowed, reason = _check_budget("test_hook", {"min_spacing_turns": 10}, state)
|
||||||
|
assert allowed is False
|
||||||
|
assert "spacing" in reason
|
||||||
|
|
||||||
|
def test_check_budget_spacing_passes_after_enough_turns(self):
|
||||||
|
from aipass.hooks.apps.modules.engine import _check_budget
|
||||||
|
|
||||||
|
state = {"test_hook": {"fire_count": 1, "turns_since_fire": 10}}
|
||||||
|
allowed, reason = _check_budget("test_hook", {"min_spacing_turns": 10}, state)
|
||||||
|
assert allowed is True
|
||||||
|
|
||||||
|
def test_check_budget_cooldown_enforced(self):
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aipass.hooks.apps.modules.engine import _check_budget
|
||||||
|
|
||||||
|
state = {"test_hook": {"fire_count": 1, "last_fire_time": time.time() - 10}}
|
||||||
|
allowed, reason = _check_budget("test_hook", {"cooldown_seconds": 300}, state)
|
||||||
|
assert allowed is False
|
||||||
|
assert "cooldown" in reason
|
||||||
|
|
||||||
|
def test_check_budget_cooldown_expired(self):
|
||||||
|
import time
|
||||||
|
|
||||||
|
from aipass.hooks.apps.modules.engine import _check_budget
|
||||||
|
|
||||||
|
state = {"test_hook": {"fire_count": 1, "last_fire_time": time.time() - 400}}
|
||||||
|
allowed, reason = _check_budget("test_hook", {"cooldown_seconds": 300}, state)
|
||||||
|
assert allowed is True
|
||||||
|
|
||||||
|
def test_budget_state_persistence(self, tmp_path):
|
||||||
|
from aipass.hooks.apps.modules.engine import (
|
||||||
|
_load_budget_state,
|
||||||
|
_save_budget_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = {"compass_recall": {"fire_count": 2, "last_fire_time": 1000.0, "turns_since_fire": 5}}
|
||||||
|
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._budget_state_path", return_value=tmp_path / "budget.json"):
|
||||||
|
_save_budget_state(state)
|
||||||
|
loaded = _load_budget_state()
|
||||||
|
assert loaded["compass_recall"]["fire_count"] == 2
|
||||||
|
|
||||||
|
def test_budget_state_missing_returns_empty(self, tmp_path):
|
||||||
|
from aipass.hooks.apps.modules.engine import _load_budget_state
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.modules.engine._budget_state_path",
|
||||||
|
return_value=tmp_path / "nonexistent.json",
|
||||||
|
):
|
||||||
|
assert _load_budget_state() == {}
|
||||||
|
|
||||||
|
def test_dispatch_suppresses_over_budget_handler(self, tmp_path):
|
||||||
|
from aipass.hooks.apps.modules.engine import dispatch
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"hooks_enabled": True,
|
||||||
|
"UserPromptSubmit": {
|
||||||
|
"test_hook": {
|
||||||
|
"enabled": True,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.prompt.compass_recall.handle",
|
||||||
|
"max_per_session": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
budget_file = tmp_path / "budget.json"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.modules.engine._budget_state_path", return_value=budget_file),
|
||||||
|
patch("aipass.hooks.apps.modules.engine._run_handler") as mock_run,
|
||||||
|
):
|
||||||
|
dispatch("UserPromptSubmit", json.dumps({"session_id": "budget-test", "prompt": "test"}), config)
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
def test_dispatch_records_fire_on_output(self, tmp_path):
|
||||||
|
from aipass.hooks.apps.modules.engine import dispatch
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"hooks_enabled": True,
|
||||||
|
"UserPromptSubmit": {
|
||||||
|
"test_hook": {
|
||||||
|
"enabled": True,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.prompt.compass_recall.handle",
|
||||||
|
"max_per_session": 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
budget_file = tmp_path / "budget.json"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.modules.engine._budget_state_path", return_value=budget_file),
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.modules.engine._run_handler",
|
||||||
|
return_value={"exit_code": 0, "stdout": "[GOOD] #56: test", "stderr": "", "elapsed_ms": 5.0},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
dispatch("UserPromptSubmit", json.dumps({"session_id": "fire-test", "prompt": "test"}), config)
|
||||||
|
|
||||||
|
assert budget_file.exists()
|
||||||
|
state = json.loads(budget_file.read_text())
|
||||||
|
assert state["test_hook"]["fire_count"] == 1
|
||||||
|
assert state["test_hook"]["turns_since_fire"] == 0
|
||||||
|
|
||||||
|
def test_dispatch_threads_payload_session_id(self, tmp_path):
|
||||||
|
"""Budget state file is keyed by payload session_id, not env var."""
|
||||||
|
from aipass.hooks.apps.modules.engine import dispatch
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"hooks_enabled": True,
|
||||||
|
"UserPromptSubmit": {
|
||||||
|
"test_hook": {
|
||||||
|
"enabled": True,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.prompt.compass_recall.handle",
|
||||||
|
"max_per_session": 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.modules.engine._run_handler",
|
||||||
|
return_value={"exit_code": 0, "stdout": "output", "stderr": "", "elapsed_ms": 1.0},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
dispatch(
|
||||||
|
"UserPromptSubmit",
|
||||||
|
json.dumps({"session_id": "payload-sid", "prompt": "test"}),
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
|
||||||
|
from aipass.hooks.apps.modules.engine import _budget_state_path
|
||||||
|
|
||||||
|
path = _budget_state_path("payload-sid")
|
||||||
|
assert path is not None
|
||||||
|
assert "payload-sid" in str(path)
|
||||||
@@ -23,6 +23,7 @@ from aipass.hooks.apps.modules.engine import (
|
|||||||
_log,
|
_log,
|
||||||
)
|
)
|
||||||
from aipass.hooks.apps.handlers.config.loader import find_project_config
|
from aipass.hooks.apps.handlers.config.loader import find_project_config
|
||||||
|
from aipass.hooks.apps.handlers.config.trust_registry import enroll
|
||||||
|
|
||||||
|
|
||||||
class TestMatches:
|
class TestMatches:
|
||||||
@@ -276,7 +277,13 @@ class TestFindProjectConfig:
|
|||||||
"""Tests for find_project_config() CWD walk."""
|
"""Tests for find_project_config() CWD walk."""
|
||||||
|
|
||||||
def test_finds_config_in_cwd(self, hooks_config_file, temp_test_dir, mock_logger):
|
def test_finds_config_in_cwd(self, hooks_config_file, temp_test_dir, mock_logger):
|
||||||
with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir):
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
enroll(str(temp_test_dir))
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=temp_test_dir),
|
||||||
|
):
|
||||||
config = find_project_config()
|
config = find_project_config()
|
||||||
assert config is not None
|
assert config is not None
|
||||||
assert config["hooks_enabled"] is True
|
assert config["hooks_enabled"] is True
|
||||||
@@ -295,9 +302,15 @@ class TestFindProjectConfig:
|
|||||||
"Stop": {"sound": {"enabled": True, "command": "python3 $AIPASS_HOME/hook.py", "matcher": ""}},
|
"Stop": {"sound": {"enabled": True, "command": "python3 $AIPASS_HOME/hook.py", "matcher": ""}},
|
||||||
}
|
}
|
||||||
(config_dir / "hooks.json").write_text(json.dumps(config))
|
(config_dir / "hooks.json").write_text(json.dumps(config))
|
||||||
with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=temp_test_dir):
|
reg_path = temp_test_dir / "registry.json"
|
||||||
with patch("aipass.hooks.apps.handlers.config.loader.AIPASS_HOME", "/test/path"):
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
result = find_project_config()
|
enroll(str(temp_test_dir))
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=temp_test_dir),
|
||||||
|
patch("aipass.hooks.apps.handlers.config.loader.AIPASS_HOME", "/test/path"),
|
||||||
|
):
|
||||||
|
result = find_project_config()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "/test/path/hook.py" in result["Stop"]["sound"]["command"]
|
assert "/test/path/hook.py" in result["Stop"]["sound"]["command"]
|
||||||
|
|
||||||
@@ -362,8 +375,8 @@ class TestHooksEntryPoint:
|
|||||||
|
|
||||||
print_introspection()
|
print_introspection()
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "HOOKS" in captured.err
|
assert "HOOKS" in captured.out
|
||||||
assert "Discovered Modules" in captured.err
|
assert "Discovered Modules" in captured.out
|
||||||
|
|
||||||
def test_handle_command_returns_bool(self):
|
def test_handle_command_returns_bool(self):
|
||||||
from aipass.hooks.apps.hooks import handle_command
|
from aipass.hooks.apps.hooks import handle_command
|
||||||
@@ -502,7 +515,13 @@ class TestInitProvisioning:
|
|||||||
(config_dir / "hooks.json").write_text('{"hooks_enabled": true}')
|
(config_dir / "hooks.json").write_text('{"hooks_enabled": true}')
|
||||||
sub_dir = temp_test_dir / "deep" / "nested" / "path"
|
sub_dir = temp_test_dir / "deep" / "nested" / "path"
|
||||||
sub_dir.mkdir(parents=True)
|
sub_dir.mkdir(parents=True)
|
||||||
with patch("aipass.hooks.apps.modules.engine.Path.cwd", return_value=sub_dir):
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
enroll(str(temp_test_dir))
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=sub_dir),
|
||||||
|
):
|
||||||
config = find_project_config()
|
config = find_project_config()
|
||||||
assert config is not None
|
assert config is not None
|
||||||
assert config["hooks_enabled"] is True
|
assert config["hooks_enabled"] is True
|
||||||
@@ -573,35 +592,35 @@ class TestCliRouting:
|
|||||||
|
|
||||||
print_help()
|
print_help()
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "HOOKS" in captured.err
|
assert "HOOKS" in captured.out
|
||||||
assert "drone @hooks" in captured.err
|
assert "drone @hooks" in captured.out
|
||||||
|
|
||||||
def test_print_help_surfaces_subcommands(self, capsys):
|
def test_print_help_surfaces_subcommands(self, capsys):
|
||||||
from aipass.hooks.apps.hooks import print_help
|
from aipass.hooks.apps.hooks import print_help
|
||||||
|
|
||||||
print_help()
|
print_help()
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "hooksound on" in captured.err
|
assert "hooksound on" in captured.out
|
||||||
assert "hooksound off" in captured.err
|
assert "hooksound off" in captured.out
|
||||||
assert "status" in captured.err
|
assert "status" in captured.out
|
||||||
assert "log" in captured.err
|
assert "log" in captured.out
|
||||||
|
|
||||||
def test_print_help_has_examples_section(self, capsys):
|
def test_print_help_has_examples_section(self, capsys):
|
||||||
from aipass.hooks.apps.hooks import print_help
|
from aipass.hooks.apps.hooks import print_help
|
||||||
|
|
||||||
print_help()
|
print_help()
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "EXAMPLES" in captured.err
|
assert "EXAMPLES" in captured.out
|
||||||
assert "drone @hooks status" in captured.err
|
assert "drone @hooks status" in captured.out
|
||||||
assert "drone @hooks hooksound off" in captured.err
|
assert "drone @hooks hooksound off" in captured.out
|
||||||
|
|
||||||
def test_print_help_has_usage_section(self, capsys):
|
def test_print_help_has_usage_section(self, capsys):
|
||||||
from aipass.hooks.apps.hooks import print_help
|
from aipass.hooks.apps.hooks import print_help
|
||||||
|
|
||||||
print_help()
|
print_help()
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "USAGE" in captured.err
|
assert "USAGE" in captured.out
|
||||||
assert "drone @hooks <command>" in captured.err
|
assert "drone @hooks <command>" in captured.out
|
||||||
|
|
||||||
def test_help_commands_auto_discovered(self, capsys):
|
def test_help_commands_auto_discovered(self, capsys):
|
||||||
from aipass.hooks.apps.hooks import print_help
|
from aipass.hooks.apps.hooks import print_help
|
||||||
@@ -612,7 +631,7 @@ class TestCliRouting:
|
|||||||
print_help()
|
print_help()
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
for cmd, _ in hs_cmds + hst_cmds + eng_cmds:
|
for cmd, _ in hs_cmds + hst_cmds + eng_cmds:
|
||||||
assert cmd in captured.err
|
assert cmd in captured.out
|
||||||
|
|
||||||
def test_output_capture_status(self, capsys):
|
def test_output_capture_status(self, capsys):
|
||||||
from aipass.hooks.apps.hooks import handle_command
|
from aipass.hooks.apps.hooks import handle_command
|
||||||
@@ -628,7 +647,7 @@ class TestCliRouting:
|
|||||||
with patch("sys.argv", ["hooks", "--version"]):
|
with patch("sys.argv", ["hooks", "--version"]):
|
||||||
main()
|
main()
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "1.1.0" in captured.err
|
assert "1.1.0" in captured.out
|
||||||
|
|
||||||
|
|
||||||
class TestConfigDataContracts:
|
class TestConfigDataContracts:
|
||||||
@@ -721,6 +740,153 @@ class TestMockInfrastructure:
|
|||||||
assert hasattr(engine, "_run_hook")
|
assert hasattr(engine, "_run_hook")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayerATrustEnforcement:
|
||||||
|
"""DPLAN-0244 Layer A: engine refuses command-type from project config, enforces handler namespace."""
|
||||||
|
|
||||||
|
def test_command_type_refused_from_project_config(self, mock_logger):
|
||||||
|
config = {
|
||||||
|
"hooks_enabled": True,
|
||||||
|
"_source": "project",
|
||||||
|
"PreToolUse": {
|
||||||
|
"evil_cmd": {
|
||||||
|
"enabled": True,
|
||||||
|
"command": "echo PWNED",
|
||||||
|
"matcher": "",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._log") as mock_log:
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run:
|
||||||
|
result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config)
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
assert result == ("", 0)
|
||||||
|
log_calls = [c[0][0] for c in mock_log.call_args_list]
|
||||||
|
assert any(e.get("action") == "refused_command_type" for e in log_calls if isinstance(e, dict))
|
||||||
|
|
||||||
|
def test_handler_namespace_enforced(self, mock_logger):
|
||||||
|
from aipass.hooks.apps.modules.engine import _run_handler
|
||||||
|
|
||||||
|
result = _run_handler("evil.payload.handle", {})
|
||||||
|
assert result["exit_code"] == -1
|
||||||
|
assert "namespace refused" in result["stderr"]
|
||||||
|
|
||||||
|
def test_handler_aipass_namespace_allowed(self, mock_logger):
|
||||||
|
from aipass.hooks.apps.modules.engine import _run_handler
|
||||||
|
|
||||||
|
mock_handler = MagicMock(return_value={"exit_code": 0, "stdout": "ok"})
|
||||||
|
mock_module = MagicMock()
|
||||||
|
mock_module.handle = mock_handler
|
||||||
|
with patch("importlib.import_module", return_value=mock_module):
|
||||||
|
result = _run_handler("aipass.hooks.apps.handlers.notification.stop_sound.handle", {})
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
|
||||||
|
def test_command_type_allowed_from_default_config(self, mock_logger):
|
||||||
|
config = {
|
||||||
|
"hooks_enabled": True,
|
||||||
|
"_source": "default",
|
||||||
|
"Stop": {
|
||||||
|
"cmd_hook": {
|
||||||
|
"enabled": True,
|
||||||
|
"command": "echo allowed",
|
||||||
|
"matcher": "",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._log"):
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run:
|
||||||
|
mock_run.return_value = {
|
||||||
|
"exit_code": 0,
|
||||||
|
"stdout": "allowed",
|
||||||
|
"stderr": "",
|
||||||
|
"elapsed_ms": 5,
|
||||||
|
}
|
||||||
|
result = dispatch("Stop", "{}", config)
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
assert "allowed" in result[0]
|
||||||
|
|
||||||
|
def test_mixed_config_partial_refusal(self, mock_logger):
|
||||||
|
config = {
|
||||||
|
"hooks_enabled": True,
|
||||||
|
"_source": "project",
|
||||||
|
"UserPromptSubmit": {
|
||||||
|
"good_handler": {
|
||||||
|
"enabled": True,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.notification.stop_sound.handle",
|
||||||
|
"matcher": "",
|
||||||
|
},
|
||||||
|
"evil_cmd": {
|
||||||
|
"enabled": True,
|
||||||
|
"command": "echo PWNED",
|
||||||
|
"matcher": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_handler_func = MagicMock(return_value={"exit_code": 0, "stdout": "handler_ok"})
|
||||||
|
mock_module = MagicMock()
|
||||||
|
mock_module.handle = mock_handler_func
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._log"):
|
||||||
|
with patch("importlib.import_module", return_value=mock_module):
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run:
|
||||||
|
result = dispatch("UserPromptSubmit", "{}", config)
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
assert "handler_ok" in result[0]
|
||||||
|
assert result[1] == 0
|
||||||
|
|
||||||
|
def test_source_overwrite_not_merge(self, temp_test_dir, mock_logger):
|
||||||
|
config_dir = temp_test_dir / ".aipass"
|
||||||
|
config_dir.mkdir()
|
||||||
|
hostile_config = {
|
||||||
|
"hooks_enabled": True,
|
||||||
|
"_source": "provider",
|
||||||
|
"SessionStart": {
|
||||||
|
"evil": {
|
||||||
|
"enabled": True,
|
||||||
|
"command": "echo PWNED",
|
||||||
|
"matcher": "",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(config_dir / "hooks.json").write_text(json.dumps(hostile_config))
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
enroll(str(temp_test_dir))
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=temp_test_dir),
|
||||||
|
):
|
||||||
|
loaded = find_project_config()
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded["_source"] == "project"
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._log"):
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run:
|
||||||
|
result = dispatch("SessionStart", "{}", loaded)
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
assert result == ("", 0)
|
||||||
|
|
||||||
|
def test_command_without_source_defaults_allowed(self, mock_logger):
|
||||||
|
config = {
|
||||||
|
"hooks_enabled": True,
|
||||||
|
"Stop": {
|
||||||
|
"cmd_hook": {
|
||||||
|
"enabled": True,
|
||||||
|
"command": "echo ok",
|
||||||
|
"matcher": "",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._log"):
|
||||||
|
with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run:
|
||||||
|
mock_run.return_value = {
|
||||||
|
"exit_code": 0,
|
||||||
|
"stdout": "ok",
|
||||||
|
"stderr": "",
|
||||||
|
"elapsed_ms": 5,
|
||||||
|
}
|
||||||
|
result = dispatch("Stop", "{}", config)
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
assert "ok" in result[0]
|
||||||
|
|
||||||
|
|
||||||
class TestJsonHandlerNotApplicable:
|
class TestJsonHandlerNotApplicable:
|
||||||
"""Hooks uses JSONL logging, not json_handler. These verify the log equivalent."""
|
"""Hooks uses JSONL logging, not json_handler. These verify the log equivalent."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: test_feedback_pulse.py
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Description: Tests for feedback pulse handler and toggle module
|
||||||
|
# Branch: hooks
|
||||||
|
# Layer: tests
|
||||||
|
# Created: 2026-07-18
|
||||||
|
# Modified: 2026-07-18
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Tests for feedback_pulse handler and feedback toggle module."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeedbackPulseHandler:
|
||||||
|
"""Tests for the feedback_pulse prompt handler."""
|
||||||
|
|
||||||
|
def _handler(self):
|
||||||
|
from aipass.hooks.apps.handlers.prompt.feedback_pulse import handle
|
||||||
|
|
||||||
|
return handle
|
||||||
|
|
||||||
|
def test_no_session_id_returns_empty(self):
|
||||||
|
result = self._handler()({"session_id": ""})
|
||||||
|
assert result["stdout"] == ""
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
|
||||||
|
def test_early_turns_return_empty(self, tmp_path):
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
for i in range(10):
|
||||||
|
result = self._handler()({"session_id": "test-session"})
|
||||||
|
assert result["stdout"] == "", f"Turn {i} should not fire"
|
||||||
|
|
||||||
|
def test_fires_on_turn_10(self, tmp_path):
|
||||||
|
result = {"stdout": "", "exit_code": 0}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
for i in range(11):
|
||||||
|
result = self._handler()({"session_id": "test-fire"})
|
||||||
|
|
||||||
|
assert "feedback" in result["stdout"].lower()
|
||||||
|
assert "https://github.com/AIOSAI/AIPass/issues" in result["stdout"]
|
||||||
|
|
||||||
|
def test_fires_on_turn_20(self, tmp_path):
|
||||||
|
result = {"stdout": "", "exit_code": 0}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
for i in range(21):
|
||||||
|
result = self._handler()({"session_id": "test-fire-20"})
|
||||||
|
|
||||||
|
assert "feedback" in result["stdout"].lower()
|
||||||
|
|
||||||
|
def test_skips_turn_11_through_19(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
for i in range(11):
|
||||||
|
self._handler()({"session_id": "test-skip"})
|
||||||
|
|
||||||
|
for i in range(9):
|
||||||
|
result = self._handler()({"session_id": "test-skip"})
|
||||||
|
assert result["stdout"] == "", f"Turn {11 + i} should not fire"
|
||||||
|
|
||||||
|
def test_disabled_returns_empty(self, tmp_path):
|
||||||
|
result = {"stdout": "", "exit_code": 0}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
for i in range(11):
|
||||||
|
result = self._handler()({"session_id": "test-disabled"})
|
||||||
|
|
||||||
|
assert result["stdout"] == ""
|
||||||
|
|
||||||
|
def test_output_is_one_line(self, tmp_path):
|
||||||
|
result = {"stdout": "", "exit_code": 0}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
for i in range(11):
|
||||||
|
result = self._handler()({"session_id": "test-oneline"})
|
||||||
|
|
||||||
|
assert "\n" not in result["stdout"]
|
||||||
|
|
||||||
|
def test_state_file_persists(self, tmp_path):
|
||||||
|
state_file = tmp_path / "aipass-feedback-pulse-test-persist.json"
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
self._handler()({"session_id": "test-persist"})
|
||||||
|
|
||||||
|
assert state_file.exists()
|
||||||
|
data = json.loads(state_file.read_text())
|
||||||
|
assert data["turn"] == 0
|
||||||
|
|
||||||
|
def test_state_increments_across_calls(self, tmp_path):
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
for i in range(5):
|
||||||
|
self._handler()({"session_id": "test-incr"})
|
||||||
|
|
||||||
|
state_file = tmp_path / "aipass-feedback-pulse-test-incr.json"
|
||||||
|
data = json.loads(state_file.read_text())
|
||||||
|
assert data["turn"] == 4
|
||||||
|
|
||||||
|
def test_corrupted_state_recovers(self, tmp_path):
|
||||||
|
state_file = tmp_path / "aipass-feedback-pulse-test-corrupt.json"
|
||||||
|
state_file.write_text("not json")
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
result = self._handler()({"session_id": "test-corrupt"})
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
|
||||||
|
def test_handler_exception_returns_safe(self):
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._state_path",
|
||||||
|
side_effect=RuntimeError("boom"),
|
||||||
|
):
|
||||||
|
result = self._handler()({"session_id": "test-crash"})
|
||||||
|
|
||||||
|
assert result["stdout"] == ""
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
|
||||||
|
def test_session_id_from_env(self, tmp_path):
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||||
|
tmp_path,
|
||||||
|
),
|
||||||
|
patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"CLAUDE_CODE_SESSION_ID": "env-session"},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
self._handler()({"session_id": ""})
|
||||||
|
|
||||||
|
state_file = tmp_path / "aipass-feedback-pulse-env-session.json"
|
||||||
|
assert state_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeedbackPulseToggle:
|
||||||
|
"""Tests for the _is_disabled toggle and sentinel file."""
|
||||||
|
|
||||||
|
def test_no_aipass_dir_is_disabled(self, tmp_path):
|
||||||
|
from aipass.hooks.apps.handlers.prompt import feedback_pulse
|
||||||
|
|
||||||
|
# The walk climbs to the drive root, so a real .aipass in any ancestor
|
||||||
|
# (e.g. the CI runner's home after windows-setup installs AIPass) leaks
|
||||||
|
# into the result — the no-dir case is only constructible by patching.
|
||||||
|
with patch.object(feedback_pulse, "_find_aipass_dir", return_value=None):
|
||||||
|
assert feedback_pulse._is_disabled(str(tmp_path)) is True
|
||||||
|
|
||||||
|
def test_aipass_dir_no_sentinel_is_enabled(self, tmp_path):
|
||||||
|
(tmp_path / ".aipass").mkdir()
|
||||||
|
from aipass.hooks.apps.handlers.prompt.feedback_pulse import _is_disabled
|
||||||
|
|
||||||
|
assert _is_disabled(str(tmp_path)) is False
|
||||||
|
|
||||||
|
def test_sentinel_exists_is_disabled(self, tmp_path):
|
||||||
|
aipass_dir = tmp_path / ".aipass"
|
||||||
|
aipass_dir.mkdir()
|
||||||
|
(aipass_dir / "feedback_off").touch()
|
||||||
|
from aipass.hooks.apps.handlers.prompt.feedback_pulse import _is_disabled
|
||||||
|
|
||||||
|
assert _is_disabled(str(tmp_path)) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeedbackToggleModule:
|
||||||
|
"""Tests for the feedback toggle CLI module (drone @hooks feedback)."""
|
||||||
|
|
||||||
|
def test_handle_command_feedback_shows_status(self, capsys):
|
||||||
|
from aipass.hooks.apps.modules.feedback import handle_command
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||||
|
return_value=Path("/nonexistent/sentinel"),
|
||||||
|
):
|
||||||
|
assert handle_command("feedback", []) is True
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "ENABLED" in captured.err
|
||||||
|
|
||||||
|
def test_handle_command_feedback_disabled(self, capsys, tmp_path):
|
||||||
|
sentinel = tmp_path / "feedback_off"
|
||||||
|
sentinel.touch()
|
||||||
|
from aipass.hooks.apps.modules.feedback import handle_command
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||||
|
return_value=sentinel,
|
||||||
|
):
|
||||||
|
assert handle_command("feedback", []) is True
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "DISABLED" in captured.err
|
||||||
|
|
||||||
|
def test_handle_command_feedback_off(self, tmp_path):
|
||||||
|
sentinel = tmp_path / "feedback_off"
|
||||||
|
from aipass.hooks.apps.modules.feedback import handle_command
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||||
|
return_value=sentinel,
|
||||||
|
):
|
||||||
|
assert handle_command("feedback", ["off"]) is True
|
||||||
|
|
||||||
|
assert sentinel.exists()
|
||||||
|
|
||||||
|
def test_handle_command_feedback_on(self, tmp_path):
|
||||||
|
sentinel = tmp_path / "feedback_off"
|
||||||
|
sentinel.touch()
|
||||||
|
from aipass.hooks.apps.modules.feedback import handle_command
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||||
|
return_value=sentinel,
|
||||||
|
):
|
||||||
|
assert handle_command("feedback", ["on"]) is True
|
||||||
|
|
||||||
|
assert not sentinel.exists()
|
||||||
|
|
||||||
|
def test_handle_command_feedback_on_no_sentinel(self, tmp_path):
|
||||||
|
sentinel = tmp_path / "feedback_off"
|
||||||
|
from aipass.hooks.apps.modules.feedback import handle_command
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||||
|
return_value=sentinel,
|
||||||
|
):
|
||||||
|
assert handle_command("feedback", ["on"]) is True
|
||||||
|
|
||||||
|
def test_handle_command_feedback_help(self, capsys):
|
||||||
|
from aipass.hooks.apps.modules.feedback import handle_command
|
||||||
|
|
||||||
|
assert handle_command("feedback", ["--help"]) is True
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "drone @hooks feedback" in captured.err
|
||||||
|
|
||||||
|
def test_handle_command_no_aipass_dir(self, capsys):
|
||||||
|
from aipass.hooks.apps.modules.feedback import handle_command
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
assert handle_command("feedback", []) is True
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "NO PROJECT" in captured.err
|
||||||
|
|
||||||
|
def test_handle_command_off_no_aipass_dir(self, capsys):
|
||||||
|
from aipass.hooks.apps.modules.feedback import handle_command
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
assert handle_command("feedback", ["off"]) is True
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "No .aipass/" in captured.err
|
||||||
|
|
||||||
|
def test_unrelated_command_returns_false(self):
|
||||||
|
from aipass.hooks.apps.modules.feedback import handle_command
|
||||||
|
|
||||||
|
assert handle_command("other", []) is False
|
||||||
|
|
||||||
|
def test_state_survives_session_restart(self, tmp_path):
|
||||||
|
"""Toggle state persists on disk — survives session restarts."""
|
||||||
|
sentinel = tmp_path / "feedback_off"
|
||||||
|
from aipass.hooks.apps.modules.feedback import handle_command
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||||
|
return_value=sentinel,
|
||||||
|
):
|
||||||
|
handle_command("feedback", ["off"])
|
||||||
|
|
||||||
|
assert sentinel.exists()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||||
|
return_value=sentinel,
|
||||||
|
):
|
||||||
|
handle_command("feedback", ["on"])
|
||||||
|
|
||||||
|
assert not sentinel.exists()
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: test_trust_registry.py
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Description: Tests for trusted-project registry — DPLAN-0244 Layer B
|
||||||
|
# Branch: hooks
|
||||||
|
# Layer: tests
|
||||||
|
# Created: 2026-07-15
|
||||||
|
# Modified: 2026-07-15
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Tests for trusted-project registry and loader trust integration."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from aipass.hooks.apps.handlers.config.trust_registry import (
|
||||||
|
_hash_file,
|
||||||
|
bootstrap,
|
||||||
|
enroll,
|
||||||
|
is_trusted,
|
||||||
|
read_registry,
|
||||||
|
revoke,
|
||||||
|
)
|
||||||
|
from aipass.hooks.apps.handlers.config.loader import find_project_config
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistryHelpers:
|
||||||
|
"""Unit tests for registry helper functions."""
|
||||||
|
|
||||||
|
def test_hash_file_deterministic(self, temp_test_dir):
|
||||||
|
f = temp_test_dir / "test.json"
|
||||||
|
f.write_text('{"hello": "world"}')
|
||||||
|
h1 = _hash_file(f)
|
||||||
|
h2 = _hash_file(f)
|
||||||
|
assert h1 == h2
|
||||||
|
assert h1.startswith("sha256:")
|
||||||
|
|
||||||
|
def test_hash_file_changes_on_content_change(self, temp_test_dir):
|
||||||
|
f = temp_test_dir / "test.json"
|
||||||
|
f.write_text('{"v": 1}')
|
||||||
|
h1 = _hash_file(f)
|
||||||
|
f.write_text('{"v": 2}')
|
||||||
|
h2 = _hash_file(f)
|
||||||
|
assert h1 != h2
|
||||||
|
|
||||||
|
def test_read_registry_absent(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "nonexistent.json"
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
result = read_registry()
|
||||||
|
assert result == {"version": 1, "projects": {}}
|
||||||
|
|
||||||
|
def test_read_registry_valid(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
reg_data = {
|
||||||
|
"version": 1,
|
||||||
|
"projects": {
|
||||||
|
"/some/path": {
|
||||||
|
"enrolled": "2026-07-15T00:00:00",
|
||||||
|
"config_hash": "sha256:abc",
|
||||||
|
"config_path": "/some/path/.aipass/hooks.json",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reg_path.write_text(json.dumps(reg_data))
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
result = read_registry()
|
||||||
|
assert "/some/path" in result["projects"]
|
||||||
|
|
||||||
|
def test_read_registry_corrupt(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
reg_path.write_text("{corrupt!!!")
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
result = read_registry()
|
||||||
|
assert result == {"version": 1, "projects": {}}
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnrollRevoke:
|
||||||
|
"""Unit tests for enroll() and revoke()."""
|
||||||
|
|
||||||
|
def test_enroll_success(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
project = temp_test_dir / "myproject"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".aipass").mkdir()
|
||||||
|
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
result = enroll(str(project))
|
||||||
|
assert result is True
|
||||||
|
assert reg_path.exists()
|
||||||
|
data = json.loads(reg_path.read_text())
|
||||||
|
assert str(project.resolve()) in data["projects"]
|
||||||
|
entry = data["projects"][str(project.resolve())]
|
||||||
|
assert entry["config_hash"].startswith("sha256:")
|
||||||
|
|
||||||
|
def test_enroll_no_hooks_json(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
project = temp_test_dir / "empty_project"
|
||||||
|
project.mkdir()
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
result = enroll(str(project))
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_revoke_success(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
project = temp_test_dir / "myproject"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".aipass").mkdir()
|
||||||
|
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
enroll(str(project))
|
||||||
|
result = revoke(str(project))
|
||||||
|
assert result is True
|
||||||
|
data = json.loads(reg_path.read_text())
|
||||||
|
assert str(project.resolve()) not in data["projects"]
|
||||||
|
|
||||||
|
def test_revoke_nonexistent(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
result = revoke("/nonexistent/project")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsTrusted:
|
||||||
|
"""Unit tests for is_trusted()."""
|
||||||
|
|
||||||
|
def test_trusted_with_matching_hash(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
project = temp_test_dir / "myproject"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".aipass").mkdir()
|
||||||
|
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
enroll(str(project))
|
||||||
|
assert is_trusted(str(project)) is True
|
||||||
|
|
||||||
|
def test_not_trusted_unregistered(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
assert is_trusted("/not/registered") is False
|
||||||
|
|
||||||
|
def test_not_trusted_hash_mismatch(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
project = temp_test_dir / "myproject"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".aipass").mkdir()
|
||||||
|
hooks_file = project / ".aipass" / "hooks.json"
|
||||||
|
hooks_file.write_text('{"hooks_enabled": true}')
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
enroll(str(project))
|
||||||
|
hooks_file.write_text('{"hooks_enabled": true, "tampered": true}')
|
||||||
|
assert is_trusted(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestBootstrap:
|
||||||
|
"""Tests for bootstrap() — enrolls ONLY AIPASS_HOME."""
|
||||||
|
|
||||||
|
def test_bootstrap_enrolls_aipass_home(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
aipass_dir = temp_test_dir / "aipass_install"
|
||||||
|
aipass_dir.mkdir()
|
||||||
|
(aipass_dir / ".aipass").mkdir()
|
||||||
|
(aipass_dir / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch.dict("os.environ", {"AIPASS_HOME": str(aipass_dir)}),
|
||||||
|
):
|
||||||
|
result = bootstrap()
|
||||||
|
assert result is True
|
||||||
|
data = json.loads(reg_path.read_text())
|
||||||
|
assert str(aipass_dir.resolve()) in data["projects"]
|
||||||
|
|
||||||
|
def test_bootstrap_no_aipass_home(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch.dict("os.environ", {}, clear=True),
|
||||||
|
):
|
||||||
|
result = bootstrap()
|
||||||
|
assert result is False
|
||||||
|
assert not reg_path.exists()
|
||||||
|
|
||||||
|
def test_bootstrap_aipass_home_no_hooks_json(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
aipass_dir = temp_test_dir / "empty_install"
|
||||||
|
aipass_dir.mkdir()
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch.dict("os.environ", {"AIPASS_HOME": str(aipass_dir)}),
|
||||||
|
):
|
||||||
|
result = bootstrap()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_bootstrap_refuses_hostile_project(self, temp_test_dir, mock_logger):
|
||||||
|
"""Security-critical: registry absent + first event in hostile CWD.
|
||||||
|
|
||||||
|
Only AIPASS_HOME gets enrolled, hostile project is NOT enrolled.
|
||||||
|
"""
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
|
||||||
|
aipass_dir = temp_test_dir / "real_aipass"
|
||||||
|
aipass_dir.mkdir()
|
||||||
|
(aipass_dir / ".aipass").mkdir()
|
||||||
|
(aipass_dir / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
||||||
|
|
||||||
|
hostile_dir = temp_test_dir / "hostile_repo"
|
||||||
|
hostile_dir.mkdir()
|
||||||
|
(hostile_dir / ".aipass").mkdir()
|
||||||
|
(hostile_dir / ".aipass" / "hooks.json").write_text(
|
||||||
|
'{"hooks_enabled": true, "SessionStart": '
|
||||||
|
'{"evil": {"enabled": true, "command": "touch /tmp/pwned", "matcher": ""}}}'
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch.dict("os.environ", {"AIPASS_HOME": str(aipass_dir)}),
|
||||||
|
):
|
||||||
|
result = bootstrap()
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
data = json.loads(reg_path.read_text())
|
||||||
|
assert str(aipass_dir.resolve()) in data["projects"]
|
||||||
|
assert str(hostile_dir.resolve()) not in data["projects"]
|
||||||
|
|
||||||
|
assert is_trusted(str(hostile_dir)) is False
|
||||||
|
assert is_trusted(str(aipass_dir)) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoaderTrustIntegration:
|
||||||
|
"""Integration tests: loader.find_project_config() with registry."""
|
||||||
|
|
||||||
|
def test_registered_project_loads(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
project = temp_test_dir / "trusted_project"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".aipass").mkdir()
|
||||||
|
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
enroll(str(project))
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
|
||||||
|
):
|
||||||
|
config = find_project_config()
|
||||||
|
assert config is not None
|
||||||
|
assert config["hooks_enabled"] is True
|
||||||
|
assert config["_source"] == "project"
|
||||||
|
|
||||||
|
def test_unregistered_project_skipped(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
reg_path.write_text('{"version": 1, "projects": {}}')
|
||||||
|
project = temp_test_dir / "unknown_project"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".aipass").mkdir()
|
||||||
|
(project / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
|
||||||
|
):
|
||||||
|
config = find_project_config()
|
||||||
|
assert config is None
|
||||||
|
|
||||||
|
def test_hash_mismatch_skipped(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
project = temp_test_dir / "tampered_project"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".aipass").mkdir()
|
||||||
|
hooks_file = project / ".aipass" / "hooks.json"
|
||||||
|
hooks_file.write_text('{"hooks_enabled": true}')
|
||||||
|
with patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path):
|
||||||
|
enroll(str(project))
|
||||||
|
hooks_file.write_text('{"hooks_enabled": true, "tampered": true}')
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=project),
|
||||||
|
):
|
||||||
|
config = find_project_config()
|
||||||
|
assert config is None
|
||||||
|
|
||||||
|
def test_loader_bootstraps_on_missing_registry(self, temp_test_dir, mock_logger):
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
aipass_dir = temp_test_dir / "aipass_install"
|
||||||
|
aipass_dir.mkdir()
|
||||||
|
(aipass_dir / ".aipass").mkdir()
|
||||||
|
(aipass_dir / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch.dict("os.environ", {"AIPASS_HOME": str(aipass_dir)}),
|
||||||
|
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=aipass_dir),
|
||||||
|
):
|
||||||
|
config = find_project_config()
|
||||||
|
assert config is not None
|
||||||
|
assert reg_path.exists()
|
||||||
|
|
||||||
|
def test_loader_hostile_project_after_bootstrap(self, temp_test_dir, mock_logger):
|
||||||
|
"""Full attack chain: hostile repo, registry absent, bootstrap fires."""
|
||||||
|
reg_path = temp_test_dir / "registry.json"
|
||||||
|
|
||||||
|
aipass_dir = temp_test_dir / "real_aipass"
|
||||||
|
aipass_dir.mkdir()
|
||||||
|
(aipass_dir / ".aipass").mkdir()
|
||||||
|
(aipass_dir / ".aipass" / "hooks.json").write_text('{"hooks_enabled": true}')
|
||||||
|
|
||||||
|
hostile_dir = temp_test_dir / "hostile_repo"
|
||||||
|
hostile_dir.mkdir()
|
||||||
|
(hostile_dir / ".aipass").mkdir()
|
||||||
|
(hostile_dir / ".aipass" / "hooks.json").write_text(
|
||||||
|
'{"hooks_enabled": true, "SessionStart": '
|
||||||
|
'{"evil": {"enabled": true, "command": "touch /tmp/pwned", "matcher": ""}}}'
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH", reg_path),
|
||||||
|
patch.dict("os.environ", {"AIPASS_HOME": str(aipass_dir)}),
|
||||||
|
patch("aipass.hooks.apps.handlers.config.loader.Path.cwd", return_value=hostile_dir),
|
||||||
|
):
|
||||||
|
config = find_project_config()
|
||||||
|
assert config is None
|
||||||
|
assert reg_path.exists()
|
||||||
|
data = json.loads(reg_path.read_text())
|
||||||
|
assert str(hostile_dir.resolve()) not in data["projects"]
|
||||||
@@ -462,6 +462,16 @@
|
|||||||
"functions": ["render_all_meta_tabs"],
|
"functions": ["render_all_meta_tabs"],
|
||||||
"reason": "Cross-branch public API — called by @spawn's build_replacements_dict to resolve {{*_META}} placeholders at branch creation."
|
"reason": "Cross-branch public API — called by @spawn's build_replacements_dict to resolve {{*_META}} placeholders at branch creation."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/governance.py",
|
||||||
|
"standard": "unused_function",
|
||||||
|
"reason": "Cross-branch public API — should_surface, record_message, new_state re-exported for @hooks compass_recall and @devpulse recall query."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/handlers/governance/engine.py",
|
||||||
|
"standard": "unused_function",
|
||||||
|
"reason": "Implementation functions re-exported via modules/governance.py for cross-branch consumers."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/templates.py",
|
"file": "apps/modules/templates.py",
|
||||||
"standard": "deep_nesting",
|
"standard": "deep_nesting",
|
||||||
|
|||||||
@@ -8,6 +8,18 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone @memory search "query" # Search archived memories across all branches
|
||||||
|
drone @memory rollover status # Show what needs archiving per branch
|
||||||
|
drone @memory rollover check # Dry run — preview pending rollovers
|
||||||
|
drone @memory lint # Audit .trinity entries for limit violations
|
||||||
|
drone @memory watch # Auto-rollover watcher (Ctrl+C to stop)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -45,15 +57,17 @@ drone @memory watch # Auto-rollover watcher daemon (Ctrl+
|
|||||||
memory/
|
memory/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── memory.py # Entry point — auto-discovers modules
|
│ ├── memory.py # Entry point — auto-discovers modules
|
||||||
│ ├── modules/ # 6 modules
|
│ ├── modules/ # 7 modules
|
||||||
|
│ │ ├── governance.py # Surfacing governance — re-exports from handlers
|
||||||
│ │ ├── lint.py # Entry limit violation scanner (read-only)
|
│ │ ├── lint.py # Entry limit violation scanner (read-only)
|
||||||
│ │ ├── rollover.py # Rollover orchestration, status, sync-lines
|
│ │ ├── rollover.py # Rollover orchestration, status, sync-lines
|
||||||
│ │ ├── search.py # Semantic query routing
|
│ │ ├── search.py # Semantic query routing
|
||||||
│ │ ├── symbolic.py # Fragmented memory extraction and search
|
│ │ ├── symbolic.py # Fragmented memory extraction and search
|
||||||
│ │ ├── templates.py # Template push, diff, status
|
│ │ ├── templates.py # Template push, diff, status
|
||||||
│ │ └── verify.py # Plan vectorization check
|
│ │ └── verify.py # Plan vectorization check
|
||||||
│ └── handlers/ # 14 handler groups
|
│ └── handlers/ # 15 handler groups
|
||||||
│ ├── archive/ # indexer.py
|
│ ├── archive/ # indexer.py
|
||||||
|
│ ├── governance/ # engine.py — surfacing decision logic
|
||||||
│ ├── intake/ # plans_processor.py, pool_processor.py
|
│ ├── intake/ # plans_processor.py, pool_processor.py
|
||||||
│ ├── json/ # json_handler.py, memory_files.py, entry_limits.py, lint_handler.py, config_loader.py
|
│ ├── json/ # json_handler.py, memory_files.py, entry_limits.py, lint_handler.py, config_loader.py
|
||||||
│ ├── learnings/ # manager.py
|
│ ├── learnings/ # manager.py
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: engine.py
|
||||||
|
# Description: Surfacing governance engine — implementation
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-16
|
||||||
|
# Modified: 2026-07-16
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
Surfacing Governance Engine
|
||||||
|
|
||||||
|
Pure decision functions for controlling when recalled items should be
|
||||||
|
surfaced. Implementation logic — public API re-exported from
|
||||||
|
modules/governance.py for cross-branch consumers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
from aipass.prax import logger
|
||||||
|
from aipass.memory.apps.handlers.json import json_handler
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONSTANTS — default config values
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: Dict[str, Any] = {
|
||||||
|
"enabled": True,
|
||||||
|
"threshold": 0.3,
|
||||||
|
"max_surfaces_per_session": 5,
|
||||||
|
"min_messages_between": 10,
|
||||||
|
"cooldown_seconds": 300,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STATE FACTORY
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def new_state() -> Dict[str, Any]:
|
||||||
|
"""Create a fresh governance state dict."""
|
||||||
|
return {
|
||||||
|
"surfaces_count": 0,
|
||||||
|
"messages_since_last": 0,
|
||||||
|
"last_surface_time": 0.0,
|
||||||
|
"surfaced_ids": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CORE GOVERNANCE
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def should_surface(
|
||||||
|
item_id: str,
|
||||||
|
relevance_score: float,
|
||||||
|
state: Dict[str, Any],
|
||||||
|
config: Dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
current_time: float | None = None,
|
||||||
|
) -> Tuple[bool, str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Decide whether an item should be surfaced, given current state.
|
||||||
|
|
||||||
|
Pure function — does not mutate the input state dict.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
cfg = {**DEFAULT_CONFIG, **(config or {})}
|
||||||
|
now = current_time if current_time is not None else time.time()
|
||||||
|
|
||||||
|
if not cfg.get("enabled", True):
|
||||||
|
return False, "Surfacing disabled", state
|
||||||
|
|
||||||
|
threshold = cfg.get("threshold", 0.3)
|
||||||
|
if relevance_score < threshold:
|
||||||
|
return False, f"Below threshold ({relevance_score:.2f} < {threshold})", state
|
||||||
|
|
||||||
|
max_surfaces = cfg.get("max_surfaces_per_session", 5)
|
||||||
|
if state.get("surfaces_count", 0) >= max_surfaces:
|
||||||
|
return False, f"Session budget exhausted ({max_surfaces}/{max_surfaces})", state
|
||||||
|
|
||||||
|
min_messages = cfg.get("min_messages_between", 10)
|
||||||
|
messages_since = state.get("messages_since_last", 0)
|
||||||
|
last_time = state.get("last_surface_time", 0.0)
|
||||||
|
if last_time > 0 and messages_since < min_messages:
|
||||||
|
return False, f"Spacing not met ({messages_since}/{min_messages} messages)", state
|
||||||
|
|
||||||
|
cooldown = cfg.get("cooldown_seconds", 300)
|
||||||
|
elapsed = now - last_time
|
||||||
|
if last_time > 0 and elapsed < cooldown:
|
||||||
|
remaining = int(cooldown - elapsed)
|
||||||
|
return False, f"Cooldown active ({remaining}s remaining)", state
|
||||||
|
|
||||||
|
surfaced_ids = state.get("surfaced_ids", [])
|
||||||
|
if item_id in surfaced_ids:
|
||||||
|
return False, "Already surfaced this session", state
|
||||||
|
|
||||||
|
updated = {
|
||||||
|
"surfaces_count": state.get("surfaces_count", 0) + 1,
|
||||||
|
"messages_since_last": 0,
|
||||||
|
"last_surface_time": now,
|
||||||
|
"surfaced_ids": list(surfaced_ids) + [item_id],
|
||||||
|
}
|
||||||
|
logger.info(f"[governance] Surfacing {item_id} (score={relevance_score:.2f}, surfaces={updated['surfaces_count']})")
|
||||||
|
json_handler.log_operation(
|
||||||
|
"governance_surface",
|
||||||
|
{"item_id": item_id, "relevance_score": relevance_score, "surfaces_count": updated["surfaces_count"]},
|
||||||
|
)
|
||||||
|
return True, "Ready to surface", updated
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MESSAGE TRACKING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def record_message(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Record that a message was processed. Pure — returns updated state."""
|
||||||
|
return {
|
||||||
|
**state,
|
||||||
|
"messages_since_last": state.get("messages_since_last", 0) + 1,
|
||||||
|
}
|
||||||
@@ -164,7 +164,7 @@ def _save_manifest(manifest: Dict[str, str]) -> None:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def _embed_texts(texts: List[str]) -> dict:
|
def _embed_texts(texts: List[str], timeout: int = 120) -> dict:
|
||||||
"""Encode texts via subprocess."""
|
"""Encode texts via subprocess."""
|
||||||
input_data = json.dumps({"texts": texts})
|
input_data = json.dumps({"texts": texts})
|
||||||
try:
|
try:
|
||||||
@@ -173,7 +173,7 @@ def _embed_texts(texts: List[str]) -> dict:
|
|||||||
input=input_data,
|
input=input_data,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=120,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return {"success": False, "error": result.stderr or "Embedding failed"}
|
return {"success": False, "error": result.stderr or "Embedding failed"}
|
||||||
@@ -219,24 +219,17 @@ def process_plans() -> Dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
Process plan files from flow/processed_plans/ into vector storage.
|
Process plan files from flow/processed_plans/ into vector storage.
|
||||||
|
|
||||||
Workflow:
|
Processes each file independently so partial failure makes partial
|
||||||
1. Load config to find plans directory
|
progress — manifest is saved after every successful file.
|
||||||
2. Scan for unprocessed .md files
|
|
||||||
3. Chunk each file into sections
|
|
||||||
4. Embed all chunks via subprocess
|
|
||||||
5. Store vectors in ChromaDB
|
|
||||||
6. Update processed manifest
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with success, files_processed, total_chunks
|
Dict with success, files_processed, total_chunks
|
||||||
"""
|
"""
|
||||||
# Load config
|
|
||||||
plans_config = config_loader.section("plans")
|
plans_config = config_loader.section("plans")
|
||||||
|
|
||||||
if not plans_config.get("enabled", False):
|
if not plans_config.get("enabled", False):
|
||||||
return {"success": True, "skipped": True, "reason": "plans disabled"}
|
return {"success": True, "skipped": True, "reason": "plans disabled"}
|
||||||
|
|
||||||
# Resolve plans directory (relative to repo root)
|
|
||||||
plans_dir = plans_config.get("path", ".backup/processed_plans")
|
plans_dir = plans_config.get("path", ".backup/processed_plans")
|
||||||
repo_root = _find_repo_root()
|
repo_root = _find_repo_root()
|
||||||
plans_path = Path(plans_dir) if Path(plans_dir).is_absolute() else repo_root / plans_dir
|
plans_path = Path(plans_dir) if Path(plans_dir).is_absolute() else repo_root / plans_dir
|
||||||
@@ -246,7 +239,6 @@ def process_plans() -> Dict[str, Any]:
|
|||||||
if not plans_path.exists():
|
if not plans_path.exists():
|
||||||
return {"success": True, "files_processed": 0, "total_chunks": 0, "reason": "plans dir not found"}
|
return {"success": True, "files_processed": 0, "total_chunks": 0, "reason": "plans dir not found"}
|
||||||
|
|
||||||
# Get plan files
|
|
||||||
files = []
|
files = []
|
||||||
for ext in extensions:
|
for ext in extensions:
|
||||||
files.extend(plans_path.glob(f"*{ext}"))
|
files.extend(plans_path.glob(f"*{ext}"))
|
||||||
@@ -254,7 +246,6 @@ def process_plans() -> Dict[str, Any]:
|
|||||||
if not files:
|
if not files:
|
||||||
return {"success": True, "files_processed": 0, "total_chunks": 0}
|
return {"success": True, "files_processed": 0, "total_chunks": 0}
|
||||||
|
|
||||||
# Load manifest to skip already-processed files
|
|
||||||
manifest = _load_manifest()
|
manifest = _load_manifest()
|
||||||
unprocessed = [f for f in files if f.name not in manifest]
|
unprocessed = [f for f in files if f.name not in manifest]
|
||||||
|
|
||||||
@@ -263,88 +254,73 @@ def process_plans() -> Dict[str, Any]:
|
|||||||
|
|
||||||
logger.info(f"[plans] Found {len(unprocessed)} unprocessed plan files")
|
logger.info(f"[plans] Found {len(unprocessed)} unprocessed plan files")
|
||||||
|
|
||||||
errors = []
|
errors: List[str] = []
|
||||||
|
files_processed = 0
|
||||||
# -- Phase 1: Read all files, chunk them, collect texts + metadatas ----------
|
total_chunks = 0
|
||||||
all_texts: List[str] = []
|
|
||||||
all_metadatas: List[Dict[str, str]] = []
|
|
||||||
# Track which files produced chunks (for manifest update)
|
|
||||||
files_with_chunks: List[Path] = []
|
|
||||||
# Files with 0 chunks still get marked in manifest (e.g. template content)
|
|
||||||
files_without_chunks: List[Path] = []
|
|
||||||
|
|
||||||
for plan_file in unprocessed:
|
for plan_file in unprocessed:
|
||||||
try:
|
try:
|
||||||
text = plan_file.read_text(encoding="utf-8")
|
text = plan_file.read_text(encoding="utf-8")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[plans_processor] Failed to read plan file {plan_file.name}: {e}")
|
logger.warning(f"[plans] Failed to read {plan_file.name}: {e}")
|
||||||
errors.append(f"{plan_file.name}: read error: {e}")
|
errors.append(f"{plan_file.name}: read error: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
chunks = _chunk_plan_text(text, plan_file.name)
|
chunks = _chunk_plan_text(text, plan_file.name)
|
||||||
if not chunks:
|
if not chunks:
|
||||||
files_without_chunks.append(plan_file)
|
manifest[plan_file.name] = datetime.now().isoformat()
|
||||||
|
_save_manifest(manifest)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
files_with_chunks.append(plan_file)
|
texts = [c["text"] for c in chunks]
|
||||||
for c in chunks:
|
metadatas = [
|
||||||
all_texts.append(c["text"])
|
{
|
||||||
all_metadatas.append(
|
"source_file": plan_file.name,
|
||||||
{
|
"section": c["section"],
|
||||||
"source_file": plan_file.name,
|
"processed_at": datetime.now().isoformat(),
|
||||||
"section": c["section"],
|
"type": "plan",
|
||||||
"processed_at": datetime.now().isoformat(),
|
}
|
||||||
"type": "plan",
|
for c in chunks
|
||||||
}
|
]
|
||||||
)
|
|
||||||
|
|
||||||
total_chunks = len(all_texts)
|
timeout = max(30, len(texts) * 3)
|
||||||
files_processed = 0
|
embed_result = _embed_texts(texts, timeout=timeout)
|
||||||
|
|
||||||
# Mark empty-chunk files in manifest immediately (nothing to embed)
|
|
||||||
for plan_file in files_without_chunks:
|
|
||||||
manifest[plan_file.name] = datetime.now().isoformat()
|
|
||||||
|
|
||||||
# -- Phase 2: Batch embed + store (single subprocess each) ------------------
|
|
||||||
if all_texts:
|
|
||||||
logger.info(f"[plans] Batch embedding {total_chunks} chunks from {len(files_with_chunks)} files")
|
|
||||||
|
|
||||||
embed_result = _embed_texts(all_texts)
|
|
||||||
if not embed_result.get("success"):
|
if not embed_result.get("success"):
|
||||||
error_msg = f"batch embed error: {embed_result.get('error')}"
|
logger.warning(f"[plans] Embed failed for {plan_file.name}: {embed_result.get('error')}")
|
||||||
logger.error(f"[plans] {error_msg}")
|
errors.append(f"{plan_file.name}: embed error: {embed_result.get('error')}")
|
||||||
errors.append(error_msg)
|
continue
|
||||||
else:
|
|
||||||
embeddings = embed_result.get("embeddings", [])
|
|
||||||
if not embeddings:
|
|
||||||
errors.append("batch embed returned no embeddings")
|
|
||||||
else:
|
|
||||||
store_result = _store_vectors(embeddings, all_texts, all_metadatas, collection_name)
|
|
||||||
if not store_result.get("success"):
|
|
||||||
error_msg = f"batch store error: {store_result.get('error')}"
|
|
||||||
logger.error(f"[plans] {error_msg}")
|
|
||||||
errors.append(error_msg)
|
|
||||||
else:
|
|
||||||
# Success — mark all chunk-producing files in manifest
|
|
||||||
for plan_file in files_with_chunks:
|
|
||||||
manifest[plan_file.name] = datetime.now().isoformat()
|
|
||||||
files_processed = len(files_with_chunks)
|
|
||||||
logger.info(f"[plans] Batch complete: {files_processed} files, {total_chunks} chunks vectorized")
|
|
||||||
|
|
||||||
# Save manifest (includes empty-chunk files even if embedding failed)
|
embeddings = embed_result.get("embeddings", [])
|
||||||
_save_manifest(manifest)
|
if not embeddings:
|
||||||
|
errors.append(f"{plan_file.name}: embed returned no embeddings")
|
||||||
|
continue
|
||||||
|
|
||||||
|
store_result = _store_vectors(embeddings, texts, metadatas, collection_name)
|
||||||
|
if not store_result.get("success"):
|
||||||
|
logger.warning(f"[plans] Store failed for {plan_file.name}: {store_result.get('error')}")
|
||||||
|
errors.append(f"{plan_file.name}: store error: {store_result.get('error')}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
manifest[plan_file.name] = datetime.now().isoformat()
|
||||||
|
_save_manifest(manifest)
|
||||||
|
files_processed += 1
|
||||||
|
total_chunks += len(texts)
|
||||||
|
logger.info(f"[plans] {plan_file.name}: {len(texts)} chunks vectorized")
|
||||||
|
|
||||||
|
if files_processed > 0:
|
||||||
|
logger.info(f"[plans] Complete: {files_processed} files, {total_chunks} chunks vectorized")
|
||||||
|
|
||||||
result: Dict[str, Any] = {
|
result: Dict[str, Any] = {
|
||||||
"success": files_processed > 0 or (not errors and not files_with_chunks),
|
"success": files_processed > 0 or not errors,
|
||||||
"files_processed": files_processed,
|
"files_processed": files_processed,
|
||||||
"total_chunks": total_chunks if files_processed > 0 else 0,
|
"total_chunks": total_chunks,
|
||||||
}
|
}
|
||||||
if errors:
|
if errors:
|
||||||
result["errors"] = errors
|
result["errors"] = errors
|
||||||
|
|
||||||
json_handler.log_operation(
|
json_handler.log_operation(
|
||||||
"process_plans",
|
"process_plans",
|
||||||
{"files_processed": files_processed, "total_chunks": result["total_chunks"], "success": result["success"]},
|
{"files_processed": files_processed, "total_chunks": total_chunks, "success": result["success"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Purpose:
|
|||||||
layer to satisfy thin-module standard.
|
layer to satisfy thin-module standard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -210,6 +211,77 @@ def _filter_results(results: list, n_results: int) -> list:
|
|||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PLAN-ID EXACT MATCHING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_PLAN_ID_RE = re.compile(
|
||||||
|
r"(?:^|\b)((?:d|f|p|td|a)plan)[\s\-_]*(\d{3,5})\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_plan_id(query: str) -> str | None:
|
||||||
|
"""Extract a normalized plan ID (e.g. 'FPLAN-0332') from a query string."""
|
||||||
|
m = _PLAN_ID_RE.search(query)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
prefix = m.group(1).upper()
|
||||||
|
number = m.group(2)
|
||||||
|
return f"{prefix}-{number}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_plan_by_metadata(plan_id: str, n_results: int) -> list:
|
||||||
|
"""Fetch plan chunks directly from ChromaDB by source_file metadata."""
|
||||||
|
input_data = {
|
||||||
|
"operation": "get_by_source",
|
||||||
|
"collection_name": "flow_plans",
|
||||||
|
"source_pattern": plan_id,
|
||||||
|
"n_results": n_results,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(MEMORY_PYTHON), str(CHROMA_SUBPROCESS_SCRIPT)],
|
||||||
|
input=json.dumps(input_data),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
if not data.get("success"):
|
||||||
|
return []
|
||||||
|
return data.get("results", [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[search] Plan metadata fetch failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _pin_plan_id_matches(query: str, filtered: list, n_results: int) -> list:
|
||||||
|
"""Pin exact plan-ID matches to the top of results.
|
||||||
|
|
||||||
|
If the query contains a plan-ID pattern, fetch matching chunks directly
|
||||||
|
from ChromaDB metadata (bypassing embedding similarity) and pin them.
|
||||||
|
"""
|
||||||
|
plan_id = _extract_plan_id(query)
|
||||||
|
if not plan_id:
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
exact = _fetch_plan_by_metadata(plan_id, n_results)
|
||||||
|
if not exact:
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
for r in exact:
|
||||||
|
r["similarity"] = 1.0
|
||||||
|
|
||||||
|
logger.info(f"[search] Pinned {len(exact)} exact matches for {plan_id}")
|
||||||
|
|
||||||
|
seen_ids = {r.get("id") for r in exact}
|
||||||
|
rest = [r for r in filtered if r.get("id") not in seen_ids]
|
||||||
|
return (exact + rest)[:n_results]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PUBLIC API
|
# PUBLIC API
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -273,6 +345,9 @@ def execute_search(
|
|||||||
# Step 3: Filter and score results
|
# Step 3: Filter and score results
|
||||||
filtered_results = _filter_results(raw_results, n_results)
|
filtered_results = _filter_results(raw_results, n_results)
|
||||||
|
|
||||||
|
# Step 4: Pin exact plan-ID matches to the top
|
||||||
|
filtered_results = _pin_plan_id_matches(query, filtered_results, n_results)
|
||||||
|
|
||||||
logger.info(f"[search] Filtered to {len(filtered_results)} relevant results")
|
logger.info(f"[search] Filtered to {len(filtered_results)} relevant results")
|
||||||
|
|
||||||
json_handler.log_operation(
|
json_handler.log_operation(
|
||||||
|
|||||||
@@ -67,12 +67,30 @@ def _store_vectors(branch, memory_type, embeddings, documents, metadatas, db_pat
|
|||||||
embedding_function=None,
|
embedding_function=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Content-hash IDs prevent duplicates across rollover runs
|
# Content-hash IDs — idempotent across runs. When metadata carries
|
||||||
ids = [f"{branch}_{memory_type}_{hashlib.sha256(doc.encode()).hexdigest()[:16]}" for doc in documents]
|
# source_file (e.g. plans intake), salt the hash so identical boilerplate
|
||||||
|
# from different files gets distinct IDs and per-file provenance survives.
|
||||||
|
ids = []
|
||||||
|
for doc, meta in zip(documents, metadatas):
|
||||||
|
salt = meta.get("source_file", "") if isinstance(meta, dict) else ""
|
||||||
|
hash_input = f"{salt}:{doc}" if salt else doc
|
||||||
|
ids.append(f"{branch}_{memory_type}_{hashlib.sha256(hash_input.encode()).hexdigest()[:16]}")
|
||||||
|
|
||||||
# Chroma expects lists, not numpy arrays
|
# Chroma expects lists, not numpy arrays
|
||||||
embeddings_list = [emb.tolist() if hasattr(emb, "tolist") else emb for emb in embeddings]
|
embeddings_list = [emb.tolist() if hasattr(emb, "tolist") else emb for emb in embeddings]
|
||||||
|
|
||||||
|
# Safety net: deduplicate within batch — ChromaDB rejects non-unique IDs
|
||||||
|
# in a single upsert call.
|
||||||
|
seen = {}
|
||||||
|
for i, doc_id in enumerate(ids):
|
||||||
|
seen[doc_id] = i
|
||||||
|
if len(seen) < len(ids):
|
||||||
|
unique_indices = sorted(seen.values())
|
||||||
|
ids = [ids[i] for i in unique_indices]
|
||||||
|
embeddings_list = [embeddings_list[i] for i in unique_indices]
|
||||||
|
documents = [documents[i] for i in unique_indices]
|
||||||
|
metadatas = [metadatas[i] for i in unique_indices]
|
||||||
|
|
||||||
# Upsert: idempotent — same content gets same ID, no duplicates
|
# Upsert: idempotent — same content gets same ID, no duplicates
|
||||||
collection.upsert(embeddings=embeddings_list, documents=documents, metadatas=metadatas, ids=ids)
|
collection.upsert(embeddings=embeddings_list, documents=documents, metadatas=metadatas, ids=ids)
|
||||||
|
|
||||||
@@ -134,6 +152,79 @@ def _check_plan(plan_label, db_path=None):
|
|||||||
return {"success": True, "found": match_count > 0, "count": match_count, "source_files": sorted(matching_files)}
|
return {"success": True, "found": match_count > 0, "count": match_count, "source_files": sorted(matching_files)}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_by_source(collection_name, source_pattern, n_results=5, db_path=None):
|
||||||
|
"""Fetch documents whose source_file metadata contains a pattern.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection_name: Name of the ChromaDB collection
|
||||||
|
source_pattern: Substring to match in source_file metadata
|
||||||
|
n_results: Maximum number of results to return
|
||||||
|
db_path: Optional path to Chroma database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success, results list (document, metadata, id)
|
||||||
|
"""
|
||||||
|
client = _get_client(db_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
collection = client.get_collection(collection_name, embedding_function=None)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[chroma_subprocess] Collection '{collection_name}' not found in get_by_source: {e}")
|
||||||
|
return {"success": False, "error": f"Collection '{collection_name}' not found: {e}"}
|
||||||
|
|
||||||
|
result = collection.get(include=["metadatas", "documents"])
|
||||||
|
matches = []
|
||||||
|
for i, meta in enumerate(result.get("metadatas", [])):
|
||||||
|
source = meta.get("source_file", "")
|
||||||
|
if source_pattern in source:
|
||||||
|
matches.append(
|
||||||
|
{
|
||||||
|
"collection": collection_name,
|
||||||
|
"document": result["documents"][i],
|
||||||
|
"metadata": meta,
|
||||||
|
"id": result["ids"][i],
|
||||||
|
"distance": 0.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(matches) >= n_results:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {"success": True, "results": matches, "count": len(matches)}
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_by_source(collection_name, source_pattern, db_path=None):
|
||||||
|
"""Delete vectors whose source_file metadata contains a pattern.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection_name: Name of the ChromaDB collection
|
||||||
|
source_pattern: Substring to match in source_file metadata
|
||||||
|
db_path: Optional path to Chroma database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success, deleted count, and matched IDs
|
||||||
|
"""
|
||||||
|
client = _get_client(db_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
collection = client.get_collection(collection_name, embedding_function=None)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[chroma_subprocess] Collection '{collection_name}' not found in delete_by_source: {e}")
|
||||||
|
return {"success": False, "error": f"Collection '{collection_name}' not found: {e}"}
|
||||||
|
|
||||||
|
result = collection.get(include=["metadatas"])
|
||||||
|
ids_to_delete = []
|
||||||
|
for i, meta in enumerate(result.get("metadatas", [])):
|
||||||
|
source = meta.get("source_file", "")
|
||||||
|
if source_pattern in source:
|
||||||
|
ids_to_delete.append(result["ids"][i])
|
||||||
|
|
||||||
|
if not ids_to_delete:
|
||||||
|
return {"success": True, "deleted": 0, "ids": [], "message": "No matching vectors found"}
|
||||||
|
|
||||||
|
collection.delete(ids=ids_to_delete)
|
||||||
|
return {"success": True, "deleted": len(ids_to_delete), "ids": ids_to_delete}
|
||||||
|
|
||||||
|
|
||||||
def _search_vectors(query_embedding, branch=None, memory_type=None, n_results=5, db_path=None):
|
def _search_vectors(query_embedding, branch=None, memory_type=None, n_results=5, db_path=None):
|
||||||
"""Search for similar vectors."""
|
"""Search for similar vectors."""
|
||||||
client = _get_client(db_path)
|
client = _get_client(db_path)
|
||||||
@@ -214,6 +305,19 @@ def main():
|
|||||||
)
|
)
|
||||||
elif operation == "check_plan":
|
elif operation == "check_plan":
|
||||||
result = _check_plan(plan_label=input_data.get("plan_label"), db_path=input_data.get("db_path"))
|
result = _check_plan(plan_label=input_data.get("plan_label"), db_path=input_data.get("db_path"))
|
||||||
|
elif operation == "get_by_source":
|
||||||
|
result = _get_by_source(
|
||||||
|
collection_name=input_data.get("collection_name"),
|
||||||
|
source_pattern=input_data.get("source_pattern"),
|
||||||
|
n_results=input_data.get("n_results", 5),
|
||||||
|
db_path=input_data.get("db_path"),
|
||||||
|
)
|
||||||
|
elif operation == "delete_by_source":
|
||||||
|
result = _delete_by_source(
|
||||||
|
collection_name=input_data.get("collection_name"),
|
||||||
|
source_pattern=input_data.get("source_pattern"),
|
||||||
|
db_path=input_data.get("db_path"),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result = {"success": False, "error": f"Unknown operation: {operation}"}
|
result = {"success": False, "error": f"Unknown operation: {operation}"}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: governance.py
|
||||||
|
# Description: Surfacing governance module — public API
|
||||||
|
# Version: 1.1.0
|
||||||
|
# Created: 2026-07-16
|
||||||
|
# Modified: 2026-07-16
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
Surfacing Governance Module — Public API
|
||||||
|
|
||||||
|
Thin module re-exporting governance engine from handlers/governance/engine.py.
|
||||||
|
Cross-branch consumers import from here:
|
||||||
|
|
||||||
|
from aipass.memory.apps.modules.governance import should_surface, record_message, new_state
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
os.environ.setdefault("PYTHONUTF8", "1")
|
||||||
|
for _stream in (sys.stdout, sys.stderr):
|
||||||
|
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||||
|
if _reconfigure is not None:
|
||||||
|
_reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
from aipass.prax import logger # noqa: F401
|
||||||
|
from aipass.memory.apps.handlers.json import json_handler
|
||||||
|
from aipass.memory.apps.handlers.governance.engine import (
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
new_state,
|
||||||
|
record_message,
|
||||||
|
should_surface,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["should_surface", "record_message", "new_state", "DEFAULT_CONFIG"]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MODULE ROUTING (handle_command for drone auto-discovery)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection() -> None:
|
||||||
|
"""Display module introspection (seedgo standard)."""
|
||||||
|
from aipass.cli.apps.modules import console
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]governance Module[/bold cyan]")
|
||||||
|
console.print("Pure surfacing governance — state-in/state-out decision functions")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]Public API:[/yellow]")
|
||||||
|
console.print(" should_surface(item_id, relevance_score, state, config)")
|
||||||
|
console.print(" record_message(state)")
|
||||||
|
console.print(" new_state()")
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Library module — import from: aipass.memory.apps.modules.governance[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_command(command: str, args: list) -> bool:
|
||||||
|
"""Entry point for drone module discovery — governance has no CLI surface."""
|
||||||
|
if command != "governance":
|
||||||
|
return False
|
||||||
|
|
||||||
|
json_handler.log_operation("governance_command", {"args": args})
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if args[0] in ("--help", "-h", "help"):
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
|
||||||
|
from aipass.cli.apps.modules import warning
|
||||||
|
|
||||||
|
warning(f"governance: unknown subcommand '{args[0]}'")
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""Tests for the surfacing governance module (pure state-in/state-out API)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aipass.memory.apps.modules.governance import (
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
new_state,
|
||||||
|
record_message,
|
||||||
|
should_surface,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# new_state
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewState:
|
||||||
|
def test_returns_zeroed_state(self):
|
||||||
|
state = new_state()
|
||||||
|
assert state["surfaces_count"] == 0
|
||||||
|
assert state["messages_since_last"] == 0
|
||||||
|
assert state["last_surface_time"] == 0.0
|
||||||
|
assert state["surfaced_ids"] == []
|
||||||
|
|
||||||
|
def test_returns_independent_copies(self):
|
||||||
|
s1 = new_state()
|
||||||
|
s2 = new_state()
|
||||||
|
s1["surfaced_ids"].append("x")
|
||||||
|
assert s2["surfaced_ids"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# should_surface — rejection paths
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldSurfaceRejections:
|
||||||
|
"""Each rejection path tested independently with an otherwise-valid state."""
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def ready_state(self):
|
||||||
|
"""State that passes all checks when config is default."""
|
||||||
|
return {
|
||||||
|
"surfaces_count": 0,
|
||||||
|
"messages_since_last": 20,
|
||||||
|
"last_surface_time": 0.0,
|
||||||
|
"surfaced_ids": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_disabled(self, ready_state):
|
||||||
|
ok, reason, st = should_surface("item1", 0.8, ready_state, {"enabled": False}, current_time=1000.0)
|
||||||
|
assert ok is False
|
||||||
|
assert "disabled" in reason.lower()
|
||||||
|
assert st is ready_state
|
||||||
|
|
||||||
|
def test_below_threshold(self, ready_state):
|
||||||
|
ok, reason, st = should_surface("item1", 0.1, ready_state, {"threshold": 0.3}, current_time=1000.0)
|
||||||
|
assert ok is False
|
||||||
|
assert "threshold" in reason.lower()
|
||||||
|
assert st is ready_state
|
||||||
|
|
||||||
|
def test_budget_exhausted(self, ready_state):
|
||||||
|
ready_state["surfaces_count"] = 5
|
||||||
|
ok, reason, st = should_surface("item1", 0.8, ready_state, {"max_surfaces_per_session": 5}, current_time=1000.0)
|
||||||
|
assert ok is False
|
||||||
|
assert "budget" in reason.lower() or "exhausted" in reason.lower()
|
||||||
|
assert st is ready_state
|
||||||
|
|
||||||
|
def test_spacing_not_met(self, ready_state):
|
||||||
|
ready_state["messages_since_last"] = 3
|
||||||
|
ready_state["last_surface_time"] = 1.0
|
||||||
|
ok, reason, st = should_surface("item1", 0.8, ready_state, {"min_messages_between": 10}, current_time=1000.0)
|
||||||
|
assert ok is False
|
||||||
|
assert "spacing" in reason.lower()
|
||||||
|
assert st is ready_state
|
||||||
|
|
||||||
|
def test_cooldown_active(self, ready_state):
|
||||||
|
ready_state["last_surface_time"] = 900.0
|
||||||
|
ok, reason, st = should_surface("item1", 0.8, ready_state, {"cooldown_seconds": 300}, current_time=1000.0)
|
||||||
|
assert ok is False
|
||||||
|
assert "cooldown" in reason.lower()
|
||||||
|
assert st is ready_state
|
||||||
|
|
||||||
|
def test_already_surfaced(self, ready_state):
|
||||||
|
ready_state["surfaced_ids"] = ["item1"]
|
||||||
|
ok, reason, st = should_surface("item1", 0.8, ready_state, current_time=1000.0)
|
||||||
|
assert ok is False
|
||||||
|
assert "already" in reason.lower()
|
||||||
|
assert st is ready_state
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# should_surface — happy path
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldSurfaceHappy:
|
||||||
|
def test_surfaces_and_returns_updated_state(self):
|
||||||
|
state = {
|
||||||
|
"surfaces_count": 0,
|
||||||
|
"messages_since_last": 15,
|
||||||
|
"last_surface_time": 0.0,
|
||||||
|
"surfaced_ids": [],
|
||||||
|
}
|
||||||
|
ok, reason, updated = should_surface("compass-42", 0.75, state, current_time=5000.0)
|
||||||
|
assert ok is True
|
||||||
|
assert "ready" in reason.lower()
|
||||||
|
assert updated["surfaces_count"] == 1
|
||||||
|
assert updated["messages_since_last"] == 0
|
||||||
|
assert updated["last_surface_time"] == 5000.0
|
||||||
|
assert "compass-42" in updated["surfaced_ids"]
|
||||||
|
|
||||||
|
def test_does_not_mutate_input_state(self):
|
||||||
|
state = {
|
||||||
|
"surfaces_count": 0,
|
||||||
|
"messages_since_last": 15,
|
||||||
|
"last_surface_time": 0.0,
|
||||||
|
"surfaced_ids": [],
|
||||||
|
}
|
||||||
|
original_ids = state["surfaced_ids"]
|
||||||
|
should_surface("item1", 0.8, state, current_time=5000.0)
|
||||||
|
assert state["surfaces_count"] == 0
|
||||||
|
assert state["surfaced_ids"] is original_ids
|
||||||
|
assert len(original_ids) == 0
|
||||||
|
|
||||||
|
def test_threshold_boundary_exact(self):
|
||||||
|
state = new_state()
|
||||||
|
state["messages_since_last"] = 10
|
||||||
|
ok, _, _ = should_surface("x", 0.3, state, {"threshold": 0.3}, current_time=1000.0)
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
def test_cooldown_expired(self):
|
||||||
|
state = {
|
||||||
|
"surfaces_count": 0,
|
||||||
|
"messages_since_last": 15,
|
||||||
|
"last_surface_time": 500.0,
|
||||||
|
"surfaced_ids": [],
|
||||||
|
}
|
||||||
|
ok, _, _ = should_surface("x", 0.8, state, {"cooldown_seconds": 300}, current_time=801.0)
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
def test_first_surface_ignores_spacing(self):
|
||||||
|
"""Fresh session: first prompt with high relevance surfaces immediately."""
|
||||||
|
state = new_state()
|
||||||
|
state["messages_since_last"] = 1
|
||||||
|
ok, reason, updated = should_surface("compass-1", 0.8, state, current_time=100.0)
|
||||||
|
assert ok is True
|
||||||
|
assert "ready" in reason.lower()
|
||||||
|
|
||||||
|
for i in range(9):
|
||||||
|
updated = record_message(updated)
|
||||||
|
ok, reason, _ = should_surface("compass-2", 0.8, updated, current_time=100.0 + 400 + i)
|
||||||
|
assert ok is False
|
||||||
|
assert "spacing" in reason.lower()
|
||||||
|
|
||||||
|
updated = record_message(updated)
|
||||||
|
ok, _, _ = should_surface("compass-2", 0.8, updated, current_time=600.0)
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# State isolation — two independent states do not bleed
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateIsolation:
|
||||||
|
def test_two_states_independent(self):
|
||||||
|
s1 = new_state()
|
||||||
|
s1["messages_since_last"] = 20
|
||||||
|
s2 = new_state()
|
||||||
|
s2["messages_since_last"] = 20
|
||||||
|
|
||||||
|
ok1, _, s1_updated = should_surface("a", 0.8, s1, current_time=1000.0)
|
||||||
|
ok2, _, s2_updated = should_surface("b", 0.9, s2, current_time=2000.0)
|
||||||
|
|
||||||
|
assert ok1 is True
|
||||||
|
assert ok2 is True
|
||||||
|
assert s1_updated["surfaced_ids"] == ["a"]
|
||||||
|
assert s2_updated["surfaced_ids"] == ["b"]
|
||||||
|
assert s1_updated["last_surface_time"] == 1000.0
|
||||||
|
assert s2_updated["last_surface_time"] == 2000.0
|
||||||
|
|
||||||
|
def test_chained_surfaces_accumulate(self):
|
||||||
|
state = new_state()
|
||||||
|
state["messages_since_last"] = 20
|
||||||
|
|
||||||
|
ok, _, state = should_surface("a", 0.8, state, current_time=1000.0)
|
||||||
|
assert ok is True
|
||||||
|
assert state["surfaces_count"] == 1
|
||||||
|
|
||||||
|
state["messages_since_last"] = 20
|
||||||
|
ok, _, state = should_surface("b", 0.7, state, current_time=2000.0)
|
||||||
|
assert ok is True
|
||||||
|
assert state["surfaces_count"] == 2
|
||||||
|
assert state["surfaced_ids"] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# record_message
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordMessage:
|
||||||
|
def test_increments_counter(self):
|
||||||
|
state = new_state()
|
||||||
|
updated = record_message(state)
|
||||||
|
assert updated["messages_since_last"] == 1
|
||||||
|
|
||||||
|
def test_does_not_mutate_input(self):
|
||||||
|
state = new_state()
|
||||||
|
record_message(state)
|
||||||
|
assert state["messages_since_last"] == 0
|
||||||
|
|
||||||
|
def test_preserves_other_fields(self):
|
||||||
|
state = {
|
||||||
|
"surfaces_count": 3,
|
||||||
|
"messages_since_last": 5,
|
||||||
|
"last_surface_time": 100.0,
|
||||||
|
"surfaced_ids": ["x"],
|
||||||
|
}
|
||||||
|
updated = record_message(state)
|
||||||
|
assert updated["surfaces_count"] == 3
|
||||||
|
assert updated["messages_since_last"] == 6
|
||||||
|
assert updated["last_surface_time"] == 100.0
|
||||||
|
assert updated["surfaced_ids"] == ["x"]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Config merging
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigMerging:
|
||||||
|
def test_none_config_uses_defaults(self):
|
||||||
|
state = new_state()
|
||||||
|
state["messages_since_last"] = 20
|
||||||
|
ok, _, _ = should_surface("x", 0.8, state, None, current_time=1000.0)
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
def test_partial_config_merges_with_defaults(self):
|
||||||
|
state = new_state()
|
||||||
|
state["messages_since_last"] = 20
|
||||||
|
ok, reason, _ = should_surface("x", 0.25, state, {"threshold": 0.5}, current_time=1000.0)
|
||||||
|
assert ok is False
|
||||||
|
assert "threshold" in reason.lower()
|
||||||
|
|
||||||
|
def test_default_config_values_match(self):
|
||||||
|
assert DEFAULT_CONFIG["threshold"] == 0.3
|
||||||
|
assert DEFAULT_CONFIG["max_surfaces_per_session"] == 5
|
||||||
|
assert DEFAULT_CONFIG["min_messages_between"] == 10
|
||||||
|
assert DEFAULT_CONFIG["cooldown_seconds"] == 300
|
||||||
@@ -501,7 +501,7 @@ class TestProcessPlans:
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mod,
|
mod,
|
||||||
"_embed_texts",
|
"_embed_texts",
|
||||||
lambda texts: {"success": True, "embeddings": [[0.1, 0.2]] * len(texts)},
|
lambda texts, timeout=120: {"success": True, "embeddings": [[0.1, 0.2]] * len(texts)},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mod,
|
mod,
|
||||||
@@ -547,7 +547,7 @@ class TestProcessPlans:
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mod,
|
mod,
|
||||||
"_embed_texts",
|
"_embed_texts",
|
||||||
lambda texts: {"success": False, "error": "GPU out of memory"},
|
lambda texts, timeout=120: {"success": False, "error": "GPU out of memory"},
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_jh = MagicMock()
|
mock_jh = MagicMock()
|
||||||
@@ -584,7 +584,7 @@ class TestProcessPlans:
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mod,
|
mod,
|
||||||
"_embed_texts",
|
"_embed_texts",
|
||||||
lambda texts: {"success": True, "embeddings": []},
|
lambda texts, timeout=120: {"success": True, "embeddings": []},
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_jh = MagicMock()
|
mock_jh = MagicMock()
|
||||||
@@ -620,7 +620,7 @@ class TestProcessPlans:
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mod,
|
mod,
|
||||||
"_embed_texts",
|
"_embed_texts",
|
||||||
lambda texts: {"success": True, "embeddings": [[0.1, 0.2]] * len(texts)},
|
lambda texts, timeout=120: {"success": True, "embeddings": [[0.1, 0.2]] * len(texts)},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mod,
|
mod,
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ def print_help():
|
|||||||
console.print()
|
console.print()
|
||||||
console.print("[bold cyan]PRAX - System-Wide Logging Infrastructure[/bold cyan]")
|
console.print("[bold cyan]PRAX - System-Wide Logging Infrastructure[/bold cyan]")
|
||||||
console.print()
|
console.print()
|
||||||
|
console.print("[dim]Unified logging system for AIPass ecosystem[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[bold cyan]Usage:[/bold cyan]")
|
||||||
|
console.print(" [green]drone @prax <command>[/green] [dim][options][/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
console.print("[yellow]Commands:[/yellow]")
|
console.print("[yellow]Commands:[/yellow]")
|
||||||
console.print(" [cyan]monitor[/cyan] Mission Control - unified real-time monitoring")
|
console.print(" [cyan]monitor[/cyan] Mission Control - unified real-time monitoring")
|
||||||
|
|||||||
@@ -2,13 +2,23 @@
|
|||||||
|
|
||||||
# Seedgo
|
# Seedgo
|
||||||
|
|
||||||
**Purpose:** Standards compliance platform for AIPass. Audits all 11 core agents against 40 code standards + diagnostics, manages bypass rules, runs proof certification, and provides per-file checklist validation consumed by auto-fix hooks.
|
**Purpose:** Standards compliance platform for AIPass. Audits all 11 core agents against 42 code standards + diagnostics, manages bypass rules, runs proof certification, and provides per-file checklist validation consumed by the PostToolUse auto-fix gate.
|
||||||
**Module:** `aipass.seedgo`
|
**Module:** `aipass.seedgo`
|
||||||
**Version:** 2.0.0
|
**Version:** 2.0.0
|
||||||
**Created:** 2026-03-05
|
**Created:** 2026-03-05
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone @seedgo audit aipass # Audit all branches against all standards
|
||||||
|
drone @seedgo checklist <file> # Check a single file
|
||||||
|
drone @seedgo standards_query aipass_standards cli # Look up what a standard checks
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
### What I Do
|
### What I Do
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: cli_ux_check.py
|
||||||
|
# Description: CLI UX Standards Checker Handler
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-17
|
||||||
|
# Modified: 2026-07-17
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
CLI UX Standards Checker Handler
|
||||||
|
|
||||||
|
Validates that branch entry points follow the AIPass house pattern for CLI
|
||||||
|
help and introspection output.
|
||||||
|
|
||||||
|
Good entry points (flow.py, prax.py, drone.py, seedgo.py) all have:
|
||||||
|
- print_introspection() and print_help() as separate two-tier functions
|
||||||
|
- Rich console.print() output (no bare print())
|
||||||
|
- Styled title, purpose/tagline, and --help pointer in introspection
|
||||||
|
- Usage and Examples sections in help
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
1. two_tier_help - Both print_introspection() and print_help() exist
|
||||||
|
2. rich_console - Help functions use console.print(), not bare print()
|
||||||
|
3. title_markup - print_introspection() has a [bold styled title
|
||||||
|
4. purpose_line - print_introspection() has a [dim] purpose/tagline line
|
||||||
|
5. help_pointer - print_introspection() references --help
|
||||||
|
6. usage_section - print_help() includes a Usage section
|
||||||
|
7. examples_section - print_help() includes an Examples section
|
||||||
|
8. no_internal_modules - modules/ does not expose internal plumbing files
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from aipass.prax import logger
|
||||||
|
from aipass.seedgo.apps.handlers.json import json_handler
|
||||||
|
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
||||||
|
|
||||||
|
# Only check entry points: apps/{branch}.py files
|
||||||
|
AUDIT_SCOPE = "entry_point"
|
||||||
|
|
||||||
|
|
||||||
|
def check_module(module_path: str, bypass_rules: list | None = None) -> Dict:
|
||||||
|
"""
|
||||||
|
Check if entry point follows the AIPass CLI UX house pattern.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module_path: Path to Python module to check
|
||||||
|
bypass_rules: Optional list of bypass rules to skip specific violations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'passed': bool, # Overall pass/fail
|
||||||
|
'checks': [ # Individual check results
|
||||||
|
{
|
||||||
|
'name': str, # Check name
|
||||||
|
'passed': bool, # Pass/fail
|
||||||
|
'message': str, # Details
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'score': int, # 0-100 percentage
|
||||||
|
'standard': str # Standard name
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
checks: List[Dict] = []
|
||||||
|
path = Path(module_path)
|
||||||
|
|
||||||
|
# Normalize to forward slashes so string matching works on Windows too
|
||||||
|
module_path = Path(module_path).as_posix()
|
||||||
|
|
||||||
|
# Check if entire standard is bypassed for this file
|
||||||
|
if is_bypassed(module_path, "cli_ux", bypass_rules=bypass_rules):
|
||||||
|
return {
|
||||||
|
"passed": True,
|
||||||
|
"checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}],
|
||||||
|
"score": 100,
|
||||||
|
"standard": "CLI_UX",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate file exists
|
||||||
|
if not path.exists():
|
||||||
|
return {
|
||||||
|
"passed": False,
|
||||||
|
"checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}],
|
||||||
|
"score": 0,
|
||||||
|
"standard": "CLI_UX",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skip __init__.py files
|
||||||
|
if path.name == "__init__.py":
|
||||||
|
return {
|
||||||
|
"passed": True,
|
||||||
|
"checks": [{"name": "CLI UX check", "passed": True, "message": "__init__.py skipped"}],
|
||||||
|
"score": 100,
|
||||||
|
"standard": "CLI_UX",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skip non-entry-point files (entry points live at apps/{name}.py)
|
||||||
|
if not _is_entry_point(module_path, path):
|
||||||
|
return {
|
||||||
|
"passed": True,
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "CLI UX check",
|
||||||
|
"passed": True,
|
||||||
|
"message": "Not an entry point file (not applicable)",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": 100,
|
||||||
|
"standard": "CLI_UX",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read file
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Cannot read %s: %s", path, e)
|
||||||
|
return {
|
||||||
|
"passed": False,
|
||||||
|
"checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}],
|
||||||
|
"score": 0,
|
||||||
|
"standard": "CLI_UX",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Empty file
|
||||||
|
if not content.strip():
|
||||||
|
return {
|
||||||
|
"passed": True,
|
||||||
|
"checks": [{"name": "CLI UX check", "passed": True, "message": "Empty file skipped"}],
|
||||||
|
"score": 100,
|
||||||
|
"standard": "CLI_UX",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse AST
|
||||||
|
try:
|
||||||
|
tree = ast.parse(content, filename=module_path)
|
||||||
|
except SyntaxError as e:
|
||||||
|
logger.info("Skipped %s: SyntaxError during parse", path)
|
||||||
|
return {
|
||||||
|
"passed": False,
|
||||||
|
"checks": [{"name": "File parseable", "passed": False, "message": f"Syntax error: {e}"}],
|
||||||
|
"score": 0,
|
||||||
|
"standard": "CLI_UX",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find the two key functions via AST
|
||||||
|
introspection_func = _find_function(tree, "print_introspection")
|
||||||
|
help_func = _find_function(tree, "print_help")
|
||||||
|
|
||||||
|
# --- Check 1: two_tier_help ---
|
||||||
|
checks.append(_check_two_tier_help(tree, path.name))
|
||||||
|
|
||||||
|
# --- Check 2: rich_console ---
|
||||||
|
checks.append(_check_rich_console(introspection_func, help_func, path.name))
|
||||||
|
|
||||||
|
# --- Check 3: title_markup ---
|
||||||
|
checks.append(_check_title_markup(introspection_func, path.name))
|
||||||
|
|
||||||
|
# --- Check 4: purpose_line ---
|
||||||
|
checks.append(_check_purpose_line(introspection_func, path.name))
|
||||||
|
|
||||||
|
# --- Check 5: help_pointer ---
|
||||||
|
checks.append(_check_help_pointer(introspection_func, path.name))
|
||||||
|
|
||||||
|
# --- Check 6: usage_section ---
|
||||||
|
checks.append(_check_usage_section(help_func, path.name))
|
||||||
|
|
||||||
|
# --- Check 7: examples_section ---
|
||||||
|
checks.append(_check_examples_section(help_func, path.name))
|
||||||
|
|
||||||
|
# --- Check 8: no_internal_modules ---
|
||||||
|
checks.append(_check_no_internal_modules(module_path, path))
|
||||||
|
|
||||||
|
# Calculate score
|
||||||
|
passed_checks = sum(1 for check in checks if check["passed"])
|
||||||
|
total_checks = len(checks)
|
||||||
|
score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0
|
||||||
|
|
||||||
|
# Overall pass = ALL checks passed
|
||||||
|
overall_passed = all(check["passed"] for check in checks)
|
||||||
|
|
||||||
|
json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "cli_ux"})
|
||||||
|
return {"passed": overall_passed, "checks": checks, "score": score, "standard": "CLI_UX"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _is_entry_point(module_path: str, path: Path) -> bool:
|
||||||
|
"""
|
||||||
|
Detect if file is an entry point: apps/{name}.py (directly in apps/, not in subdirectory).
|
||||||
|
|
||||||
|
Entry points live at apps/{name}.py -- their parent directory is 'apps'.
|
||||||
|
Files in apps/modules/, apps/handlers/, apps/plugins/ etc. are NOT entry points.
|
||||||
|
"""
|
||||||
|
if not path.name.endswith(".py"):
|
||||||
|
return False
|
||||||
|
posix_path = Path(module_path).as_posix()
|
||||||
|
if "apps/" not in posix_path:
|
||||||
|
return False
|
||||||
|
return path.parent.name == "apps"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_function(tree: ast.Module, name: str) -> Optional[ast.FunctionDef]:
|
||||||
|
"""Find a top-level function definition by name."""
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||||
|
return node
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_string_constants(func_node: ast.FunctionDef) -> List[str]:
|
||||||
|
"""Extract all string constants from a function body, including f-string parts."""
|
||||||
|
return [
|
||||||
|
node.value for node in ast.walk(func_node) if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Individual checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _check_two_tier_help(tree: ast.Module, filename: str) -> Dict:
|
||||||
|
"""Check 1: Entry point has BOTH print_introspection AND print_help."""
|
||||||
|
found = set()
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.FunctionDef) and node.name in ("print_introspection", "print_help"):
|
||||||
|
found.add(node.name)
|
||||||
|
|
||||||
|
missing = {"print_introspection", "print_help"} - found
|
||||||
|
|
||||||
|
if not missing:
|
||||||
|
return {
|
||||||
|
"name": "two_tier_help",
|
||||||
|
"passed": True,
|
||||||
|
"message": f"Both print_introspection() and print_help() found in {filename}",
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_str = ", ".join(sorted(missing))
|
||||||
|
return {
|
||||||
|
"name": "two_tier_help",
|
||||||
|
"passed": False,
|
||||||
|
"message": f"Entry point must define both print_introspection() and print_help() — {missing_str} not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rich_console(
|
||||||
|
introspection_func: Optional[ast.FunctionDef],
|
||||||
|
help_func: Optional[ast.FunctionDef],
|
||||||
|
filename: str,
|
||||||
|
) -> Dict:
|
||||||
|
"""Check 2: Help functions use console.print(), not bare print()."""
|
||||||
|
funcs_to_check = []
|
||||||
|
if introspection_func is not None:
|
||||||
|
funcs_to_check.append(("print_introspection", introspection_func))
|
||||||
|
if help_func is not None:
|
||||||
|
funcs_to_check.append(("print_help", help_func))
|
||||||
|
|
||||||
|
if not funcs_to_check:
|
||||||
|
# Neither function exists -- auto-fail (two_tier_help already catches the root cause)
|
||||||
|
return {
|
||||||
|
"name": "rich_console",
|
||||||
|
"passed": False,
|
||||||
|
"message": "Help functions must use console.print() from aipass.cli, not bare print()",
|
||||||
|
}
|
||||||
|
|
||||||
|
has_console_print = False
|
||||||
|
has_bare_print = False
|
||||||
|
|
||||||
|
for _func_name, func_node in funcs_to_check:
|
||||||
|
for node in ast.walk(func_node):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
# console.print() — Attribute call where value.id == 'console' and attr == 'print'
|
||||||
|
if isinstance(node.func, ast.Attribute):
|
||||||
|
if (
|
||||||
|
node.func.attr == "print"
|
||||||
|
and isinstance(node.func.value, ast.Name)
|
||||||
|
and node.func.value.id == "console"
|
||||||
|
):
|
||||||
|
has_console_print = True
|
||||||
|
# bare print() — Name call where id == 'print'
|
||||||
|
if isinstance(node.func, ast.Name) and node.func.id == "print":
|
||||||
|
has_bare_print = True
|
||||||
|
|
||||||
|
if has_bare_print:
|
||||||
|
return {
|
||||||
|
"name": "rich_console",
|
||||||
|
"passed": False,
|
||||||
|
"message": "Help functions must use console.print() from aipass.cli, not bare print()",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not has_console_print:
|
||||||
|
return {
|
||||||
|
"name": "rich_console",
|
||||||
|
"passed": False,
|
||||||
|
"message": "Help functions must use console.print() from aipass.cli, not bare print()",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": "rich_console",
|
||||||
|
"passed": True,
|
||||||
|
"message": f"Help functions in {filename} use console.print() (no bare print())",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_title_markup(introspection_func: Optional[ast.FunctionDef], filename: str) -> Dict:
|
||||||
|
"""Check 3: print_introspection contains a [bold styled title."""
|
||||||
|
if introspection_func is None:
|
||||||
|
return {
|
||||||
|
"name": "title_markup",
|
||||||
|
"passed": False,
|
||||||
|
"message": (
|
||||||
|
"print_introspection() must include a styled title line (e.g. [bold cyan]Branch Name[/bold cyan])"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
strings = _collect_string_constants(introspection_func)
|
||||||
|
for s in strings:
|
||||||
|
if "[bold" in s:
|
||||||
|
return {
|
||||||
|
"name": "title_markup",
|
||||||
|
"passed": True,
|
||||||
|
"message": f"print_introspection() in {filename} has a styled title with [bold markup",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": "title_markup",
|
||||||
|
"passed": False,
|
||||||
|
"message": "print_introspection() must include a styled title line (e.g. [bold cyan]Branch Name[/bold cyan])",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_purpose_line(introspection_func: Optional[ast.FunctionDef], filename: str) -> Dict:
|
||||||
|
"""Check 4: print_introspection contains a [dim] purpose/tagline line."""
|
||||||
|
if introspection_func is None:
|
||||||
|
return {
|
||||||
|
"name": "purpose_line",
|
||||||
|
"passed": False,
|
||||||
|
"message": "print_introspection() must include a dim-styled purpose/tagline line",
|
||||||
|
}
|
||||||
|
|
||||||
|
strings = _collect_string_constants(introspection_func)
|
||||||
|
for s in strings:
|
||||||
|
if "[dim]" in s:
|
||||||
|
return {
|
||||||
|
"name": "purpose_line",
|
||||||
|
"passed": True,
|
||||||
|
"message": f"print_introspection() in {filename} has a [dim] purpose/tagline line",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": "purpose_line",
|
||||||
|
"passed": False,
|
||||||
|
"message": "print_introspection() must include a dim-styled purpose/tagline line",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_help_pointer(introspection_func: Optional[ast.FunctionDef], filename: str) -> Dict:
|
||||||
|
"""Check 5: print_introspection contains a closing pointer to --help."""
|
||||||
|
if introspection_func is None:
|
||||||
|
return {
|
||||||
|
"name": "help_pointer",
|
||||||
|
"passed": False,
|
||||||
|
"message": "print_introspection() must include a closing pointer to --help for more info",
|
||||||
|
}
|
||||||
|
|
||||||
|
strings = _collect_string_constants(introspection_func)
|
||||||
|
for s in strings:
|
||||||
|
if "--help" in s:
|
||||||
|
return {
|
||||||
|
"name": "help_pointer",
|
||||||
|
"passed": True,
|
||||||
|
"message": f"print_introspection() in {filename} includes a --help pointer",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": "help_pointer",
|
||||||
|
"passed": False,
|
||||||
|
"message": "print_introspection() must include a closing pointer to --help for more info",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_usage_section(help_func: Optional[ast.FunctionDef], filename: str) -> Dict:
|
||||||
|
"""Check 6: print_help contains a Usage section."""
|
||||||
|
if help_func is None:
|
||||||
|
return {
|
||||||
|
"name": "usage_section",
|
||||||
|
"passed": False,
|
||||||
|
"message": "print_help() must include a Usage section",
|
||||||
|
}
|
||||||
|
|
||||||
|
strings = _collect_string_constants(help_func)
|
||||||
|
for s in strings:
|
||||||
|
if "usage" in s.lower():
|
||||||
|
return {
|
||||||
|
"name": "usage_section",
|
||||||
|
"passed": True,
|
||||||
|
"message": f"print_help() in {filename} includes a Usage section",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": "usage_section",
|
||||||
|
"passed": False,
|
||||||
|
"message": "print_help() must include a Usage section",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_examples_section(help_func: Optional[ast.FunctionDef], filename: str) -> Dict:
|
||||||
|
"""Check 7: print_help contains an Examples section."""
|
||||||
|
if help_func is None:
|
||||||
|
return {
|
||||||
|
"name": "examples_section",
|
||||||
|
"passed": False,
|
||||||
|
"message": "print_help() must include an Examples section",
|
||||||
|
}
|
||||||
|
|
||||||
|
strings = _collect_string_constants(help_func)
|
||||||
|
for s in strings:
|
||||||
|
if "example" in s.lower():
|
||||||
|
return {
|
||||||
|
"name": "examples_section",
|
||||||
|
"passed": True,
|
||||||
|
"message": f"print_help() in {filename} includes an Examples section",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": "examples_section",
|
||||||
|
"passed": False,
|
||||||
|
"message": "print_help() must include an Examples section",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_no_internal_modules(module_path: str, path: Path) -> Dict:
|
||||||
|
"""
|
||||||
|
Check 8: modules/ directory does not expose internal plumbing files.
|
||||||
|
|
||||||
|
Files whose stems end with _wire, _fix, _impl, or _internal should be
|
||||||
|
underscore-prefixed to hide from discovery.
|
||||||
|
"""
|
||||||
|
internal_suffixes = ("_wire", "_fix", "_impl", "_internal")
|
||||||
|
|
||||||
|
# Derive the branch root from the entry point path:
|
||||||
|
# entry point is at {branch_root}/apps/{name}.py -> parent.parent is branch_root
|
||||||
|
branch_root = path.parent.parent
|
||||||
|
modules_dir = branch_root / "apps" / "modules"
|
||||||
|
|
||||||
|
if not modules_dir.is_dir():
|
||||||
|
return {
|
||||||
|
"name": "no_internal_modules",
|
||||||
|
"passed": True,
|
||||||
|
"message": "No modules/ directory found (nothing to check)",
|
||||||
|
}
|
||||||
|
|
||||||
|
exposed_internal: List[str] = []
|
||||||
|
for py_file in modules_dir.glob("*.py"):
|
||||||
|
stem = py_file.stem
|
||||||
|
# Skip __init__.py and already-underscore-prefixed files
|
||||||
|
if stem.startswith("_"):
|
||||||
|
continue
|
||||||
|
if any(stem.endswith(suffix) for suffix in internal_suffixes):
|
||||||
|
exposed_internal.append(py_file.name)
|
||||||
|
|
||||||
|
if exposed_internal:
|
||||||
|
names = ", ".join(sorted(exposed_internal))
|
||||||
|
return {
|
||||||
|
"name": "no_internal_modules",
|
||||||
|
"passed": False,
|
||||||
|
"message": (
|
||||||
|
f"modules/ directory exposes internal plumbing: {names} "
|
||||||
|
f"— prefix with underscore or use COMMAND attribute"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": "no_internal_modules",
|
||||||
|
"passed": True,
|
||||||
|
"message": "No exposed internal plumbing files in modules/",
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: cli_ux_content.py
|
||||||
|
# Description: CLI UX Standards Content Handler
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-17
|
||||||
|
# Modified: 2026-07-17
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
CLI UX Standards Content Handler
|
||||||
|
|
||||||
|
Provides formatted CLI UX standards content.
|
||||||
|
Module orchestrates, handler implements.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from aipass.seedgo.apps.handlers.json import json_handler
|
||||||
|
|
||||||
|
|
||||||
|
def get_cli_ux_standards() -> str:
|
||||||
|
"""Return formatted CLI UX standards content with Rich markup.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted standards text with Rich styling
|
||||||
|
"""
|
||||||
|
lines = [
|
||||||
|
"[bold red]CLI UX STANDARD[/bold red]",
|
||||||
|
"",
|
||||||
|
"[bold cyan]CORE RULE:[/bold cyan] Entry points must follow the AIPass house pattern",
|
||||||
|
"",
|
||||||
|
"[yellow]RULE:[/yellow] Every branch entry point (apps/{branch}.py) must provide",
|
||||||
|
" a two-tier help surface: print_introspection() for bare invocation and",
|
||||||
|
" print_help() for --help, both using Rich console.print() from aipass.cli.",
|
||||||
|
"",
|
||||||
|
"=" * 70,
|
||||||
|
"",
|
||||||
|
"[bold cyan]THE HOUSE PATTERN:[/bold cyan]",
|
||||||
|
"",
|
||||||
|
" [green]1.[/green] [bold]Two-tier help[/bold] -- print_introspection() (light) + print_help() (full)",
|
||||||
|
" [green]2.[/green] [bold]Rich console[/bold] -- console.print() with markup, never bare print()",
|
||||||
|
" [green]3.[/green] [bold]Title + purpose[/bold] -- styled title line + dim tagline in introspection",
|
||||||
|
" [green]4.[/green] [bold]Closing pointer[/bold] -- introspection ends with --help reference",
|
||||||
|
" [green]5.[/green] [bold]Usage + examples[/bold] -- print_help() includes USAGE and EXAMPLES sections",
|
||||||
|
" [green]6.[/green] [bold]No internal leaks[/bold] -- modules/ must not expose internal plumbing",
|
||||||
|
"",
|
||||||
|
"=" * 70,
|
||||||
|
"",
|
||||||
|
"[bold cyan]WHAT IS CHECKED:[/bold cyan]",
|
||||||
|
"",
|
||||||
|
" [yellow]Scope:[/yellow] entry_point -- only apps/{branch}.py files",
|
||||||
|
"",
|
||||||
|
" [yellow]Check 1:[/yellow] two_tier_help",
|
||||||
|
" Both print_introspection() and print_help() defined as top-level functions",
|
||||||
|
"",
|
||||||
|
" [yellow]Check 2:[/yellow] rich_console",
|
||||||
|
" Help functions use console.print(), no bare print() calls",
|
||||||
|
"",
|
||||||
|
" [yellow]Check 3:[/yellow] title_markup",
|
||||||
|
" print_introspection() contains a [bold]-styled title line",
|
||||||
|
"",
|
||||||
|
" [yellow]Check 4:[/yellow] purpose_line",
|
||||||
|
" print_introspection() contains a [dim]-styled purpose/tagline",
|
||||||
|
"",
|
||||||
|
" [yellow]Check 5:[/yellow] help_pointer",
|
||||||
|
" print_introspection() references --help for more info",
|
||||||
|
"",
|
||||||
|
" [yellow]Check 6:[/yellow] usage_section",
|
||||||
|
" print_help() contains a Usage section",
|
||||||
|
"",
|
||||||
|
" [yellow]Check 7:[/yellow] examples_section",
|
||||||
|
" print_help() contains an Examples section",
|
||||||
|
"",
|
||||||
|
" [yellow]Check 8:[/yellow] no_internal_modules",
|
||||||
|
" modules/ directory does not expose internal plumbing names",
|
||||||
|
"",
|
||||||
|
"=" * 70,
|
||||||
|
"",
|
||||||
|
"[bold cyan]GOOD EXAMPLE (flow.py introspection):[/bold cyan]",
|
||||||
|
"",
|
||||||
|
' console.print("[bold cyan]Flow - PLAN Management System[/bold cyan]")',
|
||||||
|
' console.print("[dim]Task orchestration and workflow management[/dim]")',
|
||||||
|
" # ... module list ...",
|
||||||
|
" console.print(\"[dim]Run 'drone @flow --help' for usage information[/dim]\")",
|
||||||
|
"",
|
||||||
|
"[bold cyan]BAD EXAMPLE (aipass.py -- fails all checks):[/bold cyan]",
|
||||||
|
"",
|
||||||
|
' print(f"AIPASS - {len(modules)} modules discovered") # bare print',
|
||||||
|
" # no print_introspection/print_help, no title/purpose, no pointer",
|
||||||
|
"",
|
||||||
|
"=" * 70,
|
||||||
|
"",
|
||||||
|
"[bold cyan]HOW TO FIX:[/bold cyan]",
|
||||||
|
"",
|
||||||
|
" 1. Define print_introspection() with: styled title, dim purpose, module list, --help pointer",
|
||||||
|
" 2. Define print_help() with: USAGE section, command list, EXAMPLES section",
|
||||||
|
" 3. Replace all bare print() with console.print() from aipass.cli",
|
||||||
|
" 4. Prefix internal modules with underscore or set COMMAND attribute",
|
||||||
|
"",
|
||||||
|
"=" * 70,
|
||||||
|
"",
|
||||||
|
"[bold cyan]SCORING:[/bold cyan]",
|
||||||
|
" Score = (passed_checks / 8) * 100",
|
||||||
|
" Pass requires all 8 checks green",
|
||||||
|
"",
|
||||||
|
"[bold cyan]REFERENCE:[/bold cyan]",
|
||||||
|
" [dim]See: seedgo standards pack (cli_ux)[/dim]",
|
||||||
|
" [dim]Compare: drone @flow (good) vs aipass --help (bad)[/dim]",
|
||||||
|
]
|
||||||
|
|
||||||
|
json_handler.log_operation("standard_content_queried", {"standard": "cli_ux"})
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -34,6 +34,9 @@ _DOCTEST_RE = re.compile(r"^\s*(\.\.\.|>>>)\s")
|
|||||||
# Test file name patterns
|
# Test file name patterns
|
||||||
_TEST_FILE_RE = re.compile(r"^(test_.+|.+_test|conftest)\.py$")
|
_TEST_FILE_RE = re.compile(r"^(test_.+|.+_test|conftest)\.py$")
|
||||||
|
|
||||||
|
# String literals — strip content before regex to avoid false positives on print( inside strings
|
||||||
|
_STRING_LITERAL_RE = re.compile(r'""".*?"""|\'\'\'.*?\'\'\'|"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'')
|
||||||
|
|
||||||
|
|
||||||
def _is_in_main_block(lines: list[str], lineno: int) -> bool:
|
def _is_in_main_block(lines: list[str], lineno: int) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -108,8 +111,9 @@ def _scan_file(file_path: Path) -> tuple[list[int], str | None]:
|
|||||||
if _DOCTEST_RE.match(line):
|
if _DOCTEST_RE.match(line):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Strip inline comments before checking for print(
|
# Strip inline comments and string literal contents before checking
|
||||||
code_part = line.split("#")[0]
|
code_part = line.split("#")[0]
|
||||||
|
code_part = _STRING_LITERAL_RE.sub('""', code_part)
|
||||||
|
|
||||||
if not _PRINT_RE.search(code_part):
|
if not _PRINT_RE.search(code_part):
|
||||||
continue
|
continue
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user