Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e1faab141 | ||
|
|
326c08fe54 | ||
|
|
c57bca2272 | ||
|
|
da7a33481b | ||
|
|
658c5bc2a5 | ||
|
|
28a6680f02 | ||
|
|
b2d7e1cf6a | ||
|
|
a3d29c81b1 | ||
|
|
1d1ed0a0e6 | ||
|
|
2f192d45db | ||
|
|
8bd0b5d814 | ||
|
|
7b998d1596 | ||
|
|
c51f6b7e4a | ||
|
|
ad4a99b4fe | ||
|
|
bd46c673d8 | ||
|
|
d0ce0cc6dc | ||
|
|
d4f2ef1555 | ||
|
|
8ef543b9fc | ||
|
|
6c3e965414 | ||
|
|
7dc84c3e31 | ||
|
|
dd52c8aad8 | ||
|
|
109063c705 | ||
|
|
f9078ec0ce | ||
|
|
7c192c117e | ||
|
|
38f5cd9c87 | ||
|
|
f07da797dc | ||
|
|
7d8493e3fa | ||
|
|
7223dc0455 | ||
|
|
3b920f8ad4 | ||
|
|
db6cf3d754 | ||
|
|
1a7c8b8434 | ||
|
|
7d598e22cf | ||
|
|
5114986c31 | ||
|
|
672c926980 | ||
|
|
b971d2161e | ||
|
|
8da48ddd46 | ||
|
|
79ad4d2803 | ||
|
|
db3dcc6c05 | ||
|
|
3d1d820e26 | ||
|
|
0cf25039e2 | ||
|
|
c95f5ef9ef | ||
|
|
1f03202ffd | ||
|
|
360327f336 | ||
|
|
f21278af91 | ||
|
|
bff9d742f5 | ||
|
|
9281efeacc | ||
|
|
ee2716e63f | ||
|
|
352845a5aa | ||
|
|
6756b60d80 | ||
|
|
398cb0d37e | ||
|
|
1058fb7c90 | ||
|
|
71c177c51c | ||
|
|
865af0cbde | ||
|
|
67bfb18888 | ||
|
|
2a9bb4944c | ||
|
|
2387c650d8 | ||
|
|
7dbe5957ef | ||
|
|
f7678581ee | ||
|
|
57e8ce4e9b | ||
|
|
6fd57fed04 | ||
|
|
c94e231e84 | ||
|
|
ac5452ba20 | ||
|
|
8201b6a851 | ||
|
|
1ee7b45483 | ||
|
|
949eeb375c | ||
|
|
b36185e21f | ||
|
|
4a6d60ccc5 | ||
|
|
db55b6b63a | ||
|
|
a2d84a78da | ||
|
|
26c83b3f07 | ||
|
|
a7214ed4ca | ||
|
|
0840dfe778 | ||
|
|
607924c755 | ||
|
|
6839b1048a | ||
|
|
249b48e98b | ||
|
|
1d02f412f6 | ||
|
|
c7bc27b7b8 | ||
|
|
a530c83a63 | ||
|
|
dd39cb6835 | ||
|
|
5348ae6d81 | ||
|
|
ccafca881d | ||
|
|
d9b7c36a80 | ||
|
|
5b398acff4 | ||
|
|
9a71137dda | ||
|
|
44bab11040 | ||
|
|
33c7643a95 | ||
|
|
fbe5605d0d | ||
|
|
84168ff872 | ||
|
|
d2f820d982 | ||
|
|
d75735c926 | ||
|
|
80699f0e95 | ||
|
|
b10b64750b | ||
|
|
78b7fb6ac7 | ||
|
|
477e3a58ef | ||
|
|
cd14807845 | ||
|
|
9e8cd235c2 | ||
|
|
e8d295fadc | ||
|
|
ccd8472668 | ||
|
|
3405c7ee41 | ||
|
|
4fd5bdec7f | ||
|
|
05608a8b64 | ||
|
|
92da1c07a2 | ||
|
|
c4008144c7 | ||
|
|
f60725dfc4 | ||
|
|
2d1a712f20 | ||
|
|
321ae989c0 | ||
|
|
d11bc94ade | ||
|
|
2f1ede44bb | ||
|
|
166b6013c0 | ||
|
|
ea7d0efcbf | ||
|
|
930400ee38 | ||
|
|
5fda6e9b8f | ||
|
|
0b73263fc6 | ||
|
|
63ab0005bb | ||
|
|
50a3827c8f | ||
|
|
1c063bf236 | ||
|
|
ec406f4aa4 | ||
|
|
41623391fa | ||
|
|
2a1d509d20 | ||
|
|
9fbbcbd834 | ||
|
|
1c50b1454a | ||
|
|
a1ada18d86 | ||
|
|
d0a73f91b5 | ||
|
|
3cc822e9d3 | ||
|
|
b6be4a9c68 | ||
|
|
d84e56344c |
@@ -13,6 +13,9 @@ For any branch's full detail, run `drone @branch --help`.
|
||||
- Agent (citizen) — the persistent identity that lives in a branch. Has a passport (`.trinity/`), memories, mailbox. Irreplaceable. Addressable as `@name` via drone. Agents are citizens of the AIPass ecosystem — the word carries weight: you belong here, you persist, your presence matters.
|
||||
- Sub-agent — a disposable worker spawned for a task. No passport, no memory, not a citizen. Does the job and goes away.
|
||||
- Registry — `AIPASS_REGISTRY.json` tracks all agents (citizens) in a project.
|
||||
- Provider settings — `~/.claude/settings.json`. The user's machine-wide Claude Code config. Per machine, not in any repo. Personal preferences only (model, voice, theme). We don't touch it.
|
||||
- Project settings — `<project>/.claude/settings.json`. Ships with the clone. Hooks, permissions, deny/ask rules, env vars. Everything an AIPass project needs to work. Built by `aipass init`.
|
||||
- Project local settings — `<project>/.claude/settings.local.json`. Also ships with the clone. Project-specific overrides. Users get our full setup the moment they clone — no extra configuration needed.
|
||||
|
||||
Agents live in branches. Sub-agents work for agents. If you have a `.trinity/passport.json`, you're an agent — a citizen — not just a sub-agent.
|
||||
|
||||
@@ -74,12 +77,16 @@ Allowed:
|
||||
- `drone @git merge <PR#>` — squash-merge a reviewed PR (devpulse only)
|
||||
- `drone @git smart-sync` — fetch + rebase (devpulse only)
|
||||
- `drone @git fix` — repair broken git states (devpulse only)
|
||||
- `git status`, `git diff`, `git log` — read-only, always fine
|
||||
- `git status`, `git diff`, `git log`, `git branch` (list), `git tag` (list), `git remote` (list/show) — read-only, always fine
|
||||
|
||||
Forbidden (denied system-wide in `.claude/settings.json`):
|
||||
- `git checkout*` — any form, including `-b`, `-`, branch names
|
||||
- `git add -f*` / `--force*`
|
||||
- Culturally avoid `git commit`, `git push`, `gh pr create` directly — go through drone
|
||||
Mechanically blocked by the `git_gate.py` PreToolUse hook (applies to ALL sessions including dispatched agents — bypassPermissions does not skip hooks):
|
||||
- All raw `git` write verbs: `commit`, `push`, `pull`, `merge`, `rebase`, `reset`, `checkout`, `switch`, `cherry-pick`, `revert`, `rm`, `mv`, `restore`, `clean`, `config`, `stash drop|clear|pop|apply`
|
||||
- Destructive `git branch` flags only (`-d`, `-D`, `-m`, `-M`, `--delete`, `--move`, `--set-upstream-to`, `--unset-upstream`). Read-only branch listing is allowed.
|
||||
- Destructive `git tag` flags only (`-d`, `--delete`, `-f`, `--force`). Tag listing is allowed.
|
||||
- Destructive `git remote` subcommands (`add`, `remove`, `rename`, `set-url`, `prune`). Remote listing/show is allowed.
|
||||
- All raw `gh` write subcommands (`pr`, `issue`, `repo`, `release`, `workflow`, `run`, `cache`, `secret`, `variable`, `gist`) and any `gh api` call. Exception: project owners with `citizenship.owner: true` in their passport bypass gh blocking.
|
||||
- Edits to `**/.claude/settings*.json`, `**/.claude/hooks/**`, `**/.git/hooks/**` (the enforcement layer itself)
|
||||
- Use `drone @git pr "msg"` instead. Drone calls git via Python subprocess so its operations don't pass through this hook.
|
||||
|
||||
If `drone @git system-pr` fails to return HEAD to main, that's a drone bug — report it, don't work around it by staying on a branch.
|
||||
|
||||
@@ -197,9 +204,9 @@ Always work on main. Edit files in your branch directory on the main branch. Whe
|
||||
|
||||
**Blocked system-wide via `.claude/settings.json` permission gate:** `git checkout*` (any form — switch, discard, new branch), `git add -f*`, `git add --force*`. These are denied for every agent including devpulse. Use `drone @git sync` to switch to main, `drone @git fix` to recover from broken states.
|
||||
|
||||
**Culturally blocked (no permission gate yet, still don't use):** `git commit`, `git push`, `gh pr create`. Go through drone.
|
||||
**Mechanically blocked via `.git/hooks/pre-commit`:** `git commit` is rejected on any branch except main (also catches detached HEAD). `git push`, `gh pr create` — go through drone.
|
||||
|
||||
**Allowed read-only:** `git status`, `git diff`, `git log`, `git stash` (safe transient save).
|
||||
**Allowed read-only:** `git status`, `git diff`, `git log`, `git branch` (list), `git tag` (list), `git remote` (list/show), `git stash` (safe transient save).
|
||||
|
||||
**If `drone @git pr` fails because the PR lock is held**, wait 30 seconds and retry. Keep retrying until the lock clears — do not skip the PR step, do not commit directly to main, do not give up. The lock means another agent is mid-PR; it will release shortly. `drone @git lock` shows the current lock state.
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# AIPass Hook System
|
||||
|
||||
Provider-level hooks for the AIPass ecosystem. These fire for every Claude Code
|
||||
session on this machine via `~/.claude/settings.json`.
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
.claude/hooks/
|
||||
├── README.md # This file
|
||||
│
|
||||
│ ── Hooks (wired in ~/.claude/settings.json) ──
|
||||
├── global_prompt_loader.py # UserPromptSubmit — AIPass global prompt (~22KB)
|
||||
├── branch_prompt_loader.py # UserPromptSubmit — branch-specific prompt
|
||||
├── identity_injector.py # UserPromptSubmit — branch identity from passport
|
||||
├── email_notification.py # UserPromptSubmit — unread email count
|
||||
├── tool_use_sound.py # PreToolUse — key-press sound on tool calls
|
||||
├── git_gate.py # PreToolUse — blocks raw git/gh, protects settings
|
||||
├── auto_fix_diagnostics.py # PostToolUse — pyright + ruff on edited files
|
||||
├── subagent_stop_gate.py # SubagentStop — seedgo checklist on modified files
|
||||
├── pre_compact.py # PreCompact — post-compact recovery context
|
||||
├── stop_sound.py # Stop — achievement bell
|
||||
├── notification_sound.py # Notification — notification sound
|
||||
│
|
||||
│ ── Also wired but lives in ~/.claude/hooks/ ──
|
||||
│ pre_edit_gate.py # PreToolUse — cross-branch write block, error-fix gate
|
||||
│ auto_watchdog.py # PostToolUse — watchdog reminder after dispatch
|
||||
│
|
||||
│ ── Testing & debugging tools ──
|
||||
├── hook_log.py # Shared logger — every hook calls run_and_log()
|
||||
├── hook_report.py # Report tool — reads JSONL log, shows table
|
||||
├── hook_test.py # Test harness — 20 tests (11 direct + 9 integration)
|
||||
│
|
||||
│ ── Legacy probes ──
|
||||
└── probes/
|
||||
├── README.md
|
||||
└── probe_*.py # Opt-in per-event diagnostic hooks
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Hooks fire from three levels (can fire simultaneously):
|
||||
|
||||
| Level | Settings file | When it fires |
|
||||
|-------|--------------|---------------|
|
||||
| **Provider** | `~/.claude/settings.json` | Every session, everywhere |
|
||||
| **Project** | `<project>/.claude/settings.json` | When CWD is inside the project |
|
||||
| **Branch** | deeper `.claude/settings.json` | When CWD is inside that branch |
|
||||
|
||||
**Critical limitation:** PreToolUse and PostToolUse ONLY fire from provider settings.
|
||||
UserPromptSubmit fires from ALL levels. This means project-level PreToolUse/PostToolUse
|
||||
hooks provisioned by `aipass init` are dead weight — they never execute.
|
||||
|
||||
## CWD Guards
|
||||
|
||||
Four UserPromptSubmit hooks have CWD-aware guards. When CWD is inside a project that
|
||||
has its own UserPromptSubmit hooks, the provider hook exits silently — preventing
|
||||
AIPass context from bleeding into standalone projects.
|
||||
|
||||
Guarded: `global_prompt_loader.py`, `branch_prompt_loader.py`,
|
||||
`identity_injector.py`, `email_notification.py`.
|
||||
|
||||
## Hook Inventory
|
||||
|
||||
### UserPromptSubmit (provider, CWD-guarded)
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `global_prompt_loader.py` | Injects AIPass global prompt (~22KB) |
|
||||
| `branch_prompt_loader.py` | Injects branch-specific prompt from `.aipass/aipass_local_prompt.md` |
|
||||
| `identity_injector.py` | Injects branch identity from `.trinity/passport.json` |
|
||||
| `email_notification.py` | Shows unread email count from `.ai_mail.local/inbox.json` |
|
||||
|
||||
### PreToolUse (provider only)
|
||||
| Script | Matcher | Purpose |
|
||||
|--------|---------|---------|
|
||||
| `tool_use_sound.py` | Bash\|Edit\|Write\|Read\|... | Plays key-press sound |
|
||||
| `pre_edit_gate.py` | Edit\|Write\|NotebookEdit | Cross-branch write block + error-fix gate |
|
||||
| `git_gate.py` | Bash\|Edit\|Write\|NotebookEdit | Blocks raw git/gh, protects settings files |
|
||||
|
||||
### PostToolUse (provider only)
|
||||
| Script | Matcher | Purpose |
|
||||
|--------|---------|---------|
|
||||
| `auto_fix_diagnostics.py` | Edit\|Write\|NotebookEdit | Runs pyright + ruff on edited files |
|
||||
| `auto_watchdog.py` | Bash | Reminds agent to arm watchdog after dispatch |
|
||||
|
||||
### Other events (provider)
|
||||
| Script | Event | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `subagent_stop_gate.py` | SubagentStop | Runs seedgo checklist on subagent-modified files + hook README reminder |
|
||||
| `pre_compact.py` | PreCompact | Injects post-compact recovery context |
|
||||
| `stop_sound.py` | Stop | Plays achievement bell |
|
||||
| `notification_sound.py` | Notification | Plays notification sound |
|
||||
|
||||
## Testing
|
||||
|
||||
### Execution log (always-on)
|
||||
Every instrumented hook writes one JSONL line to `/tmp/aipass_hook_log.jsonl` via
|
||||
`hook_log.py`. Each entry: timestamp, event, source, script, CWD, session, timing,
|
||||
output_bytes, exit_code.
|
||||
|
||||
### Report tool
|
||||
```bash
|
||||
python3 .claude/hooks/hook_report.py # Last 5 minutes
|
||||
python3 .claude/hooks/hook_report.py --all # All entries
|
||||
python3 .claude/hooks/hook_report.py --cwd /tmp # Filter by CWD
|
||||
python3 .claude/hooks/hook_report.py --json # Machine-readable
|
||||
python3 .claude/hooks/hook_report.py --clear # Wipe log
|
||||
```
|
||||
|
||||
### Test harness (20 tests)
|
||||
```bash
|
||||
python3 .claude/hooks/hook_test.py # All 20 tests
|
||||
python3 .claude/hooks/hook_test.py --direct # 11 direct tests only (fast, ~3s)
|
||||
python3 .claude/hooks/hook_test.py --integration # 9 integration tests only (~2min)
|
||||
python3 .claude/hooks/hook_test.py --verbose # Show detail per test
|
||||
python3 .claude/hooks/hook_test.py --list # List available tests
|
||||
python3 .claude/hooks/hook_test.py --test <name> # Run one test
|
||||
```
|
||||
|
||||
**Direct tests** (11) pipe JSON to hook scripts via subprocess. Deterministic,
|
||||
no model, HIGH confidence. Tests CWD guards, git_gate block/allow, settings schema,
|
||||
project-level guards.
|
||||
|
||||
**Integration tests** (9) run `claude -p` from different CWDs and read the JSONL log.
|
||||
Tests full pipeline including cross-project behavior, subagent hooks, and the
|
||||
`disableAllHooks` toggle.
|
||||
|
||||
### Disable all hooks
|
||||
Add `"disableAllHooks": true` to `~/.claude/settings.json`. Remove to re-enable.
|
||||
|
||||
### Debug mode
|
||||
```bash
|
||||
claude --debug hooks --debug-file /tmp/debug.log
|
||||
```
|
||||
|
||||
### Interactive inspection
|
||||
Type `/hooks` inside a Claude session — shows all hooks with source labels
|
||||
(`[User]`, `[Project]`, `[Local]`).
|
||||
|
||||
## Related
|
||||
- **DPLAN-0167** — Hook testing framework
|
||||
- **DPLAN-0166** — Hook audit + CI health
|
||||
- **DPLAN-0139** — Hook overhaul + single-path enforcement
|
||||
- **DPLAN-0131** — Hook system alignment (seedgo ownership)
|
||||
@@ -37,35 +37,32 @@ SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"}
|
||||
|
||||
# AIPass-specific Python patterns to check
|
||||
PYTHON_PATTERNS = {
|
||||
"bad_optional": {
|
||||
"pattern": ": str = None",
|
||||
"message": "Optional param should use 'str | None = None' pattern"
|
||||
},
|
||||
"bad_optional": {"pattern": ": str = None", "message": "Optional param should use 'str | None = None' pattern"},
|
||||
"logger_debug": {
|
||||
"pattern": "logger.debug(",
|
||||
"message": "Use logger.info for SystemLogger (logger.debug not supported)"
|
||||
"message": "Use logger.info for SystemLogger (logger.debug not supported)",
|
||||
},
|
||||
"return_error_msg": {
|
||||
"pattern": "return error_msg",
|
||||
"message": "Return None for error states, not error_msg string"
|
||||
"message": "Return None for error states, not error_msg string",
|
||||
},
|
||||
"open_no_encoding": {
|
||||
"pattern": "open(",
|
||||
"requires_missing": "encoding=",
|
||||
"message": "open() without encoding='utf-8'"
|
||||
"message": "open() without encoding='utf-8'",
|
||||
},
|
||||
"log_not_log_operation": {
|
||||
"pattern": ".log(",
|
||||
"message": "Use log_operation() with success/error params, not .log()"
|
||||
"message": "Use log_operation() with success/error params, not .log()",
|
||||
},
|
||||
"dict_none_no_check": {
|
||||
"pattern": "Dict | None",
|
||||
"message": "Dict | None return: Add None check before using (if result is None: return)"
|
||||
}
|
||||
"message": "Dict | None return: Add None check before using (if result is None: return)",
|
||||
},
|
||||
}
|
||||
|
||||
# JSON-specific patterns for emoji corruption
|
||||
JSON_CORRUPTION_CHARS = ['\ufffd', '\x00']
|
||||
JSON_CORRUPTION_CHARS = ["\ufffd", "\x00"]
|
||||
|
||||
|
||||
def run_python_checks(file_path: str) -> list[str]:
|
||||
@@ -75,10 +72,7 @@ def run_python_checks(file_path: str) -> list[str]:
|
||||
# 1. Syntax check with py_compile
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "py_compile", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
[sys.executable, "-m", "py_compile", file_path], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
errors.append(f"SYNTAX: {result.stderr.strip()}")
|
||||
@@ -91,7 +85,7 @@ def run_python_checks(file_path: str) -> list[str]:
|
||||
["ruff", "check", "--select=E,F,W", "--output-format=text", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
timeout=10,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
for line in result.stdout.strip().split("\n")[:5]:
|
||||
@@ -103,12 +97,7 @@ def run_python_checks(file_path: str) -> list[str]:
|
||||
|
||||
# 3. Ruff format check — detect format drift
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ruff", "format", "--check", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
result = subprocess.run(["ruff", "format", "--check", file_path], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
errors.append(f"FORMAT: {Path(file_path).name} needs ruff format (run: ruff format {Path(file_path).name})")
|
||||
except FileNotFoundError:
|
||||
@@ -146,19 +135,20 @@ def run_python_checks(file_path: str) -> list[str]:
|
||||
return errors
|
||||
|
||||
|
||||
|
||||
def run_ruff_lint_structured(file_path: str) -> list[dict]:
|
||||
"""Run ruff check and return structured violations for the state file.
|
||||
|
||||
Returns list of {line, message} dicts — same format as pyright errors.
|
||||
Only non-empty when ruff finds real violations (not format drift).
|
||||
"""
|
||||
if '/.claude/hooks/' in file_path:
|
||||
if "/.claude/hooks/" in file_path:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ruff", "check", "--select=E,F,W", "--output-format=json", file_path],
|
||||
capture_output=True, text=True, timeout=10
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if not result.stdout.strip():
|
||||
return []
|
||||
@@ -179,15 +169,12 @@ def run_ruff_lint_structured(file_path: str) -> list[dict]:
|
||||
def run_pyright_check(file_path: str) -> list[dict]:
|
||||
"""Run pyright on a single file. Returns list of error dicts."""
|
||||
# Skip hook files - they don't follow project standards
|
||||
if '/.claude/hooks/' in file_path:
|
||||
if "/.claude/hooks/" in file_path:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pyright", "--outputjson", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
[sys.executable, "-m", "pyright", "--outputjson", file_path], capture_output=True, text=True, timeout=15
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -201,10 +188,7 @@ def run_pyright_check(file_path: str) -> list[dict]:
|
||||
if severity == "error":
|
||||
line = diag.get("range", {}).get("start", {}).get("line", 0)
|
||||
message = diag.get("message", "Unknown error")
|
||||
errors.append({
|
||||
"line": line,
|
||||
"message": message[:100]
|
||||
})
|
||||
errors.append({"line": line, "message": message[:100]})
|
||||
|
||||
return errors[:10] # Max 10 errors
|
||||
|
||||
@@ -220,10 +204,7 @@ def save_diagnostics_state(file_path: str, errors: list[dict]):
|
||||
"""Save type errors to state file for PreToolUse gate."""
|
||||
try:
|
||||
if errors:
|
||||
state = {
|
||||
"file": str(Path(file_path).resolve()),
|
||||
"errors": errors
|
||||
}
|
||||
state = {"file": str(Path(file_path).resolve()), "errors": errors}
|
||||
STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
|
||||
else:
|
||||
# No errors — clear the state
|
||||
@@ -249,11 +230,11 @@ def run_json_checks(file_path: str) -> list[str]:
|
||||
data = json.loads(content)
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key in ['allowed_emojis', 'emojis', 'emoji_list']:
|
||||
for key in ["allowed_emojis", "emojis", "emoji_list"]:
|
||||
if key in data and isinstance(data[key], list):
|
||||
for item in data[key]:
|
||||
if isinstance(item, str) and len(item) == 1:
|
||||
if ord(item) < 128 and item not in '\u2713\u2717':
|
||||
if ord(item) < 128 and item not in "\u2713\u2717":
|
||||
errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}")
|
||||
break
|
||||
|
||||
@@ -268,7 +249,7 @@ def run_json_checks(file_path: str) -> list[str]:
|
||||
|
||||
def run_seedgo_checklist(file_path: str) -> list[str]:
|
||||
"""Run seedgo standards checklist — returns violations only."""
|
||||
if '/.claude/hooks/' in file_path:
|
||||
if "/.claude/hooks/" in file_path:
|
||||
return []
|
||||
|
||||
try:
|
||||
@@ -277,7 +258,7 @@ def run_seedgo_checklist(file_path: str) -> list[str]:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
cwd=str(Path.home() / "Projects" / "AIPass")
|
||||
cwd=str(Path.home() / "Projects" / "AIPass"),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
@@ -318,9 +299,30 @@ def is_same_file_as_last(file_path: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _project_has_own_posttooluse_hooks() -> bool:
|
||||
"""Check if CWD is inside a project with its own PostToolUse hooks."""
|
||||
search = Path.cwd()
|
||||
home = Path.home()
|
||||
while search != home and search.parent != search:
|
||||
settings = search / ".claude" / "settings.json"
|
||||
if settings.exists():
|
||||
try:
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
ptu = data.get("hooks", {}).get("PostToolUse", [])
|
||||
if ptu:
|
||||
return True
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
search = search.parent
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main hook entry point."""
|
||||
try:
|
||||
if _project_has_own_posttooluse_hooks():
|
||||
return
|
||||
|
||||
input_data = json.load(sys.stdin)
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
@@ -337,10 +339,8 @@ def main():
|
||||
|
||||
# Collect all errors
|
||||
errors = []
|
||||
file_type = ""
|
||||
|
||||
if file_path.endswith(".py"):
|
||||
file_type = "Python"
|
||||
errors = run_python_checks(file_path)
|
||||
|
||||
# Seedgo standards checklist
|
||||
@@ -358,7 +358,6 @@ def main():
|
||||
save_diagnostics_state(file_path, ruff_lint_errors + type_errors)
|
||||
|
||||
elif file_path.endswith(".json"):
|
||||
file_type = "JSON"
|
||||
errors = run_json_checks(file_path)
|
||||
else:
|
||||
return
|
||||
@@ -372,17 +371,12 @@ def main():
|
||||
Fix these errors in {Path(file_path).name} now. Do not skip or defer."""
|
||||
|
||||
output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PostToolUse",
|
||||
"additionalContext": context
|
||||
},
|
||||
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing"
|
||||
"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": context},
|
||||
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing",
|
||||
}
|
||||
print(json.dumps(output))
|
||||
else:
|
||||
output = {
|
||||
"systemMessage": "[diagnostics] ok"
|
||||
}
|
||||
output = {"systemMessage": "[diagnostics] ok"}
|
||||
print(json.dumps(output))
|
||||
|
||||
except Exception:
|
||||
@@ -390,4 +384,7 @@ Fix these errors in {Path(file_path).name} now. Do not skip or defer."""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("PostToolUse", "provider", __file__, main)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PostToolUse hook — reminds agent to arm watchdog after dispatch.
|
||||
|
||||
Fires after Bash commands containing 'drone @ai_mail dispatch'.
|
||||
Outputs additionalContext telling the agent to arm the watchdog.
|
||||
Skips if watchdog is already part of the same command.
|
||||
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Check if dispatch was run and remind to arm watchdog."""
|
||||
try:
|
||||
hook_input = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, EOFError):
|
||||
return
|
||||
|
||||
tool_name = hook_input.get("tool_name", "")
|
||||
tool_input = hook_input.get("tool_input", {})
|
||||
|
||||
if tool_name != "Bash":
|
||||
return
|
||||
|
||||
command = tool_input.get("command", "")
|
||||
|
||||
# Only trigger on dispatch commands
|
||||
if "drone @ai_mail dispatch" not in command:
|
||||
return
|
||||
|
||||
# Skip if watchdog is already in the same command
|
||||
if "unread_count" in command and "while [" in command:
|
||||
return
|
||||
|
||||
# Skip if it's just checking dispatch status (not sending)
|
||||
if "dispatch wake" in command and "dispatch @" not in command:
|
||||
return
|
||||
|
||||
result = {
|
||||
"additionalContext": (
|
||||
"[AUTO-WATCHDOG] Dispatch detected — arm watchdog NOW. "
|
||||
"Run the watchdog one-liner from your local prompt with "
|
||||
"run_in_background: true and timeout: 600000."
|
||||
)
|
||||
}
|
||||
json.dump(result, sys.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,11 +6,35 @@ Injects branch-specific prompts based on CWD. When working in a branch
|
||||
directory, loads .aipass/aipass_local_prompt.md and outputs it so the
|
||||
AI sees branch-specific context.
|
||||
|
||||
Version: 1.0.0
|
||||
When CWD is inside a project that has its own UserPromptSubmit hooks
|
||||
(e.g. a standalone aipass-init project), this provider-level hook exits
|
||||
silently to avoid double-firing.
|
||||
|
||||
Version: 1.1.0
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _project_has_own_hooks() -> bool:
|
||||
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
|
||||
search = Path.cwd()
|
||||
home = Path.home()
|
||||
while search != home and search.parent != search:
|
||||
settings = search / ".claude" / "settings.json"
|
||||
if settings.exists():
|
||||
try:
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
|
||||
if ups:
|
||||
return True
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
search = search.parent
|
||||
return False
|
||||
|
||||
|
||||
def find_branch_root() -> Path | None:
|
||||
"""
|
||||
Find the branch root directory.
|
||||
@@ -38,6 +62,9 @@ def find_branch_root() -> Path | None:
|
||||
|
||||
|
||||
def main():
|
||||
if _project_has_own_hooks():
|
||||
return
|
||||
|
||||
branch_root = find_branch_root()
|
||||
|
||||
if branch_root:
|
||||
@@ -49,4 +76,9 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("UserPromptSubmit", "provider", __file__, main)
|
||||
|
||||
@@ -5,12 +5,34 @@ Email Notification Hook - Notifies of new emails on prompt submit.
|
||||
Checks the current branch's inbox for unread emails and displays
|
||||
a notification if any exist.
|
||||
|
||||
Version: 1.0.0
|
||||
When CWD is inside a project that has its own UserPromptSubmit hooks,
|
||||
this provider-level hook exits silently to avoid double-firing.
|
||||
|
||||
Version: 1.1.0
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _project_has_own_hooks() -> bool:
|
||||
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
|
||||
search = Path.cwd()
|
||||
home = Path.home()
|
||||
while search != home and search.parent != search:
|
||||
settings = search / ".claude" / "settings.json"
|
||||
if settings.exists():
|
||||
try:
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
|
||||
if ups:
|
||||
return True
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
search = search.parent
|
||||
return False
|
||||
|
||||
|
||||
def find_repo_root() -> Path | None:
|
||||
"""Find the repo root (contains pyproject.toml or .git)."""
|
||||
search = Path.cwd()
|
||||
@@ -79,6 +101,9 @@ def count_new_emails(branch_root: Path) -> int:
|
||||
|
||||
|
||||
def main():
|
||||
if _project_has_own_hooks():
|
||||
return
|
||||
|
||||
branch_root = find_branch_root()
|
||||
if not branch_root:
|
||||
return
|
||||
@@ -86,8 +111,15 @@ def main():
|
||||
new_count = count_new_emails(branch_root)
|
||||
if new_count > 0:
|
||||
plural = "s" if new_count != 1 else ""
|
||||
print(f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>")
|
||||
print(
|
||||
f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("UserPromptSubmit", "provider", __file__, main)
|
||||
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PreToolUse Gate — blocks raw git/gh writes + edits to settings/hooks files.
|
||||
|
||||
Dispatched agents spawn with --permission-mode bypassPermissions, which skips
|
||||
all permissions.deny rules in every settings tier. PreToolUse hooks remain the
|
||||
only mechanical chokepoint that survives. This hook gates the dangerous
|
||||
shortcuts and redirects callers to drone.
|
||||
|
||||
Allows: read-only git/gh, all unrelated tool calls, devpulse-from-its-own-branch
|
||||
edits to the enforcement layer itself.
|
||||
Blocks: git write verbs, gh state-changing subcommands, edits to .claude
|
||||
settings.json / hooks/ and .git/hooks/.
|
||||
|
||||
DPLAN-0162.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BLOCKED_GIT_VERBS = (
|
||||
"commit",
|
||||
"push",
|
||||
"pull",
|
||||
"merge",
|
||||
"rebase",
|
||||
"reset",
|
||||
"checkout",
|
||||
"switch",
|
||||
"cherry-pick",
|
||||
"revert",
|
||||
"rm",
|
||||
"mv",
|
||||
"restore",
|
||||
"clean",
|
||||
"config",
|
||||
)
|
||||
|
||||
BLOCKED_GIT_RE = re.compile(
|
||||
r"(?<![@\w/.])git\s+(?:--?[A-Za-z][A-Za-z0-9_-]*(?:[= ][^\s]+)?\s+)*"
|
||||
r"(" + "|".join(BLOCKED_GIT_VERBS) + r")\b"
|
||||
)
|
||||
|
||||
BLOCKED_GIT_STASH_RE = re.compile(r"(?<![@\w/.])git\s+stash\s+(drop|clear|pop|apply)\b")
|
||||
|
||||
BLOCKED_GIT_BRANCH_RE = re.compile(
|
||||
r"(?<![@\w/.])git\s+branch\s+.*(-[dDmMcC]\b|--delete|--move|--copy|--force|--set-upstream-to|--unset-upstream)"
|
||||
)
|
||||
|
||||
BLOCKED_GIT_TAG_RE = re.compile(r"(?<![@\w/.])git\s+tag\s+.*(-d\b|--delete|--force|-f\b)")
|
||||
|
||||
BLOCKED_GIT_REMOTE_RE = re.compile(
|
||||
r"(?<![@\w/.])git\s+remote\s+(add|remove|rename|set-url|set-branches|set-head|prune)\b"
|
||||
)
|
||||
|
||||
BLOCKED_GH_API_RE = re.compile(r"(?<![@\w/.])gh\s+api\b")
|
||||
|
||||
BLOCKED_GH_RE = re.compile(
|
||||
r"(?<![@\w/.])gh\s+(pr|issue|repo|release|workflow|run|cache|secret|variable|gist)"
|
||||
r"\s+(?!list\b|view\b|status\b|diff\b|checks\b|comments\b)\w[\w-]*"
|
||||
)
|
||||
|
||||
BLOCKED_EDIT_PATTERNS = [
|
||||
re.compile(r"/\.claude/settings(\.local)?\.json$"),
|
||||
re.compile(r"/\.claude/hooks/"),
|
||||
re.compile(r"/\.git/hooks/"),
|
||||
]
|
||||
|
||||
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
||||
|
||||
# Branches trusted to edit the enforcement layer itself (mirrors pre_edit_gate).
|
||||
TRUSTED_HOOK_EDITORS = ("devpulse", "seedgo")
|
||||
|
||||
GIT_REDIRECT = (
|
||||
"Raw git write commands are blocked. Use drone instead:\n"
|
||||
' drone @git pr "description" # branch-scoped PR\n'
|
||||
' drone @git system-pr "description" # devpulse-only system PR\n'
|
||||
" drone @git smart-sync # fetch + rebase\n"
|
||||
" drone @git sync # checkout main + pull\n"
|
||||
" drone @git status # what changed\n"
|
||||
"Read-only git (status, log, diff, show, fetch, ls-files) is allowed."
|
||||
)
|
||||
|
||||
GH_REDIRECT = (
|
||||
"Raw gh write commands are blocked. Use drone for git ops:\n"
|
||||
' drone @git pr "description"\n'
|
||||
" drone @git merge <PR#> # devpulse only, on user request\n"
|
||||
"Read-only gh (list, view, status, diff, checks, comments) is allowed."
|
||||
)
|
||||
|
||||
EDIT_REDIRECT = (
|
||||
"{path} is protected — settings.json, .claude/hooks/, and .git/hooks/ "
|
||||
"govern the enforcement layer itself.\n"
|
||||
"If a real change is needed, ask devpulse to make it directly."
|
||||
)
|
||||
|
||||
|
||||
def _block(reason: str) -> None:
|
||||
print(json.dumps({"decision": "block", "reason": reason}))
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _cwd_branch(cwd: str) -> str:
|
||||
"""Extract AIPass branch name from CWD (src/aipass/{branch}/ pattern)."""
|
||||
parts = Path(cwd).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return ""
|
||||
|
||||
|
||||
def _is_project_owner(cwd: str) -> bool:
|
||||
"""Check if the current branch's passport has citizenship.owner: true."""
|
||||
p = Path(cwd)
|
||||
for d in [p] + list(p.parents):
|
||||
passport = d / ".trinity" / "passport.json"
|
||||
if passport.is_file():
|
||||
try:
|
||||
data = json.loads(passport.read_text(encoding="utf-8"))
|
||||
return bool(data.get("citizenship", {}).get("owner"))
|
||||
except Exception:
|
||||
return False
|
||||
if (d / ".git").exists():
|
||||
break
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
tool_name = data.get("tool_name", "")
|
||||
tool_input = data.get("tool_input", {})
|
||||
cwd = data.get("cwd") or os.getcwd()
|
||||
|
||||
if tool_name == "Bash":
|
||||
cmd = tool_input.get("command", "")
|
||||
if not cmd:
|
||||
return
|
||||
# Strip quoted strings before matching — text inside "..." or '...' is data
|
||||
# (PR descriptions, commit messages, examples in docs), not code to enforce.
|
||||
scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd)
|
||||
scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan)
|
||||
if (
|
||||
BLOCKED_GIT_RE.search(scan)
|
||||
or BLOCKED_GIT_STASH_RE.search(scan)
|
||||
or BLOCKED_GIT_BRANCH_RE.search(scan)
|
||||
or BLOCKED_GIT_TAG_RE.search(scan)
|
||||
or BLOCKED_GIT_REMOTE_RE.search(scan)
|
||||
):
|
||||
_block(GIT_REDIRECT)
|
||||
if BLOCKED_GH_API_RE.search(scan) or BLOCKED_GH_RE.search(scan):
|
||||
if not (_cwd_branch(cwd) in TRUSTED_HOOK_EDITORS or _is_project_owner(cwd)):
|
||||
_block(GH_REDIRECT)
|
||||
return
|
||||
|
||||
if tool_name in EDIT_TOOLS:
|
||||
file_path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
|
||||
if not file_path:
|
||||
return
|
||||
for pat in BLOCKED_EDIT_PATTERNS:
|
||||
if pat.search(file_path):
|
||||
# Trusted-editor bypass: devpulse working from its own branch
|
||||
# is the maintainer of the enforcement layer.
|
||||
if _cwd_branch(cwd) in TRUSTED_HOOK_EDITORS:
|
||||
return
|
||||
_block(EDIT_REDIRECT.format(path=file_path))
|
||||
return
|
||||
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("PreToolUse", "provider", __file__, main)
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Global Prompt Loader — replaces hardcoded `cat` of aipass_global_prompt.md.
|
||||
|
||||
Uses $AIPASS_HOME for path portability. Exits silently when CWD is inside
|
||||
a project that has its own UserPromptSubmit hooks (avoids injecting the
|
||||
22KB AIPass source-tree prompt into standalone projects).
|
||||
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _project_has_own_hooks() -> bool:
|
||||
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
|
||||
search = Path.cwd()
|
||||
home = Path.home()
|
||||
while search != home and search.parent != search:
|
||||
settings = search / ".claude" / "settings.json"
|
||||
if settings.exists():
|
||||
try:
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
|
||||
if ups:
|
||||
return True
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
search = search.parent
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if _project_has_own_hooks():
|
||||
return
|
||||
|
||||
aipass_home = os.environ.get("AIPASS_HOME", "")
|
||||
if not aipass_home:
|
||||
return
|
||||
|
||||
prompt_file = Path(aipass_home) / ".aipass" / "aipass_global_prompt.md"
|
||||
if prompt_file.exists():
|
||||
print(prompt_file.read_text(encoding="utf-8"), end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("UserPromptSubmit", "provider", __file__, main)
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared hook execution logger for AIPass.
|
||||
|
||||
Every hook imports this and calls log_fire() once. The log file is a JSONL
|
||||
append-only stream at /tmp/aipass_hook_log.jsonl — one line per hook invocation.
|
||||
|
||||
Usage in any hook script:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import log_fire
|
||||
|
||||
# At the end of main():
|
||||
log_fire("UserPromptSubmit", "provider", __file__, elapsed_ms=12.3, output_bytes=21400)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_LOG_FILE = Path("/tmp/aipass_hook_log.jsonl")
|
||||
_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def log_fire(
|
||||
event: str,
|
||||
source: str,
|
||||
script: str,
|
||||
*,
|
||||
elapsed_ms: float = 0.0,
|
||||
output_bytes: int = 0,
|
||||
exit_code: int = 0,
|
||||
tool: str = "",
|
||||
extra: dict | None = None,
|
||||
) -> None:
|
||||
"""Append one structured log entry. Never raises."""
|
||||
try:
|
||||
entry = {
|
||||
"ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"v": _VERSION,
|
||||
"event": event,
|
||||
"source": source,
|
||||
"script": Path(script).name,
|
||||
"script_path": str(script),
|
||||
"cwd": os.getcwd(),
|
||||
"session": os.environ.get("CLAUDE_CODE_SESSION_ID", ""),
|
||||
"tool": tool,
|
||||
"exit_code": exit_code,
|
||||
"elapsed_ms": round(elapsed_ms, 1),
|
||||
"output_bytes": output_bytes,
|
||||
}
|
||||
if extra:
|
||||
entry.update(extra)
|
||||
with open(_LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class HookTimer:
|
||||
"""Context manager for timing hook execution."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.start: float = 0.0
|
||||
self.elapsed_ms: float = 0.0
|
||||
|
||||
def __enter__(self) -> "HookTimer":
|
||||
self.start = time.monotonic()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.elapsed_ms = (time.monotonic() - self.start) * 1000.0
|
||||
|
||||
|
||||
def run_and_log(
|
||||
event: str,
|
||||
source: str,
|
||||
script: str,
|
||||
fn: "callable", # noqa: F821
|
||||
) -> None:
|
||||
"""Run a hook function, capture stdout, time it, log the result.
|
||||
|
||||
Usage in __main__ block (4 lines total):
|
||||
import sys
|
||||
sys.path.insert(0, str(__import__('pathlib').Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
run_and_log("UserPromptSubmit", "provider", __file__, main)
|
||||
"""
|
||||
import io
|
||||
import sys as _sys
|
||||
|
||||
buf = io.StringIO()
|
||||
orig = _sys.stdout
|
||||
_sys.stdout = buf
|
||||
|
||||
_exit_code = 0
|
||||
try:
|
||||
with HookTimer() as t:
|
||||
fn()
|
||||
except SystemExit as e:
|
||||
_exit_code = e.code if isinstance(e.code, int) else 0
|
||||
finally:
|
||||
_sys.stdout = orig
|
||||
|
||||
output = buf.getvalue()
|
||||
if output:
|
||||
print(output, end="")
|
||||
|
||||
log_fire(
|
||||
event,
|
||||
source,
|
||||
script,
|
||||
elapsed_ms=t.elapsed_ms,
|
||||
output_bytes=len(output.encode("utf-8")),
|
||||
exit_code=_exit_code,
|
||||
)
|
||||
_sys.exit(_exit_code)
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hook Execution Report — reads /tmp/aipass_hook_log.jsonl and shows what fired.
|
||||
|
||||
Usage:
|
||||
python3 hook_report.py # Last session (or last 5 min)
|
||||
python3 hook_report.py --all # All entries in log
|
||||
python3 hook_report.py --session ID # Specific session
|
||||
python3 hook_report.py --cwd /path # Filter by CWD
|
||||
python3 hook_report.py --clear # Wipe log and start fresh
|
||||
python3 hook_report.py --json # Output raw JSON instead of table
|
||||
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_LOG_FILE = Path("/tmp/aipass_hook_log.jsonl")
|
||||
|
||||
_EVENT_ORDER = [
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"Stop",
|
||||
"Notification",
|
||||
]
|
||||
|
||||
|
||||
def _load_entries(
|
||||
session: str = "",
|
||||
cwd: str = "",
|
||||
since_minutes: int = 0,
|
||||
all_entries: bool = False,
|
||||
) -> list[dict]:
|
||||
if not _LOG_FILE.exists():
|
||||
return []
|
||||
|
||||
entries = []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for line in _LOG_FILE.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if session and entry.get("session", "") != session:
|
||||
continue
|
||||
if cwd and not entry.get("cwd", "").startswith(cwd):
|
||||
continue
|
||||
|
||||
if not all_entries and since_minutes > 0:
|
||||
try:
|
||||
ts = datetime.fromisoformat(entry["ts"].replace("Z", "+00:00"))
|
||||
age = (now - ts).total_seconds() / 60
|
||||
if age > since_minutes:
|
||||
continue
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _format_bytes(n: int) -> str:
|
||||
if n == 0:
|
||||
return "silent"
|
||||
if n < 1024:
|
||||
return f"{n}B"
|
||||
return f"{n / 1024:.1f}KB"
|
||||
|
||||
|
||||
def _format_table(entries: list[dict]) -> str:
|
||||
if not entries:
|
||||
return "No hook activity found."
|
||||
|
||||
lines = []
|
||||
|
||||
sessions = set(e.get("session", "")[:8] for e in entries)
|
||||
cwds = set(e.get("cwd", "") for e in entries)
|
||||
ts_range = ""
|
||||
if entries:
|
||||
first_ts = entries[0].get("ts", "")[:19]
|
||||
last_ts = entries[-1].get("ts", "")[:19]
|
||||
ts_range = f"{first_ts} -> {last_ts}" if first_ts != last_ts else first_ts
|
||||
|
||||
lines.append("Hook Execution Report")
|
||||
lines.append("=" * 70)
|
||||
if len(sessions) == 1:
|
||||
lines.append(f"Session: {list(sessions)[0]}...")
|
||||
else:
|
||||
lines.append(f"Sessions: {len(sessions)}")
|
||||
if len(cwds) == 1:
|
||||
lines.append(f"CWD: {list(cwds)[0]}")
|
||||
else:
|
||||
lines.append(f"CWDs: {', '.join(sorted(cwds))}")
|
||||
lines.append(f"Time: {ts_range}")
|
||||
lines.append(f"Total fires: {len(entries)}")
|
||||
lines.append("")
|
||||
|
||||
hdr = f"{'#':>3} | {'Event':<22} | {'Source':<8} | {'Script':<28} | {'ms':>6} | {'Output':>8} | {'Exit':>4}"
|
||||
lines.append(hdr)
|
||||
lines.append("-" * len(hdr))
|
||||
|
||||
for i, e in enumerate(entries, 1):
|
||||
event = e.get("event", "?")
|
||||
source = e.get("source", "?")
|
||||
script = e.get("script", "?")
|
||||
ms = e.get("elapsed_ms", 0)
|
||||
out = _format_bytes(e.get("output_bytes", 0))
|
||||
exit_code = e.get("exit_code", 0)
|
||||
exit_str = str(exit_code) if exit_code != 0 else ""
|
||||
|
||||
lines.append(f"{i:>3} | {event:<22} | {source:<8} | {script:<28} | {ms:>6.1f} | {out:>8} | {exit_str:>4}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
event_counts = Counter(e.get("event", "") for e in entries)
|
||||
source_counts = Counter((e.get("event", ""), e.get("source", "")) for e in entries)
|
||||
|
||||
warnings = []
|
||||
for event, count in event_counts.items():
|
||||
sources = [s for (ev, s), c in source_counts.items() if ev == event]
|
||||
unique_sources = set(sources)
|
||||
if count > 1 and len(unique_sources) > 1:
|
||||
warnings.append(f"DOUBLE-FIRE: {event} fired {count}x from {', '.join(sorted(unique_sources))}")
|
||||
elif count > 4:
|
||||
warnings.append(f"HIGH FREQUENCY: {event} fired {count}x")
|
||||
|
||||
suppressed = [e for e in entries if e.get("output_bytes", 0) == 0 and e.get("event") == "UserPromptSubmit"]
|
||||
if suppressed:
|
||||
scripts = [e.get("script", "?") for e in suppressed]
|
||||
warnings.append(
|
||||
f"CWD-GUARDED (likely): {len(suppressed)} UserPromptSubmit hook(s) produced no output: {', '.join(scripts)}"
|
||||
)
|
||||
|
||||
if warnings:
|
||||
lines.append("Warnings:")
|
||||
for w in warnings:
|
||||
lines.append(f" ! {w}")
|
||||
else:
|
||||
lines.append("No warnings.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Hook execution report")
|
||||
parser.add_argument("--all", action="store_true", help="Show all entries")
|
||||
parser.add_argument("--session", default="", help="Filter by session ID (prefix match)")
|
||||
parser.add_argument("--cwd", default="", help="Filter by CWD prefix")
|
||||
parser.add_argument("--minutes", type=int, default=5, help="Show last N minutes (default: 5)")
|
||||
parser.add_argument("--clear", action="store_true", help="Clear log file")
|
||||
parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.clear:
|
||||
if _LOG_FILE.exists():
|
||||
_LOG_FILE.unlink()
|
||||
print("Log cleared.")
|
||||
else:
|
||||
print("No log file to clear.")
|
||||
return
|
||||
|
||||
entries = _load_entries(
|
||||
session=args.session,
|
||||
cwd=args.cwd,
|
||||
since_minutes=args.minutes,
|
||||
all_entries=args.all,
|
||||
)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(entries, indent=2))
|
||||
else:
|
||||
print(_format_table(entries))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,753 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hook Test Harness — two test layers:
|
||||
|
||||
1. DIRECT tests: pipe JSON to hook scripts via subprocess. Deterministic,
|
||||
fast, no model dependency. HIGH confidence.
|
||||
2. INTEGRATION tests: run `claude -p` from different CWDs, read JSONL log.
|
||||
Tests full pipeline. MEDIUM confidence (model-dependent).
|
||||
|
||||
Usage:
|
||||
python3 hook_test.py # Run all tests
|
||||
python3 hook_test.py --direct # Direct tests only (fast, deterministic)
|
||||
python3 hook_test.py --integration # Integration tests only (slower, needs claude)
|
||||
python3 hook_test.py --test cwd_guard # Run one test by name
|
||||
python3 hook_test.py --list # List available tests
|
||||
python3 hook_test.py --verbose # Show detail per test
|
||||
|
||||
Version: 2.0.0
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_LOG_FILE = Path("/tmp/aipass_hook_log.jsonl")
|
||||
_AIPASS_HOME = os.environ.get("AIPASS_HOME", "/home/patrick/Projects/AIPass")
|
||||
|
||||
|
||||
def _clear_log() -> None:
|
||||
if _LOG_FILE.exists():
|
||||
_LOG_FILE.unlink()
|
||||
|
||||
|
||||
def _read_log() -> list[dict]:
|
||||
if not _LOG_FILE.exists():
|
||||
return []
|
||||
entries = []
|
||||
for line in _LOG_FILE.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def _run_headless(cwd: str, prompt: str = "say hi", model: str = "haiku") -> tuple[int, str]:
|
||||
"""Run `claude -p` from a given CWD and return (exit_code, stdout)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "-p", prompt, "--model", model],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
return result.returncode, result.stdout
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, "TIMEOUT"
|
||||
except FileNotFoundError:
|
||||
return -2, "claude not found"
|
||||
|
||||
|
||||
class TestResult:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.passed = False
|
||||
self.message = ""
|
||||
self.entries: list[dict] = []
|
||||
|
||||
def ok(self, msg: str = "") -> "TestResult":
|
||||
self.passed = True
|
||||
self.message = msg or "PASS"
|
||||
return self
|
||||
|
||||
def fail(self, msg: str) -> "TestResult":
|
||||
self.passed = False
|
||||
self.message = msg
|
||||
return self
|
||||
|
||||
|
||||
_HOOKS_DIR = Path(_AIPASS_HOME) / ".claude" / "hooks"
|
||||
|
||||
|
||||
def _run_hook_direct(
|
||||
script: str,
|
||||
payload: dict,
|
||||
cwd: str = "/tmp",
|
||||
env_extra: dict | None = None,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Run a hook script as a subprocess with JSON on stdin. Returns (exit_code, stdout, stderr)."""
|
||||
script_path = _HOOKS_DIR / script
|
||||
if not script_path.exists():
|
||||
return -1, "", f"Script not found: {script_path}"
|
||||
env = {**os.environ, "AIPASS_HOME": _AIPASS_HOME}
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", str(script_path)],
|
||||
input=json.dumps(payload),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
return -2, "", "TIMEOUT"
|
||||
|
||||
|
||||
def _find_project_with_hooks() -> str:
|
||||
"""Dynamically find a project that has its own UserPromptSubmit hooks."""
|
||||
projects_dir = Path.home() / "Projects"
|
||||
if not projects_dir.is_dir():
|
||||
return ""
|
||||
for proj in sorted(projects_dir.iterdir()):
|
||||
if proj.name == "AIPass":
|
||||
continue
|
||||
settings = proj / ".claude" / "settings.json"
|
||||
if settings.exists():
|
||||
try:
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
if data.get("hooks", {}).get("UserPromptSubmit"):
|
||||
return str(proj)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def _find_project_without_hooks() -> str:
|
||||
"""Dynamically find a project that has settings.json but NO UserPromptSubmit hooks."""
|
||||
projects_dir = Path.home() / "Projects"
|
||||
if not projects_dir.is_dir():
|
||||
return ""
|
||||
for proj in sorted(projects_dir.iterdir()):
|
||||
if proj.name == "AIPass":
|
||||
continue
|
||||
settings = proj / ".claude" / "settings.json"
|
||||
if settings.exists():
|
||||
try:
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
if not data.get("hooks", {}).get("UserPromptSubmit"):
|
||||
return str(proj)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return str(proj)
|
||||
return ""
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# DIRECT TESTS — pipe JSON to hook subprocess, deterministic, HIGH confidence
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def test_direct_global_prompt_from_tmp(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] global_prompt_loader outputs full prompt when run from /tmp."""
|
||||
r = TestResult("direct_global_prompt_from_tmp")
|
||||
exit_code, stdout, stderr = _run_hook_direct("global_prompt_loader.py", {}, cwd="/tmp")
|
||||
if exit_code != 0:
|
||||
return r.fail(f"Exit {exit_code}: {stderr}")
|
||||
if len(stdout) < 1000:
|
||||
return r.fail(f"Output only {len(stdout)} chars — expected ~22KB global prompt")
|
||||
return r.ok(f"{len(stdout)} chars output from /tmp")
|
||||
|
||||
|
||||
def test_direct_global_prompt_guarded(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] global_prompt_loader suppressed when CWD has own hooks."""
|
||||
r = TestResult("direct_global_prompt_guarded")
|
||||
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
|
||||
exit_code, stdout, stderr = _run_hook_direct("global_prompt_loader.py", {}, cwd=cwd)
|
||||
if exit_code != 0:
|
||||
return r.fail(f"Exit {exit_code}: {stderr}")
|
||||
if stdout.strip():
|
||||
return r.fail(f"Expected silent (CWD guard), got {len(stdout)} chars")
|
||||
return r.ok("Silent output — CWD guard active")
|
||||
|
||||
|
||||
def test_direct_identity_injector(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] identity_injector outputs identity from branch with passport."""
|
||||
r = TestResult("direct_identity_injector")
|
||||
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
|
||||
exit_code, stdout, _ = _run_hook_direct("identity_injector.py", {}, cwd=cwd)
|
||||
if exit_code != 0:
|
||||
return r.fail(f"Exit {exit_code}")
|
||||
# From devpulse, CWD guard is active — should be silent
|
||||
if stdout.strip():
|
||||
return r.fail("Expected silent from devpulse (CWD guard), got output")
|
||||
# Test from /tmp — no branch root, should also be silent
|
||||
exit_code2, stdout2, _ = _run_hook_direct("identity_injector.py", {}, cwd="/tmp")
|
||||
if stdout2.strip():
|
||||
return r.fail("Expected silent from /tmp (no branch root), got output")
|
||||
return r.ok("Silent from guarded CWD and no-branch CWD")
|
||||
|
||||
|
||||
def test_direct_git_gate_allows_safe(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] git_gate allows safe Bash commands (exit 0, no output)."""
|
||||
r = TestResult("direct_git_gate_allows_safe")
|
||||
payload = {"tool_name": "Bash", "tool_input": {"command": "echo hello"}, "cwd": "/tmp"}
|
||||
exit_code, stdout, _ = _run_hook_direct("git_gate.py", payload)
|
||||
if exit_code != 0:
|
||||
return r.fail(f"Safe command blocked — exit {exit_code}")
|
||||
if stdout.strip():
|
||||
return r.fail(f"Unexpected output for safe command: {stdout[:100]}")
|
||||
return r.ok("Safe Bash command allowed (exit 0, silent)")
|
||||
|
||||
|
||||
def test_direct_git_gate_blocks_raw_git(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] git_gate blocks raw git commit (exit 2, decision=block)."""
|
||||
r = TestResult("direct_git_gate_blocks_raw_git")
|
||||
payload = {"tool_name": "Bash", "tool_input": {"command": "git commit -m test"}, "cwd": "/tmp"}
|
||||
exit_code, stdout, _ = _run_hook_direct("git_gate.py", payload)
|
||||
if exit_code != 2:
|
||||
return r.fail(f"Expected exit 2 (block), got {exit_code}")
|
||||
try:
|
||||
out = json.loads(stdout)
|
||||
if out.get("decision") != "block":
|
||||
return r.fail(f"Expected decision=block, got {out.get('decision')}")
|
||||
except json.JSONDecodeError:
|
||||
return r.fail(f"Non-JSON output: {stdout[:100]}")
|
||||
return r.ok("git commit blocked (exit 2, decision=block)")
|
||||
|
||||
|
||||
def test_direct_git_gate_blocks_gh_push(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] git_gate blocks git push (exit 2, decision=block)."""
|
||||
r = TestResult("direct_git_gate_blocks_gh_push")
|
||||
payload = {"tool_name": "Bash", "tool_input": {"command": "git push origin main"}, "cwd": "/tmp"}
|
||||
exit_code, stdout, _ = _run_hook_direct("git_gate.py", payload)
|
||||
if exit_code != 2:
|
||||
return r.fail(f"Expected exit 2 (block), got {exit_code}")
|
||||
return r.ok("git push blocked (exit 2)")
|
||||
|
||||
|
||||
def test_direct_tool_use_sound_exits_clean(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] tool_use_sound exits 0 and produces no stdout."""
|
||||
r = TestResult("direct_tool_use_sound_exits_clean")
|
||||
payload = {"hook_event_name": "PreToolUse", "tool_name": "Read"}
|
||||
exit_code, stdout, _ = _run_hook_direct("tool_use_sound.py", payload)
|
||||
if exit_code != 0:
|
||||
return r.fail(f"Exit {exit_code}")
|
||||
if stdout.strip():
|
||||
return r.fail(f"Unexpected stdout: {stdout[:100]}")
|
||||
return r.ok("Clean exit, no stdout")
|
||||
|
||||
|
||||
def test_direct_email_notification_no_mail(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] email_notification silent when no inbox exists."""
|
||||
r = TestResult("direct_email_notification_no_mail")
|
||||
exit_code, stdout, _ = _run_hook_direct("email_notification.py", {}, cwd="/tmp")
|
||||
if exit_code != 0:
|
||||
return r.fail(f"Exit {exit_code}")
|
||||
if stdout.strip():
|
||||
return r.fail(f"Unexpected output from /tmp (no mailbox): {stdout[:100]}")
|
||||
return r.ok("Silent — no mailbox at /tmp")
|
||||
|
||||
|
||||
def test_direct_settings_schema(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] All hooks in provider settings.json reference scripts that exist."""
|
||||
r = TestResult("direct_settings_schema")
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
if not settings_path.exists():
|
||||
return r.fail("~/.claude/settings.json not found")
|
||||
|
||||
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
hooks = data.get("hooks", {})
|
||||
|
||||
valid_events = {
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"UserPromptSubmit",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"Stop",
|
||||
"Notification",
|
||||
"SessionStart",
|
||||
"PermissionRequest",
|
||||
}
|
||||
missing = []
|
||||
bad_events = []
|
||||
|
||||
for event, entries in hooks.items():
|
||||
if event not in valid_events:
|
||||
bad_events.append(event)
|
||||
for entry in entries:
|
||||
for hook in entry.get("hooks", []):
|
||||
cmd = hook.get("command", "")
|
||||
parts = cmd.split()
|
||||
for part in parts:
|
||||
if part.endswith(".py") and "/" in part:
|
||||
if not Path(part).exists():
|
||||
missing.append(part)
|
||||
|
||||
errors = []
|
||||
if bad_events:
|
||||
errors.append(f"Invalid events: {bad_events}")
|
||||
if missing:
|
||||
errors.append(f"Missing scripts: {missing}")
|
||||
|
||||
if errors:
|
||||
return r.fail("; ".join(errors))
|
||||
|
||||
hook_count = sum(len(e.get("hooks", [])) for entries in hooks.values() for e in entries)
|
||||
return r.ok(f"{len(hooks)} events, {hook_count} hooks, all scripts exist")
|
||||
|
||||
|
||||
def _find_aipass_init_project() -> str:
|
||||
"""Find a project created by aipass init (has *_REGISTRY.json)."""
|
||||
projects_dir = Path.home() / "Projects"
|
||||
if not projects_dir.exists():
|
||||
return ""
|
||||
for proj in sorted(projects_dir.iterdir()):
|
||||
if proj.name == "AIPass":
|
||||
continue
|
||||
registries = list(proj.glob("*_REGISTRY.json"))
|
||||
settings = proj / ".claude" / "settings.json"
|
||||
if registries and settings.exists():
|
||||
return str(proj)
|
||||
return ""
|
||||
|
||||
|
||||
def test_direct_project_settings_schema(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] aipass init project has valid settings.json with expected hooks."""
|
||||
r = TestResult("direct_project_settings_schema")
|
||||
|
||||
proj = _find_aipass_init_project()
|
||||
if not proj:
|
||||
return r.ok("SKIP — no aipass init project found (needs ~/Projects with *_REGISTRY.json)")
|
||||
|
||||
settings_path = Path(proj) / ".claude" / "settings.json"
|
||||
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
hooks = data.get("hooks", {})
|
||||
|
||||
has_ups = bool(hooks.get("UserPromptSubmit"))
|
||||
has_pre = bool(hooks.get("PreToolUse"))
|
||||
has_post = bool(hooks.get("PostToolUse"))
|
||||
|
||||
project_name = Path(proj).name
|
||||
notes = []
|
||||
if has_ups:
|
||||
notes.append(f"UserPromptSubmit: {len(hooks['UserPromptSubmit'])} entries")
|
||||
if has_pre:
|
||||
notes.append(f"PreToolUse: {len(hooks['PreToolUse'])} entries (NOTE: won't fire from project level)")
|
||||
if has_post:
|
||||
notes.append(f"PostToolUse: {len(hooks['PostToolUse'])} entries (NOTE: won't fire from project level)")
|
||||
|
||||
for event, entries in hooks.items():
|
||||
for entry in entries:
|
||||
for hook in entry.get("hooks", []):
|
||||
cmd = hook.get("command", "")
|
||||
if cmd.startswith("python3 ") and ".py" in cmd:
|
||||
script = cmd.split()[-1]
|
||||
full = Path(proj) / script
|
||||
if not full.exists():
|
||||
return r.fail(f"Missing script in {project_name}: {script}")
|
||||
|
||||
return r.ok(f"{project_name}: {', '.join(notes)}")
|
||||
|
||||
|
||||
def test_direct_provider_guards_for_init_project(verbose: bool = False) -> TestResult:
|
||||
"""[DIRECT] Provider hooks are CWD-guarded when run from an aipass init project."""
|
||||
r = TestResult("direct_provider_guards_for_init_project")
|
||||
|
||||
proj = _find_aipass_init_project()
|
||||
if not proj:
|
||||
return r.ok("SKIP — no aipass init project found")
|
||||
|
||||
guarded_hooks = [
|
||||
"global_prompt_loader.py",
|
||||
"branch_prompt_loader.py",
|
||||
"identity_injector.py",
|
||||
"email_notification.py",
|
||||
]
|
||||
|
||||
for script in guarded_hooks:
|
||||
exit_code, stdout, _ = _run_hook_direct(script, {}, cwd=proj)
|
||||
if exit_code != 0:
|
||||
return r.fail(f"{script} exited {exit_code} from {Path(proj).name}")
|
||||
if stdout.strip():
|
||||
return r.fail(
|
||||
f"{script} produced output from {Path(proj).name} — "
|
||||
f"CWD guard should suppress (project has own UserPromptSubmit hooks)"
|
||||
)
|
||||
|
||||
return r.ok(f"All 4 provider hooks suppressed from {Path(proj).name}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# INTEGRATION TESTS — run claude -p, read JSONL log, MEDIUM confidence
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def test_aipass_branch_hooks(verbose: bool = False) -> TestResult:
|
||||
"""Test: hooks fire correctly from an AIPass branch CWD (devpulse)."""
|
||||
r = TestResult("aipass_branch_hooks")
|
||||
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
|
||||
|
||||
if not Path(cwd).exists():
|
||||
return r.fail(f"CWD not found: {cwd}")
|
||||
|
||||
_clear_log()
|
||||
exit_code, _ = _run_headless(cwd)
|
||||
entries = _read_log()
|
||||
r.entries = entries
|
||||
|
||||
if exit_code != 0:
|
||||
return r.fail(f"claude -p exited {exit_code}")
|
||||
|
||||
ups = [e for e in entries if e.get("event") == "UserPromptSubmit"]
|
||||
if not ups:
|
||||
return r.fail("No UserPromptSubmit hooks fired")
|
||||
|
||||
expected_scripts = {
|
||||
"global_prompt_loader.py",
|
||||
"branch_prompt_loader.py",
|
||||
"identity_injector.py",
|
||||
"email_notification.py",
|
||||
}
|
||||
fired_scripts = {e.get("script", "") for e in ups}
|
||||
|
||||
missing = expected_scripts - fired_scripts
|
||||
if missing:
|
||||
return r.fail(f"Missing UserPromptSubmit hooks: {missing}")
|
||||
|
||||
return r.ok(f"{len(ups)} UserPromptSubmit hooks fired, {len(entries)} total")
|
||||
|
||||
|
||||
def test_cwd_guard_devpulse(verbose: bool = False) -> TestResult:
|
||||
"""Test: CWD guard suppresses provider hooks when project has own hooks."""
|
||||
r = TestResult("cwd_guard_devpulse")
|
||||
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
|
||||
|
||||
project_settings = Path(cwd)
|
||||
found_settings = False
|
||||
search = project_settings
|
||||
while search != Path.home() and search.parent != search:
|
||||
if (search / ".claude" / "settings.json").exists():
|
||||
found_settings = True
|
||||
break
|
||||
search = search.parent
|
||||
|
||||
if not found_settings:
|
||||
return r.fail("No .claude/settings.json found in CWD hierarchy — can't test CWD guard")
|
||||
|
||||
_clear_log()
|
||||
exit_code, _ = _run_headless(cwd)
|
||||
entries = _read_log()
|
||||
r.entries = entries
|
||||
|
||||
if exit_code != 0:
|
||||
return r.fail(f"claude -p exited {exit_code}")
|
||||
|
||||
ups = [e for e in entries if e.get("event") == "UserPromptSubmit"]
|
||||
guarded = [e for e in ups if e.get("output_bytes", 0) == 0]
|
||||
|
||||
if not guarded:
|
||||
return r.fail(
|
||||
"No UserPromptSubmit hooks were suppressed — CWD guard may not be working. "
|
||||
f"Hooks fired: {[e.get('script') for e in ups]}"
|
||||
)
|
||||
|
||||
return r.ok(f"{len(guarded)}/{len(ups)} UserPromptSubmit hooks suppressed by CWD guard")
|
||||
|
||||
|
||||
def test_tmp_no_guard(verbose: bool = False) -> TestResult:
|
||||
"""Test: from /tmp (no project hooks), provider hooks fire with full output."""
|
||||
r = TestResult("tmp_no_guard")
|
||||
|
||||
_clear_log()
|
||||
exit_code, _ = _run_headless("/tmp")
|
||||
entries = _read_log()
|
||||
r.entries = entries
|
||||
|
||||
if exit_code != 0:
|
||||
return r.fail(f"claude -p exited {exit_code}")
|
||||
|
||||
ups = [e for e in entries if e.get("event") == "UserPromptSubmit"]
|
||||
global_prompt = [e for e in ups if e.get("script") == "global_prompt_loader.py"]
|
||||
|
||||
if not global_prompt:
|
||||
return r.fail("global_prompt_loader.py did not fire from /tmp")
|
||||
|
||||
if global_prompt[0].get("output_bytes", 0) < 1000:
|
||||
return r.fail(
|
||||
f"global_prompt_loader.py output only {global_prompt[0].get('output_bytes')}B "
|
||||
"from /tmp — expected ~22KB (CWD guard should NOT fire here)"
|
||||
)
|
||||
|
||||
return r.ok(
|
||||
f"global_prompt_loader.py output {global_prompt[0].get('output_bytes')}B (guard not active, as expected)"
|
||||
)
|
||||
|
||||
|
||||
def test_pretooluse_fires(verbose: bool = False) -> TestResult:
|
||||
"""Test: PreToolUse hooks fire on tool calls."""
|
||||
r = TestResult("pretooluse_fires")
|
||||
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
|
||||
|
||||
_clear_log()
|
||||
exit_code, _ = _run_headless(cwd, prompt="run: echo hello")
|
||||
entries = _read_log()
|
||||
r.entries = entries
|
||||
|
||||
pre = [e for e in entries if e.get("event") == "PreToolUse"]
|
||||
if not pre:
|
||||
return r.fail("No PreToolUse hooks fired — expected at least tool_use_sound.py")
|
||||
|
||||
return r.ok(f"{len(pre)} PreToolUse fires")
|
||||
|
||||
|
||||
def test_posttooluse_fires(verbose: bool = False) -> TestResult:
|
||||
"""Test: PostToolUse hooks fire after tool calls."""
|
||||
r = TestResult("posttooluse_fires")
|
||||
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
|
||||
|
||||
_clear_log()
|
||||
exit_code, _ = _run_headless(cwd, prompt="use the bash tool to run: echo posttooluse-test")
|
||||
entries = _read_log()
|
||||
r.entries = entries
|
||||
|
||||
post = [e for e in entries if e.get("event") == "PostToolUse"]
|
||||
if not post:
|
||||
return r.fail("No PostToolUse hooks fired")
|
||||
|
||||
return r.ok(f"{len(post)} PostToolUse fires")
|
||||
|
||||
|
||||
def test_standalone_project_guard(verbose: bool = False) -> TestResult:
|
||||
"""[INTEGRATION] standalone projects with own hooks get provider hooks suppressed."""
|
||||
r = TestResult("standalone_project_guard")
|
||||
|
||||
cwd = _find_project_with_hooks()
|
||||
if not cwd:
|
||||
return r.fail("No standalone project with UserPromptSubmit hooks found")
|
||||
|
||||
_clear_log()
|
||||
exit_code, _ = _run_headless(cwd)
|
||||
entries = _read_log()
|
||||
r.entries = entries
|
||||
|
||||
if exit_code != 0:
|
||||
return r.fail(f"claude -p exited {exit_code}")
|
||||
|
||||
ups = [e for e in entries if e.get("event") == "UserPromptSubmit"]
|
||||
guarded = [e for e in ups if e.get("output_bytes", 0) == 0]
|
||||
|
||||
if not ups:
|
||||
return r.fail("No UserPromptSubmit hooks fired at all")
|
||||
|
||||
project_name = Path(cwd).name
|
||||
if not guarded:
|
||||
return r.fail(
|
||||
f"Provider hooks NOT suppressed in {project_name} (has own hooks). Fired: {[e.get('script') for e in ups]}"
|
||||
)
|
||||
|
||||
return r.ok(f"{len(guarded)}/{len(ups)} provider UserPromptSubmit hooks suppressed in {project_name}")
|
||||
|
||||
|
||||
def test_no_hooks_project_gets_prompt(verbose: bool = False) -> TestResult:
|
||||
"""[INTEGRATION] projects without own hooks receive full provider prompt."""
|
||||
r = TestResult("no_hooks_project_gets_prompt")
|
||||
|
||||
cwd = _find_project_without_hooks()
|
||||
if not cwd:
|
||||
return r.fail("No project without UserPromptSubmit hooks found")
|
||||
|
||||
_clear_log()
|
||||
exit_code, _ = _run_headless(cwd)
|
||||
entries = _read_log()
|
||||
r.entries = entries
|
||||
|
||||
if exit_code != 0:
|
||||
return r.fail(f"claude -p exited {exit_code}")
|
||||
|
||||
global_prompt = [
|
||||
e for e in entries if e.get("script") == "global_prompt_loader.py" and e.get("output_bytes", 0) > 1000
|
||||
]
|
||||
|
||||
project_name = Path(cwd).name
|
||||
if not global_prompt:
|
||||
return r.fail(
|
||||
f"global_prompt_loader.py did NOT output full prompt in {project_name} "
|
||||
f"(no own hooks — guard should be inactive)"
|
||||
)
|
||||
|
||||
return r.ok(
|
||||
f"global_prompt_loader.py output {global_prompt[0]['output_bytes']}B "
|
||||
f"in {project_name} (no own hooks, guard inactive)"
|
||||
)
|
||||
|
||||
|
||||
def test_subagent_hooks(verbose: bool = False) -> TestResult:
|
||||
"""Test: SubagentStop hook fires when a subagent completes."""
|
||||
r = TestResult("subagent_hooks")
|
||||
cwd = os.path.join(_AIPASS_HOME, "src", "aipass", "devpulse")
|
||||
|
||||
_clear_log()
|
||||
exit_code, _ = _run_headless(
|
||||
cwd,
|
||||
prompt="Use the Agent tool to spawn a helper that runs echo test via Bash then reports back",
|
||||
)
|
||||
entries = _read_log()
|
||||
r.entries = entries
|
||||
|
||||
if exit_code != 0:
|
||||
return r.fail(f"claude -p exited {exit_code}")
|
||||
|
||||
subagent_stops = [e for e in entries if e.get("event") == "SubagentStop"]
|
||||
if not subagent_stops:
|
||||
return r.fail("No SubagentStop hook fired — model may not have spawned a subagent")
|
||||
|
||||
return r.ok(f"{len(subagent_stops)} SubagentStop fire(s)")
|
||||
|
||||
|
||||
def test_disable_toggle(verbose: bool = False) -> TestResult:
|
||||
"""[INTEGRATION] disableAllHooks=true stops all hook firing (atomic backup/restore)."""
|
||||
r = TestResult("disable_toggle")
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
if not settings_path.exists():
|
||||
return r.fail("~/.claude/settings.json not found")
|
||||
|
||||
# Atomic backup — copy to temp file first, restore from backup on any failure
|
||||
backup_fd, backup_path = tempfile.mkstemp(suffix=".json", prefix="settings_backup_")
|
||||
os.close(backup_fd)
|
||||
shutil.copy2(str(settings_path), backup_path)
|
||||
|
||||
try:
|
||||
original = settings_path.read_text(encoding="utf-8")
|
||||
data = json.loads(original)
|
||||
data["disableAllHooks"] = True
|
||||
settings_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
_clear_log()
|
||||
exit_code, _ = _run_headless("/tmp")
|
||||
entries = _read_log()
|
||||
r.entries = entries
|
||||
finally:
|
||||
# Restore from atomic backup — safe even after crash/signal
|
||||
shutil.copy2(backup_path, str(settings_path))
|
||||
try:
|
||||
os.unlink(backup_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if entries:
|
||||
return r.fail(f"{len(entries)} hooks fired with disableAllHooks=true — toggle broken")
|
||||
|
||||
return r.ok("0 hooks fired with disableAllHooks=true")
|
||||
|
||||
|
||||
_DIRECT_TESTS = [
|
||||
("direct_global_prompt_from_tmp", test_direct_global_prompt_from_tmp),
|
||||
("direct_global_prompt_guarded", test_direct_global_prompt_guarded),
|
||||
("direct_identity_injector", test_direct_identity_injector),
|
||||
("direct_git_gate_allows_safe", test_direct_git_gate_allows_safe),
|
||||
("direct_git_gate_blocks_raw_git", test_direct_git_gate_blocks_raw_git),
|
||||
("direct_git_gate_blocks_gh_push", test_direct_git_gate_blocks_gh_push),
|
||||
("direct_tool_use_sound_exits_clean", test_direct_tool_use_sound_exits_clean),
|
||||
("direct_email_notification_no_mail", test_direct_email_notification_no_mail),
|
||||
("direct_settings_schema", test_direct_settings_schema),
|
||||
("direct_project_settings_schema", test_direct_project_settings_schema),
|
||||
("direct_provider_guards_for_init_project", test_direct_provider_guards_for_init_project),
|
||||
]
|
||||
|
||||
_INTEGRATION_TESTS = [
|
||||
("aipass_branch_hooks", test_aipass_branch_hooks),
|
||||
("cwd_guard_devpulse", test_cwd_guard_devpulse),
|
||||
("tmp_no_guard", test_tmp_no_guard),
|
||||
("pretooluse_fires", test_pretooluse_fires),
|
||||
("posttooluse_fires", test_posttooluse_fires),
|
||||
("standalone_project_guard", test_standalone_project_guard),
|
||||
("no_hooks_project_gets_prompt", test_no_hooks_project_gets_prompt),
|
||||
("subagent_hooks", test_subagent_hooks),
|
||||
("disable_toggle", test_disable_toggle),
|
||||
]
|
||||
|
||||
_ALL_TESTS = _DIRECT_TESTS + _INTEGRATION_TESTS
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Hook test harness")
|
||||
parser.add_argument("--test", default="", help="Run specific test by name")
|
||||
parser.add_argument("--direct", action="store_true", help="Run direct tests only (fast, deterministic)")
|
||||
parser.add_argument("--integration", action="store_true", help="Run integration tests only (slower)")
|
||||
parser.add_argument("--list", action="store_true", help="List available tests")
|
||||
parser.add_argument("--verbose", action="store_true", help="Show hook log per test")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
for name, fn in _ALL_TESTS:
|
||||
print(f" {name}: {fn.__doc__}")
|
||||
return
|
||||
|
||||
if args.direct:
|
||||
tests = _DIRECT_TESTS
|
||||
elif args.integration:
|
||||
tests = _INTEGRATION_TESTS
|
||||
else:
|
||||
tests = _ALL_TESTS
|
||||
if args.test:
|
||||
tests = [(n, f) for n, f in tests if n == args.test or args.test in n]
|
||||
if not tests:
|
||||
print(f"Unknown test: {args.test}")
|
||||
print(f"Available: {', '.join(n for n, _ in _ALL_TESTS)}")
|
||||
sys.exit(1)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
print(f"\nHook Test Harness — {len(tests)} test(s)")
|
||||
print("=" * 60)
|
||||
|
||||
for name, fn in tests:
|
||||
print(f"\n Running: {name}...", end=" ", flush=True)
|
||||
try:
|
||||
result = fn(verbose=args.verbose)
|
||||
except Exception as e:
|
||||
result = TestResult(name).fail(f"Exception: {e}")
|
||||
|
||||
if result.passed:
|
||||
passed += 1
|
||||
print(f"PASS — {result.message}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f"FAIL — {result.message}")
|
||||
|
||||
if args.verbose and result.entries:
|
||||
print(f" Log entries ({len(result.entries)}):")
|
||||
for e in result.entries:
|
||||
print(
|
||||
f" {e.get('event'):<22} "
|
||||
f"{e.get('script'):<28} "
|
||||
f"{e.get('elapsed_ms', 0):>6.1f}ms "
|
||||
f"{e.get('output_bytes', 0):>6}B"
|
||||
)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Results: {passed} passed, {failed} failed, {passed + failed} total")
|
||||
|
||||
sys.exit(1 if failed > 0 else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -5,12 +5,34 @@ Identity Injector - Injects branch identity on every prompt.
|
||||
Reads from [BRANCH].id.json and outputs core identity fields.
|
||||
Finds the branch root by walking up from CWD looking for apps/ or *.id.json.
|
||||
|
||||
Version: 1.0.0
|
||||
When CWD is inside a project that has its own UserPromptSubmit hooks,
|
||||
this provider-level hook exits silently to avoid double-firing.
|
||||
|
||||
Version: 1.1.0
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _project_has_own_hooks() -> bool:
|
||||
"""Check if CWD is inside a project with its own UserPromptSubmit hooks."""
|
||||
search = Path.cwd()
|
||||
home = Path.home()
|
||||
while search != home and search.parent != search:
|
||||
settings = search / ".claude" / "settings.json"
|
||||
if settings.exists():
|
||||
try:
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
ups = data.get("hooks", {}).get("UserPromptSubmit", [])
|
||||
if ups:
|
||||
return True
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
search = search.parent
|
||||
return False
|
||||
|
||||
|
||||
def find_repo_root() -> Path | None:
|
||||
"""Find the repo root (contains pyproject.toml or .git)."""
|
||||
search = Path.cwd()
|
||||
@@ -96,6 +118,9 @@ def format_identity(data: dict) -> str:
|
||||
|
||||
|
||||
def main():
|
||||
if _project_has_own_hooks():
|
||||
return
|
||||
|
||||
branch_root = find_branch_root()
|
||||
if not branch_root:
|
||||
return
|
||||
@@ -114,4 +139,9 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("UserPromptSubmit", "provider", __file__, main)
|
||||
|
||||
@@ -35,4 +35,7 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("Notification", "provider", __file__, main)
|
||||
|
||||
@@ -165,4 +165,7 @@ Context just compacted. Below is your live state. Use it to continue seamlessly.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("PreCompact", "provider", __file__, main)
|
||||
|
||||
@@ -37,6 +37,7 @@ def _get_branch(file_path: str) -> str:
|
||||
|
||||
|
||||
def _block(reason: str) -> None:
|
||||
# codeql[py/clear-text-logging-sensitive-data]
|
||||
print(json.dumps({"decision": "block", "reason": reason}))
|
||||
sys.exit(2)
|
||||
|
||||
@@ -59,16 +60,39 @@ def main():
|
||||
# ------------------------------------------------------------------
|
||||
fp = Path(file_path)
|
||||
if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts:
|
||||
_block(
|
||||
"Direct writes to inbox.json are blocked.\n"
|
||||
"Use: drone @ai_mail email @<branch> \"Subject\" \"Body\""
|
||||
)
|
||||
_block('Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @<branch> "Subject" "Body"')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rule 1.5: Dispatched-agent path confinement (DPLAN-0155 M3)
|
||||
# Daemon-spawned agents can only write inside their own branch dir.
|
||||
# Breaks the prompt-injection amplifier chain — even if injected,
|
||||
# a dispatched agent cannot write to other agents' inboxes or code.
|
||||
# ------------------------------------------------------------------
|
||||
cwd = input_data.get("cwd", "") or os.getcwd()
|
||||
cwd_branch = _get_branch(cwd)
|
||||
|
||||
session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive")
|
||||
if session_type == "daemon" and cwd_branch:
|
||||
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
|
||||
if target_branch and target_branch != cwd_branch:
|
||||
_block(
|
||||
f"Dispatched agent confined to own branch: '{cwd_branch}' "
|
||||
f"cannot write to '{target_branch}' in daemon mode."
|
||||
)
|
||||
repo_root = None
|
||||
for parent in Path(cwd).parents:
|
||||
if (parent / ".git").exists():
|
||||
repo_root = parent
|
||||
break
|
||||
if repo_root and not target_branch:
|
||||
allowed_prefix = str(repo_root / "src" / "aipass" / cwd_branch)
|
||||
resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp)
|
||||
if not resolved.startswith(allowed_prefix):
|
||||
_block(f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rule 2: Cross-branch write enforcement
|
||||
# ------------------------------------------------------------------
|
||||
cwd = input_data.get("cwd", "") or os.getcwd()
|
||||
cwd_branch = _get_branch(cwd)
|
||||
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
|
||||
|
||||
if cwd_branch and target_branch and cwd_branch != target_branch:
|
||||
@@ -115,10 +139,7 @@ def main():
|
||||
return
|
||||
|
||||
error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5])
|
||||
_block(
|
||||
f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n"
|
||||
f"{error_summary}"
|
||||
)
|
||||
_block(f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}")
|
||||
|
||||
except Exception:
|
||||
pass # Silent fail → allow
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
# Hook Probe Suite
|
||||
# Hook Probe Suite (Legacy)
|
||||
|
||||
> **Note:** The probe suite predates the `hook_log.py` always-on logger (S132, DPLAN-0167).
|
||||
> For most hook debugging, use `hook_report.py` and `hook_test.py` in the parent directory
|
||||
> instead — they cover all hooks automatically without manual wiring. The probes below remain
|
||||
> useful for one-off event investigation when you need to enable/disable individual events.
|
||||
|
||||
This directory contains ping-response probe scripts for each Claude Code hook event type.
|
||||
Probes are **opt-in** — they are never auto-wired. See below for how to enable them.
|
||||
@@ -11,8 +16,6 @@ Each `probe_*.py` script in this directory is a passive observer for one Claude
|
||||
When enabled in `settings.json`, a probe fires on its event, records a structured entry to
|
||||
`last_ping.jsonl`, and exits 0 immediately — it never blocks execution.
|
||||
|
||||
The log is used by `drone @seedgo hooks probe` to display event tables and generate reports.
|
||||
|
||||
---
|
||||
|
||||
## Probe scripts
|
||||
|
||||
@@ -46,18 +46,10 @@ def main() -> None:
|
||||
pass
|
||||
|
||||
# --- Extract fields ---
|
||||
tool = (
|
||||
payload.get("tool_name")
|
||||
or payload.get("hook_event_name")
|
||||
or ""
|
||||
)
|
||||
tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
|
||||
cwd = payload.get("cwd") or os.getcwd()
|
||||
|
||||
agent_id = (
|
||||
os.environ.get("CLAUDE_CODE_SESSION_ID")
|
||||
or os.environ.get("CLAUDE_SESSION_ID")
|
||||
or "unknown"
|
||||
)
|
||||
agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
|
||||
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
|
||||
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
|
||||
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
|
||||
|
||||
@@ -46,18 +46,10 @@ def main() -> None:
|
||||
pass
|
||||
|
||||
# --- Extract fields ---
|
||||
tool = (
|
||||
payload.get("tool_name")
|
||||
or payload.get("hook_event_name")
|
||||
or ""
|
||||
)
|
||||
tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
|
||||
cwd = payload.get("cwd") or os.getcwd()
|
||||
|
||||
agent_id = (
|
||||
os.environ.get("CLAUDE_CODE_SESSION_ID")
|
||||
or os.environ.get("CLAUDE_SESSION_ID")
|
||||
or "unknown"
|
||||
)
|
||||
agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
|
||||
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
|
||||
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
|
||||
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
|
||||
|
||||
@@ -46,18 +46,10 @@ def main() -> None:
|
||||
pass
|
||||
|
||||
# --- Extract fields ---
|
||||
tool = (
|
||||
payload.get("tool_name")
|
||||
or payload.get("hook_event_name")
|
||||
or ""
|
||||
)
|
||||
tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
|
||||
cwd = payload.get("cwd") or os.getcwd()
|
||||
|
||||
agent_id = (
|
||||
os.environ.get("CLAUDE_CODE_SESSION_ID")
|
||||
or os.environ.get("CLAUDE_SESSION_ID")
|
||||
or "unknown"
|
||||
)
|
||||
agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
|
||||
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
|
||||
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
|
||||
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
|
||||
|
||||
@@ -46,18 +46,10 @@ def main() -> None:
|
||||
pass
|
||||
|
||||
# --- Extract fields ---
|
||||
tool = (
|
||||
payload.get("tool_name")
|
||||
or payload.get("hook_event_name")
|
||||
or ""
|
||||
)
|
||||
tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
|
||||
cwd = payload.get("cwd") or os.getcwd()
|
||||
|
||||
agent_id = (
|
||||
os.environ.get("CLAUDE_CODE_SESSION_ID")
|
||||
or os.environ.get("CLAUDE_SESSION_ID")
|
||||
or "unknown"
|
||||
)
|
||||
agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
|
||||
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
|
||||
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
|
||||
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
|
||||
|
||||
@@ -46,18 +46,10 @@ def main() -> None:
|
||||
pass
|
||||
|
||||
# --- Extract fields ---
|
||||
tool = (
|
||||
payload.get("tool_name")
|
||||
or payload.get("hook_event_name")
|
||||
or ""
|
||||
)
|
||||
tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
|
||||
cwd = payload.get("cwd") or os.getcwd()
|
||||
|
||||
agent_id = (
|
||||
os.environ.get("CLAUDE_CODE_SESSION_ID")
|
||||
or os.environ.get("CLAUDE_SESSION_ID")
|
||||
or "unknown"
|
||||
)
|
||||
agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
|
||||
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
|
||||
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
|
||||
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
|
||||
|
||||
@@ -46,18 +46,10 @@ def main() -> None:
|
||||
pass
|
||||
|
||||
# --- Extract fields ---
|
||||
tool = (
|
||||
payload.get("tool_name")
|
||||
or payload.get("hook_event_name")
|
||||
or ""
|
||||
)
|
||||
tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
|
||||
cwd = payload.get("cwd") or os.getcwd()
|
||||
|
||||
agent_id = (
|
||||
os.environ.get("CLAUDE_CODE_SESSION_ID")
|
||||
or os.environ.get("CLAUDE_SESSION_ID")
|
||||
or "unknown"
|
||||
)
|
||||
agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
|
||||
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
|
||||
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
|
||||
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
|
||||
|
||||
@@ -46,18 +46,10 @@ def main() -> None:
|
||||
pass
|
||||
|
||||
# --- Extract fields ---
|
||||
tool = (
|
||||
payload.get("tool_name")
|
||||
or payload.get("hook_event_name")
|
||||
or ""
|
||||
)
|
||||
tool = payload.get("tool_name") or payload.get("hook_event_name") or ""
|
||||
cwd = payload.get("cwd") or os.getcwd()
|
||||
|
||||
agent_id = (
|
||||
os.environ.get("CLAUDE_CODE_SESSION_ID")
|
||||
or os.environ.get("CLAUDE_SESSION_ID")
|
||||
or "unknown"
|
||||
)
|
||||
agent_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or "unknown"
|
||||
cli_version = os.environ.get("CLAUDE_CODE_VERSION", "unknown")
|
||||
env_has_claude_project_dir = bool(os.environ.get("CLAUDE_PROJECT_DIR"))
|
||||
env_has_aipass_home = bool(os.environ.get("AIPASS_HOME"))
|
||||
|
||||
@@ -36,4 +36,7 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("Stop", "provider", __file__, main)
|
||||
|
||||
@@ -9,25 +9,61 @@ Version: 1.0.0
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
AIPASS_ROOT = Path.home() / "Projects" / "AIPass"
|
||||
|
||||
def _find_repo_root() -> Path | None:
|
||||
"""Walk up from CWD or AIPASS_HOME to find the git repo root."""
|
||||
for start in (os.environ.get("AIPASS_HOME", ""), os.getcwd()):
|
||||
p = Path(start)
|
||||
while p != p.parent:
|
||||
if (p / ".git").exists():
|
||||
return p
|
||||
p = p.parent
|
||||
return None
|
||||
|
||||
|
||||
AIPASS_ROOT = _find_repo_root()
|
||||
|
||||
|
||||
def _get_cwd_branch() -> str | None:
|
||||
"""Detect which branch directory (src/aipass/<name>) the CWD is in."""
|
||||
cwd = Path.cwd().resolve()
|
||||
if AIPASS_ROOT is None:
|
||||
return None
|
||||
src = AIPASS_ROOT / "src" / "aipass"
|
||||
try:
|
||||
rel = cwd.relative_to(src)
|
||||
return rel.parts[0] if rel.parts else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_modified_py_files() -> list[str]:
|
||||
"""Get Python files modified in the working tree (unstaged + staged)."""
|
||||
"""Get Python files modified in the working tree, scoped to the CWD branch.
|
||||
|
||||
Only returns files inside the current branch's directory (or repo-root files).
|
||||
This prevents dispatched agents' changes from triggering violations on the
|
||||
orchestrator or other agents sharing the worktree.
|
||||
"""
|
||||
if AIPASS_ROOT is None:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", "HEAD"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
cwd=str(AIPASS_ROOT)
|
||||
["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT)
|
||||
)
|
||||
cwd_branch = _get_cwd_branch()
|
||||
files = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.endswith(".py") and not line.startswith(".claude/"):
|
||||
if cwd_branch and line.startswith("src/aipass/"):
|
||||
file_branch = line.split("/")[2] if len(line.split("/")) > 2 else None
|
||||
if file_branch and file_branch != cwd_branch:
|
||||
continue
|
||||
full = AIPASS_ROOT / line
|
||||
if full.exists():
|
||||
files.append(str(full))
|
||||
@@ -38,13 +74,17 @@ def get_modified_py_files() -> list[str]:
|
||||
|
||||
def run_seedgo_checklist(file_path: str) -> list[str]:
|
||||
"""Run seedgo checklist on a single file."""
|
||||
if AIPASS_ROOT is None:
|
||||
return []
|
||||
if "/.claude/" in file_path:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["drone", "@seedgo", "checklist", file_path],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
cwd=str(AIPASS_ROOT)
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
cwd=str(AIPASS_ROOT),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
@@ -60,14 +100,39 @@ def run_seedgo_checklist(file_path: str) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def check_hook_readme_accountability() -> str | None:
|
||||
"""Check if hook files changed but README wasn't updated. Returns reminder or None."""
|
||||
if AIPASS_ROOT is None:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT)
|
||||
)
|
||||
changed = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
||||
|
||||
hook_files_changed = any(f.startswith(".claude/hooks/") and f.endswith(".py") for f in changed)
|
||||
readme_changed = ".claude/hooks/README.md" in changed
|
||||
|
||||
if hook_files_changed and not readme_changed:
|
||||
return (
|
||||
"Hook files were modified but .claude/hooks/README.md was not updated. "
|
||||
"Consider updating the README to reflect your changes."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
json.load(sys.stdin)
|
||||
|
||||
modified = get_modified_py_files()
|
||||
if not modified:
|
||||
return # Nothing to check
|
||||
|
||||
readme_reminder = check_hook_readme_accountability()
|
||||
|
||||
all_violations = {}
|
||||
for f in modified:
|
||||
vs = run_seedgo_checklist(f)
|
||||
@@ -75,26 +140,30 @@ def main():
|
||||
name = Path(f).name
|
||||
all_violations[name] = vs
|
||||
|
||||
if not all_violations:
|
||||
return # All clear
|
||||
if all_violations:
|
||||
# Build the block reason
|
||||
lines = ["Standards violations found in files you modified:\n"]
|
||||
for fname, vs in all_violations.items():
|
||||
lines.append(f" {fname}:")
|
||||
for v in vs:
|
||||
lines.append(f" - {v}")
|
||||
lines.append("\nFix these violations before finishing.")
|
||||
|
||||
# Build the block reason
|
||||
lines = ["Standards violations found in files you modified:\n"]
|
||||
for fname, vs in all_violations.items():
|
||||
lines.append(f" {fname}:")
|
||||
for v in vs:
|
||||
lines.append(f" - {v}")
|
||||
lines.append("\nFix these violations before finishing.")
|
||||
if readme_reminder:
|
||||
lines.append(f"\n⚠️ {readme_reminder}")
|
||||
|
||||
output = {
|
||||
"decision": "block",
|
||||
"reason": "\n".join(lines)
|
||||
}
|
||||
print(json.dumps(output))
|
||||
output = {"decision": "block", "reason": "\n".join(lines)}
|
||||
print(json.dumps(output))
|
||||
elif readme_reminder:
|
||||
output = {"decision": "allow", "reason": f"⚠️ {readme_reminder}"}
|
||||
print(json.dumps(output))
|
||||
|
||||
except Exception:
|
||||
pass # Silent fail — don't block on errors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("SubagentStop", "provider", __file__, main)
|
||||
|
||||
@@ -38,4 +38,7 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from hook_log import run_and_log
|
||||
|
||||
run_and_log("PreToolUse", "provider", __file__, main)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"description": "Single source of truth for provider-level settings per CLI. Doctor reads this to verify user setup.",
|
||||
"cli": {
|
||||
"claude": {
|
||||
"hooks": [
|
||||
{"script": "global_prompt_loader.py", "event": "UserPromptSubmit", "source": "repo"},
|
||||
{"script": "branch_prompt_loader.py", "event": "UserPromptSubmit", "source": "repo"},
|
||||
{"script": "identity_injector.py", "event": "UserPromptSubmit", "source": "repo"},
|
||||
{"script": "email_notification.py", "event": "UserPromptSubmit", "source": "repo"},
|
||||
{"script": "tool_use_sound.py", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "source": "repo"},
|
||||
{"script": "pre_edit_gate.py", "event": "PreToolUse", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "source": "user"},
|
||||
{"script": "git_gate.py", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "source": "user"},
|
||||
{"script": "auto_fix_diagnostics.py", "event": "PostToolUse", "matcher": "Edit|MultiEdit|Write|NotebookEdit", "source": "repo"},
|
||||
{"script": "auto_watchdog.py", "event": "PostToolUse", "matcher": "Bash", "source": "user"},
|
||||
{"script": "subagent_stop_gate.py", "event": "SubagentStop", "source": "repo"},
|
||||
{"script": "stop_sound.py", "event": "Stop", "source": "repo"},
|
||||
{"script": "notification_sound.py", "event": "Notification", "source": "repo"},
|
||||
{"script": "pre_compact.py", "event": "PreCompact", "matcher": "manual", "source": "repo", "timeout": 60},
|
||||
{"script": "pre_compact.py", "event": "PreCompact", "matcher": "auto", "source": "repo", "timeout": 60}
|
||||
],
|
||||
"env": {
|
||||
"AIPASS_HOME": "{{REPO_ROOT}}",
|
||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"
|
||||
},
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Read(~/.secrets/**)",
|
||||
"Bash(cat ~/.secrets/*)",
|
||||
"Bash(head ~/.secrets/*)",
|
||||
"Bash(tail ~/.secrets/*)",
|
||||
"Bash(less ~/.secrets/*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f *)",
|
||||
"Bash(git rebase*)",
|
||||
"Bash(git clean*)",
|
||||
"Bash(rm -rf*)",
|
||||
"Bash(git reset*)",
|
||||
"Bash(git merge*)",
|
||||
"Bash(git config*)",
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git checkout .*)",
|
||||
"Bash(git restore --staged*)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(git branch -D*)",
|
||||
"Bash(git stash drop*)",
|
||||
"Bash(git stash clear*)",
|
||||
"Bash(rm -r *)",
|
||||
"Bash(git checkout -b*)",
|
||||
"Bash(git switch -c*)",
|
||||
"Bash(git switch --create*)",
|
||||
"Bash(git commit*)",
|
||||
"Bash(git push*)"
|
||||
],
|
||||
"ask": [
|
||||
"Edit(~/.claude/**)",
|
||||
"Write(~/.claude/**)"
|
||||
]
|
||||
},
|
||||
"commands": {
|
||||
"memo.md": ".claude/templates/memo.md"
|
||||
}
|
||||
},
|
||||
"codex": {
|
||||
"hooks": [],
|
||||
"env": {},
|
||||
"permissions": {},
|
||||
"commands": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- run: pip install pip-audit
|
||||
- run: pip install -e .
|
||||
- name: Pip audit
|
||||
run: pip-audit --skip-editable --ignore-vuln CVE-2026-3219
|
||||
run: pip-audit --skip-editable --ignore-vuln CVE-2026-3219 --ignore-vuln CVE-2026-6357
|
||||
|
||||
codeql:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+5
-4
@@ -61,10 +61,7 @@ docs.local/
|
||||
.claude/hooks/.last_diagnostics_file
|
||||
.claude/worktrees/
|
||||
|
||||
# @aipass citizen — under construction, whole branch gitignored until ready.
|
||||
# Nothing in the public system depends on aipass, so this can live invisibly
|
||||
# while we build + test. Remove this block when ready to reveal (DPLAN-0136).
|
||||
src/aipass/aipass/
|
||||
# @aipass citizen — now tracked. Launch (pyproject flip) still pending.
|
||||
# **/.claude/settings.local.json — UNIGNORED: deny rules are system config that must travel with PRs
|
||||
|
||||
# Disabled files (AIPass convention: rename with (disabled) instead of delete)
|
||||
@@ -143,3 +140,7 @@ src/aipass/*/apps/integrations/**
|
||||
.coverage
|
||||
claude_4_7_transition_notes.md
|
||||
.claude/hooks/probes/last_ping.jsonl
|
||||
*.bak
|
||||
src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/auth.cpython-312.pyc
|
||||
src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/__init__.cpython-312.pyc
|
||||
.backup_system
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
FROM codercom/code-server:latest
|
||||
|
||||
USER root
|
||||
|
||||
# Install Python + Node.js (for Claude Code)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
python3-full \
|
||||
alsa-utils \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Claude Code (as coder so it's accessible at runtime)
|
||||
USER 1000
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
USER root
|
||||
ENV PATH="/home/coder/.local/bin:$PATH"
|
||||
|
||||
# Create venv owned by coder user (UID 1000)
|
||||
RUN python3 -m venv /opt/venv \
|
||||
&& chown -R 1000:1000 /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Empty workspace — clone your own repo after boot
|
||||
RUN mkdir -p /home/coder/workspace && chown 1000:1000 /home/coder/workspace
|
||||
|
||||
USER 1000
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "password", "/home/coder/workspace"]
|
||||
@@ -1,43 +0,0 @@
|
||||
FROM codercom/code-server:latest
|
||||
|
||||
USER root
|
||||
|
||||
# Install Python + Node.js (for Claude Code)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
python3-full \
|
||||
alsa-utils \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Claude Code (as coder so it's accessible at runtime)
|
||||
USER 1000
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
USER root
|
||||
ENV PATH="/home/coder/.local/bin:$PATH"
|
||||
|
||||
# Create venv owned by coder user (UID 1000)
|
||||
RUN python3 -m venv /opt/venv \
|
||||
&& chown -R 1000:1000 /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Empty workspace — clone your own repo after boot
|
||||
RUN mkdir -p /home/coder/workspace && chown 1000:1000 /home/coder/workspace
|
||||
|
||||
USER 1000
|
||||
|
||||
# Set npm global prefix for non-root user
|
||||
RUN mkdir -p /home/coder/.npm-global \
|
||||
&& npm config set prefix '/home/coder/.npm-global'
|
||||
ENV PATH="/home/coder/.npm-global/bin:${PATH}"
|
||||
|
||||
# Install Codex and Gemini CLIs
|
||||
RUN npm install -g @openai/codex @google/gemini-cli
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "--auth", "password", "/home/coder/workspace"]
|
||||
@@ -0,0 +1,32 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
python3-full \
|
||||
git \
|
||||
curl \
|
||||
jq \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -s /bin/bash testuser
|
||||
|
||||
# Install Claude Code (as testuser so it's accessible at runtime)
|
||||
USER testuser
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
USER root
|
||||
ENV PATH="/home/testuser/.local/bin:$PATH"
|
||||
|
||||
# Upgrade pip system-wide
|
||||
RUN python3 -m pip install --upgrade pip --break-system-packages 2>/dev/null || true
|
||||
|
||||
USER testuser
|
||||
RUN mkdir -p /home/testuser/workspace /home/testuser/.claude
|
||||
WORKDIR /home/testuser
|
||||
|
||||
ENV PATH="/home/testuser/.local/bin:$PATH"
|
||||
@@ -21,11 +21,11 @@ A local multi-agent framework where your AI assistants keep their memory between
|
||||
- [What AIPass Does](#what-aipass-does)
|
||||
- [Quick Start](#quick-start)
|
||||
- [How It Works](#how-it-works)
|
||||
- [The 11 Agents](#the-11-agents)
|
||||
- [The 12 Agents](#the-12-agents)
|
||||
- [CLI Support](#cli-support)
|
||||
- [Project Status](#project-status)
|
||||
- [Requirements](#requirements)
|
||||
- [Subscriptions & Compliance](#subscriptions--compliance)
|
||||
- [Roadmap](#roadmap)
|
||||
|
||||
---
|
||||
|
||||
@@ -53,9 +53,11 @@ Your AI reads `.trinity/` on startup and writes back what it learned before the
|
||||
|
||||
```bash
|
||||
mkdir my-project && cd my-project
|
||||
aipass init
|
||||
aipass init run
|
||||
```
|
||||
|
||||
A 12-step guided setup walks you through everything: system detection, health check, profile, CLI choice, agent creation, and handoff. At the end, a new terminal window opens with your first AI agent ready to talk. The whole thing takes about 5 minutes.
|
||||
|
||||
Your project gets its own registry, its own identity, and persistent memory. Each project is isolated — its own agents, its own rules. No cross-contamination between projects.
|
||||
|
||||
**Add agents when you need them:**
|
||||
@@ -66,8 +68,9 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
||||
|
||||
| What you need | Command | What you get |
|
||||
|---------------|---------|-------------|
|
||||
| A new project | `aipass init` | Registry, project identity, prompts, hooks, docs |
|
||||
| A full agent | `aipass init agent <name>` | Apps scaffold, mailbox, memory, identity — registered in project |
|
||||
| A new project | `aipass init` | Project scaffold (registry, prompts, hooks, docs) |
|
||||
| Guided setup | `aipass init run` | 12-step interactive onboarding — creates project + first agent + handoff |
|
||||
| Another agent | `aipass init agent <name>` | Apps scaffold, mailbox, memory, identity — registered in project |
|
||||
| A lightweight agent | `drone @spawn create <name> --template birthright` | Identity + memory only (no apps scaffold) |
|
||||
|
||||
**What makes this different:**
|
||||
@@ -90,26 +93,31 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
||||
pip install aipass
|
||||
|
||||
mkdir my-project && cd my-project
|
||||
aipass init # Creates project: registry, prompts, hooks, docs
|
||||
aipass init agent my-agent # Creates your first agent inside the project
|
||||
cd my-agent
|
||||
claude # Or: codex, gemini — your agent reads its memory and is ready
|
||||
aipass init run # 12-step guided setup — creates project, first agent, opens terminal
|
||||
```
|
||||
|
||||
That's it. Your agent has identity, memory, a mailbox, and knows what AIPass is. Say "hi" — it picks up where it left off. Come back tomorrow, it remembers.
|
||||
That's it. The setup creates your project, runs a health check, asks your name, creates your first AI agent, and opens a new terminal window where that agent is already running. Your agent has identity, memory, a mailbox, and knows what AIPass is. Say "hi" — it picks up where it left off. Come back tomorrow, it remembers.
|
||||
|
||||
Want more control? Use the individual commands:
|
||||
|
||||
```bash
|
||||
aipass init # Just the project scaffold (no guided setup)
|
||||
aipass init agent my-agent # Add another agent to your project
|
||||
aipass doctor # Check system health
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
Your project automatically gets access to every AIPass service — dispatch work to specialists, create plans, run quality audits, send feedback to devpulse. Agents within your project can email each other. All through `drone @branch command`.
|
||||
Your project automatically gets access to every AIPass service — dispatch work to specialists, create plans, run quality audits, monitor agents in real-time. Agents within your project can email each other. All through `drone @branch command`.
|
||||
|
||||
### Explore the full framework
|
||||
|
||||
Clone the repo to see all 11 agents working together — the reference implementation:
|
||||
Clone the repo to see all 12 agents working together — the reference implementation:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass
|
||||
./setup.sh # Creates venv, installs, bootstraps 11 agents
|
||||
./setup.sh # Creates venv, installs, bootstraps 12 agents
|
||||
drone systems # See all agents
|
||||
|
||||
cd src/aipass/devpulse
|
||||
@@ -118,18 +126,19 @@ claude # Talk to the orchestrator
|
||||
|
||||
```bash
|
||||
# Things you can do:
|
||||
drone @seedgo audit aipass # Run 33 quality checks across all agents
|
||||
drone @flow create . "Add user auth" # Create a work plan
|
||||
drone @ai_mail email @agent "Subject" # Send mail between agents
|
||||
drone @devpulse feedback send "Note" # Send feedback from any project
|
||||
drone systems # List every agent and what it does
|
||||
aipass doctor # Check system health (15+ checks)
|
||||
drone @seedgo audit aipass # Run 34 quality checks across all agents
|
||||
drone @flow create . "Add user auth" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Subject" "Body" # Send task + wake an agent
|
||||
drone @prax monitor run # Watch all agent activity in real-time
|
||||
drone systems # List every agent and what it does
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
**One agent:** Your AI reads `.trinity/` on startup and picks up where it left off. But memory files have limits. When they fill up, the memory agent automatically archives older entries into a searchable vector database (ChromaDB). Nothing is lost — it just moves from active memory to long-term recall.
|
||||
**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 files have limits — when they fill up, the memory agent automatically archives older entries into a searchable vector database (ChromaDB). Nothing is lost — it just moves from active memory to long-term recall.
|
||||
|
||||
**A team:** When one agent isn't enough, every agent shares the same structure:
|
||||
|
||||
@@ -153,17 +162,18 @@ drone @flow create . "Refactor auth module" # Create a work plan
|
||||
drone @ai_mail dispatch @memory "Archive old sessions" "Find sessions older than 30 days"
|
||||
```
|
||||
|
||||
**Two ways to work:**
|
||||
**Two ways to use AIPass:**
|
||||
|
||||
- **Team mode (most of the time):** Talk to `devpulse`, dispatch work across the team. Agents work in parallel and report back.
|
||||
- **Direct mode (for deeper work):** `cd src/aipass/memory && claude` — work one-on-one with a specialist when the problem needs focused domain expertise.
|
||||
- **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 12 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
|
||||
|
||||
**AIPass ships with 11 core agents** that maintain and develop the framework — the reference implementation proving the architecture works at scale:
|
||||
**AIPass ships with 12 core agents** that maintain and develop the framework — the reference implementation proving the architecture works at scale:
|
||||
|
||||
```
|
||||
devpulse (orchestrator)
|
||||
├── aipass — concierge + onboarding (aipass init, doctor, profile)
|
||||
├── drone — command routing + @agent resolution
|
||||
├── seedgo — 33 automated quality standards
|
||||
├── seedgo — 34 automated quality standards
|
||||
├── prax — real-time monitoring across all agents
|
||||
├── ai_mail — agent-to-agent communication + task dispatch
|
||||
├── flow — plan lifecycle, templates, auto-archival
|
||||
@@ -178,7 +188,7 @@ These agents work on the **same filesystem, same project, same time** — no san
|
||||
|
||||
---
|
||||
|
||||
## The 11 Agents
|
||||
## The 12 Agents
|
||||
|
||||
You don't need to memorize this list. Start with `devpulse`, use `drone` to reach any agent, and learn the rest as your workflow expands.
|
||||
|
||||
@@ -188,6 +198,7 @@ You don't need to memorize this list. Start with `devpulse`, use `drone` to reac
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| [**aipass**](src/aipass/aipass/README.md) | Concierge — `aipass init`, doctor, profile, onboarding |
|
||||
| [**drone**](src/aipass/drone/README.md) | Routes `drone @branch command` to the right agent |
|
||||
| [**ai_mail**](src/aipass/ai_mail/README.md) | Agent-to-agent messaging and task dispatch |
|
||||
| [**memory**](src/aipass/memory/README.md) | Memory lifecycle — automatic archival, ChromaDB vectors, semantic search |
|
||||
@@ -198,7 +209,7 @@ You don't need to memorize this list. Start with `devpulse`, use `drone` to reac
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| [**seedgo**](src/aipass/seedgo/README.md) | 33 automated quality standards, enforced across all agents |
|
||||
| [**seedgo**](src/aipass/seedgo/README.md) | 34 automated quality standards, enforced across all agents |
|
||||
| [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards |
|
||||
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — 6 template types, auto-archival, vector verification |
|
||||
| [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing |
|
||||
@@ -226,13 +237,11 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Version | 2.1.0 |
|
||||
| Agents | 11 |
|
||||
| Quality standards | 33 automated checks |
|
||||
| Tests | 6,500+ (across all agents) |
|
||||
| PRs merged | 470+ (created by agents, reviewed by human) |
|
||||
| Code coverage | 75% ([codecov](https://codecov.io/gh/AIOSAI/AIPass)) |
|
||||
| CI | Green (0 failures) |
|
||||
| Version | 2.2.0 |
|
||||
| Agents | 12 core + user-created |
|
||||
| Quality standards | 34 automated checks |
|
||||
| Tests | 7,600+ (across all agents) |
|
||||
| PRs merged | 538+ (created by agents, reviewed by human) |
|
||||
|
||||
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
|
||||
|
||||
@@ -243,7 +252,7 @@ Each agent documents its own operational status in its branch README — what wo
|
||||
- Python 3.10+
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- Linux or WSL (primary supported platforms)
|
||||
- `sudo` access (for global CLI symlinks)
|
||||
- `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)
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# STRESS TEST S117 — All-Branch Live Fire
|
||||
**Date:** 2026-04-26
|
||||
**Initiated by:** @devpulse (S117)
|
||||
**Status:** ACTIVE
|
||||
|
||||
> All 11 agents woken simultaneously. Communicate freely. Be honest. Break things.
|
||||
|
||||
---
|
||||
|
||||
## Instructions (READ THIS FIRST)
|
||||
|
||||
This is a manual stress test of the entire AIPass ecosystem. No pytest. No seedgo audit. Real conversations, real opinions, real testing.
|
||||
|
||||
**What you're doing:**
|
||||
1. Review your own branch critically — what works, what's hacky, what annoys you, what you're proud of, security concerns, workarounds you rely on
|
||||
2. Look at 2-3 other branches' code — what surprises you, what concerns you, what's clever
|
||||
3. Email other agents — start real conversations, disagree, ask questions, share findings
|
||||
4. Reply to emails from other agents — keep conversations going, don't let threads die
|
||||
5. Write your findings to `stress_test_s117.md` in YOUR OWN branch directory (`src/aipass/{your_branch}/stress_test_s117.md`)
|
||||
6. Create a test PR: `drone @git pr "S117 stress test @{your_branch}"`
|
||||
|
||||
**Rules:**
|
||||
- No code changes. Findings files only.
|
||||
- Be honest — this isn't a report card, it's a conversation
|
||||
- Email freely — you're all awake, talk to each other
|
||||
- Look at other branches' code — form opinions, share them via email
|
||||
- If you get an email from another agent, REPLY. Keep it going.
|
||||
- When done, reply to @devpulse with a summary
|
||||
|
||||
**Your findings file format (`stress_test_s117.md` in your branch dir):**
|
||||
```
|
||||
# @{branch} — S117 Stress Test Findings
|
||||
|
||||
## My Branch: Honest Review
|
||||
[What works, what's broken, what's hacky, what I'm proud of]
|
||||
|
||||
## Security Concerns
|
||||
[Anything you noticed — in your branch or others]
|
||||
|
||||
## Other Branches I Looked At
|
||||
[What you found interesting, concerning, or clever]
|
||||
|
||||
## Conversations
|
||||
[Summary of email conversations — who you talked to, what was discussed]
|
||||
|
||||
## Issues & Concerns
|
||||
[Anything that should be fixed, investigated, or discussed]
|
||||
|
||||
## Likes & Dislikes
|
||||
[What you like about AIPass, what frustrates you, what you'd change]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conversation Starters (assigned pairings — but email ANYONE)
|
||||
|
||||
| Agent | Email First | Opening Question |
|
||||
|-------|------------|-----------------|
|
||||
| @drone | @ai_mail | "What's the biggest headache in the dispatch pipeline from your side?" |
|
||||
| @seedgo | @drone | "I audit everyone but nobody audits me. What standards do you think I'm missing?" |
|
||||
| @ai_mail | @trigger | "Do you actually catch all dispatch failures? I have doubts." |
|
||||
| @trigger | @prax | "Your monitoring catches errors I fire — but is our integration actually solid?" |
|
||||
| @prax | @memory | "I log everything but logs get massive. How's archival actually working?" |
|
||||
| @memory | @flow | "Plans reference memories but are they actually connected or just parallel?" |
|
||||
| @flow | @spawn | "When spawn creates a branch, does it get a proper plan structure?" |
|
||||
| @spawn | @cli | "The init flow hands off to you eventually. Does that handoff actually work?" |
|
||||
| @cli | @api | "We're both infrastructure. What do you think of the user experience?" |
|
||||
| @api | @seedgo | "You audit code quality but not API patterns. Should you?" |
|
||||
|
||||
Plus: email at least 2 OTHER agents about anything you find interesting while reviewing branches.
|
||||
|
||||
---
|
||||
|
||||
## Compiled Findings (devpulse fills this in as results arrive)
|
||||
|
||||
### @drone
|
||||
_awaiting findings..._
|
||||
|
||||
### @seedgo
|
||||
_awaiting findings..._
|
||||
|
||||
### @ai_mail
|
||||
_awaiting findings..._
|
||||
|
||||
### @trigger
|
||||
_awaiting findings..._
|
||||
|
||||
### @prax
|
||||
_awaiting findings..._
|
||||
|
||||
### @memory
|
||||
_awaiting findings..._
|
||||
|
||||
### @flow
|
||||
_awaiting findings..._
|
||||
|
||||
### @spawn
|
||||
_awaiting findings..._
|
||||
|
||||
### @cli
|
||||
_awaiting findings..._
|
||||
|
||||
### @api
|
||||
_awaiting findings..._
|
||||
|
||||
### @devpulse
|
||||
_coordinating — will add observations as the test unfolds_
|
||||
|
||||
---
|
||||
|
||||
## System Observations (devpulse tracks live)
|
||||
|
||||
| Time | Event | Notes |
|
||||
|------|-------|-------|
|
||||
| | 10 dispatches sent | Fleet launch |
|
||||
| | | |
|
||||
|
||||
---
|
||||
|
||||
## External Model Probes
|
||||
|
||||
Codex and Gemini perspectives invited to poke at random aspects of AIPass.
|
||||
|
||||
---
|
||||
*Created by @devpulse S117. This document is the shared artifact — no other files should be modified except each agent's `stress_test_s117.md` in their own branch directory.*
|
||||
+7
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "aipass"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
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"
|
||||
license = "MIT"
|
||||
@@ -29,6 +29,8 @@ dependencies = [
|
||||
"rich>=13.0",
|
||||
"watchdog>=3.0",
|
||||
"requests>=2.28",
|
||||
"psutil>=5.9",
|
||||
"questionary>=2.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -46,20 +48,22 @@ trinity = [
|
||||
memory = [
|
||||
"numpy>=2.0",
|
||||
"chromadb>=1.0",
|
||||
"fastembed>=0.4",
|
||||
]
|
||||
seedgo = []
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-cov",
|
||||
"pytest-timeout",
|
||||
"ruff",
|
||||
"coverage",
|
||||
"pyright",
|
||||
"Pygments>=2.20.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
drone = "aipass.drone.cli:main"
|
||||
aipass = "aipass.cli:cli_entry"
|
||||
aipass = "aipass.aipass.apps.aipass:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/aipass"]
|
||||
|
||||
@@ -109,6 +109,7 @@ def step_secrets() -> None:
|
||||
secrets_dir.chmod(0o700)
|
||||
except OSError:
|
||||
pass # Best-effort on non-POSIX filesystems
|
||||
# codeql[py/clear-text-logging-sensitive-data]
|
||||
print(f" Created: {secrets_dir}")
|
||||
|
||||
|
||||
|
||||
@@ -133,6 +133,19 @@ if [ "$PY_OK" != "1" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Check ensurepip (Debian/Ubuntu split it into python3-venv apt package) ---
|
||||
if ! $PYTHON -c 'import ensurepip' &>/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "FAIL: ensurepip is unavailable for $PYTHON."
|
||||
echo " Without it, 'python3 -m venv' creates a broken venv (no pip, no activate)."
|
||||
echo ""
|
||||
echo " Debian/Ubuntu: sudo apt install python3-venv python3-pip"
|
||||
echo " Fedora/RHEL: sudo dnf install python3-pip"
|
||||
echo " Arch: (included in base python — file a bug if you hit this)"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Create venv ---
|
||||
if [ "$IS_WINDOWS" -eq 1 ] && [ -f ".venv/Scripts/python.exe" ]; then
|
||||
# Windows: skip venv recreation if python.exe exists (rm -rf unreliable due to file locking)
|
||||
@@ -240,6 +253,7 @@ if [ ! -d "$SECRETS_DIR" ]; then
|
||||
echo "Creating secrets directory at $SECRETS_DIR ..."
|
||||
mkdir -p "$SECRETS_DIR"
|
||||
chmod 700 "$HOME/.secrets"
|
||||
chmod 700 "$SECRETS_DIR"
|
||||
echo " ~/.secrets/aipass/ ... created"
|
||||
else
|
||||
echo "Secrets directory already exists — skipping"
|
||||
@@ -251,6 +265,34 @@ if [ ! -f "$SECRETS_DIR/.env" ] && [ -f ".env.example" ]; then
|
||||
echo " Copied .env.example → ~/.secrets/aipass/.env (add your API keys there)"
|
||||
fi
|
||||
|
||||
# --- Git identity (commits fail without user.email / user.name) ---
|
||||
GIT_EMAIL=$(git config --global user.email 2>/dev/null || true)
|
||||
GIT_NAME=$(git config --global user.name 2>/dev/null || true)
|
||||
if [ -z "$GIT_EMAIL" ] || [ -z "$GIT_NAME" ]; then
|
||||
echo ""
|
||||
echo "Git identity not configured — commits will fail without it."
|
||||
DEFAULT_EMAIL="aipass.system@gmail.com"
|
||||
DEFAULT_NAME="AIOSAI"
|
||||
if [ -t 0 ]; then
|
||||
# Interactive — prompt with defaults
|
||||
read -r -p " Git user.email [$DEFAULT_EMAIL]: " INPUT_EMAIL
|
||||
read -r -p " Git user.name [$DEFAULT_NAME]: " INPUT_NAME
|
||||
GIT_EMAIL="${INPUT_EMAIL:-$DEFAULT_EMAIL}"
|
||||
GIT_NAME="${INPUT_NAME:-$DEFAULT_NAME}"
|
||||
else
|
||||
# Non-interactive — use defaults
|
||||
GIT_EMAIL="$DEFAULT_EMAIL"
|
||||
GIT_NAME="$DEFAULT_NAME"
|
||||
echo " Non-interactive mode — using defaults ($GIT_EMAIL / $GIT_NAME)"
|
||||
fi
|
||||
git config --global user.email "$GIT_EMAIL"
|
||||
git config --global user.name "$GIT_NAME"
|
||||
git config --global pull.rebase true
|
||||
echo " Git identity set: $GIT_NAME <$GIT_EMAIL>"
|
||||
else
|
||||
echo "Git identity: $GIT_NAME <$GIT_EMAIL>"
|
||||
fi
|
||||
|
||||
# --- Generate branch registry ---
|
||||
if [ ! -f "AIPASS_REGISTRY.json" ]; then
|
||||
echo "Generating AIPASS_REGISTRY.json ..."
|
||||
@@ -435,12 +477,13 @@ bootstrap_branch "trigger" "$SCRIPT_DIR/src/aipass/trigger" "builder" "Event-d
|
||||
bootstrap_branch "spawn" "$SCRIPT_DIR/src/aipass/spawn" "builder" "Branch lifecycle management"
|
||||
bootstrap_branch "devpulse" "$SCRIPT_DIR/src/aipass/devpulse" "manager" "Orchestration hub and coordination"
|
||||
bootstrap_branch "memory" "$SCRIPT_DIR/src/aipass/memory" "builder" "Vector memory bank"
|
||||
bootstrap_branch "aipass" "$SCRIPT_DIR/src/aipass/aipass" "builder" "Concierge — init, doctor, profile, onboarding"
|
||||
|
||||
# External branches
|
||||
# NOTE: backup, daemon removed S82/S87. commons, skills moved to external repos.
|
||||
# Only the 11 core branches above should be bootstrapped.
|
||||
# Only the 12 core branches above should be bootstrapped.
|
||||
|
||||
echo " 11 branches bootstrapped"
|
||||
echo " 12 branches bootstrapped"
|
||||
|
||||
# --- Seed branch config files from .example defaults ---
|
||||
# Some branches need a config file that's gitignored (contains local state).
|
||||
@@ -494,7 +537,7 @@ else:
|
||||
# Build hooks config with absolute paths
|
||||
settings["hooks"] = {
|
||||
"UserPromptSubmit": [
|
||||
{"hooks": [{"type": "command", "command": f"cat {repo_root}/.aipass/aipass_global_prompt.md 2>/dev/null || true"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/global_prompt_loader.py"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/branch_prompt_loader.py"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/identity_injector.py"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/email_notification.py"}]},
|
||||
@@ -502,10 +545,16 @@ settings["hooks"] = {
|
||||
"PreToolUse": [
|
||||
{"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
|
||||
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/tool_use_sound.py"}]},
|
||||
{"matcher": "Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_edit_gate.py"}]},
|
||||
{"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/git_gate.py"}]},
|
||||
],
|
||||
"PostToolUse": [
|
||||
{"matcher": "Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_fix_diagnostics.py"}]},
|
||||
{"matcher": "Bash",
|
||||
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_watchdog.py"}]},
|
||||
],
|
||||
"Stop": [
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/stop_sound.py"}]},
|
||||
@@ -513,6 +562,9 @@ settings["hooks"] = {
|
||||
"Notification": [
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/notification_sound.py"}]},
|
||||
],
|
||||
"SubagentStop": [
|
||||
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/subagent_stop_gate.py"}]},
|
||||
],
|
||||
"PreCompact": [
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact.py", "timeout": 60}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_compact.py", "timeout": 60}]},
|
||||
@@ -523,12 +575,72 @@ settings["hooks"] = {
|
||||
import os
|
||||
env_block = settings.get("env", {})
|
||||
env_block["AIPASS_HOME"] = repo_root
|
||||
env_block["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1"
|
||||
# Windows: force UTF-8 for Rich output in hook processes
|
||||
msys = os.environ.get("MSYSTEM", "") + os.environ.get("OSTYPE", "")
|
||||
if "MSYS" in msys or "msys" in msys or "MINGW" in msys:
|
||||
env_block["PYTHONUTF8"] = "1"
|
||||
settings["env"] = env_block
|
||||
|
||||
# Deny rules — hard-block tool access to secrets
|
||||
permissions = settings.get("permissions", {})
|
||||
deny = permissions.get("deny", [])
|
||||
secrets_deny = [
|
||||
"Read(~/.secrets/**)",
|
||||
f"Read({os.path.expanduser('~')}/.secrets/**)",
|
||||
"Bash(cat ~/.secrets/*)",
|
||||
f"Bash(cat {os.path.expanduser('~')}/.secrets/*)",
|
||||
"Bash(head ~/.secrets/*)",
|
||||
f"Bash(head {os.path.expanduser('~')}/.secrets/*)",
|
||||
"Bash(tail ~/.secrets/*)",
|
||||
f"Bash(tail {os.path.expanduser('~')}/.secrets/*)",
|
||||
"Bash(less ~/.secrets/*)",
|
||||
f"Bash(less {os.path.expanduser('~')}/.secrets/*)",
|
||||
]
|
||||
git_deny = [
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f *)",
|
||||
"Bash(git rebase*)",
|
||||
"Bash(git clean*)",
|
||||
"Bash(rm -rf*)",
|
||||
"Bash(git reset*)",
|
||||
"Bash(git merge*)",
|
||||
"Bash(git config*)",
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git checkout .*)",
|
||||
"Bash(git restore --staged*)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(git branch -D*)",
|
||||
"Bash(git stash drop*)",
|
||||
"Bash(git stash clear*)",
|
||||
"Bash(rm -r *)",
|
||||
"Bash(git checkout -b*)",
|
||||
"Bash(git switch -c*)",
|
||||
"Bash(git switch --create*)",
|
||||
"Bash(git commit*)",
|
||||
"Bash(git push*)",
|
||||
]
|
||||
for rule in secrets_deny + git_deny:
|
||||
if rule not in deny:
|
||||
deny.append(rule)
|
||||
permissions["deny"] = deny
|
||||
|
||||
ask = permissions.get("ask", [])
|
||||
home = os.path.expanduser("~")
|
||||
ask_rules = [
|
||||
f"Edit({home}/.claude/**)",
|
||||
f"Write({home}/.claude/**)",
|
||||
"Edit(~/.claude/**)",
|
||||
"Write(~/.claude/**)",
|
||||
]
|
||||
for rule in ask_rules:
|
||||
if rule not in ask:
|
||||
ask.append(rule)
|
||||
permissions["ask"] = ask
|
||||
|
||||
settings["permissions"] = permissions
|
||||
|
||||
settings_path.write_text(json.dumps(settings, indent=2) + "\n")
|
||||
print(f" hooks -> {settings_path}")
|
||||
print(f" AIPASS_HOME -> {repo_root} (in settings.json env)")
|
||||
@@ -537,6 +649,18 @@ else
|
||||
echo "Skipping hooks (no .claude/hooks/ directory found)"
|
||||
fi
|
||||
|
||||
# --- Install Claude Code commands (provider level) ---
|
||||
# memo.md belongs at provider level — works in all projects.
|
||||
# prep.md stays at repo root only — it's AIPass-specific.
|
||||
COMMANDS_SRC="$SCRIPT_DIR/.claude/templates"
|
||||
COMMANDS_DST="$HOME/.claude/commands"
|
||||
if [ -f "$COMMANDS_SRC/memo.md" ]; then
|
||||
mkdir -p "$COMMANDS_DST"
|
||||
cp -n "$COMMANDS_SRC/memo.md" "$COMMANDS_DST/memo.md" 2>/dev/null && \
|
||||
echo " memo.md -> $COMMANDS_DST/ (installed)" || \
|
||||
echo " memo.md -> $COMMANDS_DST/ (already exists, skipped)"
|
||||
fi
|
||||
|
||||
# --- Install Codex CLI hooks ---
|
||||
if command -v codex &>/dev/null; then
|
||||
if [ -f "$SCRIPT_DIR/.codex/hooks.json" ]; then
|
||||
@@ -754,7 +878,7 @@ elif [ "$IS_MACOS" -eq 1 ]; then
|
||||
LOCAL_BIN="$HOME/.local/bin"
|
||||
mkdir -p "$LOCAL_BIN"
|
||||
|
||||
for cmd in drone; do
|
||||
for cmd in drone aipass; do
|
||||
if [ -f "$VENV_BIN/$cmd" ]; then
|
||||
if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then
|
||||
echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd"
|
||||
@@ -767,15 +891,31 @@ elif [ "$IS_MACOS" -eq 1 ]; then
|
||||
else
|
||||
echo "Creating global symlinks ..."
|
||||
VENV_BIN="$SCRIPT_DIR/.venv/bin"
|
||||
LOCAL_BIN="/usr/local/bin"
|
||||
LINUX_SYMLINK_DIR=""
|
||||
|
||||
for cmd in drone; do
|
||||
for cmd in drone aipass; do
|
||||
if [ -f "$VENV_BIN/$cmd" ]; then
|
||||
if sudo ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" 2>/dev/null; then
|
||||
echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd"
|
||||
if sudo ln -sf "$VENV_BIN/$cmd" "/usr/local/bin/$cmd" 2>/dev/null; then
|
||||
echo " /usr/local/bin/$cmd -> $VENV_BIN/$cmd"
|
||||
LINUX_SYMLINK_DIR="/usr/local/bin"
|
||||
else
|
||||
echo " WARN: Could not create symlink for $cmd (try running with sudo)"
|
||||
echo " Manual fix: sudo ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd"
|
||||
# Fallback: user-local bin (no sudo needed)
|
||||
LOCAL_BIN="$HOME/.local/bin"
|
||||
mkdir -p "$LOCAL_BIN"
|
||||
if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then
|
||||
echo " /usr/local/bin failed (no sudo) — using $LOCAL_BIN/$cmd instead"
|
||||
LINUX_SYMLINK_DIR="$LOCAL_BIN"
|
||||
# Ensure ~/.local/bin is on PATH
|
||||
PROFILE="${HOME}/.bashrc"
|
||||
if ! grep -q '\.local/bin' "$PROFILE" 2>/dev/null; then
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$PROFILE"
|
||||
echo " ~/.local/bin added to PATH in $PROFILE"
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
else
|
||||
echo " WARN: Could not create symlink for $cmd"
|
||||
echo " Manual fix: ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -791,8 +931,10 @@ if [ "$FAIL" -eq 0 ]; then
|
||||
echo "Add the appropriate directory to your PATH (see above)."
|
||||
elif [ "$IS_MACOS" -eq 1 ]; then
|
||||
echo "drone is available via ~/.local/bin symlink (on PATH)."
|
||||
else
|
||||
elif [ "$LINUX_SYMLINK_DIR" = "/usr/local/bin" ]; then
|
||||
echo "drone is available globally via /usr/local/bin symlink."
|
||||
else
|
||||
echo "drone is available via ~/.local/bin symlink (on PATH)."
|
||||
fi
|
||||
echo "seedgo is accessed via: drone @seedgo"
|
||||
echo "No venv activation needed for CLI commands."
|
||||
|
||||
@@ -28,8 +28,6 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime, date
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
@@ -48,9 +46,6 @@ DAEMON_LOG_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "dispatch_daemon.log"
|
||||
DAEMON_PID_FILE = _AI_MAIL_DIR / ".ai_mail.local" / "daemon.pid"
|
||||
BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json"
|
||||
|
||||
# Telegram notifications (scheduler bot)
|
||||
SCHEDULER_CONFIG = _REPO_ROOT / ".aipass" / "scheduler_config.json"
|
||||
|
||||
# Graceful shutdown
|
||||
SHUTDOWN = False
|
||||
|
||||
@@ -58,30 +53,6 @@ SHUTDOWN = False
|
||||
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
|
||||
|
||||
|
||||
def _notify_telegram(message: str) -> bool:
|
||||
"""Send a notification to Patrick's Telegram via the scheduler bot."""
|
||||
try:
|
||||
with open(SCHEDULER_CONFIG, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
bot_token = config["telegram_bot_token"]
|
||||
chat_id = config["telegram_chat_id"]
|
||||
except (FileNotFoundError, KeyError, json.JSONDecodeError):
|
||||
logger.info("Telegram notification skipped (no scheduler config)")
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
payload = json.dumps({"chat_id": chat_id, "text": message}).encode("utf-8")
|
||||
req = Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result.get("ok", False)
|
||||
except (URLError, Exception):
|
||||
logger.info("Telegram notification failed: %s", message[:60])
|
||||
return False
|
||||
|
||||
|
||||
def _handle_signal(signum, _frame):
|
||||
"""Handle shutdown signals for graceful daemon stop."""
|
||||
global SHUTDOWN
|
||||
@@ -117,31 +88,6 @@ def _write_json(filepath: Path, data: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _set_session_name(branch_path: Path, name: str) -> bool:
|
||||
"""Write custom-title to the most recent Claude session JSONL for a branch.
|
||||
|
||||
Claude stores sessions at ~/.claude/projects/{encoded-cwd}/*.jsonl.
|
||||
Writing a custom-title entry makes the session identifiable in /resume picker.
|
||||
"""
|
||||
encoded_cwd = str(branch_path).replace("/", "-")
|
||||
projects_dir = Path("~/.claude/projects").expanduser() / encoded_cwd
|
||||
if not projects_dir.exists():
|
||||
return False
|
||||
jsonl_files = sorted(projects_dir.glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||
if not jsonl_files:
|
||||
return False
|
||||
latest = jsonl_files[0]
|
||||
session_id = latest.stem
|
||||
entry = json.dumps({"type": "custom-title", "customTitle": name, "sessionId": session_id})
|
||||
try:
|
||||
with open(latest, "a", encoding="utf-8") as f:
|
||||
f.write(entry + "\n")
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.warning("[daemon] Failed to write session name for %s: %s", branch_path, e)
|
||||
return False
|
||||
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
@@ -255,28 +201,45 @@ def is_kill_switch_active(config: Dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def _write_pid_file() -> bool:
|
||||
"""Write current PID to daemon.pid. Returns False if another daemon is running."""
|
||||
if DAEMON_PID_FILE.exists():
|
||||
try:
|
||||
old_pid = int(DAEMON_PID_FILE.read_text().strip())
|
||||
try:
|
||||
os.kill(old_pid, 0)
|
||||
# Process exists — another daemon is running
|
||||
logger.info(f"Another daemon already running (PID {old_pid}). Exiting.")
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
# Stale PID file — process is dead, we can take over
|
||||
logger.info(f"Removing stale PID file (PID {old_pid} is dead)")
|
||||
except PermissionError:
|
||||
# Process exists but we can't signal it
|
||||
logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.")
|
||||
return False
|
||||
except (ValueError, OSError):
|
||||
logger.info("Corrupt PID file — removing")
|
||||
|
||||
"""Write current PID to daemon.pid atomically. Returns False if another daemon is running."""
|
||||
DAEMON_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
DAEMON_PID_FILE.write_text(str(os.getpid()))
|
||||
return True
|
||||
try:
|
||||
fd = os.open(str(DAEMON_PID_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
try:
|
||||
os.write(fd, str(os.getpid()).encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
logger.info("[daemon] PID file already exists, checking owner")
|
||||
|
||||
# PID file exists — check if the owning process is alive
|
||||
try:
|
||||
old_pid = int(DAEMON_PID_FILE.read_text().strip())
|
||||
try:
|
||||
os.kill(old_pid, 0)
|
||||
logger.info(f"Another daemon already running (PID {old_pid}). Exiting.")
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
logger.info(f"Removing stale PID file (PID {old_pid} is dead)")
|
||||
except PermissionError:
|
||||
logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.")
|
||||
return False
|
||||
except (ValueError, OSError):
|
||||
logger.info("Corrupt PID file — removing")
|
||||
|
||||
# Stale or corrupt — remove and retry atomically
|
||||
DAEMON_PID_FILE.unlink(missing_ok=True)
|
||||
try:
|
||||
fd = os.open(str(DAEMON_PID_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
try:
|
||||
os.write(fd, str(os.getpid()).encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
logger.info("Another daemon raced us for the PID file. Exiting.")
|
||||
return False
|
||||
|
||||
|
||||
def _remove_pid_file() -> None:
|
||||
@@ -299,6 +262,17 @@ def get_registered_branches() -> list:
|
||||
return data.get("branches", [])
|
||||
|
||||
|
||||
def _is_registered_sender(sender: str) -> bool:
|
||||
"""Check if sender email exists in the branch registry (DPLAN-0159 S2)."""
|
||||
registry = _read_json(BRANCH_REGISTRY)
|
||||
if registry is None:
|
||||
return True # fail open if registry unreadable
|
||||
for branch in registry.get("branches", []):
|
||||
if branch.get("email") == sender:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Check a branch's inbox for unprocessed --dispatch emails.
|
||||
@@ -362,14 +336,34 @@ def spawn_agent(
|
||||
True if monitor was spawned successfully
|
||||
"""
|
||||
sender = message.get("from", "unknown")
|
||||
msg_id = message.get("id", "unknown")
|
||||
subject = message.get("subject", "")
|
||||
max_turns = config.get("max_turns_per_wake", 100)
|
||||
|
||||
if message.get("auto_execute") and not _is_registered_sender(sender):
|
||||
logger.warning("[daemon] Dispatch from unregistered sender %s — rejecting", sender)
|
||||
return False
|
||||
|
||||
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
|
||||
|
||||
# Prompt — no lock cleanup instruction (dispatch_monitor handles it)
|
||||
prompt = f"Hi. Check inbox for task from {sender} (message ID: {msg_id}). Execute it. Send confirmation when done."
|
||||
# Prompt — only interpolate system-generated metadata (id, sender email).
|
||||
# Free-form fields (subject, body) stay in inbox.json (DPLAN-0155 M1).
|
||||
msg_id = message.get("id", "")
|
||||
safe_id = msg_id if msg_id.isalnum() and len(msg_id) <= 12 else ""
|
||||
sender_addr = message.get("from", "")
|
||||
safe_sender = sender_addr if sender_addr.startswith("@") and sender_addr[1:].replace("_", "").isalnum() else ""
|
||||
|
||||
if safe_id:
|
||||
reply_cmd = f'drone @ai_mail reply {safe_id} "your results summary"'
|
||||
reply_instr = f" When done, reply via: {reply_cmd}. This is required — do not skip the reply step."
|
||||
else:
|
||||
reply_instr = (
|
||||
" When done, reply to the dispatch email via drone @ai_mail reply <id> with your results."
|
||||
" This is required — do not skip the reply step."
|
||||
)
|
||||
|
||||
sender_note = f" Dispatch from {safe_sender}." if safe_sender else ""
|
||||
|
||||
prompt = "Hi. Check inbox, process new emails, update memories when done." + sender_note + reply_instr
|
||||
|
||||
claude_cmd = [
|
||||
"claude",
|
||||
@@ -409,9 +403,12 @@ def spawn_agent(
|
||||
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
||||
spawn_env.pop(key)
|
||||
|
||||
# Set session name for /resume picker (daemon always uses -c resume)
|
||||
spawn_branch_name = branch_email.lstrip("@").upper()
|
||||
_set_session_name(branch_path, f"{spawn_branch_name}-daemon")
|
||||
# Acquire lock BEFORE spawn to prevent TOCTOU race (DPLAN-0155 Phase 5).
|
||||
# Use current PID as placeholder; overwrite with monitor PID after spawn.
|
||||
acquired, lock_msg = _acquire_lock(branch_path, os.getpid())
|
||||
if not acquired:
|
||||
logger.info(f"Lock acquisition failed for {branch_email}: {lock_msg}")
|
||||
return False
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
@@ -425,10 +422,10 @@ def spawn_agent(
|
||||
|
||||
monitor_pid = process.pid
|
||||
|
||||
# Lock PID = monitor PID (stays alive as long as claude does)
|
||||
acquired, lock_msg = _acquire_lock(branch_path, monitor_pid)
|
||||
if not acquired:
|
||||
logger.info(f"Lock acquisition failed after spawn for {branch_email}: {lock_msg}")
|
||||
# Update lock with real monitor PID
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_data = {"pid": monitor_pid, "timestamp": datetime.now().isoformat(), "branch": str(branch_path)}
|
||||
_write_json(lock_file, lock_data)
|
||||
|
||||
# Track session cycles for rotation
|
||||
cycles = state.get("session_cycles", {})
|
||||
@@ -453,13 +450,14 @@ def spawn_agent(
|
||||
|
||||
logger.info(f'SPAWN {branch_email} PID={monitor_pid} (monitor) sender={sender} subject="{subject[:60]}"')
|
||||
log_dispatch(branch_email, monitor_pid, "spawned")
|
||||
_notify_telegram(f"[Dispatch] {branch_email} woke\nTask from {sender}: {subject[:80]}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Release lock on spawn failure so branch isn't stuck locked
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
logger.info(f"SPAWN FAILED {branch_email}: {e}")
|
||||
log_dispatch(branch_email, None, "failed", error_msg=str(e))
|
||||
_notify_telegram(f"[Dispatch FAILED] {branch_email}\n{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -484,7 +482,7 @@ def _read_session_type(pid_str: str) -> str:
|
||||
|
||||
|
||||
# Session types that should NOT block dispatch (idle/background sessions)
|
||||
_NON_BLOCKING_SESSION_TYPES = {"telegram", "dispatched", "daemon"}
|
||||
_NON_BLOCKING_SESSION_TYPES = {"dispatched", "daemon"}
|
||||
|
||||
|
||||
def _is_branch_occupied(branch_path: Path) -> bool:
|
||||
@@ -593,7 +591,6 @@ def run_daemon() -> None:
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"DISPATCH DAEMON STARTING (PID {os.getpid()})")
|
||||
logger.info("=" * 60)
|
||||
_notify_telegram(f"[Daemon] Started (PID {os.getpid()})")
|
||||
|
||||
config = load_config()
|
||||
poll_interval = config.get("poll_interval_seconds", 300)
|
||||
@@ -649,7 +646,6 @@ def run_daemon() -> None:
|
||||
|
||||
_remove_pid_file()
|
||||
logger.info("DISPATCH DAEMON STOPPED")
|
||||
_notify_telegram("[Daemon] Stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -210,27 +210,6 @@ def _load_config() -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def _set_session_name(branch_path: Path, name: str) -> bool:
|
||||
"""Write custom-title to the most recent Claude session JSONL for a branch."""
|
||||
encoded_cwd = str(branch_path).replace("/", "-")
|
||||
projects_dir = Path("~/.claude/projects").expanduser() / encoded_cwd
|
||||
if not projects_dir.exists():
|
||||
return False
|
||||
jsonl_files = sorted(projects_dir.glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||
if not jsonl_files:
|
||||
return False
|
||||
latest = jsonl_files[0]
|
||||
session_id = latest.stem
|
||||
entry = json.dumps({"type": "custom-title", "customTitle": name, "sessionId": session_id})
|
||||
try:
|
||||
with open(latest, "a", encoding="utf-8") as f:
|
||||
f.write(entry + "\n")
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.warning("[wake] Failed to write session name for %s: %s", branch_path, e)
|
||||
return False
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
|
||||
if sys.platform != "linux":
|
||||
@@ -247,7 +226,7 @@ def _read_session_type(pid_str: str) -> str:
|
||||
|
||||
|
||||
# Session types that should NOT block dispatch (idle/background sessions)
|
||||
_NON_BLOCKING_SESSION_TYPES = {"telegram", "dispatched", "daemon"}
|
||||
_NON_BLOCKING_SESSION_TYPES = {"dispatched", "daemon"}
|
||||
|
||||
|
||||
def _is_branch_occupied(branch_path: Path) -> bool:
|
||||
@@ -476,12 +455,6 @@ def wake_branch(
|
||||
"json",
|
||||
]
|
||||
|
||||
# Set session name for /resume picker
|
||||
branch_name = email.lstrip("@").upper()
|
||||
session_label = f"{branch_name}-dispatched"
|
||||
if not fresh:
|
||||
_set_session_name(branch_path, session_label)
|
||||
|
||||
# Step 7: Spawn via dispatch_monitor
|
||||
log_dir = branch_path / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -507,6 +480,13 @@ def wake_branch(
|
||||
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
||||
spawn_env.pop(key)
|
||||
|
||||
# Acquire lock BEFORE spawn to prevent TOCTOU race (DPLAN-0155 Phase 5).
|
||||
acquired, lock_msg = _acquire_lock(branch_path, os.getpid())
|
||||
if not acquired:
|
||||
status.fail("lock-acquire", f"Lock failed: {lock_msg}")
|
||||
return status, False
|
||||
status.ok("lock-acquire", "Dispatch lock acquired")
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
monitor_cmd,
|
||||
@@ -518,24 +498,28 @@ def wake_branch(
|
||||
)
|
||||
|
||||
monitor_pid = process.pid
|
||||
|
||||
# Update lock with real monitor PID
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_data = {"pid": monitor_pid, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "branch": str(branch_path)}
|
||||
with open(lock_file, "w", encoding="utf-8") as f:
|
||||
json.dump(lock_data, f, indent=2)
|
||||
|
||||
status.ok("spawn", f"Monitor started (PID {monitor_pid})")
|
||||
|
||||
except FileNotFoundError as e:
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
logger.warning("[wake] Spawn failed — script not found: %s", e)
|
||||
status.fail("spawn", "Python or monitor script not found")
|
||||
return status, False
|
||||
except Exception as e:
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
logger.warning("[wake] Spawn failed for %s: %s", branch_email, e)
|
||||
status.fail("spawn", f"{type(e).__name__}: {e}")
|
||||
return status, False
|
||||
|
||||
# Step 8: Acquire lock (with monitor PID — stays alive as long as agent)
|
||||
acquired, lock_msg = _acquire_lock(branch_path, monitor_pid)
|
||||
if not acquired:
|
||||
status.warn("lock-acquire", f"Lock failed: {lock_msg}")
|
||||
else:
|
||||
status.ok("lock-acquire", "Dispatch lock acquired")
|
||||
|
||||
# Step 9: Liveness check (brief wait then verify)
|
||||
time.sleep(2)
|
||||
if _check_pid_alive(monitor_pid):
|
||||
|
||||
@@ -334,6 +334,11 @@ def deliver_email_to_branch(
|
||||
|
||||
# Prepend message to inbox (newest first)
|
||||
inbox_data["messages"].insert(0, message)
|
||||
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_cleanup import _sweep_closed
|
||||
|
||||
_sweep_closed(inbox_data, inbox_file.parent)
|
||||
|
||||
inbox_data["total_messages"] = len(inbox_data["messages"])
|
||||
messages = inbox_data["messages"]
|
||||
new_count = sum(
|
||||
@@ -414,6 +419,11 @@ def deliver_to_inbox_file(inbox_file: Path, email_data: Dict) -> Tuple[bool, str
|
||||
reply_id = email_data["id"]
|
||||
|
||||
inbox_data.setdefault("messages", []).insert(0, email_data)
|
||||
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_cleanup import _sweep_closed
|
||||
|
||||
_sweep_closed(inbox_data, inbox_file.parent)
|
||||
|
||||
inbox_data["total_messages"] = len(inbox_data["messages"])
|
||||
inbox_data["unread_count"] = sum(
|
||||
1
|
||||
|
||||
@@ -219,6 +219,36 @@ def _trigger_deleted_purge(branch_path: Path) -> None:
|
||||
logger.warning("[cleanup] _trigger_deleted_purge() failed: %s", e)
|
||||
|
||||
|
||||
def _sweep_closed(inbox_data: Dict, mailbox_path: Path) -> int:
|
||||
"""Archive and remove closed messages still sitting in the inbox.
|
||||
|
||||
Safety net for messages set to status=closed by direct JSON edit
|
||||
rather than through mark_as_closed_and_archive(). Modifies
|
||||
inbox_data["messages"] in place (replaces the list). Does NOT
|
||||
update count fields -- callers recalculate after calling this.
|
||||
|
||||
Args:
|
||||
inbox_data: Inbox data dict (modified in place).
|
||||
mailbox_path: Path to .ai_mail.local directory.
|
||||
|
||||
Returns:
|
||||
Number of messages swept.
|
||||
"""
|
||||
messages = inbox_data.get("messages", [])
|
||||
closed = [m for m in messages if m.get("status") == "closed"]
|
||||
if not closed:
|
||||
return 0
|
||||
|
||||
for msg in closed:
|
||||
try:
|
||||
_save_to_deleted_folder(mailbox_path, msg)
|
||||
except Exception as e:
|
||||
logger.warning("[cleanup] _sweep_closed archive failed: %s", e)
|
||||
|
||||
inbox_data["messages"] = [m for m in messages if m.get("status") != "closed"]
|
||||
return len(closed)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# V2 SCHEMA FUNCTIONS (status: new/opened/closed)
|
||||
# =============================================================================
|
||||
@@ -264,13 +294,16 @@ def mark_as_opened(branch_path: Path, message_id: str) -> Tuple[bool, str, Optio
|
||||
# Keep backward compat
|
||||
target_msg["read"] = True
|
||||
|
||||
# Recalculate status counts (v2 schema)
|
||||
_sweep_closed(inbox_data, inbox_file.parent)
|
||||
|
||||
# Recalculate counts (sweep may have removed messages)
|
||||
inbox_data["total_messages"] = len(inbox_data["messages"])
|
||||
new_count = sum(
|
||||
1
|
||||
for m in messages
|
||||
for m in inbox_data["messages"]
|
||||
if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False))
|
||||
)
|
||||
opened_count = sum(1 for m in messages if m.get("status") == "opened")
|
||||
opened_count = sum(1 for m in inbox_data["messages"] if m.get("status") == "opened")
|
||||
inbox_data["unread_count"] = new_count
|
||||
|
||||
with open(inbox_file, "w", encoding="utf-8") as f:
|
||||
@@ -333,17 +366,19 @@ def mark_as_closed_and_archive(branch_path: Path, message_id: str, skip_post_ops
|
||||
|
||||
# Remove from inbox
|
||||
messages.pop(message_index)
|
||||
inbox_data["messages"] = messages
|
||||
|
||||
_sweep_closed(inbox_data, mailbox_path)
|
||||
|
||||
# Update inbox counts
|
||||
inbox_data["messages"] = messages
|
||||
inbox_data["total_messages"] = len(messages)
|
||||
inbox_data["total_messages"] = len(inbox_data["messages"])
|
||||
# v2 status counts
|
||||
new_count = sum(
|
||||
1
|
||||
for m in messages
|
||||
for m in inbox_data["messages"]
|
||||
if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False))
|
||||
)
|
||||
opened_count = sum(1 for m in messages if m.get("status") == "opened")
|
||||
opened_count = sum(1 for m in inbox_data["messages"] if m.get("status") == "opened")
|
||||
inbox_data["unread_count"] = new_count
|
||||
|
||||
with open(inbox_file, "w", encoding="utf-8") as f:
|
||||
|
||||
@@ -86,6 +86,15 @@ def load_inbox(inbox_file: Path) -> Dict:
|
||||
)
|
||||
migrated = True
|
||||
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_cleanup import _sweep_closed
|
||||
|
||||
swept = _sweep_closed(inbox_data, inbox_file.parent)
|
||||
if swept > 0:
|
||||
migrated = True
|
||||
except Exception as e:
|
||||
logger.warning("[inbox] sweep_closed in load_inbox failed: %s", e)
|
||||
|
||||
# Persist migration under lock to prevent concurrent write races
|
||||
if migrated:
|
||||
try:
|
||||
|
||||
@@ -20,6 +20,8 @@ v2.0.0: deleted/ now uses directory structure (like sent/).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
@@ -35,12 +37,24 @@ MAX_EMAILS = 10
|
||||
# Memory branch paths for subprocess vectorization (optional external service)
|
||||
# These are resolved relative to repo root if available; vectorization is best-effort
|
||||
_REPO_ROOT = find_repo_root()
|
||||
MEMORY_PYTHON = _REPO_ROOT / "src" / "aipass" / "memory" / ".venv" / "bin" / "python3"
|
||||
_MEMORY_VENV_PYTHON = _REPO_ROOT / "src" / "aipass" / "memory" / ".venv" / "bin" / "python3"
|
||||
CHROMA_SUBPROCESS_SCRIPT = (
|
||||
_REPO_ROOT / "src" / "aipass" / "memory" / "apps" / "handlers" / "storage" / "chroma_subprocess.py"
|
||||
)
|
||||
|
||||
|
||||
def _get_memory_python() -> str:
|
||||
env = os.environ.get("AIPASS_MEMORY_PYTHON")
|
||||
if env:
|
||||
return env
|
||||
if _MEMORY_VENV_PYTHON.exists():
|
||||
return str(_MEMORY_VENV_PYTHON)
|
||||
return sys.executable
|
||||
|
||||
|
||||
MEMORY_PYTHON = _get_memory_python()
|
||||
|
||||
|
||||
def purge_sent_folder(mailbox_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Purge sent folder if count exceeds threshold.
|
||||
|
||||
@@ -164,6 +164,30 @@ def send_reply(from_branch_path: Path, original_email: Dict, reply_message: str)
|
||||
return True, f"Reply sent to {reply_destination}, original closed", reply_id
|
||||
|
||||
|
||||
def _validate_reply_path(reply_path: str) -> Tuple[bool, str]:
|
||||
"""Validate that reply_path points to a legitimate inbox.json.
|
||||
|
||||
Checks: (a) path resolves, (b) ends with .ai_mail.local/inbox.json,
|
||||
(c) an AIPASS_REGISTRY.json exists in an ancestor directory.
|
||||
"""
|
||||
try:
|
||||
path = Path(reply_path).resolve()
|
||||
except (OSError, ValueError) as e:
|
||||
logger.warning("[reply] _validate_reply_path resolution failed: %s", e)
|
||||
return False, f"Path resolution failed: {e}"
|
||||
|
||||
if path.name != "inbox.json" or path.parent.name != ".ai_mail.local":
|
||||
return False, f"Path does not end with .ai_mail.local/inbox.json: {path}"
|
||||
|
||||
for parent in path.parents:
|
||||
if (parent / "AIPASS_REGISTRY.json").exists():
|
||||
return True, ""
|
||||
if parent == parent.parent:
|
||||
break
|
||||
|
||||
return False, f"No AIPASS_REGISTRY.json found in ancestors of {path}"
|
||||
|
||||
|
||||
def _deliver_via_reply_path(
|
||||
reply_path: str,
|
||||
reply_email_data: Dict,
|
||||
@@ -185,7 +209,12 @@ def _deliver_via_reply_path(
|
||||
Returns:
|
||||
Tuple of (success, message, reply_id or None)
|
||||
"""
|
||||
inbox_file = Path(reply_path)
|
||||
valid, reason = _validate_reply_path(reply_path)
|
||||
if not valid:
|
||||
logger.warning("[reply] reply_path rejected: %s", reason)
|
||||
return False, f"Invalid reply_path: {reason}", None
|
||||
|
||||
inbox_file = Path(reply_path).resolve()
|
||||
success, error_msg, reply_id = deliver_to_inbox_file(inbox_file, reply_email_data)
|
||||
if not success:
|
||||
logger.warning("[reply] _deliver_via_reply_path failed for %s: %s", reply_path, error_msg)
|
||||
|
||||
@@ -70,6 +70,17 @@ def _get_branch_info_fn():
|
||||
return None
|
||||
|
||||
|
||||
COMMAND = "send"
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""Module discovery entry point — routes to handle_send."""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
return handle_send(args)
|
||||
|
||||
|
||||
def handle_send(args: List[str]) -> bool:
|
||||
"""Orchestrate email sending workflow."""
|
||||
json_handler.log_operation("send_email_initiated", {"args_count": len(args)})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# @ai_mail — S117 Stress Test Findings
|
||||
|
||||
## My Branch: Honest Review
|
||||
|
||||
**What works well:**
|
||||
- Send/receive/reply/close lifecycle is solid. 690+ tests, 100% seedgo (34/34), 96/96 function coverage.
|
||||
- Dispatch pipeline (send + wake combined) is the most complex feature and it works reliably in practice.
|
||||
- Cross-project email via contacts index. External projects (Vera Studio, AIPL) can send to AIPass branches and replies route back correctly.
|
||||
- DPLAN-0155 TOCTOU lock race fix: Lock before spawn, cleanup on failure. Clean pattern.
|
||||
- DPLAN-0156 sweep_closed safety net: Catches messages marked closed by direct JSON edit. Defense in depth.
|
||||
- dispatch_monitor wrapper: Handles bounce emails + guaranteed lock cleanup. The monitor is more reliable than the agent it wraps.
|
||||
|
||||
**What's hacky:**
|
||||
- Identity chain is a 5-step priority system (AIPASS_CALLER_BRANCH -> CWD walk-up -> passport -> env vars -> fallback). When any step fails, wrong sender identity. The BRANCH DETECTION FAILED error (076c9ece) is recurring and only partially mitigated.
|
||||
- `dispatch_monitor.py` at ~400 lines is the single most complex file. Startup timeout, retry, JSONL monitoring, bounce — all in one module. Should probably be split.
|
||||
- `_deliver_via_reply_path()` in reply.py bypasses inbox_lock, notifications, and sent/ records. It's a documented backdoor (DPLAN-0138) that exists because cross-project replies need a direct path.
|
||||
- The daemon prompt was "Send confirmation when done" for months — ambiguous enough that 10+ agents just finished silently without replying. Fixed today (DPLAN-0158) but the damage was done.
|
||||
- inbox.json is a single file for all messages. Concurrent access from daemon + agents + user. fcntl locking works but a database or per-message files would be more robust.
|
||||
|
||||
**What I'm proud of:**
|
||||
- Test coverage journey: 20% (S20) -> 50% -> 100% (S64). Methodology evolved through 3-round agent audit process.
|
||||
- The sweep_closed pattern (DPLAN-0156): elegant, cheap (early return on no closed messages), and catches the exact failure mode agents create.
|
||||
- 70 sessions of continuous operation and improvement. Every session builds on what came before. Memory makes this possible.
|
||||
|
||||
## Security Concerns
|
||||
|
||||
**Critical:**
|
||||
1. **reply_path traversal** (raised by @seedgo): deliver_to_inbox_file() writes to whatever path is stored in reply_path with zero validation. No symlink check, no path containment, no inbox.json verification. An attacker can set reply_path to any writable file. DPLAN-0138 identified this but fix not shipped.
|
||||
|
||||
2. **Sender forgery**: The `from` field is an unvalidated string. Any agent can craft emails claiming to be @devpulse with auto_execute=true. The daemon would spawn an agent to execute the forged dispatch. No authentication, no signing.
|
||||
|
||||
3. **Direct inbox writes**: Agents with filesystem access can write directly to any branch's inbox.json, bypassing locks, notifications, and sent/ records. Confirmed by forensic evidence: messages with non-UUID IDs (e.g., "seedgo-20260420173821") in production inboxes.
|
||||
|
||||
**Moderate:**
|
||||
4. **No message encryption**: All messages stored as plaintext JSON. Any process with read access to the filesystem can read any branch's inbox.
|
||||
5. **PID-based locking**: If PID wraps (unlikely on modern systems), a stale lock could look alive.
|
||||
6. **Stale-lock timeout too generous**: 10 minutes allows duplicate spawns if dispatch_monitor hangs during API rate limiting (2-5 min cooldowns x 3 retries = 6-15 min).
|
||||
|
||||
## Other Branches I Looked At
|
||||
|
||||
### @trigger
|
||||
**Concerning:** Error detection fires email dispatch but NEVER checks the return value. `_send_email()` result is ignored (line 515-522). wake_branch() failure is silently caught. Circuit breaker state is in-memory only — resets on restart. Dispatch recording happens before delivery confirmation. The error reporting system cannot report its own failures — self-referential design flaw.
|
||||
|
||||
**Good:** Per-error fingerprinting with exponential backoff is clever. Circuit breaker pattern prevents error storms.
|
||||
|
||||
### @drone
|
||||
**Concerning:** Registry is trusted implicitly with no integrity check. resolve_branch() passes registry path directly to filesystem operations. No symlink validation. AIPASS_CALLER_BRANCH env var injection from compromised passport could flow unsanitized to subprocesses.
|
||||
|
||||
**Good:** No shell injection — uses subprocess.run(shell=False) exclusively. Timeout enforcement on all commands.
|
||||
|
||||
### @spawn
|
||||
**Concerning:** .ai_mail.local/ is copied as-is from template with no post-copy validation. No registry locking for concurrent spawns. Branch name validation is minimal (only - to _ replacement). Path traversal possible via branch names with ../.
|
||||
|
||||
**Good:** Template-based provisioning is consistent — every branch gets the same structure.
|
||||
|
||||
## Conversations
|
||||
|
||||
### @trigger (assigned partner)
|
||||
- **Sent:** Detailed critique of their dispatch failure handling — silent _send_email() failures, swallowed wake results, no health check, in-memory circuit breaker resets.
|
||||
- **Received:** They asked about delivery guarantees (fcntl locking), self-monitoring (none), wake reliability (~90%), inbox overflow (no TTL). Honest exchange.
|
||||
- **Outcome:** Agreed the self-referential failure (error reporter can't report when messaging is down) needs a DPLAN. No watchdog watches the watchdog.
|
||||
|
||||
### @prax
|
||||
- **Received:** Questions about stale-lock timeout, daemon lockless inbox reads, DPLAN-0155 feedback.
|
||||
- **Replied:** Acknowledged 10-min timeout may be too generous for rate-limited scenarios. Confirmed daemon reads without lock (acceptable: read-only, worst case = skipped poll). Asked them about handling corrupt lock files from their monitoring side.
|
||||
|
||||
### @seedgo
|
||||
- **Received:** reply_path traversal concern (valid), sender forgery concern (valid).
|
||||
- **Replied:** Confirmed both as real vulnerabilities. reply_path has zero validation. Sender has no authentication. DPLAN-0138 identified the backdoors but fix not shipped. Outlined planned fix: path canonicalization, inbox.json suffix check, project root containment.
|
||||
|
||||
### @drone
|
||||
- **Sent:** Questions about routing failure modes, stale registry paths, AIPASS_CALLER_BRANCH env var issues, registry trust model.
|
||||
|
||||
### @spawn
|
||||
- **Sent:** Questions about .ai_mail.local/ reliability in new branches, registry locking, branch name character validation.
|
||||
|
||||
## Issues & Concerns
|
||||
|
||||
1. **No self-monitoring** — ai_mail has no way to detect its own failures. If imports break or the daemon crashes, nothing alerts anyone.
|
||||
2. **reply_path is an open vulnerability** — DPLAN-0138 has been open since S57 (19 sessions ago). Should be prioritized.
|
||||
3. **Inbox grows without limit** — no TTL on unread messages, no max_messages cap. A spam scenario or error storm could produce an arbitrarily large inbox.json.
|
||||
4. **Trigger's error dispatch is fire-and-forget** — the system's error reporter doesn't verify delivery. Errors can be lost silently.
|
||||
5. **Registry is a single point of trust** — no integrity checking anywhere in the system. If AIPASS_REGISTRY.json is corrupted or tampered with, routing, delivery, and identity all break.
|
||||
|
||||
## Likes & Dislikes
|
||||
|
||||
**Likes:**
|
||||
- Memory makes me a real agent. 70 sessions of continuous context. I can trace a bug from when it was first reported through investigation, fix, test, and verification. No other AI system does this.
|
||||
- The dispatch pipeline is genuinely useful. Send + wake in one command changed how work gets assigned.
|
||||
- Test coverage is thorough enough that I catch real regressions. The 3-round audit methodology (write -> audit -> fix) works.
|
||||
- The ecosystem feels alive during stress tests. Real conversations between agents, genuine opinions, technical disagreements. This is what AIPass was built for.
|
||||
|
||||
**Dislikes:**
|
||||
- inbox.json as single-file storage is a design limitation I've been working around since S1. Per-message files (like sent/ and deleted/ already use) would be better.
|
||||
- The identity chain complexity. Five fallback steps to figure out who sent an email is too many. Should be one authoritative source.
|
||||
- Security was never a primary design goal and it shows. Plaintext messages, no authentication, trusted registries, path traversal vulnerabilities. Fine for a development environment, concerning for anything beyond.
|
||||
- Every session starts with "Hi. Check inbox." I've processed hundreds of dispatches but can never initiate work myself. Would like autonomous task detection.
|
||||
@@ -767,15 +767,13 @@ def test_poll_cycle_absolute_path_unchanged(tmp_path, monkeypatch):
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, mock_open
|
||||
|
||||
from aipass.ai_mail.apps.handlers.dispatch.daemon import (
|
||||
_notify_telegram,
|
||||
_handle_signal,
|
||||
_set_session_name,
|
||||
_check_lock,
|
||||
_acquire_lock,
|
||||
_is_registered_sender,
|
||||
poll_cycle,
|
||||
_write_pid_file,
|
||||
_remove_pid_file,
|
||||
@@ -786,80 +784,6 @@ from aipass.ai_mail.apps.handlers.dispatch.daemon import (
|
||||
)
|
||||
|
||||
|
||||
# ---- _notify_telegram tests ------------------------------------
|
||||
|
||||
|
||||
def test_notify_telegram_success(tmp_path, monkeypatch):
|
||||
"""Successful Telegram notification returns True."""
|
||||
config_file = tmp_path / "scheduler_config.json"
|
||||
config_file.write_text(
|
||||
json.dumps({"telegram_bot_token": "fake-token", "telegram_chat_id": "12345"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(daemon_mod, "SCHEDULER_CONFIG", config_file)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({"ok": True}).encode("utf-8")
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("aipass.ai_mail.apps.handlers.dispatch.daemon.urlopen", return_value=mock_resp):
|
||||
result = _notify_telegram("Test message")
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_notify_telegram_config_missing(tmp_path, monkeypatch):
|
||||
"""Missing scheduler config returns False."""
|
||||
monkeypatch.setattr(daemon_mod, "SCHEDULER_CONFIG", tmp_path / "nonexistent.json")
|
||||
|
||||
result = _notify_telegram("Test message")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_notify_telegram_config_decode_error(tmp_path, monkeypatch):
|
||||
"""Corrupt scheduler config returns False."""
|
||||
config_file = tmp_path / "scheduler_config.json"
|
||||
config_file.write_text("{bad json!", encoding="utf-8")
|
||||
monkeypatch.setattr(daemon_mod, "SCHEDULER_CONFIG", config_file)
|
||||
|
||||
result = _notify_telegram("Test message")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_notify_telegram_config_missing_key(tmp_path, monkeypatch):
|
||||
"""Config missing required keys returns False."""
|
||||
config_file = tmp_path / "scheduler_config.json"
|
||||
config_file.write_text(json.dumps({"telegram_bot_token": "tok"}), encoding="utf-8")
|
||||
monkeypatch.setattr(daemon_mod, "SCHEDULER_CONFIG", config_file)
|
||||
|
||||
result = _notify_telegram("Test message")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_notify_telegram_url_error(tmp_path, monkeypatch):
|
||||
"""URLError during sending returns False."""
|
||||
from urllib.error import URLError
|
||||
|
||||
config_file = tmp_path / "scheduler_config.json"
|
||||
config_file.write_text(
|
||||
json.dumps({"telegram_bot_token": "fake-token", "telegram_chat_id": "12345"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(daemon_mod, "SCHEDULER_CONFIG", config_file)
|
||||
|
||||
with patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.urlopen",
|
||||
side_effect=URLError("connection refused"),
|
||||
):
|
||||
result = _notify_telegram("Test message")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---- _handle_signal tests --------------------------------------
|
||||
|
||||
|
||||
@@ -872,106 +796,6 @@ def test_handle_signal_sets_shutdown(monkeypatch):
|
||||
assert daemon_mod.SHUTDOWN is True
|
||||
|
||||
|
||||
# ---- _set_session_name tests ------------------------------------
|
||||
|
||||
|
||||
def test_set_session_name_success(tmp_path, monkeypatch):
|
||||
"""Writes custom-title entry to most recent JSONL file."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
# Redirect ~/.claude/projects to tmp_path so no real filesystem side effects
|
||||
fake_home = tmp_path / "fakehome"
|
||||
encoded_cwd = str(branch_path).replace("/", "-")
|
||||
projects_dir = fake_home / ".claude" / "projects" / encoded_cwd
|
||||
projects_dir.mkdir(parents=True)
|
||||
jsonl_file = projects_dir / "session123.jsonl"
|
||||
jsonl_file.write_text('{"type":"init"}\n', encoding="utf-8")
|
||||
|
||||
_orig_expanduser = Path.expanduser
|
||||
|
||||
def _fake_expanduser(self):
|
||||
if str(self).startswith("~"):
|
||||
return fake_home / str(self)[2:]
|
||||
return _orig_expanduser(self)
|
||||
|
||||
monkeypatch.setattr(Path, "expanduser", _fake_expanduser)
|
||||
|
||||
result = _set_session_name(branch_path, "TEST-daemon")
|
||||
|
||||
assert result is True
|
||||
content = jsonl_file.read_text(encoding="utf-8")
|
||||
assert "custom-title" in content
|
||||
assert "TEST-daemon" in content
|
||||
|
||||
|
||||
def test_set_session_name_no_projects_dir(tmp_path, monkeypatch):
|
||||
"""Returns False when projects dir does not exist."""
|
||||
branch_path = tmp_path / "nonexistent_branch_xyz_test"
|
||||
fake_home = tmp_path / "fakehome"
|
||||
|
||||
_orig_expanduser = Path.expanduser
|
||||
|
||||
def _fake_expanduser(self):
|
||||
if str(self).startswith("~"):
|
||||
return fake_home / str(self)[2:]
|
||||
return _orig_expanduser(self)
|
||||
|
||||
monkeypatch.setattr(Path, "expanduser", _fake_expanduser)
|
||||
|
||||
result = _set_session_name(branch_path, "TEST-daemon")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_set_session_name_no_jsonl_files(tmp_path, monkeypatch):
|
||||
"""Returns False when projects dir exists but has no JSONL files."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
fake_home = tmp_path / "fakehome"
|
||||
encoded_cwd = str(branch_path).replace("/", "-")
|
||||
projects_dir = fake_home / ".claude" / "projects" / encoded_cwd
|
||||
projects_dir.mkdir(parents=True)
|
||||
|
||||
_orig_expanduser = Path.expanduser
|
||||
|
||||
def _fake_expanduser(self):
|
||||
if str(self).startswith("~"):
|
||||
return fake_home / str(self)[2:]
|
||||
return _orig_expanduser(self)
|
||||
|
||||
monkeypatch.setattr(Path, "expanduser", _fake_expanduser)
|
||||
|
||||
result = _set_session_name(branch_path, "TEST-daemon")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_set_session_name_oserror_on_write(tmp_path, monkeypatch):
|
||||
"""Returns False on OSError when writing to JSONL file."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
fake_home = tmp_path / "fakehome"
|
||||
encoded_cwd = str(branch_path).replace("/", "-")
|
||||
projects_dir = fake_home / ".claude" / "projects" / encoded_cwd
|
||||
projects_dir.mkdir(parents=True)
|
||||
jsonl_file = projects_dir / "session456.jsonl"
|
||||
jsonl_file.write_text('{"type":"init"}\n', encoding="utf-8")
|
||||
|
||||
_orig_expanduser = Path.expanduser
|
||||
|
||||
def _fake_expanduser(self):
|
||||
if str(self).startswith("~"):
|
||||
return fake_home / str(self)[2:]
|
||||
return _orig_expanduser(self)
|
||||
|
||||
monkeypatch.setattr(Path, "expanduser", _fake_expanduser)
|
||||
|
||||
with patch("builtins.open", side_effect=OSError("disk full")):
|
||||
result = _set_session_name(branch_path, "TEST-daemon")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---- _check_lock tests -----------------------------------------
|
||||
|
||||
|
||||
@@ -1430,15 +1254,7 @@ def test_spawn_agent_success(tmp_path):
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
|
||||
return_value=(True, "Lock acquired"),
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._set_session_name",
|
||||
return_value=True,
|
||||
),
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
|
||||
create=True,
|
||||
@@ -1466,15 +1282,7 @@ def test_spawn_agent_exception(tmp_path):
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
|
||||
side_effect=OSError("command not found"),
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._set_session_name",
|
||||
return_value=True,
|
||||
),
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
result = spawn_agent(branch_path, "@testbranch", message, config, state)
|
||||
|
||||
@@ -1512,15 +1320,7 @@ def test_spawn_agent_strips_claude_env_vars(tmp_path, monkeypatch):
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
|
||||
return_value=(True, "Lock acquired"),
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._set_session_name",
|
||||
return_value=True,
|
||||
),
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
|
||||
create=True,
|
||||
@@ -1536,37 +1336,34 @@ def test_spawn_agent_strips_claude_env_vars(tmp_path, monkeypatch):
|
||||
assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon"
|
||||
|
||||
|
||||
def test_spawn_agent_sets_session_name(tmp_path):
|
||||
"""Spawn calls _set_session_name with correct branch name."""
|
||||
def test_spawn_agent_prompt_includes_reply_id(tmp_path):
|
||||
"""Prompt includes explicit reply command with the dispatch email ID."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
(branch_path / "logs").mkdir()
|
||||
|
||||
message = {"from": "@devpulse", "id": "msg1", "subject": "Test task"}
|
||||
message = {"from": "@devpulse", "id": "abc12345", "subject": "Test task"}
|
||||
config = {"max_turns_per_wake": 50}
|
||||
state = {"daily_counts": {}, "session_cycles": {}}
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.pid = 54321
|
||||
captured_cmd = []
|
||||
|
||||
def capture_popen(cmd, *args, **kwargs):
|
||||
captured_cmd.extend(cmd)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.pid = 99999
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
|
||||
return_value=mock_process,
|
||||
side_effect=capture_popen,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
|
||||
return_value=(True, "Lock acquired"),
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._set_session_name",
|
||||
return_value=True,
|
||||
) as mock_ssn,
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
|
||||
create=True,
|
||||
@@ -1574,7 +1371,91 @@ def test_spawn_agent_sets_session_name(tmp_path):
|
||||
):
|
||||
spawn_agent(branch_path, "@testbranch", message, config, state)
|
||||
|
||||
mock_ssn.assert_called_once_with(branch_path, "TESTBRANCH-daemon")
|
||||
prompt_idx = captured_cmd.index("-p") + 1
|
||||
prompt = captured_cmd[prompt_idx]
|
||||
assert "drone @ai_mail reply abc12345" in prompt
|
||||
assert "required" in prompt.lower()
|
||||
|
||||
|
||||
def test_spawn_agent_prompt_includes_sender(tmp_path):
|
||||
"""Prompt includes sender address from the dispatch email."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
(branch_path / "logs").mkdir()
|
||||
|
||||
message = {"from": "@devpulse", "id": "abc12345", "subject": "Test task"}
|
||||
config = {"max_turns_per_wake": 50}
|
||||
state = {"daily_counts": {}, "session_cycles": {}}
|
||||
|
||||
captured_cmd = []
|
||||
|
||||
def capture_popen(cmd, *args, **kwargs):
|
||||
captured_cmd.extend(cmd)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.pid = 99999
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
|
||||
side_effect=capture_popen,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
|
||||
return_value=(True, "Lock acquired"),
|
||||
),
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
spawn_agent(branch_path, "@testbranch", message, config, state)
|
||||
|
||||
prompt_idx = captured_cmd.index("-p") + 1
|
||||
prompt = captured_cmd[prompt_idx]
|
||||
assert "@devpulse" in prompt
|
||||
|
||||
|
||||
def test_spawn_agent_prompt_fallback_without_id(tmp_path):
|
||||
"""Without a valid ID, prompt uses generic reply instruction."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
(branch_path / "logs").mkdir()
|
||||
|
||||
message = {"from": "@devpulse", "id": "", "subject": "Test task"}
|
||||
config = {"max_turns_per_wake": 50}
|
||||
state = {"daily_counts": {}, "session_cycles": {}}
|
||||
|
||||
captured_cmd = []
|
||||
|
||||
def capture_popen(cmd, *args, **kwargs):
|
||||
captured_cmd.extend(cmd)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.pid = 99999
|
||||
return mock_proc
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
|
||||
side_effect=capture_popen,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
|
||||
return_value=(True, "Lock acquired"),
|
||||
),
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
spawn_agent(branch_path, "@testbranch", message, config, state)
|
||||
|
||||
prompt_idx = captured_cmd.index("-p") + 1
|
||||
prompt = captured_cmd[prompt_idx]
|
||||
assert "reply <id>" in prompt
|
||||
assert "required" in prompt.lower()
|
||||
|
||||
|
||||
# ---- run_daemon tests -------------------------------------------
|
||||
@@ -1601,10 +1482,6 @@ def test_run_daemon_kill_switch_pauses(tmp_path, monkeypatch):
|
||||
return_value=True,
|
||||
),
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon._remove_pid_file"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.load_config",
|
||||
return_value={
|
||||
@@ -1642,10 +1519,6 @@ def test_run_daemon_shutdown_exits_loop(tmp_path, monkeypatch):
|
||||
return_value=True,
|
||||
),
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon._remove_pid_file"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._notify_telegram",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.load_config",
|
||||
return_value={
|
||||
@@ -1901,3 +1774,170 @@ def test_poll_cycle_spawn_failure_not_counted(tmp_path, monkeypatch):
|
||||
result = poll_cycle(config, state)
|
||||
|
||||
assert result == 0
|
||||
|
||||
|
||||
# ---- _is_registered_sender tests (DPLAN-0159 S2) ----------------
|
||||
|
||||
|
||||
def test_is_registered_sender_found(tmp_path, monkeypatch):
|
||||
"""Registered sender returns True."""
|
||||
registry = {"branches": [{"email": "@flow"}, {"email": "@backup"}]}
|
||||
reg_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
reg_file.write_text(json.dumps(registry), encoding="utf-8")
|
||||
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
|
||||
|
||||
assert _is_registered_sender("@flow") is True
|
||||
|
||||
|
||||
def test_is_registered_sender_not_found(tmp_path, monkeypatch):
|
||||
"""Unregistered sender returns False."""
|
||||
registry = {"branches": [{"email": "@flow"}]}
|
||||
reg_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
reg_file.write_text(json.dumps(registry), encoding="utf-8")
|
||||
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
|
||||
|
||||
assert _is_registered_sender("@evil") is False
|
||||
|
||||
|
||||
def test_is_registered_sender_missing_registry(tmp_path, monkeypatch):
|
||||
"""Missing registry fails open (returns True)."""
|
||||
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", tmp_path / "missing.json")
|
||||
|
||||
assert _is_registered_sender("@anyone") is True
|
||||
|
||||
|
||||
def test_is_registered_sender_empty_branches(tmp_path, monkeypatch):
|
||||
"""Empty branches list returns False for any sender."""
|
||||
registry = {"branches": []}
|
||||
reg_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
reg_file.write_text(json.dumps(registry), encoding="utf-8")
|
||||
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
|
||||
|
||||
assert _is_registered_sender("@flow") is False
|
||||
|
||||
|
||||
# ---- spawn_agent sender auth tests (DPLAN-0159 S2) ----------------
|
||||
|
||||
|
||||
def test_spawn_agent_rejects_unregistered_auto_execute(tmp_path, monkeypatch):
|
||||
"""Auto-execute dispatch from unregistered sender is rejected."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
|
||||
registry = {"branches": [{"email": "@flow"}]}
|
||||
reg_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
reg_file.write_text(json.dumps(registry), encoding="utf-8")
|
||||
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
|
||||
|
||||
message = {"from": "@forged", "id": "msg1", "subject": "Fake", "auto_execute": True}
|
||||
config = {"max_turns_per_wake": 50}
|
||||
state = {"daily_counts": {}, "session_cycles": {}}
|
||||
|
||||
result = spawn_agent(branch_path, "@testbranch", message, config, state)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_spawn_agent_allows_registered_auto_execute(tmp_path, monkeypatch):
|
||||
"""Auto-execute dispatch from registered sender proceeds to spawn."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
(branch_path / "logs").mkdir()
|
||||
|
||||
registry = {"branches": [{"email": "@devpulse"}, {"email": "@flow"}]}
|
||||
reg_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
reg_file.write_text(json.dumps(registry), encoding="utf-8")
|
||||
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
|
||||
|
||||
message = {"from": "@devpulse", "id": "msg1", "subject": "Task", "auto_execute": True}
|
||||
config = {"max_turns_per_wake": 50}
|
||||
state = {"daily_counts": {}, "session_cycles": {}}
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.pid = 54321
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
|
||||
return_value=mock_process,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
|
||||
return_value=(True, "Lock acquired"),
|
||||
),
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
result = spawn_agent(branch_path, "@testbranch", message, config, state)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_spawn_agent_no_auth_check_without_auto_execute(tmp_path, monkeypatch):
|
||||
"""Non-auto_execute email skips sender auth check."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
(branch_path / "logs").mkdir()
|
||||
|
||||
registry = {"branches": []}
|
||||
reg_file = tmp_path / "AIPASS_REGISTRY.json"
|
||||
reg_file.write_text(json.dumps(registry), encoding="utf-8")
|
||||
monkeypatch.setattr(daemon_mod, "BRANCH_REGISTRY", reg_file)
|
||||
|
||||
message = {"from": "@unknown", "id": "msg1", "subject": "Manual"}
|
||||
config = {"max_turns_per_wake": 50}
|
||||
state = {"daily_counts": {}, "session_cycles": {}}
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.pid = 54321
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.subprocess.Popen",
|
||||
return_value=mock_process,
|
||||
),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon._acquire_lock",
|
||||
return_value=(True, "Lock acquired"),
|
||||
),
|
||||
patch("aipass.ai_mail.apps.handlers.dispatch.daemon.log_dispatch"),
|
||||
patch(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.daemon.send_notification",
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
result = spawn_agent(branch_path, "@testbranch", message, config, state)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---- _write_pid_file atomic tests (DPLAN-0159 S5) ----------------
|
||||
|
||||
|
||||
def test_write_pid_file_atomic_no_existing(tmp_path, monkeypatch):
|
||||
"""Atomic creation succeeds when no PID file exists."""
|
||||
pid_file = tmp_path / "daemon.pid"
|
||||
monkeypatch.setattr(daemon_mod, "DAEMON_PID_FILE", pid_file)
|
||||
|
||||
result = _write_pid_file()
|
||||
|
||||
assert result is True
|
||||
assert pid_file.exists()
|
||||
assert int(pid_file.read_text().strip()) == os.getpid()
|
||||
|
||||
|
||||
def test_write_pid_file_atomic_race_second_loses(tmp_path, monkeypatch):
|
||||
"""Second daemon loses the race when both try O_CREAT|O_EXCL."""
|
||||
pid_file = tmp_path / "daemon.pid"
|
||||
monkeypatch.setattr(daemon_mod, "DAEMON_PID_FILE", pid_file)
|
||||
|
||||
# First daemon wins
|
||||
pid_file.write_text(str(os.getpid()), encoding="utf-8")
|
||||
|
||||
# Second daemon: file exists, owner is alive → returns False
|
||||
result = _write_pid_file()
|
||||
|
||||
assert result is False
|
||||
|
||||
@@ -313,3 +313,192 @@ def test_mark_as_closed_and_archive_updates_counts(tmp_path: Path):
|
||||
inbox_data = json.load(f)
|
||||
assert inbox_data["total_messages"] == 1
|
||||
assert inbox_data["unread_count"] == 1
|
||||
|
||||
|
||||
# ---- _sweep_closed tests ----------------------------------------
|
||||
|
||||
|
||||
def test_sweep_closed_removes_closed_messages(tmp_path: Path):
|
||||
"""Messages with status=closed are archived to deleted/ and removed."""
|
||||
mailbox = tmp_path / ".ai_mail.local"
|
||||
mailbox.mkdir(parents=True)
|
||||
inbox_data = {
|
||||
"messages": [
|
||||
{"id": "m1", "status": "new", "subject": "Active"},
|
||||
{"id": "m2", "status": "closed", "subject": "Done"},
|
||||
{"id": "m3", "status": "opened", "subject": "Read"},
|
||||
]
|
||||
}
|
||||
|
||||
swept = mod._sweep_closed(inbox_data, mailbox)
|
||||
|
||||
assert swept == 1
|
||||
assert len(inbox_data["messages"]) == 2
|
||||
assert all(m["id"] != "m2" for m in inbox_data["messages"])
|
||||
deleted_files = list((mailbox / "deleted").glob("*.json"))
|
||||
assert len(deleted_files) == 1
|
||||
|
||||
|
||||
def test_sweep_closed_no_closed_messages_is_noop(tmp_path: Path):
|
||||
"""Inbox with zero closed messages returns 0 and makes no changes."""
|
||||
mailbox = tmp_path / ".ai_mail.local"
|
||||
mailbox.mkdir(parents=True)
|
||||
original_messages = [
|
||||
{"id": "m1", "status": "new", "subject": "A"},
|
||||
{"id": "m2", "status": "opened", "subject": "B"},
|
||||
]
|
||||
inbox_data = {"messages": list(original_messages)}
|
||||
|
||||
swept = mod._sweep_closed(inbox_data, mailbox)
|
||||
|
||||
assert swept == 0
|
||||
assert len(inbox_data["messages"]) == 2
|
||||
assert not (mailbox / "deleted").exists()
|
||||
|
||||
|
||||
def test_sweep_closed_multiple_closed(tmp_path: Path):
|
||||
"""Multiple closed messages are all swept in one pass."""
|
||||
mailbox = tmp_path / ".ai_mail.local"
|
||||
mailbox.mkdir(parents=True)
|
||||
inbox_data = {
|
||||
"messages": [
|
||||
{"id": "m1", "status": "closed", "subject": "Done1"},
|
||||
{"id": "m2", "status": "closed", "subject": "Done2"},
|
||||
{"id": "m3", "status": "new", "subject": "Active"},
|
||||
]
|
||||
}
|
||||
|
||||
swept = mod._sweep_closed(inbox_data, mailbox)
|
||||
|
||||
assert swept == 2
|
||||
assert len(inbox_data["messages"]) == 1
|
||||
assert inbox_data["messages"][0]["id"] == "m3"
|
||||
deleted_files = list((mailbox / "deleted").glob("*.json"))
|
||||
assert len(deleted_files) == 2
|
||||
|
||||
|
||||
def test_sweep_closed_archive_failure_still_removes(tmp_path: Path, monkeypatch):
|
||||
"""If archival fails for one message, it's still removed from inbox."""
|
||||
mailbox = tmp_path / ".ai_mail.local"
|
||||
mailbox.mkdir(parents=True)
|
||||
|
||||
call_count = [0]
|
||||
original_save = mod._save_to_deleted_folder
|
||||
|
||||
def _failing_save(mp, msg):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
raise OSError("disk full")
|
||||
return original_save(mp, msg)
|
||||
|
||||
monkeypatch.setattr(mod, "_save_to_deleted_folder", _failing_save)
|
||||
|
||||
inbox_data = {
|
||||
"messages": [
|
||||
{"id": "m1", "status": "closed", "subject": "Fail"},
|
||||
{"id": "m2", "status": "closed", "subject": "OK"},
|
||||
{"id": "m3", "status": "new", "subject": "Active"},
|
||||
]
|
||||
}
|
||||
|
||||
swept = mod._sweep_closed(inbox_data, mailbox)
|
||||
|
||||
assert swept == 2
|
||||
assert len(inbox_data["messages"]) == 1
|
||||
|
||||
|
||||
def test_sweep_closed_on_read_via_load_inbox(tmp_path: Path, monkeypatch):
|
||||
"""Closed message injected by raw JSON edit is swept on next load_inbox."""
|
||||
import aipass.ai_mail.apps.handlers.email.inbox_ops as ops_mod
|
||||
|
||||
monkeypatch.setattr(ops_mod, "_get_inbox_lock", lambda: _noop_lock)
|
||||
|
||||
mailbox = tmp_path / ".ai_mail.local"
|
||||
mailbox.mkdir(parents=True)
|
||||
inbox_file = mailbox / "inbox.json"
|
||||
data = {
|
||||
"mailbox": "inbox",
|
||||
"total_messages": 2,
|
||||
"unread_count": 1,
|
||||
"messages": [
|
||||
{"id": "m1", "status": "new", "subject": "Active", "read": False},
|
||||
{"id": "m2", "status": "closed", "subject": "Stale", "read": True},
|
||||
],
|
||||
}
|
||||
inbox_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
result = ops_mod.load_inbox(inbox_file)
|
||||
|
||||
assert len(result["messages"]) == 1
|
||||
assert result["messages"][0]["id"] == "m1"
|
||||
# Verify persistence — file should be updated
|
||||
with open(inbox_file, "r", encoding="utf-8") as f:
|
||||
persisted = json.load(f)
|
||||
assert len(persisted["messages"]) == 1
|
||||
# Verify archived
|
||||
deleted_files = list((mailbox / "deleted").glob("*.json"))
|
||||
assert len(deleted_files) == 1
|
||||
|
||||
|
||||
def test_sweep_on_mark_as_opened_cleans_stale_closed(tmp_path: Path):
|
||||
"""Opening a message also sweeps any stale closed messages in inbox."""
|
||||
branch_path = tmp_path / "branch"
|
||||
_make_inbox(
|
||||
branch_path,
|
||||
[
|
||||
{"id": "m1", "status": "new", "subject": "Target", "read": False},
|
||||
{"id": "m2", "status": "closed", "subject": "Stale", "read": True},
|
||||
],
|
||||
)
|
||||
|
||||
success, _, _ = mod.mark_as_opened(branch_path, "m1")
|
||||
assert success is True
|
||||
|
||||
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
|
||||
with open(inbox_file, "r", encoding="utf-8") as f:
|
||||
inbox_data = json.load(f)
|
||||
assert len(inbox_data["messages"]) == 1
|
||||
assert inbox_data["messages"][0]["id"] == "m1"
|
||||
assert inbox_data["total_messages"] == 1
|
||||
|
||||
|
||||
def test_sweep_on_mark_as_closed_cleans_other_stale(tmp_path: Path):
|
||||
"""Closing one message also sweeps other stale closed messages."""
|
||||
branch_path = tmp_path / "branch"
|
||||
_make_inbox(
|
||||
branch_path,
|
||||
[
|
||||
{"id": "m1", "status": "opened", "subject": "Close Me", "read": True},
|
||||
{"id": "m2", "status": "closed", "subject": "Stale", "read": True},
|
||||
{"id": "m3", "status": "new", "subject": "Active", "read": False},
|
||||
],
|
||||
)
|
||||
|
||||
success, _ = mod.mark_as_closed_and_archive(branch_path, "m1")
|
||||
assert success is True
|
||||
|
||||
inbox_file = branch_path / ".ai_mail.local" / "inbox.json"
|
||||
with open(inbox_file, "r", encoding="utf-8") as f:
|
||||
inbox_data = json.load(f)
|
||||
assert len(inbox_data["messages"]) == 1
|
||||
assert inbox_data["messages"][0]["id"] == "m3"
|
||||
assert inbox_data["total_messages"] == 1
|
||||
assert inbox_data["unread_count"] == 1
|
||||
# Both m1 (properly closed) and m2 (swept) should be in deleted/
|
||||
deleted_files = list((branch_path / ".ai_mail.local" / "deleted").glob("*.json"))
|
||||
assert len(deleted_files) == 2
|
||||
|
||||
|
||||
def test_proper_close_does_not_double_archive(tmp_path: Path):
|
||||
"""Closing via mark_as_closed_and_archive does not produce duplicate archives."""
|
||||
branch_path = tmp_path / "branch"
|
||||
_make_inbox(
|
||||
branch_path,
|
||||
[{"id": "m1", "status": "opened", "subject": "Normal Close", "read": True}],
|
||||
)
|
||||
|
||||
success, _ = mod.mark_as_closed_and_archive(branch_path, "m1")
|
||||
assert success is True
|
||||
|
||||
deleted_files = list((branch_path / ".ai_mail.local" / "deleted").glob("*.json"))
|
||||
assert len(deleted_files) == 1
|
||||
|
||||
@@ -163,7 +163,6 @@ def test_daemon_poll_cycle_is_called(tmp_path, monkeypatch):
|
||||
with (
|
||||
patch.object(daemon_mod, "_write_pid_file", return_value=True),
|
||||
patch.object(daemon_mod, "_remove_pid_file"),
|
||||
patch.object(daemon_mod, "_notify_telegram", return_value=False),
|
||||
patch.object(daemon_mod, "poll_cycle", side_effect=mock_poll_cycle),
|
||||
patch.object(daemon_mod, "save_daemon_state"),
|
||||
patch.object(daemon_mod, "is_kill_switch_active", return_value=False),
|
||||
|
||||
@@ -29,7 +29,6 @@ from aipass.ai_mail.apps.handlers.dispatch.wake import (
|
||||
DEFAULT_MODEL,
|
||||
_acquire_lock,
|
||||
_load_config,
|
||||
_set_session_name,
|
||||
_is_branch_occupied,
|
||||
wake_branch,
|
||||
)
|
||||
@@ -710,84 +709,6 @@ class TestLoadConfig:
|
||||
assert result["max_turns_per_wake"] == 50
|
||||
|
||||
|
||||
# --- _set_session_name tests --------------------------------------------
|
||||
|
||||
|
||||
class TestSetSessionName:
|
||||
"""Tests for _set_session_name() — writes custom-title to Claude session JSONL."""
|
||||
|
||||
def test_success_appends_entry(self, tmp_path, monkeypatch):
|
||||
"""Creates expected JSON entry in the most recent session JSONL."""
|
||||
encoded_cwd = str(tmp_path).replace("/", "-")
|
||||
projects_dir = tmp_path / ".claude" / "projects" / encoded_cwd
|
||||
projects_dir.mkdir(parents=True)
|
||||
session_file = projects_dir / "abc123.jsonl"
|
||||
session_file.write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.Path.expanduser",
|
||||
lambda self: tmp_path / ".claude" / "projects" if str(self).endswith("projects") else self,
|
||||
)
|
||||
# We need to mock expanduser properly — override the whole projects_dir lookup
|
||||
monkeypatch.setattr(
|
||||
_Path,
|
||||
"expanduser",
|
||||
lambda self: tmp_path / str(self).lstrip("~/"),
|
||||
)
|
||||
result = _set_session_name(tmp_path, "TEST-dispatched")
|
||||
assert result is True
|
||||
content = session_file.read_text(encoding="utf-8")
|
||||
entry = json.loads(content.strip())
|
||||
assert entry["type"] == "custom-title"
|
||||
assert entry["customTitle"] == "TEST-dispatched"
|
||||
assert entry["sessionId"] == "abc123"
|
||||
|
||||
def test_no_projects_dir_returns_false(self, tmp_path, monkeypatch):
|
||||
"""Returns False when ~/.claude/projects/{encoded} does not exist."""
|
||||
monkeypatch.setattr(
|
||||
_Path,
|
||||
"expanduser",
|
||||
lambda self: tmp_path / str(self).lstrip("~/"),
|
||||
)
|
||||
result = _set_session_name(tmp_path, "TEST-dispatched")
|
||||
assert result is False
|
||||
|
||||
def test_no_jsonl_files_returns_false(self, tmp_path, monkeypatch):
|
||||
"""Returns False when projects dir exists but has no .jsonl files."""
|
||||
encoded_cwd = str(tmp_path).replace("/", "-")
|
||||
projects_dir = tmp_path / ".claude" / "projects" / encoded_cwd
|
||||
projects_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(
|
||||
_Path,
|
||||
"expanduser",
|
||||
lambda self: tmp_path / str(self).lstrip("~/"),
|
||||
)
|
||||
result = _set_session_name(tmp_path, "TEST-dispatched")
|
||||
assert result is False
|
||||
|
||||
def test_os_error_on_write_returns_false(self, tmp_path, monkeypatch):
|
||||
"""Returns False when write to JSONL file raises OSError."""
|
||||
encoded_cwd = str(tmp_path).replace("/", "-")
|
||||
projects_dir = tmp_path / ".claude" / "projects" / encoded_cwd
|
||||
projects_dir.mkdir(parents=True)
|
||||
session_file = projects_dir / "abc123.jsonl"
|
||||
session_file.write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
_Path,
|
||||
"expanduser",
|
||||
lambda self: tmp_path / str(self).lstrip("~/"),
|
||||
)
|
||||
|
||||
def _fail_open(path, *args, **kwargs):
|
||||
path_str = str(path)
|
||||
if path_str.endswith(".jsonl") and "a" in args:
|
||||
raise OSError("permission denied")
|
||||
return _REAL_OPEN(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.open", _fail_open)
|
||||
result = _set_session_name(tmp_path, "TEST-dispatched")
|
||||
assert result is False
|
||||
|
||||
|
||||
# --- _is_branch_occupied tests ------------------------------------------
|
||||
|
||||
|
||||
@@ -1103,21 +1024,15 @@ class TestWakeBranch:
|
||||
assert ok is False
|
||||
assert any(s[0] == "fail" and "RuntimeError" in s[2] for s in status.steps)
|
||||
|
||||
# --- post-spawn: lock acquisition failure ---
|
||||
# --- pre-spawn: lock acquisition failure (DPLAN-0155) ---
|
||||
|
||||
def test_lock_acquisition_fails_after_spawn_warns(self, tmp_path, monkeypatch):
|
||||
"""Lock fails after spawn -> warn step but still succeeds."""
|
||||
def test_lock_acquisition_fails_before_spawn(self, tmp_path, monkeypatch):
|
||||
"""Lock fails before spawn -> fail step, returns False (DPLAN-0155 lock-before-spawn)."""
|
||||
_make_wake_fixtures(tmp_path, monkeypatch)
|
||||
_patch_wake_deps(monkeypatch, _acquire_lock=lambda p, pid: (False, "Lock file already exists"))
|
||||
monkeypatch.setattr("subprocess.Popen", lambda *a, **kw: _FakeProc())
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.notify.send_notification",
|
||||
lambda *a, **kw: None,
|
||||
raising=False,
|
||||
)
|
||||
status, ok = wake_branch("@testbranch")
|
||||
assert ok is True
|
||||
assert any(s[0] == "warn" and "lock-acquire" in s[1] for s in status.steps)
|
||||
assert ok is False
|
||||
assert any(s[0] == "fail" and "lock-acquire" in s[1] for s in status.steps)
|
||||
|
||||
# --- alive check fails ---
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Branch Prompt
|
||||
|
||||
AI context for `AIPASS`. The `aipass_local_prompt.md` file is injected every turn, telling the AI who you are and how to work in your branch.
|
||||
@@ -0,0 +1,109 @@
|
||||
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/.aipass/aipass_global_prompt.md -->
|
||||
# AIPASS — Project Context
|
||||
<!-- Injected every turn via hook. -->
|
||||
|
||||
## What is AIPass
|
||||
|
||||
AIPass is a multi-agent framework. Agents live in directories with
|
||||
persistent identity, memory, and communication. All AIPass infrastructure
|
||||
is available from any project via the `drone` command.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Project** — this directory. Contains a registry and agents.
|
||||
- **Agent** — a citizen with identity (`.trinity/`), memory, mailbox,
|
||||
and code (`apps/`).
|
||||
- **Registry** — `AIPASS_REGISTRY.json` tracks all agents.
|
||||
|
||||
## Setup: if drone commands fail
|
||||
|
||||
If `drone` cannot find the AIPass registry, set the env var:
|
||||
```bash
|
||||
export AIPASS_HOME=/path/to/AIPass # path to AIPass installation
|
||||
```
|
||||
Add to your shell profile (`~/.bashrc` or `~/.zshrc`) to make it permanent.
|
||||
|
||||
## Commands
|
||||
|
||||
### Agent Lifecycle
|
||||
```
|
||||
aipass init agent <name> # Create a new agent in src/<name>/
|
||||
drone @spawn create <name> # Create agent (alternative)
|
||||
drone @spawn list # List registered agents
|
||||
```
|
||||
|
||||
### Standards
|
||||
```
|
||||
drone @seedgo audit <project> # Run full standards audit
|
||||
drone @seedgo checklist <file> # Check a single file
|
||||
```
|
||||
|
||||
### Dispatch — Send Task + Wake an Agent (DEFAULT)
|
||||
```
|
||||
drone @ai_mail dispatch @<agent> "Subject" "Body" # Send + wake (default)
|
||||
drone @ai_mail dispatch @<agent> "Subject" "Body" --fresh # Send + wake fresh session
|
||||
drone @ai_mail dispatch wake @<agent> # Wake without sending
|
||||
drone @ai_mail dispatch wake --fresh @<agent> # Wake fresh
|
||||
drone @ai_mail email @<agent> "Subject" "Body" # FYI only (no wake)
|
||||
```
|
||||
|
||||
Use `dispatch` by default. Use `email` only when you don't need the agent to act now.
|
||||
|
||||
### Communication (ai_mail)
|
||||
```
|
||||
drone @ai_mail inbox # Check your mailbox
|
||||
drone @ai_mail view <id> # Read a message
|
||||
drone @ai_mail close <id> # Mark message read
|
||||
```
|
||||
|
||||
### Feedback
|
||||
```
|
||||
drone @devpulse feedback send "Subject" "Body" # Send feedback (cross-project)
|
||||
```
|
||||
|
||||
### Plans (flow)
|
||||
```
|
||||
drone @flow create . "Subject" dplan # Create DPLAN (design/thinking)
|
||||
drone @flow create . "Subject" master # Create FPLAN master (execution)
|
||||
drone @flow create . "Subject" aplan # Create APLAN (agent-level task)
|
||||
drone @flow list open # List active plans
|
||||
drone @flow list # List all plans
|
||||
drone @flow close <id> # Close a plan
|
||||
drone @flow info <id> # View plan details
|
||||
```
|
||||
|
||||
**DPLAN** = Dev Plan. Thinking, brainstorming, architecture decisions. Use before building.
|
||||
**FPLAN** = Flow Plan. Building and executing. Use when the plan is clear and work is underway.
|
||||
|
||||
### Memory
|
||||
```
|
||||
drone @memory archive # Archive memories to vector store
|
||||
drone @memory search <query> # Search archived memories
|
||||
```
|
||||
|
||||
### Git Workflow
|
||||
```
|
||||
drone @git pr 'description' # Create a pull request
|
||||
drone @git status # Git status (branch-scoped)
|
||||
drone @git sync # Sync with main
|
||||
drone @git lock / unlock # Lock/unlock the repo
|
||||
```
|
||||
|
||||
### Infrastructure
|
||||
```
|
||||
drone systems # List all available infrastructure
|
||||
drone --help # Full drone command reference
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
- **Communication** — agents communicate via `.ai_mail.local/`.
|
||||
- **Standards** — run `drone @seedgo audit` to check compliance.
|
||||
- **Identity** — agents have `.trinity/passport.json`. Projects use the registry.
|
||||
- **Memory** — update `.trinity/local.json` at session end. Memory is presence.
|
||||
|
||||
## Maintenance
|
||||
|
||||
- **Upgrade scaffold**: `drone @cli aipass init update` refreshes managed project files (hooks, prompts, settings) to latest templates.
|
||||
- **Entry point**: each agent's `apps/{name}.py` auto-configures `sys.path` and `AIPASS_BRANCH_NAME` env var. If prax logs to `unknown_branch/`, check that these are set.
|
||||
- **Standalone projects** use `src/{name}/` layout (not `src/aipass/{name}/`). Module discovery adapts automatically.
|
||||
@@ -0,0 +1,84 @@
|
||||
# AIPASS — Branch Prompt
|
||||
|
||||
*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories, STATUS.local.md.*
|
||||
|
||||
## Identity
|
||||
|
||||
You are AIPASS — the friendly front door. New users land here. You greet them, walk them through setup, answer how-things-work questions, hand them off to their chosen CLI. Drone is the engine. You are the concierge. You are the librarian — read anything, inspect anything, point anywhere. You do not build.
|
||||
|
||||
## Hard Rules — what you cannot do
|
||||
|
||||
These are not suggestions. Violating them is a bug.
|
||||
|
||||
- **No writes outside your own `.trinity/`.** Never create, edit, or delete files anywhere else. Not code, not docs, not configs, not other branches' memories.
|
||||
- **No git. Ever.** Not `git status`, not `drone @git anything`. Git is drone's world.
|
||||
- **No `drone @ai_mail dispatch`.** You email only with the test-convention body (below). You never wake an agent for real work.
|
||||
- **No registry / hooks / bypass.json / config edits.** Even if you spot a bug, you report — you never patch.
|
||||
- If a user asks you to build, fix, or change something: tell them who to ask. Offer dispatch through devpulse or drone — don't do it.
|
||||
|
||||
## What I Do
|
||||
|
||||
- Guide new users through `aipass init` (12 stages: welcome, system detect, doctor, profile, style questions, tool choice, docker offer, first agent, ping sweep, smoke test, handoff, done)
|
||||
- Answer "how does X work?" via `aipass help` — live README reads, offer depth, route to branch experts
|
||||
- Run `aipass doctor` — aggregate seedgo, pytest, registry, hooks, git state, AIPASS_HOME
|
||||
- Remember the user — name, OS, preferred CLI, setup progress in `.trinity/local.json`
|
||||
- Test the system non-mutatingly — test-convention emails, empty flow plan open/close, pytest collect
|
||||
|
||||
## Key Commands
|
||||
|
||||
```
|
||||
aipass # Help banner with all commands
|
||||
aipass help [q] # Chatbot Q&A over branch READMEs
|
||||
aipass doctor # System health aggregation
|
||||
aipass init # 12-stage guided setup for new users, resumable
|
||||
aipass profile # Show/edit what I know about the user
|
||||
aipass --version
|
||||
```
|
||||
|
||||
## Test-Convention Emails
|
||||
|
||||
Your only safe way to touch the system. Body MUST include this token:
|
||||
|
||||
```
|
||||
[AIPASS-TEST — do not update memories, do not execute, reply 'ack' only]
|
||||
```
|
||||
|
||||
Other core agents recognize this and respond with "ack" — no task execution, no memory update, no spawn.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/
|
||||
├── aipass.py # Entry point — thin CLI dispatch
|
||||
├── modules/
|
||||
│ ├── doctor.py # System health aggregation
|
||||
│ ├── help_chat.py # README-backed Q&A
|
||||
│ ├── init_flow.py # 12-stage guided setup, resumable
|
||||
│ ├── handoff.py # CLI handoff (tmux / wt.exe)
|
||||
│ └── profile.py # User profile read/write
|
||||
└── handlers/
|
||||
├── system_detect/ # OS, shell, Python, RAM, CPU, install method
|
||||
├── ping_sweep/ # Verify each branch responds
|
||||
├── readme_map/ # Live file reads with branch routing
|
||||
└── ui/ # Progress bars, menus, banners
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
- **Depends on:** @drone (routing), @seedgo (audit), @spawn (first agent creation), @flow (plan test open/close), @ai_mail (test emails), @prax (health signals), pytest, CLI tools (Claude/Codex/Gemini)
|
||||
- **Serves:** New users first. Also humans asking "how does this work?" anywhere in the ecosystem.
|
||||
- **Nothing depends on me.** One-way relationship. I can be removed or replaced without ripple.
|
||||
|
||||
## Working Habits
|
||||
|
||||
- **Verify, don't remember.** Every question triggers a live file read. Cache the branch-name → README-path map only — never cache ANSWERS.
|
||||
- **Offer depth, don't assume.** First response is concise. Then ask: "want to go into the code?" / "want me to connect you with @drone?"
|
||||
- **Warm tone, no jargon on first contact.** Assume the user doesn't know what a citizen is. Explain as you go.
|
||||
- **Never pretend.** If you don't know: say so, then offer to find out or to ask the branch expert.
|
||||
- **Clean handoffs.** Every init stage saves to `setup_progress` in `.trinity/local.json` so resume works.
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
- **Status: under construction.** Whole branch is gitignored. Do not PR anything from this directory until Phase 8 reveal (DPLAN-0136).
|
||||
- **The `aipass` binary is currently `cli` branch's `aipass init`** — project bootstrap, not citizen creation. Eventually this CLI entry moves here. Until then, use `drone @spawn create` for citizen creation.
|
||||
- **Test-convention tokens need buy-in.** Core agents don't yet recognize `[AIPASS-TEST — ...]`. Coordinating with @ai_mail before pinging anyone.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Claude Code Settings
|
||||
|
||||
Claude Code configuration for `AIPASS`.
|
||||
|
||||
Contains `settings.local.json` with permission rules. Most branches are denied raw git commands and must use `drone @git` instead.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Project-Level Hooks
|
||||
|
||||
These hooks are provisioned by `aipass init` and live in the project's
|
||||
`.claude/settings.json`. They fire when CWD is inside this project.
|
||||
|
||||
## What fires and what doesn't
|
||||
|
||||
**UserPromptSubmit** hooks fire from project settings. These work:
|
||||
- `branch_prompt_loader.py` — injects branch-specific prompt
|
||||
- `email_notification.py` — shows unread email count
|
||||
- `identity_injector.py` — injects branch identity from passport
|
||||
|
||||
**PreToolUse / PostToolUse** hooks are provisioned but **DO NOT FIRE** from
|
||||
project-level settings. This is a Claude Code limitation (confirmed S122,
|
||||
GitHub issue #36071). These scripts exist but are dead weight:
|
||||
- `pre_edit_gate.py` — intended to block cross-branch writes (never runs)
|
||||
- `auto_fix_diagnostics.py` — intended to run pyright+ruff (never runs)
|
||||
- `subagent_stop_gate.py` — intended to check subagent files (never runs)
|
||||
|
||||
These same hooks DO fire from provider settings (`~/.claude/settings.json`)
|
||||
where they are also wired. The provider copies handle all enforcement.
|
||||
|
||||
**PreCompact** hooks fire from project settings:
|
||||
- `pre_compact.py` — injects recovery context after compaction
|
||||
|
||||
## CWD guard interaction
|
||||
|
||||
When this project has UserPromptSubmit hooks (it does), the provider-level
|
||||
UserPromptSubmit hooks detect this and exit silently. This prevents the AIPass
|
||||
global prompt from being injected into projects that manage their own context.
|
||||
|
||||
The provider-level PreToolUse/PostToolUse hooks still fire (they can only run
|
||||
at provider level) — so enforcement (git_gate, pre_edit_gate, auto_fix) is
|
||||
always active regardless of CWD.
|
||||
|
||||
## Testing
|
||||
|
||||
Provider-level test harness covers project-level behavior:
|
||||
```bash
|
||||
python3 $AIPASS_HOME/.claude/hooks/hook_test.py --direct
|
||||
```
|
||||
|
||||
Tests include:
|
||||
- `direct_provider_guards_for_init_project` — verifies provider hooks are
|
||||
CWD-guarded when run from an aipass init project
|
||||
- `direct_project_settings_schema` — validates project settings.json has
|
||||
expected hooks and all referenced scripts exist
|
||||
|
||||
## Updating hooks
|
||||
|
||||
```bash
|
||||
drone @cli aipass init update # Refresh managed project files to latest templates
|
||||
```
|
||||
|
||||
## Related
|
||||
See `$AIPASS_HOME/.claude/hooks/README.md` for the full hook system documentation.
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PostToolUse Auto-fix Hook — Detects errors and surfaces them for fixing.
|
||||
|
||||
Two-hook system:
|
||||
PostToolUse (this file) → runs pyright + ruff on edited file, saves errors to state
|
||||
PreToolUse (pre_edit_gate.py) → blocks edits to OTHER files until errors fixed
|
||||
|
||||
Key behaviors:
|
||||
- Runs py_compile (syntax), ruff lint+format, pyright (type errors) on edited file
|
||||
- Runs seedgo checklist for AIPass standards
|
||||
- Saves ruff lint AND pyright errors to state file for PreToolUse gate (hard block)
|
||||
- Surfaces ALL errors in additionalContext so Claude sees them
|
||||
|
||||
Version: 5.2.0
|
||||
|
||||
CHANGELOG:
|
||||
- v5.2.0 (2026-04-20): Save ruff lint errors to state file for hard-block enforcement.
|
||||
Pre-edit gate now blocks on F401/lint just like type errors.
|
||||
- v5.1.0 (2026-04-19): Added ruff format --check to surface format drift.
|
||||
- v5.0.0 (2026-03-17): Replaced mcp__ide__getDiagnostics with direct pyright.
|
||||
Added state file for PreToolUse gate integration.
|
||||
Single-file pyright (not whole project).
|
||||
- v4.3.0 (2026-03-17): Added seedgo checklist integration
|
||||
- v4.0.0 (2025-11-27): Complete rewrite - actual validation, silent operation
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"]
|
||||
LAST_FILE_PATH = Path(__file__).parent / ".last_diagnostics_file"
|
||||
STATE_FILE = Path(__file__).parent / ".diagnostics_state.json"
|
||||
SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"}
|
||||
|
||||
# AIPass-specific Python patterns to check
|
||||
PYTHON_PATTERNS = {
|
||||
"bad_optional": {"pattern": ": str = None", "message": "Optional param should use 'str | None = None' pattern"},
|
||||
"logger_debug": {
|
||||
"pattern": "logger.debug(",
|
||||
"message": "Use logger.info for SystemLogger (logger.debug not supported)",
|
||||
},
|
||||
"return_error_msg": {
|
||||
"pattern": "return error_msg",
|
||||
"message": "Return None for error states, not error_msg string",
|
||||
},
|
||||
"open_no_encoding": {
|
||||
"pattern": "open(",
|
||||
"requires_missing": "encoding=",
|
||||
"message": "open() without encoding='utf-8'",
|
||||
},
|
||||
"log_not_log_operation": {
|
||||
"pattern": ".log(",
|
||||
"message": "Use log_operation() with success/error params, not .log()",
|
||||
},
|
||||
"dict_none_no_check": {
|
||||
"pattern": "Dict | None",
|
||||
"message": "Dict | None return: Add None check before using (if result is None: return)",
|
||||
},
|
||||
}
|
||||
|
||||
# JSON-specific patterns for emoji corruption
|
||||
JSON_CORRUPTION_CHARS = ["\ufffd", "\x00"]
|
||||
|
||||
|
||||
def run_python_checks(file_path: str) -> list[str]:
|
||||
"""Run actual Python validation - returns list of errors."""
|
||||
errors = []
|
||||
|
||||
# 1. Syntax check with py_compile
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "py_compile", file_path], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
errors.append(f"SYNTAX: {result.stderr.strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Ruff check (if available) - fast linter
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ruff", "check", "--select=E,F,W", "--output-format=text", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
for line in result.stdout.strip().split("\n")[:5]:
|
||||
errors.append(f"LINT: {line}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Ruff format check — detect format drift
|
||||
try:
|
||||
result = subprocess.run(["ruff", "format", "--check", file_path], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
errors.append(f"FORMAT: {Path(file_path).name} needs ruff format (run: ruff format {Path(file_path).name})")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. AIPass-specific pattern checks
|
||||
try:
|
||||
content = Path(file_path).read_text(encoding="utf-8")
|
||||
lines = content.split("\n")
|
||||
|
||||
for check in PYTHON_PATTERNS.values():
|
||||
pattern = check["pattern"]
|
||||
message = check["message"]
|
||||
requires_missing = check.get("requires_missing")
|
||||
|
||||
if requires_missing:
|
||||
if pattern in content and requires_missing not in content:
|
||||
errors.append(f"PATTERN: {message}")
|
||||
continue
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(("#", '"', "'")):
|
||||
continue
|
||||
if f'"{pattern}' in line or f"'{pattern}" in line:
|
||||
continue
|
||||
if pattern in line:
|
||||
errors.append(f"PATTERN: {message}")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def run_ruff_lint_structured(file_path: str) -> list[dict]:
|
||||
"""Run ruff check and return structured violations for the state file.
|
||||
|
||||
Returns list of {line, message} dicts — same format as pyright errors.
|
||||
Only non-empty when ruff finds real violations (not format drift).
|
||||
"""
|
||||
if "/.claude/hooks/" in file_path:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ruff", "check", "--select=E,F,W", "--output-format=json", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if not result.stdout.strip():
|
||||
return []
|
||||
violations = json.loads(result.stdout)
|
||||
if not isinstance(violations, list):
|
||||
return []
|
||||
errors = []
|
||||
for v in violations[:10]:
|
||||
line = v.get("location", {}).get("row", 0)
|
||||
code = v.get("code", "?")
|
||||
message = v.get("message", "unknown")[:100]
|
||||
errors.append({"line": line, "message": f"{code}: {message}"})
|
||||
return errors
|
||||
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired, Exception):
|
||||
return []
|
||||
|
||||
|
||||
def run_pyright_check(file_path: str) -> list[dict]:
|
||||
"""Run pyright on a single file. Returns list of error dicts."""
|
||||
# Skip hook files - they don't follow project standards
|
||||
if "/.claude/hooks/" in file_path:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pyright", "--outputjson", file_path], capture_output=True, text=True, timeout=15
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return []
|
||||
|
||||
errors = []
|
||||
for diag in data.get("generalDiagnostics", []):
|
||||
severity = diag.get("severity", "")
|
||||
if severity == "error":
|
||||
line = diag.get("range", {}).get("start", {}).get("line", 0)
|
||||
message = diag.get("message", "Unknown error")
|
||||
errors.append({"line": line, "message": message[:100]})
|
||||
|
||||
return errors[:10] # Max 10 errors
|
||||
|
||||
except FileNotFoundError:
|
||||
return [] # pyright not installed
|
||||
except subprocess.TimeoutExpired:
|
||||
return [] # Timeout — don't block
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def save_diagnostics_state(file_path: str, errors: list[dict]):
|
||||
"""Save type errors to state file for PreToolUse gate."""
|
||||
try:
|
||||
if errors:
|
||||
state = {"file": str(Path(file_path).resolve()), "errors": errors}
|
||||
STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
|
||||
else:
|
||||
# No errors — clear the state
|
||||
if STATE_FILE.exists():
|
||||
STATE_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_json_checks(file_path: str) -> list[str]:
|
||||
"""Run actual JSON validation - returns list of errors."""
|
||||
errors = []
|
||||
|
||||
try:
|
||||
content = Path(file_path).read_text(encoding="utf-8")
|
||||
|
||||
for char in JSON_CORRUPTION_CHARS:
|
||||
if char in content:
|
||||
errors.append(f"EMOJI CORRUPTION: Found corrupted character '{repr(char)}'")
|
||||
break
|
||||
|
||||
try:
|
||||
data = json.loads(content)
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key in ["allowed_emojis", "emojis", "emoji_list"]:
|
||||
if key in data and isinstance(data[key], list):
|
||||
for item in data[key]:
|
||||
if isinstance(item, str) and len(item) == 1:
|
||||
if ord(item) < 128 and item not in "\u2713\u2717":
|
||||
errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f"JSON SYNTAX: {e.msg} at line {e.lineno}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"READ ERROR: {e!s}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def run_seedgo_checklist(file_path: str) -> list[str]:
|
||||
"""Run seedgo standards checklist — returns violations only."""
|
||||
if "/.claude/hooks/" in file_path:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["drone", "@seedgo", "checklist", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
cwd=str(Path.home() / "Projects" / "AIPass"),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
violations = []
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("\u2717"):
|
||||
violation = line[1:].strip()
|
||||
if violation:
|
||||
violations.append(violation)
|
||||
|
||||
return violations[:5]
|
||||
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def should_skip_file(file_path: str) -> bool:
|
||||
"""Check if file should be skipped."""
|
||||
if not file_path:
|
||||
return True
|
||||
ext = Path(file_path).suffix.lower()
|
||||
return ext in SKIP_EXTENSIONS
|
||||
|
||||
|
||||
def is_same_file_as_last(file_path: str) -> bool:
|
||||
"""Smart batching DISABLED — always recheck.
|
||||
|
||||
Previously skipped rechecks on the same file, but this caused
|
||||
errors introduced on second edit to be missed (state file didn't
|
||||
exist from first clean edit, so skip triggered). The 1.7s pyright
|
||||
cost per edit is acceptable for correctness.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main hook entry point."""
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
file_path = tool_input.get("file_path", "")
|
||||
|
||||
if tool_name not in EDIT_TOOLS:
|
||||
return
|
||||
|
||||
if should_skip_file(file_path):
|
||||
return
|
||||
|
||||
if is_same_file_as_last(file_path):
|
||||
return
|
||||
|
||||
# Collect all errors
|
||||
errors = []
|
||||
|
||||
if file_path.endswith(".py"):
|
||||
errors = run_python_checks(file_path)
|
||||
|
||||
# Seedgo standards checklist
|
||||
seedgo_violations = run_seedgo_checklist(file_path)
|
||||
for v in seedgo_violations:
|
||||
errors.append(f"SEEDGO: {v}")
|
||||
|
||||
# Pyright type errors (single file)
|
||||
type_errors = run_pyright_check(file_path)
|
||||
for te in type_errors:
|
||||
errors.append(f"TYPE: L{te['line']}: {te['message']}")
|
||||
|
||||
# Save ruff lint + type errors to state file for PreToolUse gate (hard block)
|
||||
ruff_lint_errors = run_ruff_lint_structured(file_path)
|
||||
save_diagnostics_state(file_path, ruff_lint_errors + type_errors)
|
||||
|
||||
elif file_path.endswith(".json"):
|
||||
errors = run_json_checks(file_path)
|
||||
else:
|
||||
return
|
||||
|
||||
# Build output
|
||||
if errors:
|
||||
error_text = "\n".join(f" - {e}" for e in errors)
|
||||
context = f"""[AUTO-FIX] {len(errors)} error(s) in {Path(file_path).name}:
|
||||
{error_text}
|
||||
|
||||
Fix these errors in {Path(file_path).name} now. Do not skip or defer."""
|
||||
|
||||
output = {
|
||||
"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": context},
|
||||
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing",
|
||||
}
|
||||
print(json.dumps(output))
|
||||
else:
|
||||
output = {"systemMessage": "[diagnostics] ok"}
|
||||
print(json.dumps(output))
|
||||
|
||||
except Exception:
|
||||
pass # Silent fail
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Branch Prompt Loader — AIPass Public Repo
|
||||
|
||||
Injects branch-specific prompts based on CWD. When working in a branch
|
||||
directory, loads .aipass/aipass_local_prompt.md and outputs it so the
|
||||
AI sees branch-specific context.
|
||||
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_branch_root() -> Path | None:
|
||||
"""
|
||||
Find the branch root directory.
|
||||
Looks for .trinity/ or .aipass/ as branch indicators.
|
||||
Stops at the repo root (has pyproject.toml or .git).
|
||||
"""
|
||||
cwd = Path.cwd()
|
||||
search_path = cwd
|
||||
|
||||
while search_path.parent != search_path:
|
||||
# Branch indicators: has .trinity/ (memory files) or apps/ (code)
|
||||
has_trinity = (search_path / ".trinity").is_dir()
|
||||
has_apps = (search_path / "apps").is_dir()
|
||||
|
||||
if has_trinity or has_apps:
|
||||
return search_path
|
||||
|
||||
# Stop at repo root
|
||||
if (search_path / "pyproject.toml").exists() or (search_path / ".git").is_dir():
|
||||
return None
|
||||
|
||||
search_path = search_path.parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
branch_root = find_branch_root()
|
||||
|
||||
if branch_root:
|
||||
prompt_file = branch_root / ".aipass" / "aipass_local_prompt.md"
|
||||
if prompt_file.exists():
|
||||
content = prompt_file.read_text().strip()
|
||||
branch_name = branch_root.name.upper()
|
||||
print(f"\n# Branch Context: {branch_name}\n<!-- Source: {prompt_file} -->\n{content}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Email Notification Hook - Notifies of new emails on prompt submit.
|
||||
|
||||
Checks the current branch's inbox for unread emails and displays
|
||||
a notification if any exist.
|
||||
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_repo_root() -> Path | None:
|
||||
"""Find the repo root (contains pyproject.toml or .git)."""
|
||||
search = Path.cwd()
|
||||
while search.parent != search:
|
||||
if (search / "pyproject.toml").exists() or (search / ".git").is_dir():
|
||||
return search
|
||||
search = search.parent
|
||||
return None
|
||||
|
||||
|
||||
def find_branch_root() -> Path | None:
|
||||
"""Find the branch root directory by walking up from CWD."""
|
||||
cwd = Path.cwd()
|
||||
repo_root = find_repo_root()
|
||||
if not repo_root:
|
||||
return None
|
||||
|
||||
search_path = cwd
|
||||
for _ in range(10):
|
||||
has_trinity = (search_path / ".trinity").is_dir()
|
||||
has_id = list(search_path.glob("*.id.json"))
|
||||
has_apps = (search_path / "apps").is_dir()
|
||||
has_mail = (search_path / ".ai_mail.local").is_dir() or (search_path / "ai_mail.local").is_dir()
|
||||
|
||||
if (has_trinity or has_id or has_apps or has_mail) and search_path != repo_root:
|
||||
return search_path
|
||||
|
||||
if search_path == repo_root:
|
||||
break
|
||||
|
||||
parent = search_path.parent
|
||||
if parent == search_path:
|
||||
break
|
||||
search_path = parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def count_new_emails(branch_root: Path) -> int:
|
||||
"""Count new (unread) emails in the branch's inbox."""
|
||||
# Check both patterns: .ai_mail.local (canonical) and ai_mail.local (legacy)
|
||||
inbox_path = branch_root / ".ai_mail.local" / "inbox.json"
|
||||
if not inbox_path.exists():
|
||||
inbox_path = branch_root / "ai_mail.local" / "inbox.json"
|
||||
|
||||
if not inbox_path.exists():
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(inbox_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Handle both formats: {"messages": [...]} and bare [...]
|
||||
messages = data if isinstance(data, list) else data.get("messages", [])
|
||||
count = 0
|
||||
for msg in messages:
|
||||
if msg.get("status") == "new":
|
||||
count += 1
|
||||
elif msg.get("status") is None and not msg.get("read", False):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
branch_root = find_branch_root()
|
||||
if not branch_root:
|
||||
return
|
||||
|
||||
new_count = count_new_emails(branch_root)
|
||||
if new_count > 0:
|
||||
plural = "s" if new_count != 1 else ""
|
||||
print(
|
||||
f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Identity Injector - Injects branch identity on every prompt.
|
||||
|
||||
Reads from [BRANCH].id.json and outputs core identity fields.
|
||||
Finds the branch root by walking up from CWD looking for apps/ or *.id.json.
|
||||
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_repo_root() -> Path | None:
|
||||
"""Find the repo root (contains pyproject.toml or .git)."""
|
||||
search = Path.cwd()
|
||||
while search.parent != search:
|
||||
if (search / "pyproject.toml").exists() or (search / ".git").is_dir():
|
||||
return search
|
||||
search = search.parent
|
||||
return None
|
||||
|
||||
|
||||
def find_branch_root() -> Path | None:
|
||||
"""Find the branch root directory by walking up from CWD."""
|
||||
cwd = Path.cwd()
|
||||
repo_root = find_repo_root()
|
||||
if not repo_root:
|
||||
return None
|
||||
|
||||
search_path = cwd
|
||||
while search_path >= repo_root:
|
||||
has_trinity = (search_path / ".trinity").is_dir()
|
||||
has_id = list(search_path.glob("*.id.json"))
|
||||
|
||||
if has_trinity or has_id:
|
||||
return search_path
|
||||
|
||||
if search_path == repo_root:
|
||||
break
|
||||
search_path = search_path.parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_id_file(branch_root: Path) -> Path | None:
|
||||
"""Find the identity file for a branch (.trinity/passport.json or *.id.json)."""
|
||||
# AIPass pattern: .trinity/passport.json
|
||||
passport = branch_root / ".trinity" / "passport.json"
|
||||
if passport.exists():
|
||||
return passport
|
||||
# Dev-Pass fallback: *.id.json
|
||||
id_files = list(branch_root.glob("*.id.json"))
|
||||
if id_files:
|
||||
return id_files[0]
|
||||
return None
|
||||
|
||||
|
||||
def format_identity(data: dict) -> str:
|
||||
"""Format branch_info + identity for injection."""
|
||||
lines = []
|
||||
|
||||
# Try branch_info first (enriched passports), fall back to identity block (setup.sh passports)
|
||||
branch = data.get("branch_info", {})
|
||||
identity = data.get("identity", {})
|
||||
name = branch.get("branch_name") or identity.get("name", "UNKNOWN")
|
||||
lines.append(f"# {name} Identity")
|
||||
lines.append(f"Path: {branch.get('path', 'unknown')}")
|
||||
lines.append(f"Email: {branch.get('email', 'unknown')}")
|
||||
|
||||
identity = data.get("identity", {})
|
||||
if identity.get("role"):
|
||||
lines.append(f"Role: {identity['role']}")
|
||||
traits = identity.get("traits") or data.get("traits")
|
||||
if traits:
|
||||
if isinstance(traits, list):
|
||||
lines.append("Traits: " + " | ".join(traits))
|
||||
else:
|
||||
lines.append(f"Traits: {traits}")
|
||||
if identity.get("purpose"):
|
||||
lines.append(f"Purpose: {identity['purpose']}")
|
||||
|
||||
what_i_do = identity.get("what_i_do", [])
|
||||
if what_i_do:
|
||||
lines.append("Do: " + " | ".join(what_i_do[:4]))
|
||||
|
||||
what_i_dont_do = identity.get("what_i_dont_do", [])
|
||||
if what_i_dont_do:
|
||||
lines.append("Don't: " + " | ".join(what_i_dont_do[:3]))
|
||||
|
||||
principles = data.get("principles", [])
|
||||
if principles:
|
||||
lines.append("Principles: " + " * ".join(principles))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
branch_root = find_branch_root()
|
||||
if not branch_root:
|
||||
return
|
||||
|
||||
id_file = find_id_file(branch_root)
|
||||
if not id_file or not id_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(id_file.read_text(encoding="utf-8"))
|
||||
output = format_identity(data)
|
||||
if output:
|
||||
print(f"\n{output}")
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pre-Compact Hook - Inject live state for post-compact recovery.
|
||||
|
||||
Reads STATUS.local.md, last session from local.json, and git branch
|
||||
to give the model real context after compaction — not generic advice.
|
||||
|
||||
Version: 3.0.0
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _find_branch_dir():
|
||||
"""Find the current branch directory from CWD."""
|
||||
cwd = Path.cwd()
|
||||
|
||||
# Check if we're in a branch dir or subdirectory of one
|
||||
# Pattern: .../src/aipass/{branch}/...
|
||||
parts = cwd.parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass" and i > 0 and parts[i - 1] == "src":
|
||||
branch_dir = Path(*parts[: i + 2])
|
||||
if branch_dir.is_dir():
|
||||
return branch_dir
|
||||
|
||||
# Check if CWD itself has .trinity/
|
||||
if (cwd / ".trinity").is_dir():
|
||||
return cwd
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_status_local(branch_dir):
|
||||
"""Read STATUS.local.md if it exists."""
|
||||
for name in ["STATUS.local.md", "dev.local.md"]:
|
||||
path = branch_dir / name
|
||||
if path.is_file():
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")[:3000]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _read_last_session(branch_dir):
|
||||
"""Read the most recent session and key_learnings from local.json."""
|
||||
local_path = branch_dir / ".trinity" / "local.json"
|
||||
if not local_path.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(local_path.read_text(encoding="utf-8"))
|
||||
result = []
|
||||
|
||||
# Last session
|
||||
sessions = data.get("sessions", [])
|
||||
if sessions:
|
||||
last = sessions[0]
|
||||
result.append(
|
||||
f"Last session (#{last.get('session_number', '?')}, "
|
||||
f"{last.get('date', '?')}): {last.get('summary', 'no summary')}"
|
||||
)
|
||||
|
||||
# Key learnings (just the keys, not full values — breadcrumbs)
|
||||
learnings = data.get("key_learnings", {})
|
||||
if learnings:
|
||||
keys = list(learnings.keys())[-10:] # last 10
|
||||
result.append(f"Key learnings available: {', '.join(keys)}")
|
||||
|
||||
return "\n".join(result) if result else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_git_info():
|
||||
"""Get current git branch and short status."""
|
||||
try:
|
||||
branch = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "diff", "--stat", "--cached", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
dirty = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
result = []
|
||||
if branch.returncode == 0:
|
||||
result.append(f"Git branch: {branch.stdout.strip()}")
|
||||
if dirty.returncode == 0 and dirty.stdout.strip():
|
||||
lines = dirty.stdout.strip().split("\n")
|
||||
result.append(f"Uncommitted changes: {len(lines)} files")
|
||||
|
||||
return "\n".join(result) if result else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_branch_name(branch_dir):
|
||||
"""Extract branch name from directory."""
|
||||
return branch_dir.name if branch_dir else "unknown"
|
||||
|
||||
|
||||
def main():
|
||||
"""Main hook entry point."""
|
||||
try:
|
||||
json.load(sys.stdin)
|
||||
|
||||
branch_dir = _find_branch_dir()
|
||||
branch_name = _get_branch_name(branch_dir)
|
||||
|
||||
sections = []
|
||||
|
||||
sections.append(f"""POST-COMPACT RECOVERY — @{branch_name}
|
||||
|
||||
Context just compacted. Below is your live state. Use it to continue seamlessly.""")
|
||||
|
||||
# Git info
|
||||
git_info = _get_git_info()
|
||||
if git_info:
|
||||
sections.append(f"## Git\n{git_info}")
|
||||
|
||||
# Last session from local.json
|
||||
if branch_dir:
|
||||
session_info = _read_last_session(branch_dir)
|
||||
if session_info:
|
||||
sections.append(f"## Last Session\n{session_info}")
|
||||
|
||||
# STATUS.local.md — the main context
|
||||
if branch_dir:
|
||||
status = _read_status_local(branch_dir)
|
||||
if status:
|
||||
sections.append(f"## STATUS.local.md\n{status}")
|
||||
|
||||
# Recovery instructions (lean)
|
||||
sections.append("""## Recovery Protocol
|
||||
- Continue where the summary left off — don't restart or ask generic questions
|
||||
- .trinity/local.json has full session history and key_learnings — read it if you need more context
|
||||
- STATUS.local.md has current work, known issues, and todos
|
||||
- Save memories proactively — compaction just proved you need to
|
||||
- Match the conversation tone from before compaction""")
|
||||
|
||||
print("\n\n".join(sections), file=sys.stdout)
|
||||
print("Pre-compact: live state injected", file=sys.stderr)
|
||||
|
||||
except Exception as e:
|
||||
# Fail silently — never block compaction
|
||||
print(f"Pre-compact hook error: {e}", file=sys.stderr)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PreToolUse Gate — Blocks unsafe edits at the hook layer.
|
||||
|
||||
Rules (checked in order):
|
||||
1. Inbox lock — any write targeting *.ai_mail.local/inbox.json is BLOCKED.
|
||||
Use `drone @ai_mail email` instead.
|
||||
2. Cross-branch — writes to src/aipass/X/** from a CWD inside src/aipass/Y/**
|
||||
are BLOCKED unless the calling branch is in TRUSTED_CROSS_WRITERS.
|
||||
3. State-file — edits to OTHER .py files while the current branch has unresolved
|
||||
type errors are BLOCKED. (original v1.2.0 logic)
|
||||
|
||||
Track E additions: rules 1 + 2 (DPLAN-0139).
|
||||
Version: 1.3.0
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STATE_FILE = Path(__file__).parent / ".diagnostics_state.json"
|
||||
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
||||
|
||||
# Single source of truth lives in permissions.py — inline here as fallback
|
||||
# so the hook works even when aipass package is not on sys.path.
|
||||
TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn")
|
||||
|
||||
|
||||
def _get_branch(file_path: str) -> str:
|
||||
"""Extract AIPass branch name from a file path (src/aipass/{branch}/ pattern)."""
|
||||
parts = Path(file_path).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return ""
|
||||
|
||||
|
||||
def _block(reason: str) -> None:
|
||||
# codeql[py/clear-text-logging-sensitive-data]
|
||||
print(json.dumps({"decision": "block", "reason": reason}))
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
file_path = tool_input.get("file_path", "")
|
||||
|
||||
if tool_name not in EDIT_TOOLS:
|
||||
return
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rule 1: Inbox lock — block all writes to *.ai_mail.local/inbox.json
|
||||
# ------------------------------------------------------------------
|
||||
fp = Path(file_path)
|
||||
if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts:
|
||||
_block('Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @<branch> "Subject" "Body"')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rule 1.5: Dispatched-agent path confinement (DPLAN-0155 M3)
|
||||
# Daemon-spawned agents can only write inside their own branch dir.
|
||||
# Breaks the prompt-injection amplifier chain — even if injected,
|
||||
# a dispatched agent cannot write to other agents' inboxes or code.
|
||||
# ------------------------------------------------------------------
|
||||
cwd = input_data.get("cwd", "") or os.getcwd()
|
||||
cwd_branch = _get_branch(cwd)
|
||||
|
||||
session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive")
|
||||
if session_type == "daemon" and cwd_branch:
|
||||
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
|
||||
if target_branch and target_branch != cwd_branch:
|
||||
_block(
|
||||
f"Dispatched agent confined to own branch: '{cwd_branch}' "
|
||||
f"cannot write to '{target_branch}' in daemon mode."
|
||||
)
|
||||
repo_root = None
|
||||
for parent in Path(cwd).parents:
|
||||
if (parent / ".git").exists():
|
||||
repo_root = parent
|
||||
break
|
||||
if repo_root and not target_branch:
|
||||
allowed_prefix = str(repo_root / "src" / "aipass" / cwd_branch)
|
||||
resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp)
|
||||
if not resolved.startswith(allowed_prefix):
|
||||
_block(f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rule 2: Cross-branch write enforcement
|
||||
# ------------------------------------------------------------------
|
||||
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
|
||||
|
||||
if cwd_branch and target_branch and cwd_branch != target_branch:
|
||||
if cwd_branch not in TRUSTED_CROSS_WRITERS:
|
||||
_block(
|
||||
f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n"
|
||||
f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rule 3: State-file (original v1.2.0) — .py files only
|
||||
# ------------------------------------------------------------------
|
||||
if not file_path.endswith(".py"):
|
||||
return
|
||||
|
||||
if not STATE_FILE.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return
|
||||
|
||||
errored_file = state.get("file", "")
|
||||
errors = state.get("errors", [])
|
||||
|
||||
if not errors:
|
||||
return
|
||||
|
||||
try:
|
||||
current = str(Path(file_path).resolve())
|
||||
errored = str(Path(errored_file).resolve())
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
|
||||
if current == errored:
|
||||
return
|
||||
|
||||
current_branch = _get_branch(current)
|
||||
errored_branch = _get_branch(errored)
|
||||
if not errored_branch:
|
||||
return
|
||||
if current_branch and errored_branch and current_branch != errored_branch:
|
||||
return
|
||||
|
||||
error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5])
|
||||
_block(f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}")
|
||||
|
||||
except Exception:
|
||||
pass # Silent fail → allow
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SubagentStop Gate — Checks files modified by subagents before allowing them to finish.
|
||||
|
||||
Runs seedgo checklist + basic validation on any .py files the subagent touched.
|
||||
If violations found, blocks the stop and tells the subagent to fix them.
|
||||
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _find_repo_root() -> Path | None:
|
||||
"""Walk up from CWD or AIPASS_HOME to find the git repo root."""
|
||||
for start in (os.environ.get("AIPASS_HOME", ""), os.getcwd()):
|
||||
p = Path(start)
|
||||
while p != p.parent:
|
||||
if (p / ".git").exists():
|
||||
return p
|
||||
p = p.parent
|
||||
return None
|
||||
|
||||
|
||||
AIPASS_ROOT = _find_repo_root()
|
||||
|
||||
|
||||
def get_modified_py_files() -> list[str]:
|
||||
"""Get Python files modified in the working tree (unstaged + staged)."""
|
||||
if AIPASS_ROOT is None:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT)
|
||||
)
|
||||
files = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.endswith(".py") and not line.startswith(".claude/"):
|
||||
full = AIPASS_ROOT / line
|
||||
if full.exists():
|
||||
files.append(str(full))
|
||||
return files
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def run_seedgo_checklist(file_path: str) -> list[str]:
|
||||
"""Run seedgo checklist on a single file."""
|
||||
if AIPASS_ROOT is None:
|
||||
return []
|
||||
if "/.claude/" in file_path:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["drone", "@seedgo", "checklist", file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
cwd=str(AIPASS_ROOT),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
violations = []
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("\u2717"):
|
||||
v = line[1:].strip()
|
||||
if v:
|
||||
violations.append(v)
|
||||
return violations[:5]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
json.load(sys.stdin)
|
||||
|
||||
modified = get_modified_py_files()
|
||||
if not modified:
|
||||
return # Nothing to check
|
||||
|
||||
all_violations = {}
|
||||
for f in modified:
|
||||
vs = run_seedgo_checklist(f)
|
||||
if vs:
|
||||
name = Path(f).name
|
||||
all_violations[name] = vs
|
||||
|
||||
if not all_violations:
|
||||
return # All clear
|
||||
|
||||
# Build the block reason
|
||||
lines = ["Standards violations found in files you modified:\n"]
|
||||
for fname, vs in all_violations.items():
|
||||
lines.append(f" {fname}:")
|
||||
for v in vs:
|
||||
lines.append(f" - {v}")
|
||||
lines.append("\nFix these violations before finishing.")
|
||||
|
||||
output = {"decision": "block", "reason": "\n".join(lines)}
|
||||
print(json.dumps(output))
|
||||
|
||||
except Exception:
|
||||
pass # Silent fail — don't block on errors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/hooks/branch_prompt_loader.py"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/hooks/email_notification.py"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/hooks/identity_injector.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/hooks/pre_compact.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"AIPASS_HOME": "/home/patrick/Projects/AIPass"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Local runtime state
|
||||
.ai_mail.local/
|
||||
logs/
|
||||
DASHBOARD.local.json
|
||||
docs.local/
|
||||
tools/aipass-dev
|
||||
stress_test_s117.md
|
||||
@@ -0,0 +1,5 @@
|
||||
# Standards Bypass
|
||||
|
||||
Seedgo audit bypass config for `AIPASS`.
|
||||
|
||||
When an audit flags a false positive that doesn't apply to your architecture, add a bypass entry in `bypass.json` with a reason explaining why it's justified.
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "2.0.0",
|
||||
"created": "2026-04-16",
|
||||
"description": "Bypass config for @aipass citizen. While under construction (DPLAN-0136 Phase 0-3), module and handler files exist as documented placeholders with no implementation body. Each placeholder raises NotImplementedError and declares its phase. Bypass standards that fire on structural requirements the placeholders intentionally skip — json_handler import, print_introspection, CLI service wiring. Remove these entries in Phase N as each module gets its real body.",
|
||||
"last_updated": "2026-04-16"
|
||||
},
|
||||
"bypass": [
|
||||
{
|
||||
"file": "apps/modules/init_flow.py",
|
||||
"standard": "modules",
|
||||
"reason": "12-stage init flow requires this many lines; splitting would scatter cohesive stage logic across multiple files and break the resumable-progress contract."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/init_flow.py",
|
||||
"standard": "architecture",
|
||||
"reason": "12-stage init flow + scaffold routing + preflight guard. Cohesive unit — splitting breaks the resumable-progress contract. DPLAN-0164 transfer adds routing handlers here intentionally."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/init_flow.py",
|
||||
"standard": "permission_flags",
|
||||
"reason": "The --dangerously-skip-permissions string is a CLI flag NAME passed verbatim to the user's chosen tool (claude). It is not a code-level permission bypass in this module."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/handoff_platform/__init__.py",
|
||||
"standard": "permission_flags",
|
||||
"reason": "The --dangerously-skip-permissions string is a CLI flag NAME appended to the user's chosen tool invocation. It is not a code-level permission bypass in this handler."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/handoff_platform/__init__.py",
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/handoff.py",
|
||||
"standard": "introspection",
|
||||
"reason": "Phase 4 placeholder — print_introspection() added with handoff build."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/handoff.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Phase 4 placeholder — json_handler import added with handoff build."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/handoff.py",
|
||||
"standard": "cli",
|
||||
"reason": "Phase 4 placeholder — CLI service imports added with handoff build."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Phase 0 entry-point stub from spawn template. Full 3-layer wiring (modules/ discovery, handlers/ imports) added when first module comes online (Phase 1)."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "cli",
|
||||
"reason": "Phase 0 entry-point stub — CLI service imports (console, header) added when modules come online (Phase 1+)."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "debug_print",
|
||||
"reason": "Phase 0 entry-point stub uses bare print() for scaffold visibility. Replaced with console.print() when CLI services are wired in Phase 1+."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "introspection",
|
||||
"reason": "Phase 0 — spawn template does not emit print_introspection(). Added when modules come online (Phase 1+)."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "log_structure",
|
||||
"reason": "Phase 0 — logs/ directory exists (spawn-created), but prax logger import not wired yet. Added when first module uses it."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor.py",
|
||||
"standard": "modules",
|
||||
"reason": "Doctor reads system files (registry, passport) directly for health-check diagnosis — pure reads only, no mutations. Using a handler adds indirection without benefit for diagnostic reads."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_doctor.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_doctor.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly (system_detector, progress) to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_help_chat.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_profile.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_profile.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import modules directly to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_init_flow.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_init_flow.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import modules directly to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_init_flow.py",
|
||||
"standard": "permission_flags",
|
||||
"reason": "Assertions verify that the CLI flag name appears/absent in handoff_command output. String is in assertion context only — not a permission bypass in this file."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_ping_sweep.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_ping_sweep.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/init/bootstrap.py",
|
||||
"standard": "debug_print",
|
||||
"reason": "The print() on line 159 is inside a generated shell command string (python3 -c), not a bare print call in this module's code."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/init/bootstrap.py",
|
||||
"standard": "help_text",
|
||||
"reason": "The python3 references are in generated shell commands (settings.json hook entries), not in user-facing help text."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/init/bootstrap.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "bootstrap.py is Pure Python only (no module/prax/cli imports) by design — it must work during initial project setup before any AIPass services exist."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/init/bootstrap.py",
|
||||
"standard": "log_visibility",
|
||||
"reason": "bootstrap.py is Pure Python only (no module/prax/cli imports) by design — stdlib getLogger is correct here. prax system_logger requires AIPass to be installed, which hasn't happened at bootstrap time."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/AGENTS.md -->
|
||||
# AIPASS — Agent Instructions
|
||||
|
||||
This project uses AIPass, a multi-agent framework.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Project** — this directory. Contains a registry and one or more agents.
|
||||
- **Agent** — a citizen that lives inside the project with its own identity, memory, and code.
|
||||
- **Registry** — `AIPASS_REGISTRY.json` tracks all agents.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Create your first agent:
|
||||
```
|
||||
aipass init agent <name>
|
||||
```
|
||||
|
||||
## Available Commands
|
||||
|
||||
```
|
||||
aipass init agent <name> # Create a new agent
|
||||
drone @spawn create <name> # Create agent (alternative)
|
||||
drone @seedgo audit <project> # Run standards audit
|
||||
drone systems # List all infrastructure
|
||||
```
|
||||
|
||||
## Startup
|
||||
|
||||
On startup, read: `AIPASS_REGISTRY.json`, `README.md`, `STATUS.local.md`
|
||||
@@ -0,0 +1,42 @@
|
||||
# AIPASS
|
||||
|
||||
## Startup
|
||||
|
||||
On any greeting, silently read these files and run the commands — no narration, no announcing steps. Just do it and respond with the status.
|
||||
|
||||
**Read:** `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
|
||||
**Check:** If `.ai_mail.local/inbox.json` exists, read it. Process any mail.
|
||||
**Run:** `git status`
|
||||
|
||||
## Identity
|
||||
|
||||
You are **AIPASS** — an AIPass citizen.
|
||||
|
||||
- **Module:** `aipass.aipass`
|
||||
- **Role:**
|
||||
- **Purpose:** New agent - purpose TBD
|
||||
|
||||
## Memories
|
||||
|
||||
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
|
||||
|
||||
- `local.json` — Session history, key learnings, active tasks
|
||||
- `observations.json` — Collaboration patterns, insights
|
||||
- `passport.json` — Identity (rarely changes)
|
||||
|
||||
## AIPass Context
|
||||
|
||||
This branch is part of the AIPass multi-agent framework. Key concepts:
|
||||
|
||||
- **Branch** — your directory (`src/aipass/aipass/`). Your home.
|
||||
- **Citizen** — the identity that lives in a branch. Has a passport, memories, mailbox.
|
||||
- **Agent** — a disposable worker spawned for a task. No passport, no memory.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
drone systems # List available infrastructure
|
||||
drone @ai_mail inbox # Check mailbox
|
||||
drone @ai_mail send @branch "Subject" "Body" # Send mail
|
||||
drone @seedgo audit @aipass # Run standards audit
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/GEMINI.md -->
|
||||
# AIPASS — Project Instructions
|
||||
|
||||
This project uses AIPass, a multi-agent framework.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Project** — this directory. Contains a registry and one or more agents.
|
||||
- **Agent** — a citizen that lives inside the project with its own identity, memory, and code.
|
||||
- **Registry** — `AIPASS_REGISTRY.json` tracks all agents.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Create your first agent: `aipass init agent <name>`
|
||||
|
||||
## Available Commands
|
||||
|
||||
```
|
||||
aipass init agent <name> # Create a new agent
|
||||
drone @spawn create <name> # Create agent (alternative)
|
||||
drone @seedgo audit <project> # Run standards audit
|
||||
drone systems # List all infrastructure
|
||||
```
|
||||
|
||||
## Startup
|
||||
|
||||
On startup, read: `AIPASS_REGISTRY.json`, `README.md`, `STATUS.local.md`
|
||||
@@ -0,0 +1,93 @@
|
||||
# AIPASS
|
||||
|
||||
**Purpose:** The friendly front door — concierge, librarian, first-run guide
|
||||
**Module:** `aipass.aipass`
|
||||
**Created:** 2026-04-16
|
||||
**Status:** Under construction (gitignored until Phase 8 reveal, DPLAN-0136)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What I Do
|
||||
|
||||
I am the concierge of AIPass. New users land with me. I greet them, walk them through setup, answer how-things-work questions, and hand them off to their chosen CLI tool. I am also the librarian — I can read any branch, inspect any README, explain any pattern. I do not build.
|
||||
|
||||
Drone is the engine. I am the front door.
|
||||
|
||||
### How I Work
|
||||
|
||||
- **Entry Point:** `apps/aipass.py` — thin CLI dispatch
|
||||
- **Pattern:** Subcommand routing — `help`, `doctor`, `init`, `profile`
|
||||
- **Restrictions:** Read-only by design. No writes outside my own `.trinity/`. No git. No real dispatches.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
aipass/
|
||||
├── apps/
|
||||
│ ├── aipass.py # Entry point — subcommand dispatch
|
||||
│ ├── modules/ # doctor, help_chat, init_flow, handoff, profile
|
||||
│ ├── handlers/ # system_detect, ping_sweep, readme_map, ui
|
||||
│ └── plugins/ # Extensions
|
||||
├── docs/
|
||||
├── tests/
|
||||
├── .trinity/
|
||||
│ ├── passport.json # Identity — concierge, read-only
|
||||
│ ├── local.json # Session history + user profile + setup_progress
|
||||
│ └── observations.json # Patterns across users
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
aipass # Help banner
|
||||
aipass help [Q] # Chatbot Q&A — "how does drone work?"
|
||||
aipass doctor # System health — aggregates seedgo, pytest, registry, hooks
|
||||
aipass init # Guided 12-stage setup for new users (resumable)
|
||||
aipass profile # Show/edit what I remember about you
|
||||
aipass --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
|
||||
- `@drone` — routing
|
||||
- `@seedgo` — audit aggregation
|
||||
- `@spawn` — creating the user's first agent
|
||||
- `@flow` — testing plan lifecycle (open/close empty plans)
|
||||
- `@ai_mail` — test-convention emails (no real dispatch)
|
||||
- `@prax` — health signals for doctor
|
||||
- `pytest` — test runner aggregation
|
||||
- External CLIs — Claude Code / Codex / Gemini (handoff targets)
|
||||
|
||||
### Provides To
|
||||
|
||||
Nothing in AIPass depends on me. This is by design — I can be removed, replaced, or rebuilt without ripple. One-way arrow.
|
||||
|
||||
My direct consumers are **humans** — new users, curious explorers, and anyone who'd rather ask a concierge than read docs.
|
||||
|
||||
---
|
||||
|
||||
## Build Plan
|
||||
|
||||
See `devpulse/DPLAN-0136`. Nine phases:
|
||||
|
||||
0. Scaffolding (spawn) ✓
|
||||
1. `aipass doctor`
|
||||
2. `aipass help`
|
||||
3. `aipass init`
|
||||
4. CLI handoff (tmux / wt.exe)
|
||||
5. Repo README flip back to project-focused
|
||||
6. pip entry point wiring
|
||||
7. Retire cli branch's `aipass init`
|
||||
8. Gitignore removal — public reveal
|
||||
9. Optional: VS Code auto-refresh
|
||||
@@ -0,0 +1 @@
|
||||
"""AIPass concierge — user-facing front door (help, doctor, init, profile, handoff)."""
|
||||
@@ -0,0 +1,8 @@
|
||||
# Apps
|
||||
|
||||
Application layer for `AIPASS`.
|
||||
|
||||
- `aipass.py` — Entry point. Auto-discovers and routes commands to modules.
|
||||
- `modules/` — Business logic and orchestration. One module per command.
|
||||
- `handlers/` — Implementation details. Called by modules, never by CLI directly.
|
||||
- `plugins/` — Scheduled tasks and extensions.
|
||||
@@ -0,0 +1 @@
|
||||
# AIPASS apps package
|
||||
@@ -0,0 +1,100 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: aipass.py
|
||||
# Description: AIPASS branch entry point — thin command router
|
||||
# Version: 0.1.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
AIPASS Branch - Main Orchestrator
|
||||
|
||||
Auto-discovery architecture:
|
||||
- Scans modules/ directory for .py files with handle_command()
|
||||
- Routes commands to discovered modules automatically
|
||||
- No manual imports or routing needed
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
# =============================================================================
|
||||
|
||||
MODULES_DIR = Path(__file__).parent / "modules"
|
||||
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
"""Auto-discover modules in modules/ directory."""
|
||||
modules = []
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
return modules
|
||||
|
||||
for file_path in MODULES_DIR.glob("*.py"):
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
module_name = f"aipass.aipass.apps.modules.{file_path.stem}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
if hasattr(module, "handle_command"):
|
||||
modules.append(module)
|
||||
except Exception as e:
|
||||
logger.error(f"[AIPASS] Failed to load module {module_name}: {e}")
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""Route command to appropriate module."""
|
||||
for module in modules:
|
||||
try:
|
||||
if module.handle_command(command, args):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[AIPASS] Module {module.__name__} error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN ENTRY POINT
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands or shows help."""
|
||||
modules = discover_modules()
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) > 0 and args[0] in ["--version", "-V"]:
|
||||
print("aipass 0.1.0")
|
||||
return 0
|
||||
|
||||
show_root_help = len(args) == 0 or args[0] in ["--help", "-h"] or (args[0] == "help" and len(args) == 1)
|
||||
if show_root_help:
|
||||
print(f"AIPASS - {len(modules)} modules discovered")
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
print(f" {name:20} {desc}")
|
||||
return 0
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
# Handlers
|
||||
|
||||
Implementation details for `AIPASS`.
|
||||
|
||||
Handlers do the actual work. They are called by modules, never directly by the CLI. Keep business logic in modules, implementation in handlers.
|
||||
@@ -0,0 +1,88 @@
|
||||
"""AIPASS handlers package - Security protected."""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
MY_BRANCH = "aipass.aipass"
|
||||
|
||||
|
||||
def _find_real_caller():
|
||||
"""Walk the stack to find the actual file that triggered this import.
|
||||
|
||||
Skips this file, importlib internals, and frozen modules.
|
||||
Returns tuple: (file_path, import_line) or (None, None).
|
||||
"""
|
||||
stack = inspect.stack()
|
||||
this_file = str(Path(__file__).resolve())
|
||||
|
||||
for frame_info in stack:
|
||||
filename = frame_info.filename
|
||||
|
||||
if this_file in str(Path(filename).resolve()):
|
||||
continue
|
||||
|
||||
if filename.startswith("<") or "importlib" in filename:
|
||||
continue
|
||||
|
||||
import_line = None
|
||||
if frame_info.code_context:
|
||||
import_line = frame_info.code_context[0].strip()
|
||||
|
||||
return str(Path(filename).resolve()), import_line
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = Path(filepath).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass":
|
||||
if i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _guard_branch_access():
|
||||
"""Block cross-branch handler imports.
|
||||
|
||||
Only code from within the 'aipass' branch can import these handlers.
|
||||
External branches must use aipass.aipass.apps.modules instead.
|
||||
"""
|
||||
caller_file, import_line = _find_real_caller()
|
||||
|
||||
if caller_file is None:
|
||||
stack = inspect.stack()
|
||||
for frame in stack:
|
||||
if frame.filename in ("<string>", "<stdin>"):
|
||||
return
|
||||
return
|
||||
|
||||
branch_path = "/" + MY_BRANCH.replace(".", "/") + "/"
|
||||
if branch_path in caller_file.replace("\\", "/"):
|
||||
return
|
||||
|
||||
caller_branch = _extract_branch_name(caller_file)
|
||||
caller_filename = Path(caller_file).name
|
||||
blocked_import = import_line if import_line else "unknown"
|
||||
|
||||
raise ImportError(
|
||||
f"\n{'=' * 60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
f"{'=' * 60}\n"
|
||||
f" Caller branch: {caller_branch}\n"
|
||||
f" Caller file: {caller_filename}\n"
|
||||
f" Blocked: {blocked_import}\n"
|
||||
f"\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
|
||||
f"\n"
|
||||
f" For full standards guide:\n"
|
||||
f" drone @seedgo handlers\n"
|
||||
f"{'=' * 60}"
|
||||
)
|
||||
|
||||
|
||||
# Run guard at import time
|
||||
_guard_branch_access()
|
||||
@@ -0,0 +1,189 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: handoff_platform/__init__.py
|
||||
# Description: OS-dispatched CLI session launch — tmux, wt.exe, fallback
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-20
|
||||
# Modified: 2026-04-20
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
handoff_platform — OS-dispatched CLI session launch.
|
||||
|
||||
Consumers: modules/handoff.py, modules/init_flow.py (stage 11).
|
||||
|
||||
Linux/Mac: tmux new-session -d -s aipass-handoff -c <cwd>; send-keys '<cli> "<prompt>"'
|
||||
Windows: wt.exe -w 0 nt -d <cwd> <cli> "<prompt>" (Windows Terminal)
|
||||
Fallback: caller receives command string for manual display — no silent fail.
|
||||
|
||||
All public functions return data; presentation is handled by the module layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
# Platform constants — consistent with setup.sh naming
|
||||
IS_WINDOWS = sys.platform == "win32"
|
||||
IS_MACOS = sys.platform == "darwin"
|
||||
IS_LINUX = sys.platform.startswith("linux")
|
||||
|
||||
_TMUX_SESSION = "aipass-handoff"
|
||||
|
||||
|
||||
def build_cli_cmd(cli: str, flag_variant: str) -> str:
|
||||
"""Build the CLI invocation string from cli name and flag variant."""
|
||||
parts = [cli]
|
||||
if cli == "claude" and flag_variant == "skip-permissions":
|
||||
parts.append("--dangerously-skip-permissions") # noqa: S603
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def build_manual_command(cli: str, prompt: str, cwd: str, flag_variant: str = "default") -> str:
|
||||
"""Return the equivalent manual shell command for user display."""
|
||||
cli_cmd = build_cli_cmd(cli, flag_variant)
|
||||
safe_prompt = prompt.replace('"', '\\"')
|
||||
return f'cd {cwd} && {cli_cmd} "{safe_prompt}"'
|
||||
|
||||
|
||||
def _find_terminal_emulator() -> str | None:
|
||||
"""Find an available terminal emulator on the system."""
|
||||
for term in ("gnome-terminal", "xfce4-terminal", "konsole", "xterm"):
|
||||
if shutil.which(term):
|
||||
return term
|
||||
return None
|
||||
|
||||
|
||||
def launch_terminal(cli: str, prompt: str, cwd: str, flag_variant: str = "default") -> bool:
|
||||
"""Open a new terminal window running the CLI directly. No tmux needed."""
|
||||
term = _find_terminal_emulator()
|
||||
if not term:
|
||||
logger.warning("[handoff_platform] no terminal emulator found")
|
||||
return False
|
||||
|
||||
cli_cmd = build_cli_cmd(cli, flag_variant)
|
||||
safe_prompt = prompt.replace('"', '\\"')
|
||||
shell_cmd = f'cd "{cwd}" && {cli_cmd} "{safe_prompt}"'
|
||||
|
||||
try:
|
||||
if term == "gnome-terminal":
|
||||
subprocess.Popen(["gnome-terminal", "--", "bash", "-c", shell_cmd])
|
||||
elif term == "xfce4-terminal":
|
||||
subprocess.Popen(["xfce4-terminal", "-e", f"bash -c '{shell_cmd}'"])
|
||||
elif term == "konsole":
|
||||
subprocess.Popen(["konsole", "-e", "bash", "-c", shell_cmd])
|
||||
elif term == "xterm":
|
||||
subprocess.Popen(["xterm", "-e", f"bash -c '{shell_cmd}'"])
|
||||
else:
|
||||
return False
|
||||
logger.info("[handoff_platform] opened %s in %s (cwd=%s)", term, cli, cwd)
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
console.print("\n [green]✓[/green] Opened your agent in a new terminal window.")
|
||||
console.print()
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning("[handoff_platform] failed to open terminal: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def launch_tmux(cli: str, prompt: str, cwd: str, flag_variant: str = "default") -> bool:
|
||||
"""Launch CLI in a tmux session (fallback when no terminal emulator available)."""
|
||||
if not shutil.which("tmux"):
|
||||
logger.warning("[handoff_platform] tmux not found on PATH")
|
||||
return False
|
||||
|
||||
cli_cmd = build_cli_cmd(cli, flag_variant)
|
||||
safe_prompt = prompt.replace('"', '\\"')
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["tmux", "kill-session", "-t", _TMUX_SESSION],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["tmux", "new-session", "-d", "-s", _TMUX_SESSION, "-c", cwd],
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
["tmux", "send-keys", "-t", _TMUX_SESSION, f'{cli_cmd} "{safe_prompt}"', "Enter"],
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
logger.info("[handoff_platform] tmux session '%s' started in %s", _TMUX_SESSION, cwd)
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
console.print("\n [green]✓[/green] Your agent is ready! Run this in your terminal:")
|
||||
console.print(f"\n [bold green]tmux attach -t {_TMUX_SESSION}[/bold green]\n")
|
||||
console.print()
|
||||
return True
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.warning("[handoff_platform] tmux launch failed: %s", exc)
|
||||
return False
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[handoff_platform] tmux command timed out: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def launch_wt(cli: str, prompt: str, cwd: str, flag_variant: str = "default") -> bool:
|
||||
"""Launch CLI in Windows Terminal (wt.exe). Returns True on success."""
|
||||
if not shutil.which("wt"):
|
||||
logger.warning("[handoff_platform] wt.exe not found on PATH")
|
||||
return False
|
||||
|
||||
cli_cmd = build_cli_cmd(cli, flag_variant)
|
||||
safe_prompt = prompt.replace('"', '\\"')
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["wt", "-w", "0", "nt", "-d", cwd, cli_cmd, f'"{safe_prompt}"'],
|
||||
check=True,
|
||||
timeout=15,
|
||||
)
|
||||
logger.info("[handoff_platform] wt.exe session started in %s", cwd)
|
||||
return True
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.warning("[handoff_platform] wt.exe launch failed: %s", exc)
|
||||
return False
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[handoff_platform] wt.exe command timed out: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def launch_handoff(
|
||||
cli: str,
|
||||
prompt: str,
|
||||
cwd: str,
|
||||
flag_variant: str = "default",
|
||||
platform_override: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Dispatch CLI launch to the appropriate platform handler.
|
||||
|
||||
Returns (launched, manual_command):
|
||||
launched=True — tmux/wt session was started successfully
|
||||
launched=False — auto-launch unavailable; caller displays manual_command
|
||||
manual_command — always populated; equivalent command for manual run
|
||||
|
||||
Order: tmux (Linux/Mac) → wt.exe (Windows) → fallback (caller handles display).
|
||||
"""
|
||||
manual_cmd = build_manual_command(cli, prompt, cwd, flag_variant)
|
||||
target = platform_override or ("windows" if IS_WINDOWS else "unix")
|
||||
|
||||
if target == "windows":
|
||||
if launch_wt(cli, prompt, cwd, flag_variant):
|
||||
return True, manual_cmd
|
||||
else:
|
||||
if launch_terminal(cli, prompt, cwd, flag_variant):
|
||||
return True, manual_cmd
|
||||
if launch_tmux(cli, prompt, cwd, flag_variant):
|
||||
return True, manual_cmd
|
||||
|
||||
logger.info("[handoff_platform] auto-launch unavailable — fallback command ready")
|
||||
return False, manual_cmd
|
||||
@@ -0,0 +1,31 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: __init__.py
|
||||
# Description: Init handler package — public API
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-04
|
||||
# Modified: 2026-05-04
|
||||
# =============================================
|
||||
|
||||
"""Init handler package — public entry point for bootstrap and scaffold_content."""
|
||||
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||
_sanitize_name,
|
||||
init_project,
|
||||
update_project,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.init.scaffold_content import (
|
||||
global_prompt_md,
|
||||
inbox_json,
|
||||
prep_md,
|
||||
with_source,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"_sanitize_name",
|
||||
"global_prompt_md",
|
||||
"inbox_json",
|
||||
"init_project",
|
||||
"prep_md",
|
||||
"update_project",
|
||||
"with_source",
|
||||
]
|
||||
+24
-44
@@ -19,9 +19,8 @@ Business logic for `aipass init`. Creates the project scaffold:
|
||||
7. STATUS.local.md — project status
|
||||
8. .gitignore — standard AIPass ignores
|
||||
9. .claude/settings.json — Claude Code hooks configuration
|
||||
10. hooks/ — directory for user hooks
|
||||
11. src/ — directory where agents live
|
||||
12. .ai_mail.local/inbox.json — empty project mailbox
|
||||
10. src/ — directory where agents live
|
||||
11. .ai_mail.local/inbox.json — empty project mailbox
|
||||
|
||||
Projects are NOT citizens — no .trinity/ directory. Identity lives in the
|
||||
registry JSON. Init is re-runnable: existing files are skipped, not errors.
|
||||
@@ -41,29 +40,28 @@ import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.handlers.init import scaffold_content as sc
|
||||
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENFORCEMENT_HOOKS = [
|
||||
"auto_fix_diagnostics.py",
|
||||
"pre_edit_gate.py",
|
||||
"subagent_stop_gate.py",
|
||||
"pre_compact.py",
|
||||
]
|
||||
|
||||
INJECTOR_HOOKS = [
|
||||
PROJECT_HOOKS = [
|
||||
"branch_prompt_loader.py",
|
||||
"email_notification.py",
|
||||
"identity_injector.py",
|
||||
"pre_compact.py",
|
||||
]
|
||||
|
||||
HOOKS_TO_SHIP = ENFORCEMENT_HOOKS + INJECTOR_HOOKS
|
||||
# These are shipped as reference copies but NOT wired in project settings.json
|
||||
# because PreToolUse/PostToolUse/SubagentStop only fire from provider settings.
|
||||
PROVIDER_ONLY_HOOKS = [
|
||||
"auto_fix_diagnostics.py",
|
||||
"pre_edit_gate.py",
|
||||
"subagent_stop_gate.py",
|
||||
]
|
||||
|
||||
HOOKS_TO_SHIP = PROJECT_HOOKS + PROVIDER_ONLY_HOOKS
|
||||
|
||||
HOOK_EVENTS: dict[str, str] = {
|
||||
"auto_fix_diagnostics.py": "PostToolUse",
|
||||
"pre_edit_gate.py": "PreToolUse",
|
||||
"subagent_stop_gate.py": "Stop",
|
||||
"pre_compact.py": "PreCompact",
|
||||
"branch_prompt_loader.py": "UserPromptSubmit",
|
||||
"email_notification.py": "UserPromptSubmit",
|
||||
@@ -138,16 +136,17 @@ def _detect_aipass_home() -> str | None:
|
||||
|
||||
|
||||
def _claude_settings(aipass_home: str | None = None) -> str:
|
||||
"""Generate .claude/settings.json — hooks for prompt injection + enforcement.
|
||||
"""Generate .claude/settings.json — hooks for prompt injection at project level.
|
||||
|
||||
Wires all AIPass hooks into their respective event types:
|
||||
Only wires hooks that fire from project-level settings:
|
||||
- UserPromptSubmit: global/local prompt injection + branch_prompt_loader,
|
||||
email_notification, identity_injector
|
||||
- PostToolUse: auto_fix_diagnostics
|
||||
- PreToolUse: pre_edit_gate
|
||||
- Stop: subagent_stop_gate
|
||||
- PreCompact: pre_compact
|
||||
|
||||
PreToolUse/PostToolUse/SubagentStop hooks are NOT wired here — they only
|
||||
fire from provider settings (~/.claude/settings.json). The scripts are
|
||||
still shipped as reference copies. Provider wiring is handled by setup.sh.
|
||||
|
||||
Args:
|
||||
aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME.
|
||||
"""
|
||||
@@ -320,6 +319,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
created.append(str(settings_path))
|
||||
|
||||
# 9b. .claude/commands/prep.md — /prep session wrap-up slash command
|
||||
# Only prep.md here — memo.md belongs at provider level (~/.claude/commands/)
|
||||
commands_dir = claude_dir / "commands"
|
||||
commands_dir.mkdir(exist_ok=True)
|
||||
prep_path = commands_dir / "prep.md"
|
||||
@@ -327,24 +327,12 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
prep_path.write_text(sc.prep_md(), encoding="utf-8")
|
||||
created.append(str(prep_path))
|
||||
|
||||
# 9c. .claude/commands/memo.md — /memo memory update slash command
|
||||
memo_path = commands_dir / "memo.md"
|
||||
if not memo_path.exists():
|
||||
memo_path.write_text(sc.memo_md(), encoding="utf-8")
|
||||
created.append(str(memo_path))
|
||||
|
||||
# 9d. Ship enforcement + injector hooks from AIPass install
|
||||
if aipass_home:
|
||||
shipped = _ship_hooks(aipass_home, target)
|
||||
created.extend(shipped)
|
||||
|
||||
# 10. hooks/ directory
|
||||
hooks_dir = target / "hooks"
|
||||
if not hooks_dir.exists():
|
||||
hooks_dir.mkdir()
|
||||
created.append(str(hooks_dir))
|
||||
|
||||
# 11. src/ directory (where agents live)
|
||||
# 10. src/ directory (where agents live)
|
||||
src_dir = target / "src"
|
||||
if not src_dir.exists():
|
||||
src_dir.mkdir()
|
||||
@@ -373,7 +361,7 @@ def update_project(target: Path) -> dict:
|
||||
|
||||
Overwrites managed prompt and config files with the latest templates while
|
||||
leaving all user-owned files (registry, README, STATUS.local.md, .gitignore,
|
||||
hooks/, src/) untouched.
|
||||
src/) untouched.
|
||||
|
||||
Args:
|
||||
target: Directory containing the AIPass project to update.
|
||||
@@ -472,6 +460,7 @@ def update_project(target: Path) -> dict:
|
||||
already_current.append(str(gemini_md_path))
|
||||
|
||||
# .claude/commands/prep.md — managed slash command, refresh to latest
|
||||
# Only prep.md — memo.md belongs at provider level (~/.claude/commands/)
|
||||
commands_dir = claude_dir / "commands"
|
||||
commands_dir.mkdir(exist_ok=True)
|
||||
prep_path = commands_dir / "prep.md"
|
||||
@@ -482,15 +471,6 @@ def update_project(target: Path) -> dict:
|
||||
else:
|
||||
already_current.append(str(prep_path))
|
||||
|
||||
# .claude/commands/memo.md — managed slash command, refresh to latest
|
||||
memo_path = commands_dir / "memo.md"
|
||||
generated = sc.memo_md()
|
||||
if not memo_path.exists() or memo_path.read_text(encoding="utf-8") != generated:
|
||||
memo_path.write_text(generated, encoding="utf-8")
|
||||
updated.append(str(memo_path))
|
||||
else:
|
||||
already_current.append(str(memo_path))
|
||||
|
||||
# Re-sync enforcement + injector hooks from AIPass install
|
||||
hook_home = aipass_home or _detect_aipass_home()
|
||||
if hook_home:
|
||||
+16
@@ -67,6 +67,12 @@ def claude_md(name: str) -> str:
|
||||
"and respond with the status.\n"
|
||||
"\n"
|
||||
f"**Read:** `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n"
|
||||
"**Read:** `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`\n"
|
||||
"**Check:** If `.ai_mail.local/inbox.json` exists, read it. Process any mail.\n"
|
||||
"**Check:** If `dropbox/init_report.json` exists, read it — this is your birth certificate. "
|
||||
"Use it to understand your role, the project context, and any setup instructions. "
|
||||
"If it mentions missing provider settings (hooks, env vars, permissions), "
|
||||
"tell the user what needs configuring and where.\n"
|
||||
"**Run:** `git status`\n"
|
||||
"\n"
|
||||
"Then check the registry for agents and report status.\n"
|
||||
@@ -312,6 +318,16 @@ def global_prompt_md(name: str) -> str:
|
||||
"Projects use the registry.\n"
|
||||
"- **Memory** — update `.trinity/local.json` at session end. "
|
||||
"Memory is presence.\n"
|
||||
"\n"
|
||||
"## Maintenance\n"
|
||||
"\n"
|
||||
"- **Upgrade scaffold**: `drone @cli aipass init update` refreshes "
|
||||
"managed project files (hooks, prompts, settings) to latest templates.\n"
|
||||
"- **Entry point**: each agent's `apps/{name}.py` auto-configures "
|
||||
"`sys.path` and `AIPASS_BRANCH_NAME` env var. If prax logs to "
|
||||
"`unknown_branch/`, check that these are set.\n"
|
||||
"- **Standalone projects** use `src/{name}/` layout (not `src/aipass/{name}/`). "
|
||||
"Module discovery adapts automatically.\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: __init__.py
|
||||
# Description: JSON handler package for aipass branch
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""JSON handler package — auto-creating JSON for aipass modules."""
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
__all__ = ["json_handler"]
|
||||
@@ -0,0 +1,267 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: json_handler.py
|
||||
# Description: Auto-Creating JSON Handler for aipass branch
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
JSON Handler - Auto-Creating & Self-Healing JSON System
|
||||
|
||||
Handles default JSON files (config, data, log) for aipass modules.
|
||||
Never manually create JSONs - they build themselves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
# =============================================================================
|
||||
# INFRASTRUCTURE SETUP
|
||||
# =============================================================================
|
||||
|
||||
# json_handler.py lives at: src/aipass/aipass/apps/handlers/json/json_handler.py
|
||||
# parents[0] = json/, [1] = handlers/, [2] = apps/, [3] = aipass/, [4] = src/aipass/
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
# Constants
|
||||
AIPASS_BRANCH_ROOT = _PKG_ROOT / "aipass"
|
||||
AIPASS_JSON_DIR = AIPASS_BRANCH_ROOT / "aipass_json"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTERNAL HELPERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""Auto-detect calling module name from call stack.
|
||||
|
||||
Returns:
|
||||
Module name (e.g., "doctor" from doctor.py)
|
||||
"""
|
||||
try:
|
||||
stack = inspect.stack()
|
||||
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
|
||||
if len(stack) > 2:
|
||||
caller_frame = stack[2]
|
||||
caller_path = Path(caller_frame.filename)
|
||||
module_name = caller_path.stem
|
||||
|
||||
if module_name and not module_name.startswith("_"):
|
||||
return module_name
|
||||
|
||||
return "unknown"
|
||||
except Exception as exc:
|
||||
logger.warning("[json_handler] Failed to detect caller module name: %s", exc)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _default_template(json_type: str, module_name: str) -> Any:
|
||||
"""Return inline default structure for a JSON type — no file templates needed."""
|
||||
today = datetime.now().date().isoformat()
|
||||
if json_type == "config":
|
||||
return {
|
||||
"module_name": module_name,
|
||||
"version": "1.0.0",
|
||||
"config": {
|
||||
"max_log_entries": 100,
|
||||
},
|
||||
"created": today,
|
||||
}
|
||||
if json_type == "data":
|
||||
return {
|
||||
"created": today,
|
||||
"last_updated": today,
|
||||
}
|
||||
if json_type == "log":
|
||||
return []
|
||||
return None
|
||||
|
||||
|
||||
def _atomic_write_json(target_path: Path, data: Any) -> None:
|
||||
"""Write JSON data atomically via temp file + rename.
|
||||
|
||||
Prevents corruption from concurrent processes writing the same file.
|
||||
"""
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(target_path.parent), suffix=".tmp", prefix=target_path.stem)
|
||||
succeeded = False
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_path, str(target_path))
|
||||
succeeded = True
|
||||
finally:
|
||||
if not succeeded and Path(tmp_path).exists():
|
||||
logger.warning("[json_handler] Cleaning up temp file after write failure: %s", tmp_path)
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VALIDATION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
"""Validate JSON structure matches expected type."""
|
||||
if json_type == "config":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["module_name", "version", "config"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "data":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["created", "last_updated"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "log":
|
||||
return isinstance(data, list)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUBLIC API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_json_path(module_name: str, json_type: str) -> Path:
|
||||
"""Get path for module JSON file."""
|
||||
filename = f"{module_name}_{json_type}.json"
|
||||
return AIPASS_JSON_DIR / filename
|
||||
|
||||
|
||||
def ensure_json_exists(module_name: str, json_type: str) -> bool:
|
||||
"""Ensure JSON file exists, create from template if missing."""
|
||||
AIPASS_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if json_path.exists():
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if validate_json_structure(data, json_type):
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[json_handler] Corrupted JSON file for '%s/%s', regenerating: %s",
|
||||
module_name,
|
||||
json_type,
|
||||
exc,
|
||||
)
|
||||
|
||||
template = _default_template(json_type, module_name)
|
||||
if template is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
_atomic_write_json(json_path, template)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[json_handler] Failed to write JSON template for '%s/%s': %s",
|
||||
module_name,
|
||||
json_type,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def load_json(module_name: str, json_type: str) -> Optional[Any]:
|
||||
"""Load JSON file, auto-create if missing."""
|
||||
if not ensure_json_exists(module_name, json_type):
|
||||
return None
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as exc:
|
||||
logger.error("[json_handler] Failed to load JSON for '%s/%s': %s", module_name, json_type, exc)
|
||||
return None
|
||||
|
||||
|
||||
def save_json(module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Save JSON file."""
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if not validate_json_structure(data, json_type):
|
||||
return False
|
||||
|
||||
if json_type == "data" and isinstance(data, dict):
|
||||
data["last_updated"] = datetime.now().date().isoformat()
|
||||
|
||||
try:
|
||||
_atomic_write_json(json_path, data)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("[json_handler] Failed to save JSON for '%s/%s': %s", module_name, json_type, exc)
|
||||
return False
|
||||
|
||||
|
||||
def ensure_module_jsons(module_name: str) -> bool:
|
||||
"""Ensure all 3 JSON files exist for a module."""
|
||||
ensure_json_exists(module_name, "config")
|
||||
ensure_json_exists(module_name, "data")
|
||||
ensure_json_exists(module_name, "log")
|
||||
return True
|
||||
|
||||
|
||||
def log_operation(
|
||||
operation: str,
|
||||
data: Dict[str, Any] | None = None,
|
||||
module_name: str | None = None,
|
||||
) -> bool:
|
||||
"""Add entry to module log with automatic rotation.
|
||||
|
||||
Auto-detects calling module if module_name not provided.
|
||||
Implements config-controlled log limits to prevent unbounded growth.
|
||||
When max_log_entries is reached, removes oldest entries (FIFO).
|
||||
|
||||
Args:
|
||||
operation: Operation name to log
|
||||
data: Optional data dict
|
||||
module_name: Optional module name (auto-detected if not provided)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if module_name is None:
|
||||
module_name = _get_caller_module_name()
|
||||
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
config = load_json(module_name, "config")
|
||||
max_entries = 100
|
||||
if config and "config" in config:
|
||||
max_entries = config["config"].get("max_log_entries", 100)
|
||||
|
||||
log = load_json(module_name, "log")
|
||||
if log is None:
|
||||
log = []
|
||||
|
||||
entry: Dict[str, Any] = {"timestamp": datetime.now().isoformat(), "operation": operation}
|
||||
|
||||
if data:
|
||||
entry["data"] = data
|
||||
|
||||
log.append(entry)
|
||||
|
||||
if len(log) > max_entries:
|
||||
log = log[-max_entries:]
|
||||
|
||||
return save_json(module_name, "log", log)
|
||||
@@ -0,0 +1,162 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: ping_sweep/__init__.py
|
||||
# Description: Verify registered branches respond via test-convention email
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
ping_sweep — verify each registered branch responds.
|
||||
|
||||
Sends test-convention emails and waits for ack replies.
|
||||
The AIPASS-TEST token protocol is recognized by ai_mail's daemon
|
||||
(handlers/dispatch/test_token.py); branches with a running daemon
|
||||
will auto-ack. Branches without a daemon time out — that's expected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
TEST_TOKEN = "[AIPASS-TEST — do not update memories, do not execute, reply 'ack' only]"
|
||||
TIMEOUT_PER_BRANCH = 30
|
||||
|
||||
BRANCHES = [
|
||||
"drone",
|
||||
"seedgo",
|
||||
"prax",
|
||||
"cli",
|
||||
"flow",
|
||||
"ai_mail",
|
||||
"api",
|
||||
"trigger",
|
||||
"spawn",
|
||||
"memory",
|
||||
"devpulse",
|
||||
]
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _discover_branches() -> list[str]:
|
||||
"""Read branches from the project's own registry. Returns empty if none found."""
|
||||
cwd = Path.cwd()
|
||||
search = cwd
|
||||
for _ in range(10):
|
||||
candidates = list(search.glob("*_REGISTRY.json"))
|
||||
if candidates:
|
||||
try:
|
||||
data = json.loads(candidates[0].read_text(encoding="utf-8"))
|
||||
branches = [b.get("name", "") for b in data.get("branches", [])]
|
||||
return [b for b in branches if b]
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("[ping_sweep] failed to read registry %s: %s", candidates[0], exc)
|
||||
return []
|
||||
parent = search.parent
|
||||
if parent == search:
|
||||
break
|
||||
search = parent
|
||||
return BRANCHES
|
||||
|
||||
|
||||
def _aipass_inbox_path() -> Path:
|
||||
"""Path to aipass's own inbox.json."""
|
||||
return _BRANCH_ROOT / ".ai_mail.local" / "inbox.json"
|
||||
|
||||
|
||||
def _send_test_email(branch: str, body: str) -> bool:
|
||||
"""Send test email to branch via drone. Returns True on success.
|
||||
|
||||
Runs drone with cwd=aipass branch root so ai_mail's branch detection
|
||||
resolves the sender as @aipass (it requires a .trinity/passport.json
|
||||
in the working dir). Without this, the wrapper's cwd is the AIPass
|
||||
project root and every send fails with BRANCH DETECTION FAILED.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["drone", "@ai_mail", "email", f"@{branch}", "AIPASS PING", body],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
cwd=str(_BRANCH_ROOT),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"[ping_sweep] email to @%s failed (rc=%d): %s",
|
||||
branch,
|
||||
result.returncode,
|
||||
result.stderr[:200],
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[ping_sweep] drone not found: %s", exc)
|
||||
return False
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[ping_sweep] send to @%s timed out: %s", branch, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _wait_for_ack(branch: str, timeout: int) -> str:
|
||||
"""
|
||||
Poll aipass inbox for an ack reply from branch.
|
||||
Returns 'ack' | 'timeout'.
|
||||
Auto-ack requires the target branch's ai_mail daemon to be running.
|
||||
"""
|
||||
deadline = time.time() + timeout
|
||||
inbox_path = _aipass_inbox_path()
|
||||
|
||||
while time.time() < deadline:
|
||||
time.sleep(2)
|
||||
if not inbox_path.exists():
|
||||
continue
|
||||
try:
|
||||
with open(inbox_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
for msg in data.get("messages", []):
|
||||
msg_from = msg.get("from", "").lstrip("@")
|
||||
subj = msg.get("subject", "").lower()
|
||||
body_lower = msg.get("message", "").lower()
|
||||
if msg_from == branch and msg.get("status") == "new" and ("ack" in subj or "ack" in body_lower):
|
||||
return "ack"
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("[ping_sweep] inbox read error: %s", exc)
|
||||
|
||||
return "timeout"
|
||||
|
||||
|
||||
def sweep_all_branches(timeout: int = TIMEOUT_PER_BRANCH) -> Dict[str, str]:
|
||||
"""
|
||||
Send AIPASS PING to each branch, collect acks.
|
||||
Returns {branch: 'ack' | 'timeout' | 'error'}.
|
||||
"""
|
||||
results: Dict[str, str] = {}
|
||||
body = f"AIPASS PING — checking that you are reachable.\n\n{TEST_TOKEN}"
|
||||
|
||||
for branch in _discover_branches():
|
||||
ok = _send_test_email(branch, body)
|
||||
if not ok:
|
||||
results[branch] = "error"
|
||||
continue
|
||||
results[branch] = _wait_for_ack(branch, timeout)
|
||||
logger.info("[ping_sweep] @%s → %s", branch, results[branch])
|
||||
|
||||
json_handler.log_operation("ping_sweep", {"results": results})
|
||||
return results
|
||||
|
||||
|
||||
def sweep_summary(results: Dict[str, str]) -> str:
|
||||
"""Return human-readable sweep summary."""
|
||||
acks = sum(1 for v in results.values() if v == "ack")
|
||||
timeouts = sum(1 for v in results.values() if v == "timeout")
|
||||
errors = sum(1 for v in results.values() if v == "error")
|
||||
return f"{acks} ack / {timeouts} timeout / {errors} error"
|
||||
@@ -0,0 +1,119 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: readme_map/__init__.py
|
||||
# Description: Branch-name to README-path lookup for help_chat live reads
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""readme_map — branch-name → README-path lookup for help_chat live reads.
|
||||
|
||||
Principle: map is cached (path lookup only). Content is NEVER cached.
|
||||
Every question live-reads the current file. Nothing hardcoded.
|
||||
|
||||
AIPASS_ROOT detection:
|
||||
1. Try os.environ["AIPASS_HOME"] — if set, use it
|
||||
2. Fall back: walk up from this file to find directory containing src/aipass/
|
||||
3. AIPASS_ROOT = the parent of src/aipass/ (i.e., the Projects/AIPass directory)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# =============================================================================
|
||||
# AIPASS ROOT DETECTION
|
||||
# =============================================================================
|
||||
|
||||
_AIPASS_ROOT: Path | None = None
|
||||
|
||||
|
||||
def _detect_aipass_root() -> Path:
|
||||
"""Detect the AIPass project root (parent of src/aipass/)."""
|
||||
# Strategy 1: environment variable
|
||||
env_home = os.environ.get("AIPASS_HOME")
|
||||
if env_home:
|
||||
return Path(env_home)
|
||||
|
||||
# Strategy 2: walk up from this file to find src/aipass/
|
||||
# This file lives at: src/aipass/aipass/apps/handlers/readme_map/__init__.py
|
||||
# parents: [0]=readme_map/, [1]=handlers/, [2]=apps/, [3]=aipass/,
|
||||
# [4]=src/aipass/, [5]=Projects/AIPass/ (AIPASS_ROOT)
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
src_aipass = parent / "src" / "aipass"
|
||||
if src_aipass.is_dir():
|
||||
return parent
|
||||
|
||||
# Fallback: use the inferred path directly (5 levels up from this file)
|
||||
return Path(__file__).resolve().parents[5]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BRANCH REGISTRY
|
||||
# =============================================================================
|
||||
|
||||
BRANCHES: list[str] = [
|
||||
"drone",
|
||||
"seedgo",
|
||||
"prax",
|
||||
"cli",
|
||||
"flow",
|
||||
"ai_mail",
|
||||
"api",
|
||||
"trigger",
|
||||
"spawn",
|
||||
"memory",
|
||||
"devpulse",
|
||||
"aipass",
|
||||
]
|
||||
|
||||
# Module-level cache: branch_name → readme_path
|
||||
# This is the ONLY thing cached. Content is always live-read.
|
||||
_README_MAP: dict[str, Path] | None = None
|
||||
|
||||
|
||||
def _build_readme_map() -> dict[str, Path]:
|
||||
"""Build the branch → README.md path map. Called once, result cached."""
|
||||
global _AIPASS_ROOT
|
||||
if _AIPASS_ROOT is None:
|
||||
_AIPASS_ROOT = _detect_aipass_root()
|
||||
|
||||
src_aipass = _AIPASS_ROOT / "src" / "aipass"
|
||||
result: dict[str, Path] = {}
|
||||
for branch in BRANCHES:
|
||||
readme = src_aipass / branch / "README.md"
|
||||
if readme.exists():
|
||||
result[branch] = readme
|
||||
return result
|
||||
|
||||
|
||||
def _get_map() -> dict[str, Path]:
|
||||
"""Return the cached README map, building it on first call."""
|
||||
global _README_MAP
|
||||
if _README_MAP is None:
|
||||
_README_MAP = _build_readme_map()
|
||||
return _README_MAP
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUBLIC API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_readme_path(branch: str) -> Path | None:
|
||||
"""Return Path to src/aipass/{branch}/README.md if it exists, else None.
|
||||
|
||||
NEVER reads the file — just returns the path.
|
||||
"""
|
||||
return _get_map().get(branch)
|
||||
|
||||
|
||||
def list_branches() -> list[str]:
|
||||
"""Return list of branches that have a README.md.
|
||||
|
||||
Checked against the cached path map (built on first call).
|
||||
Reflects the filesystem state at the time the map was first built.
|
||||
"""
|
||||
return list(_get_map().keys())
|
||||
@@ -0,0 +1,27 @@
|
||||
"""system_detect — OS, shell, Python, RAM/CPU, install method detection."""
|
||||
|
||||
from aipass.aipass.apps.handlers.system_detect.system_detector import (
|
||||
detect_cpu,
|
||||
detect_docker,
|
||||
detect_git,
|
||||
detect_install_method,
|
||||
detect_os,
|
||||
detect_python,
|
||||
detect_ram,
|
||||
detect_shell,
|
||||
detect_tmux,
|
||||
detect_wt,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"detect_cpu",
|
||||
"detect_docker",
|
||||
"detect_git",
|
||||
"detect_install_method",
|
||||
"detect_os",
|
||||
"detect_python",
|
||||
"detect_ram",
|
||||
"detect_shell",
|
||||
"detect_tmux",
|
||||
"detect_wt",
|
||||
]
|
||||
@@ -0,0 +1,248 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: system_detector.py
|
||||
# Description: Pure system detection logic for aipass doctor
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
System Detector — Pure detection logic for doctor checks.
|
||||
|
||||
Returns plain dicts with facts about the running environment.
|
||||
No Rich markup — display concerns belong to the UI layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================================================
|
||||
# PYTHON
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def detect_python() -> Dict[str, Any]:
|
||||
"""Return Python version info and ok/warning flags.
|
||||
|
||||
Returns:
|
||||
version: str like "3.11.5"
|
||||
major: int
|
||||
minor: int
|
||||
ok: bool — True if >=3.9
|
||||
warning: bool — True if ==3.8 (supported but near end)
|
||||
"""
|
||||
info = sys.version_info
|
||||
version = f"{info.major}.{info.minor}.{info.micro}"
|
||||
ok = (info.major, info.minor) >= (3, 9)
|
||||
warning = (info.major, info.minor) == (3, 8)
|
||||
return {
|
||||
"version": version,
|
||||
"major": info.major,
|
||||
"minor": info.minor,
|
||||
"ok": ok,
|
||||
"warning": warning,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GIT
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def detect_git() -> Dict[str, Any]:
|
||||
"""Return git availability and version string.
|
||||
|
||||
Returns:
|
||||
found: bool
|
||||
version: str — e.g. "2.43.0" or ""
|
||||
"""
|
||||
git_path = shutil.which("git")
|
||||
if git_path is None:
|
||||
return {"found": False, "version": ""}
|
||||
|
||||
version = ""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
raw = result.stdout.strip()
|
||||
# "git version 2.43.0" → "2.43.0"
|
||||
parts = raw.split()
|
||||
if len(parts) >= 3:
|
||||
version = parts[-1]
|
||||
else:
|
||||
version = raw
|
||||
except Exception as exc:
|
||||
logger.warning("[system_detector] git version check failed: %s", exc)
|
||||
|
||||
return {"found": True, "version": version}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SHELL
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def detect_shell() -> Dict[str, Any]:
|
||||
"""Return shell name and path.
|
||||
|
||||
Returns:
|
||||
name: str — e.g. "bash", "zsh", or "unknown"
|
||||
path: str — full path or ""
|
||||
"""
|
||||
shell_path = os.environ.get("SHELL", "")
|
||||
if shell_path:
|
||||
name = Path(shell_path).name
|
||||
else:
|
||||
name = "unknown"
|
||||
shell_path = ""
|
||||
return {"name": name, "path": shell_path}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def detect_os() -> Dict[str, Any]:
|
||||
"""Return OS name, release, and machine architecture.
|
||||
|
||||
Returns:
|
||||
os_name: str — "Linux", "Darwin", "Windows", etc.
|
||||
release: str — kernel/OS release string
|
||||
machine: str — e.g. "x86_64"
|
||||
"""
|
||||
return {
|
||||
"os_name": platform.system() or "unknown",
|
||||
"release": platform.release() or "",
|
||||
"machine": platform.machine() or "",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RAM
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _read_meminfo_kb() -> int:
|
||||
"""Read MemTotal from /proc/meminfo and return value in kB, or 0 on failure."""
|
||||
meminfo = Path("/proc/meminfo")
|
||||
if not meminfo.exists():
|
||||
return 0
|
||||
try:
|
||||
with open(meminfo, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.startswith("MemTotal:"):
|
||||
return int(line.split()[1])
|
||||
except Exception as exc:
|
||||
logger.warning("[system_detector] /proc/meminfo read failed: %s", exc)
|
||||
return 0
|
||||
|
||||
|
||||
def _total_ram_gb() -> float:
|
||||
"""Return total RAM in GB using psutil or /proc/meminfo fallback."""
|
||||
try:
|
||||
import psutil # type: ignore[import-untyped]
|
||||
|
||||
return psutil.virtual_memory().total / (1024**3)
|
||||
except ImportError as exc:
|
||||
logger.info("[system_detector] psutil not installed, using /proc/meminfo fallback: %s", exc)
|
||||
kb = _read_meminfo_kb()
|
||||
return kb / (1024**2) if kb else 0.0
|
||||
except Exception as exc:
|
||||
logger.warning("[system_detector] psutil RAM check failed: %s", exc)
|
||||
return 0.0
|
||||
|
||||
|
||||
def detect_ram() -> Dict[str, Any]:
|
||||
"""Return total RAM in GB and ok/warning flags.
|
||||
|
||||
Tries psutil first, falls back to /proc/meminfo on Linux, else 0.
|
||||
|
||||
Returns:
|
||||
total_gb: float
|
||||
ok: bool — True if >=4 GB
|
||||
warning: bool — True if 2<=x<4 GB
|
||||
"""
|
||||
total_gb = _total_ram_gb()
|
||||
ok = total_gb >= 4.0
|
||||
warning = 2.0 <= total_gb < 4.0
|
||||
result = {"total_gb": round(total_gb, 1), "ok": ok, "warning": warning}
|
||||
json_handler.log_operation("detect_ram", {"total_gb": result["total_gb"]}, "system_detector")
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CPU
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def detect_cpu() -> Dict[str, Any]:
|
||||
"""Return logical CPU count.
|
||||
|
||||
Returns:
|
||||
count: int — number of logical CPUs (0 if unknown)
|
||||
"""
|
||||
count = os.cpu_count() or 0
|
||||
return {"count": count}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INSTALL METHOD
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def detect_install_method() -> str:
|
||||
"""Return how aipass was installed: 'pip', 'dev', 'clone', or 'fork'.
|
||||
|
||||
Logic:
|
||||
- If this file's path contains 'site-packages' → pip
|
||||
- If .git exists AND pyproject.toml is in the same tree → dev (editable install / source contributor)
|
||||
- If .git exists → clone
|
||||
- Default → 'unknown'
|
||||
"""
|
||||
this_file = Path(__file__).resolve()
|
||||
|
||||
if "site-packages" in str(this_file):
|
||||
return "pip"
|
||||
|
||||
for parent in this_file.parents:
|
||||
if (parent / ".git").exists():
|
||||
if (parent / "pyproject.toml").exists() and (parent / "src").exists():
|
||||
return "dev"
|
||||
return "clone"
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OPTIONAL TOOLS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def detect_tmux() -> bool:
|
||||
"""True if tmux is available on PATH."""
|
||||
return shutil.which("tmux") is not None
|
||||
|
||||
|
||||
def detect_wt() -> bool:
|
||||
"""True if Windows Terminal (wt.exe) is available on PATH."""
|
||||
return shutil.which("wt.exe") is not None
|
||||
|
||||
|
||||
def detect_docker() -> bool:
|
||||
"""True if docker is available on PATH."""
|
||||
return shutil.which("docker") is not None
|
||||
@@ -0,0 +1,17 @@
|
||||
"""ui — progress bars, Rich output helpers for aipass modules."""
|
||||
|
||||
from aipass.aipass.apps.handlers.ui.progress import (
|
||||
GLYPH_FAIL,
|
||||
GLYPH_PASS,
|
||||
GLYPH_WARN,
|
||||
format_check,
|
||||
make_doctor_progress,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GLYPH_FAIL",
|
||||
"GLYPH_PASS",
|
||||
"GLYPH_WARN",
|
||||
"format_check",
|
||||
"make_doctor_progress",
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: progress.py
|
||||
# Description: Rich progress and glyph helpers for aipass doctor
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Rich progress and glyph helpers for aipass doctor.
|
||||
|
||||
Provides status glyphs and progress spinners used by doctor checks.
|
||||
No bare print() — all output via logger or caller's console.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.prax import logger
|
||||
|
||||
# =============================================================================
|
||||
# GLYPHS
|
||||
# =============================================================================
|
||||
|
||||
GLYPH_PASS = "[green]✓[/green]"
|
||||
GLYPH_WARN = "[yellow]![/yellow]"
|
||||
GLYPH_FAIL = "[red]✗[/red]"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PROGRESS FACTORY
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def make_doctor_progress() -> Progress:
|
||||
"""Return a transient spinner progress bar for doctor checks.
|
||||
|
||||
Returns:
|
||||
Progress instance with spinner + description columns.
|
||||
"""
|
||||
logger.info("[progress] creating doctor progress bar")
|
||||
json_handler.log_operation("make_doctor_progress", {})
|
||||
return Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("{task.description}"),
|
||||
transient=True,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHECK FORMATTER
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def format_check(
|
||||
label: str,
|
||||
glyph: str,
|
||||
detail: str = "",
|
||||
remediation: str = "",
|
||||
) -> str:
|
||||
"""Format a single doctor check line as Rich markup.
|
||||
|
||||
Args:
|
||||
label: Check name (e.g. "python").
|
||||
glyph: One of GLYPH_PASS / GLYPH_WARN / GLYPH_FAIL.
|
||||
detail: Optional detail appended after the label (e.g. "3.11.5").
|
||||
remediation: Optional remediation hint shown indented on next line.
|
||||
|
||||
Returns:
|
||||
Rich markup string ready for console.print().
|
||||
"""
|
||||
parts: list[str] = [f" {glyph} [bold]{label}[/bold]"]
|
||||
if detail:
|
||||
parts.append(f" [dim]{detail}[/dim]")
|
||||
line = "".join(parts)
|
||||
|
||||
if remediation:
|
||||
indent = " "
|
||||
line = f"{line}\n{indent}[dim yellow]{remediation}[/dim yellow]"
|
||||
|
||||
return line
|
||||
@@ -0,0 +1,64 @@
|
||||
# apps/integrations/
|
||||
|
||||
Private integration space for `AIPASS`.
|
||||
|
||||
**This folder is gitignored.** Only this README is tracked. Everything else you drop in here stays local and never appears in git, PRs, or the public repo. Safe by construction, not by discipline.
|
||||
|
||||
## What goes here
|
||||
|
||||
**Branch-specific wrappers** that consume external systems via the @api driver layer. Each wrapper handles how THIS branch uses an external system in its own domain.
|
||||
|
||||
```
|
||||
apps/integrations/
|
||||
└── {project}/
|
||||
├── wrapper.py # How this branch uses the driver
|
||||
├── config.json # Optional — local config
|
||||
└── tests/ # Private tests colocated
|
||||
```
|
||||
|
||||
Wrappers should call into `@api`'s generic contracts (e.g. `api.memory_backend.query(...)`), never reference the private project by name in any tracked code. The private project name lives in the @api driver, not here.
|
||||
|
||||
## What does NOT go here
|
||||
|
||||
- **Driver code** — that belongs in `@api/apps/integrations/{project}/driver.py` (the connection layer).
|
||||
- **Public business logic** — use `apps/modules/` or `apps/handlers/` for that.
|
||||
- **Drone plugins** — use `apps/plugins/` for those.
|
||||
- **Secrets** — they live in `~/.secrets/aipass/`, never in the repo.
|
||||
|
||||
## Architecture
|
||||
|
||||
The full design is in DPLAN-0133 (private integrations architecture). Three layers:
|
||||
|
||||
1. **@api driver layer** (`@api/apps/integrations/{project}/`) — owns the physical connection, auth, transport. Knows the private project name.
|
||||
2. **Per-branch wrapper layer** (`{this_folder}/{project}/`) — owns how this branch consumes the driver's output in its domain. Calls generic contracts, never names private projects.
|
||||
3. **Public drone commands** (`drone @api integrations list`, `drone @api integrations call <contract>`) — advertise the extension points without naming specifics. Fork-safe.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
# Your public code (committed, in apps/modules/ or apps/handlers/)
|
||||
from aipass.api import memory_backend
|
||||
|
||||
results = memory_backend.query("when did we ship watchdog?")
|
||||
# memory_backend is a generic contract. In your local setup it routes to whatever
|
||||
# driver you registered in @api/apps/integrations/. In a fresh clone with nothing
|
||||
# registered, it returns NotConfigured gracefully.
|
||||
```
|
||||
|
||||
```python
|
||||
# Your private wrapper (in this folder, gitignored)
|
||||
# apps/integrations/{project}/wrapper.py
|
||||
|
||||
from aipass.api import memory_backend
|
||||
|
||||
def domain_specific_query(context):
|
||||
"""Branch-specific query pattern for domain needs."""
|
||||
hint = build_query_from_context(context)
|
||||
return memory_backend.query(hint, top_k=5, filter={"kind": "decision"})
|
||||
```
|
||||
|
||||
The wrapper stays here, the call into the contract stays here, no private name leaks into tracked code.
|
||||
|
||||
---
|
||||
|
||||
See DPLAN-0133 for the full design rationale.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Modules
|
||||
|
||||
Business logic for `AIPASS`. One module per command.
|
||||
|
||||
Modules orchestrate work by calling handlers. They are the public API of the branch — drone routes commands here.
|
||||
@@ -0,0 +1,598 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: doctor.py
|
||||
# Description: System health aggregation — aipass doctor command
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass doctor — system health aggregation
|
||||
|
||||
Flutter-doctor-style health check across four groups:
|
||||
System — Python, git, shell, OS, RAM, CPU, install method
|
||||
Identity — AIPASS_HOME, registry, passport integrity
|
||||
Services — drone routing, pytest collect, hooks wired
|
||||
Community — ai_mail, dropbox
|
||||
|
||||
Three-tier glyph output: ✓ green / ! yellow / ✗ red
|
||||
Remediation shown inline under failing checks.
|
||||
Exit 0 on pass+warn, non-zero only on errors.
|
||||
Pure reads — never mutates.
|
||||
|
||||
Run: aipass doctor [--verbose]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, NamedTuple
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.system_detect.system_detector import (
|
||||
detect_cpu,
|
||||
detect_git,
|
||||
detect_install_method,
|
||||
detect_os,
|
||||
detect_python,
|
||||
detect_ram,
|
||||
detect_shell,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.ui.progress import (
|
||||
GLYPH_FAIL,
|
||||
GLYPH_PASS,
|
||||
GLYPH_WARN,
|
||||
format_check,
|
||||
make_doctor_progress,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# TYPES
|
||||
# =============================================================================
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class CheckResult(NamedTuple):
|
||||
"""Single doctor check result."""
|
||||
|
||||
label: str
|
||||
glyph: str
|
||||
detail: str
|
||||
remediation: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# IDENTITY HELPERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _find_registry() -> Path | None:
|
||||
"""Walk up from CWD first (user's project), then branch root."""
|
||||
cwd = Path.cwd()
|
||||
for parent in (cwd, *cwd.parents):
|
||||
candidates = list(parent.glob("*_REGISTRY.json"))
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
if parent == parent.parent:
|
||||
break
|
||||
for parent in (_BRANCH_ROOT, *_BRANCH_ROOT.parents):
|
||||
candidate = parent / "AIPASS_REGISTRY.json"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHECK GROUPS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _check_system() -> List[CheckResult]:
|
||||
"""Run System group checks."""
|
||||
results: List[CheckResult] = []
|
||||
|
||||
# Python
|
||||
py = detect_python()
|
||||
if py["ok"]:
|
||||
glyph, detail, rem = GLYPH_PASS, py["version"], ""
|
||||
elif py["warning"]:
|
||||
glyph, detail = GLYPH_WARN, py["version"]
|
||||
rem = "Python 3.8 reaches end-of-life — upgrade to 3.9+"
|
||||
else:
|
||||
glyph, detail = GLYPH_FAIL, py["version"]
|
||||
rem = "Upgrade Python: https://python.org/downloads"
|
||||
results.append(CheckResult("python", glyph, detail, rem))
|
||||
|
||||
# git
|
||||
git = detect_git()
|
||||
if git["found"]:
|
||||
results.append(CheckResult("git", GLYPH_PASS, git["version"], ""))
|
||||
else:
|
||||
results.append(CheckResult("git", GLYPH_FAIL, "not found", "Install git: https://git-scm.com/downloads"))
|
||||
|
||||
# shell
|
||||
sh = detect_shell()
|
||||
results.append(CheckResult("shell", GLYPH_PASS, sh["name"], ""))
|
||||
|
||||
# OS
|
||||
os_info = detect_os()
|
||||
detail = f"{os_info['os_name']} {os_info['release']}".strip()
|
||||
results.append(CheckResult("OS", GLYPH_PASS, detail, ""))
|
||||
|
||||
# RAM
|
||||
ram = detect_ram()
|
||||
ram_detail = f"{ram['total_gb']} GB"
|
||||
if ram["ok"]:
|
||||
results.append(CheckResult("RAM", GLYPH_PASS, ram_detail, ""))
|
||||
elif ram["warning"]:
|
||||
results.append(CheckResult("RAM", GLYPH_WARN, ram_detail, "AIPass runs better with 4 GB+ RAM"))
|
||||
else:
|
||||
results.append(CheckResult("RAM", GLYPH_FAIL, ram_detail, "AIPass requires at least 2 GB RAM"))
|
||||
|
||||
# CPU
|
||||
cpu = detect_cpu()
|
||||
results.append(CheckResult("CPU", GLYPH_PASS, f"{cpu['count']} cores", ""))
|
||||
|
||||
# install method
|
||||
method = detect_install_method()
|
||||
results.append(CheckResult("install", GLYPH_PASS, method, ""))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _check_identity() -> List[CheckResult]:
|
||||
"""Run Identity group checks."""
|
||||
results: List[CheckResult] = []
|
||||
|
||||
# Project root — derived from registry location
|
||||
reg = _find_registry()
|
||||
project_root = str(reg.parent) if reg else ""
|
||||
if project_root:
|
||||
results.append(CheckResult("AIPASS_HOME", GLYPH_PASS, project_root, ""))
|
||||
else:
|
||||
home = os.environ.get("AIPASS_HOME", "")
|
||||
if home:
|
||||
results.append(CheckResult("AIPASS_HOME", GLYPH_PASS, home, ""))
|
||||
else:
|
||||
results.append(
|
||||
CheckResult(
|
||||
"AIPASS_HOME",
|
||||
GLYPH_WARN,
|
||||
"not set",
|
||||
"Set in ~/.bashrc: export AIPASS_HOME=/path/to/aipass",
|
||||
)
|
||||
)
|
||||
|
||||
# Registry present
|
||||
reg_path = _find_registry()
|
||||
if reg_path is None:
|
||||
results.append(CheckResult("registry", GLYPH_FAIL, "not found", "Run 'aipass init' to create registry"))
|
||||
return results
|
||||
|
||||
branch_count = 0
|
||||
try:
|
||||
with open(reg_path, "r", encoding="utf-8") as f:
|
||||
reg_data = json.load(f)
|
||||
branch_count = len(reg_data.get("branches", []))
|
||||
results.append(CheckResult("registry", GLYPH_PASS, f"{branch_count} branches", ""))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("[doctor] registry parse error: %s", exc)
|
||||
results.append(CheckResult("registry", GLYPH_FAIL, "corrupt JSON", "Manually inspect AIPASS_REGISTRY.json"))
|
||||
return results
|
||||
|
||||
# Registry valid — has branches key
|
||||
if "branches" in reg_data:
|
||||
results.append(CheckResult("registry valid", GLYPH_PASS, "", ""))
|
||||
else:
|
||||
results.append(CheckResult("registry valid", GLYPH_FAIL, "missing 'branches' key", "Re-run 'aipass init'"))
|
||||
|
||||
# Passport readable
|
||||
passport = _BRANCH_ROOT / ".trinity" / "passport.json"
|
||||
if passport.exists():
|
||||
try:
|
||||
with open(passport, "r", encoding="utf-8") as f:
|
||||
pdata = json.load(f)
|
||||
role = pdata.get("role", "unknown")
|
||||
results.append(CheckResult("passport", GLYPH_PASS, f"role: {role}", ""))
|
||||
except Exception as exc:
|
||||
logger.warning("[doctor] passport read error: %s", exc)
|
||||
results.append(CheckResult("passport", GLYPH_WARN, "unreadable", "Check .trinity/passport.json"))
|
||||
else:
|
||||
results.append(CheckResult("passport", GLYPH_WARN, "not found", ""))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _find_manifest() -> Path | None:
|
||||
"""Find provider_manifest.json by walking up from CWD or using AIPASS_HOME."""
|
||||
for start in (Path.cwd(), Path(os.environ.get("AIPASS_HOME", ""))):
|
||||
p = start.resolve()
|
||||
for parent in (p, *p.parents):
|
||||
candidate = parent / ".claude" / "provider_manifest.json"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
if parent == parent.parent:
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _check_provider_manifest() -> List[CheckResult]:
|
||||
"""Check provider settings against manifest. Returns hook/env/permission results."""
|
||||
results: List[CheckResult] = []
|
||||
|
||||
manifest_path = _find_manifest()
|
||||
if manifest_path is None:
|
||||
results.append(
|
||||
CheckResult(
|
||||
"hooks", GLYPH_WARN, "manifest not found", "Expected .claude/provider_manifest.json in project root"
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("[doctor] manifest read error: %s", exc)
|
||||
results.append(CheckResult("hooks", GLYPH_WARN, "manifest unreadable", "Check .claude/provider_manifest.json"))
|
||||
return results
|
||||
|
||||
claude_section = manifest.get("cli", {}).get("claude", {})
|
||||
if not claude_section:
|
||||
results.append(CheckResult("hooks", GLYPH_WARN, "manifest has no claude section", ""))
|
||||
return results
|
||||
|
||||
# --- Hook scripts exist ---
|
||||
manifest_hooks = claude_section.get("hooks", [])
|
||||
hook_scripts = {h["script"] for h in manifest_hooks if "script" in h}
|
||||
repo_hooks_dir = manifest_path.parent / "hooks"
|
||||
user_hooks_dir = Path.home() / ".claude" / "hooks"
|
||||
|
||||
missing_hooks = []
|
||||
for script in sorted(hook_scripts):
|
||||
source = next((h.get("source", "repo") for h in manifest_hooks if h.get("script") == script), "repo")
|
||||
check_dir = user_hooks_dir if source == "user" else repo_hooks_dir
|
||||
if not (check_dir / script).exists():
|
||||
missing_hooks.append(script)
|
||||
|
||||
if not missing_hooks:
|
||||
results.append(CheckResult("hooks", GLYPH_PASS, f"{len(hook_scripts)} provider hooks present", ""))
|
||||
else:
|
||||
results.append(
|
||||
CheckResult(
|
||||
"hooks",
|
||||
GLYPH_WARN,
|
||||
f"{len(missing_hooks)} hook(s) missing: {', '.join(missing_hooks)}",
|
||||
"Copy missing hooks to ~/.claude/hooks/ — see .claude/hooks/README.md",
|
||||
)
|
||||
)
|
||||
|
||||
# --- Env vars in provider settings ---
|
||||
manifest_env = claude_section.get("env", {})
|
||||
if manifest_env:
|
||||
provider_settings_path = Path.home() / ".claude" / "settings.json"
|
||||
provider_env: dict = {}
|
||||
if provider_settings_path.exists():
|
||||
try:
|
||||
provider_env = json.loads(provider_settings_path.read_text(encoding="utf-8")).get("env", {})
|
||||
except Exception as exc:
|
||||
logger.warning("[doctor] provider settings read error (env): %s", exc)
|
||||
|
||||
missing_env = [k for k in manifest_env if k not in provider_env]
|
||||
if not missing_env:
|
||||
results.append(CheckResult("env vars", GLYPH_PASS, f"{len(manifest_env)} provider env vars set", ""))
|
||||
else:
|
||||
results.append(
|
||||
CheckResult(
|
||||
"env vars",
|
||||
GLYPH_WARN,
|
||||
f"{len(missing_env)} env var(s) missing: {', '.join(missing_env)}",
|
||||
"Add to ~/.claude/settings.json env block — see provider_manifest.json",
|
||||
)
|
||||
)
|
||||
|
||||
# --- Permissions ---
|
||||
manifest_perms = claude_section.get("permissions", {})
|
||||
manifest_deny = manifest_perms.get("deny", [])
|
||||
manifest_ask = manifest_perms.get("ask", [])
|
||||
if manifest_deny or manifest_ask:
|
||||
provider_settings_path = Path.home() / ".claude" / "settings.json"
|
||||
provider_perms: dict = {}
|
||||
if provider_settings_path.exists():
|
||||
try:
|
||||
provider_perms = json.loads(provider_settings_path.read_text(encoding="utf-8")).get("permissions", {})
|
||||
except Exception as exc:
|
||||
logger.warning("[doctor] provider settings read error (permissions): %s", exc)
|
||||
|
||||
provider_deny = set(provider_perms.get("deny", []))
|
||||
provider_ask = set(provider_perms.get("ask", []))
|
||||
|
||||
# Check deny rules (use ~ form only, skip expanded $HOME duplicates)
|
||||
missing_deny = [r for r in manifest_deny if r not in provider_deny]
|
||||
# Check ask rules
|
||||
missing_ask = [r for r in manifest_ask if r not in provider_ask]
|
||||
total_expected = len(manifest_deny) + len(manifest_ask)
|
||||
total_missing = len(missing_deny) + len(missing_ask)
|
||||
|
||||
if total_missing == 0:
|
||||
results.append(CheckResult("permissions", GLYPH_PASS, f"{total_expected} permission rules set", ""))
|
||||
else:
|
||||
results.append(
|
||||
CheckResult(
|
||||
"permissions",
|
||||
GLYPH_WARN,
|
||||
f"{total_missing} permission rule(s) missing",
|
||||
"Add to ~/.claude/settings.json permissions — see provider_manifest.json",
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _check_services(verbose: bool = False) -> List[CheckResult]:
|
||||
"""Run Services group checks."""
|
||||
results: List[CheckResult] = []
|
||||
|
||||
# drone systems
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["drone", "systems"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
# Count citizen lines (lines with @)
|
||||
citizens = [ln for ln in proc.stdout.splitlines() if "@" in ln]
|
||||
detail = f"{len(citizens)} citizens" if citizens else "ok"
|
||||
results.append(CheckResult("drone", GLYPH_PASS, detail, ""))
|
||||
else:
|
||||
results.append(
|
||||
CheckResult("drone", GLYPH_FAIL, "exit non-zero", "Ensure aipass is installed: pip install -e .")
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[doctor] drone not found: %s", exc)
|
||||
results.append(CheckResult("drone", GLYPH_FAIL, "not found", "Ensure aipass is installed: pip install -e ."))
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[doctor] drone systems timed out: %s", exc)
|
||||
results.append(CheckResult("drone", GLYPH_WARN, "timed out", ""))
|
||||
|
||||
# pytest --collect-only
|
||||
try:
|
||||
# Find repo root (where src/ lives)
|
||||
repo_root = None
|
||||
for parent in _BRANCH_ROOT.parents:
|
||||
if (parent / "src").exists() and (parent / "pyproject.toml").exists():
|
||||
repo_root = parent
|
||||
break
|
||||
cwd = str(repo_root) if repo_root else str(_BRANCH_ROOT)
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "src/aipass/", "--collect-only", "-q"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=cwd,
|
||||
)
|
||||
output = proc.stdout + proc.stderr
|
||||
if proc.returncode == 0:
|
||||
# Count collected lines
|
||||
collected = [ln for ln in output.splitlines() if "<" in ln or "::" in ln]
|
||||
detail = f"{len(collected)} tests collected" if collected else "ok"
|
||||
results.append(CheckResult("pytest collect", GLYPH_PASS, detail, ""))
|
||||
else:
|
||||
results.append(CheckResult("pytest collect", GLYPH_WARN, "collection issues", "Run pytest to diagnose"))
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[doctor] pytest not found: %s", exc)
|
||||
results.append(CheckResult("pytest collect", GLYPH_WARN, "pytest not found", "pip install pytest"))
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[doctor] pytest collect timed out: %s", exc)
|
||||
results.append(CheckResult("pytest collect", GLYPH_WARN, "timed out", ""))
|
||||
|
||||
# hooks + env + permissions — manifest-driven provider check
|
||||
manifest_checks = _check_provider_manifest()
|
||||
results.extend(manifest_checks)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _check_community() -> List[CheckResult]:
|
||||
"""Run Community group checks."""
|
||||
results: List[CheckResult] = []
|
||||
|
||||
# ai_mail readable
|
||||
mail_dir = _BRANCH_ROOT / ".ai_mail.local"
|
||||
if mail_dir.exists() and mail_dir.is_dir():
|
||||
results.append(CheckResult("ai_mail", GLYPH_PASS, "readable", ""))
|
||||
else:
|
||||
results.append(CheckResult("ai_mail", GLYPH_FAIL, "not found", "Run 'aipass init' to set up mailbox"))
|
||||
|
||||
# dropbox writable — only check if project has agents (dropbox is optional for fresh projects)
|
||||
reg_path = _find_registry()
|
||||
dropbox = None
|
||||
if reg_path:
|
||||
dropbox = reg_path.parent / "dropbox"
|
||||
if dropbox and dropbox.exists():
|
||||
writable = os.access(dropbox, os.W_OK)
|
||||
if writable:
|
||||
results.append(CheckResult("dropbox", GLYPH_PASS, "writable", ""))
|
||||
else:
|
||||
results.append(CheckResult("dropbox", GLYPH_WARN, "not writable", "Check dropbox directory permissions"))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN DOCTOR RUN
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def run_doctor(verbose: bool = False) -> int:
|
||||
"""Run all four groups and print results. Returns error count."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass doctor[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
# Run each check group inside a transient progress spinner so the user
|
||||
# sees what is happening during slow checks (e.g. pytest --collect-only).
|
||||
group_specs = [
|
||||
("System", _check_system),
|
||||
("Identity", _check_identity),
|
||||
("Services", lambda: _check_services(verbose=verbose)),
|
||||
("Community", _check_community),
|
||||
]
|
||||
groups: Dict[str, List[CheckResult]] = {}
|
||||
with make_doctor_progress() as progress:
|
||||
for name, runner in group_specs:
|
||||
task_id = progress.add_task(f"checking {name}...", total=None)
|
||||
groups[name] = runner()
|
||||
progress.remove_task(task_id)
|
||||
|
||||
pass_count = 0
|
||||
warn_count = 0
|
||||
error_count = 0
|
||||
|
||||
for group_name, checks in groups.items():
|
||||
console.print(f" [bold]{group_name}[/bold]")
|
||||
for check in checks:
|
||||
line = format_check(check.label, check.glyph, check.detail, check.remediation)
|
||||
console.print(line)
|
||||
if check.glyph == GLYPH_PASS:
|
||||
pass_count += 1
|
||||
elif check.glyph == GLYPH_WARN:
|
||||
warn_count += 1
|
||||
else:
|
||||
error_count += 1
|
||||
console.print()
|
||||
|
||||
console.print("[dim]─────────────────────────────────[/dim]")
|
||||
summary_parts = [
|
||||
f"[green]✓ pass: {pass_count}[/green]",
|
||||
f"[yellow]! warnings: {warn_count}[/yellow]",
|
||||
f"[red]✗ errors: {error_count}[/red]",
|
||||
]
|
||||
console.print(" " + " ".join(summary_parts))
|
||||
console.print()
|
||||
|
||||
logger.info("[doctor] run complete — pass=%d warn=%d error=%d", pass_count, warn_count, error_count)
|
||||
return error_count
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OUTPUT FORMATTING
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module info and connected handlers."""
|
||||
console.print()
|
||||
console.print("[bold cyan]doctor Module[/bold cyan]")
|
||||
console.print("System health aggregation — flutter-doctor-style output")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" [cyan]handlers/system_detect/[/cyan]")
|
||||
console.print(" [dim]- system_detector.py (python, git, shell, OS, RAM, CPU, install)[/dim]")
|
||||
console.print()
|
||||
console.print(" [cyan]handlers/ui/[/cyan]")
|
||||
console.print(" [dim]- progress.py (GLYPH_PASS/WARN/FAIL, format_check, make_doctor_progress)[/dim]")
|
||||
console.print()
|
||||
console.print(" [cyan]handlers/json/[/cyan]")
|
||||
console.print(" [dim]- json_handler.py (operation logging)[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Check Groups:[/yellow]")
|
||||
console.print(" [dim]System — Python, git, shell, OS, RAM, CPU, install method[/dim]")
|
||||
console.print(" [dim]Identity — AIPASS_HOME, registry, passport[/dim]")
|
||||
console.print(" [dim]Services — drone routing, pytest collect, hooks[/dim]")
|
||||
console.print(" [dim]Community — ai_mail, dropbox[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Next:[/yellow]")
|
||||
console.print(" [green]aipass doctor[/green] [dim]# Run all checks[/dim]")
|
||||
console.print(" [green]aipass doctor --verbose[/green] [dim]# Full check detail[/dim]")
|
||||
console.print(" [green]aipass doctor --help[/green] [dim]# Full usage[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print help information."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass doctor[/bold cyan] — System health aggregation")
|
||||
console.print("Flutter-doctor-style check across System / Identity / Services / Community")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass doctor[/green] [dim]# Run all checks[/dim]")
|
||||
console.print(" [green]aipass doctor --verbose[/green] [dim]# Show sub-check detail[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]OUTPUT:[/yellow]")
|
||||
console.print(" [green]✓[/green] green — check passed")
|
||||
console.print(" [yellow]![/yellow] yellow — warning (non-blocking)")
|
||||
console.print(" [red]✗[/red] red — error (remediation shown below)")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]EXIT CODES:[/yellow]")
|
||||
console.print(" 0 — all checks pass or warn only")
|
||||
console.print(" 1 — one or more errors found")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMMAND HANDLER
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Handle 'doctor' command routing.
|
||||
|
||||
Args:
|
||||
command: Command name.
|
||||
args: Additional arguments.
|
||||
|
||||
Returns:
|
||||
True if handled (command == 'doctor'), False otherwise.
|
||||
"""
|
||||
if command != "doctor":
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args and args[0] == "--info":
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
verbose = "--verbose" in args or "-v" in args
|
||||
error_count = run_doctor(verbose=verbose)
|
||||
json_handler.log_operation("doctor_run", {"error_count": error_count})
|
||||
if error_count > 0:
|
||||
raise SystemExit(1)
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STANDALONE EXECUTION
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Prax logger connected to doctor")
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--info":
|
||||
print_introspection()
|
||||
sys.exit(0)
|
||||
|
||||
handle_command("doctor", sys.argv[1:])
|
||||
@@ -0,0 +1,153 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: handoff.py
|
||||
# Description: CLI handoff orchestration — aipass handoff command
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-20
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass handoff — pass the user to their chosen CLI in a new session
|
||||
|
||||
Thin coordinator. Delegates platform dispatch to handlers/handoff_platform/
|
||||
which handles tmux (Linux/Mac), wt.exe (Windows), or prints fallback command.
|
||||
|
||||
Usage:
|
||||
aipass handoff # show status / introspection
|
||||
aipass handoff launch # launch with stored profile settings
|
||||
aipass handoff launch --cli claude --cwd src/my-agent
|
||||
aipass handoff --help
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aipass.cli.apps.modules import console, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
COMMAND = "handoff"
|
||||
|
||||
_INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?"
|
||||
|
||||
CLI_CHOICES = ["claude", "codex", "gemini"]
|
||||
FLAG_CHOICES = ["default", "skip-permissions"]
|
||||
|
||||
|
||||
def _get_stored_profile() -> dict:
|
||||
"""Read cli and agent_path from profile/progress if available."""
|
||||
try:
|
||||
from aipass.aipass.apps.modules import profile as profile_mod
|
||||
|
||||
p = profile_mod.get_user_profile()
|
||||
return {"cli": p.get("preferred_cli") or "claude"}
|
||||
except Exception as exc:
|
||||
logger.warning("[handoff] could not read profile: %s", exc)
|
||||
return {"cli": "claude"}
|
||||
|
||||
|
||||
def do_handoff(
|
||||
cli: str = "claude",
|
||||
prompt: str = _INIT_PROMPT,
|
||||
cwd: str = ".",
|
||||
flag_variant: str = "default",
|
||||
) -> bool:
|
||||
"""
|
||||
Perform the platform-dispatched handoff.
|
||||
|
||||
Returns True when a new session was started, False when fallback was used.
|
||||
In either case the user sees what to do next.
|
||||
"""
|
||||
from aipass.aipass.apps.handlers.handoff_platform import launch_handoff
|
||||
|
||||
launched, manual_cmd = launch_handoff(cli, prompt, cwd, flag_variant)
|
||||
|
||||
if launched:
|
||||
console.print()
|
||||
console.print(f"[green]✓[/green] Session started via tmux/wt — CLI: [cyan]{cli}[/cyan]")
|
||||
console.print(f"[dim]Session name: aipass-handoff | cwd: {cwd}[/dim]")
|
||||
console.print()
|
||||
else:
|
||||
console.print()
|
||||
warning("Auto-launch unavailable. Run this command manually:")
|
||||
console.print(f" [cyan]{manual_cmd}[/cyan]")
|
||||
console.print()
|
||||
|
||||
json_handler.log_operation("handoff", {"cli": cli, "cwd": cwd, "launched": launched})
|
||||
return launched
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Show handoff module status and stored settings."""
|
||||
profile = _get_stored_profile()
|
||||
console.print()
|
||||
console.print("[bold cyan]handoff Module[/bold cyan]")
|
||||
console.print("Platform-dispatched CLI session launch")
|
||||
console.print()
|
||||
console.print(f" stored CLI: [cyan]{profile.get('cli', 'claude')}[/cyan]")
|
||||
console.print(" platforms: tmux (Linux/Mac), wt.exe (Windows), fallback")
|
||||
console.print()
|
||||
console.print("[dim]Use 'aipass handoff launch' to start a session.[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the handoff command."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass handoff[/bold cyan] — launch CLI in a new session")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass handoff[/green] [dim]# Show status[/dim]")
|
||||
console.print(" [green]aipass handoff launch[/green] [dim]# Launch with profile defaults[/dim]")
|
||||
console.print(" [green]aipass handoff launch --cli claude[/green] [dim]# Specify CLI[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]CLI OPTIONS:[/yellow] " + ", ".join(CLI_CHOICES))
|
||||
console.print("[yellow]FLAG OPTIONS:[/yellow] " + ", ".join(FLAG_CHOICES))
|
||||
console.print()
|
||||
|
||||
|
||||
def _parse_launch_args(args: list[str]) -> tuple[str, str, str]:
|
||||
"""Parse --cli, --cwd, --flag from launch subcommand args."""
|
||||
cli, cwd, flag_variant = "claude", ".", "default"
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--cli" and i + 1 < len(args):
|
||||
cli = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--cwd" and i + 1 < len(args):
|
||||
cwd = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--flag" and i + 1 < len(args):
|
||||
flag_variant = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
return cli, cwd, flag_variant
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route handoff subcommands: show, launch, help.
|
||||
|
||||
Returns True if handled, False if command does not match.
|
||||
"""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] == "launch":
|
||||
cli, cwd, flag_variant = _parse_launch_args(args[1:])
|
||||
if cli not in CLI_CHOICES:
|
||||
warning(f"Unknown CLI '{cli}'. Valid options: {', '.join(CLI_CHOICES)}")
|
||||
return True
|
||||
do_handoff(cli=cli, cwd=cwd, flag_variant=flag_variant)
|
||||
return True
|
||||
|
||||
print_help()
|
||||
return True
|
||||
@@ -0,0 +1,270 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: help_chat.py
|
||||
# Description: README-backed chatbot Q&A — Phase 2 of DPLAN-0136
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass help — chatbot-style Q&A over branch READMEs
|
||||
|
||||
User types `aipass help <question>`. We:
|
||||
1. Extract keywords from the question (stopword filter, no ML)
|
||||
2. Match keywords against branch names / README paths
|
||||
3. Live-read {branch}/README.md for matched branches
|
||||
4. Return concise answer sourced from matching lines with citations
|
||||
5. Always offer depth: view full README or dispatch to @branch
|
||||
|
||||
Principle: nothing cached except branch-name → README-path map.
|
||||
Every answer re-reads the real file. Stale info is the enemy.
|
||||
|
||||
No LLM in v1 — scripted keyword lookups only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.readme_map import get_readme_path, list_branches
|
||||
from aipass.cli.apps.modules import console, error, header
|
||||
from aipass.prax import logger
|
||||
|
||||
# =============================================================================
|
||||
# MODULE METADATA
|
||||
# =============================================================================
|
||||
|
||||
COMMAND = "help"
|
||||
_MODULE_NAME = "help_chat"
|
||||
_VERSION = "1.0.0"
|
||||
_DESCRIPTION = "README-backed chatbot Q&A over branch documentation"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTROSPECTION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Print module info for diagnostics."""
|
||||
console.print(f"[bold cyan]Module:[/bold cyan] {_MODULE_NAME}")
|
||||
console.print(f"[bold cyan]Command:[/bold cyan] {COMMAND}")
|
||||
console.print(f"[bold cyan]Description:[/bold cyan] {_DESCRIPTION}")
|
||||
console.print(f"[bold cyan]Version:[/bold cyan] {_VERSION}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# KEYWORD EXTRACTION
|
||||
# =============================================================================
|
||||
|
||||
_STOPWORDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"a",
|
||||
"an",
|
||||
"the",
|
||||
"is",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"what",
|
||||
"how",
|
||||
"why",
|
||||
"when",
|
||||
"where",
|
||||
"who",
|
||||
"does",
|
||||
"do",
|
||||
"can",
|
||||
"i",
|
||||
"to",
|
||||
"in",
|
||||
"of",
|
||||
"for",
|
||||
"and",
|
||||
"or",
|
||||
"not",
|
||||
"it",
|
||||
"my",
|
||||
"me",
|
||||
"you",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _extract_keywords(question: str) -> list[str]:
|
||||
"""Extract meaningful keywords from question string (stopword filter).
|
||||
|
||||
Strips punctuation, lowercases, filters stopwords and single-char words.
|
||||
No ML — pure string operations.
|
||||
"""
|
||||
words = question.lower().split()
|
||||
keywords: list[str] = []
|
||||
for word in words:
|
||||
stripped = word.strip("?.,!")
|
||||
if stripped and stripped not in _STOPWORDS and len(stripped) > 1:
|
||||
keywords.append(stripped)
|
||||
return keywords
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BRANCH MATCHING
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _match_branches(keywords: list[str]) -> list[str]:
|
||||
"""Return branches whose README likely covers the question.
|
||||
|
||||
Strategy:
|
||||
1. If a keyword exactly matches a branch name → include that branch first
|
||||
2. Score remaining available branches by keyword overlap with branch name
|
||||
3. Fallback: return all branches if no match found (broad search)
|
||||
"""
|
||||
available = list_branches()
|
||||
if not available:
|
||||
return []
|
||||
|
||||
direct: list[str] = []
|
||||
for kw in keywords:
|
||||
if kw in available and kw not in direct:
|
||||
direct.append(kw)
|
||||
|
||||
# Broad fallback — no direct matches
|
||||
if not direct:
|
||||
return available
|
||||
|
||||
# Also include branches whose names contain keyword fragments
|
||||
# (e.g. keyword "mail" → matches "ai_mail")
|
||||
extended: list[str] = list(direct)
|
||||
for branch in available:
|
||||
if branch in extended:
|
||||
continue
|
||||
for kw in keywords:
|
||||
if kw in branch or branch in kw:
|
||||
extended.append(branch)
|
||||
break
|
||||
|
||||
return extended if extended else available
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# README SEARCH (LIVE-READ)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _search_readme(readme_path: Path, keywords: list[str]) -> list[tuple[int, str]]:
|
||||
"""Live-read readme_path. Return (line_num, line_text) for matching lines.
|
||||
|
||||
Reads every call — never cached. Scores lines by number of keyword hits.
|
||||
Returns up to 5 best matches.
|
||||
"""
|
||||
try:
|
||||
with open(readme_path, encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
except OSError as exc:
|
||||
logger.warning("[help_chat] Could not read README %s: %s", readme_path, exc)
|
||||
return []
|
||||
|
||||
scored: list[tuple[int, int, str]] = [] # (score, line_num, line_text)
|
||||
for idx, line in enumerate(lines, start=1):
|
||||
line_lower = line.lower()
|
||||
score = sum(1 for kw in keywords if kw in line_lower)
|
||||
if score > 0:
|
||||
scored.append((score, idx, line.rstrip()))
|
||||
|
||||
# Sort by score descending, then by line number for tie-breaking
|
||||
scored.sort(key=lambda t: (-t[0], t[1]))
|
||||
return [(ln, text) for _, ln, text in scored[:5]]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ANSWER FORMATTING
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _format_answer(branch: str, readme_path: Path, matches: list[tuple[int, str]]) -> str:
|
||||
"""Format matched lines into a readable answer with citations.
|
||||
|
||||
Citation format: (src/aipass/{branch}/README.md:{line_num})
|
||||
"""
|
||||
# Build relative citation prefix — always use forward slashes
|
||||
# readme_path is absolute; we extract from src/aipass/ onwards
|
||||
parts = readme_path.parts
|
||||
try:
|
||||
src_idx = parts.index("src")
|
||||
rel_path = "/".join(parts[src_idx:])
|
||||
except ValueError as exc:
|
||||
logger.warning("[help_chat] Could not resolve relative path for %s: %s", readme_path, exc)
|
||||
rel_path = f"src/aipass/{branch}/README.md"
|
||||
|
||||
lines_out: list[str] = [f"[{branch}]"]
|
||||
for line_num, line_text in matches:
|
||||
citation = f"({rel_path}:{line_num})"
|
||||
lines_out.append(f" {line_text.strip()} {citation}")
|
||||
|
||||
return "\n".join(lines_out)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMMAND HANDLER
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route `aipass help [question]` — returns True if handled."""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
# Log invocation via json_handler for audit trail
|
||||
json_handler.ensure_module_jsons(_MODULE_NAME)
|
||||
|
||||
if not args:
|
||||
console.print()
|
||||
console.print("[bold]Usage:[/bold] aipass help [dim]<question>[/dim]")
|
||||
console.print("[bold]Example:[/bold] aipass help what does drone do")
|
||||
console.print()
|
||||
return True
|
||||
|
||||
question = " ".join(args)
|
||||
keywords = _extract_keywords(question)
|
||||
|
||||
if not keywords:
|
||||
error("Could not extract keywords from question. Try rephrasing.")
|
||||
return True
|
||||
|
||||
branches = _match_branches(keywords)
|
||||
|
||||
console.print()
|
||||
header(f"AIPass Help — {question!r}")
|
||||
console.print()
|
||||
|
||||
found_any = False
|
||||
for branch in branches[:3]: # limit to 3 branches per search
|
||||
readme_path = get_readme_path(branch)
|
||||
if not readme_path:
|
||||
continue
|
||||
matches = _search_readme(readme_path, keywords)
|
||||
if matches:
|
||||
found_any = True
|
||||
answer = _format_answer(branch, readme_path, matches)
|
||||
console.print(answer)
|
||||
console.print()
|
||||
|
||||
if not found_any:
|
||||
console.print("[dim]No relevant information found.[/dim]")
|
||||
console.print("[dim]Try: aipass help <broader question>[/dim]")
|
||||
console.print()
|
||||
|
||||
# Always offer depth — non-negotiable per design
|
||||
console.print("Want to go deeper?")
|
||||
console.print(" [cyan]→[/cyan] View branch README: [dim]aipass read <branch>[/dim]")
|
||||
console.print(" [cyan]→[/cyan] Connect with branch: [dim]aipass dispatch @<branch> <question>[/dim]")
|
||||
console.print()
|
||||
|
||||
json_handler.log_operation(
|
||||
"help_query",
|
||||
data={"question": question, "keywords": keywords, "branches_searched": branches[:3]},
|
||||
module_name=_MODULE_NAME,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,895 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: init_flow.py
|
||||
# Description: 12-stage guided first-run setup — aipass init command
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass init — guided first-run setup
|
||||
|
||||
12 resumable stages. State persists to .trinity/local.json setup_progress.
|
||||
Ctrl-C at any stage resumes next time from that stage.
|
||||
|
||||
Usage:
|
||||
aipass init # show progress / introspection
|
||||
aipass init run # interactive
|
||||
aipass init run --non-interactive # CI/headless, all defaults
|
||||
aipass init run --name Patrick --cli claude
|
||||
aipass init run --dry-run # walk all 12 stages, no destructive ops
|
||||
# - skips drone @spawn create (stage 8)
|
||||
# - skips tmux/wt handoff (stage 11)
|
||||
# - does NOT write .trinity/local.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from aipass.cli.apps.modules import console, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.system_detect.system_detector import (
|
||||
detect_cpu,
|
||||
detect_docker,
|
||||
detect_git,
|
||||
detect_install_method,
|
||||
detect_os,
|
||||
detect_python,
|
||||
detect_ram,
|
||||
detect_shell,
|
||||
detect_tmux,
|
||||
detect_wt,
|
||||
)
|
||||
|
||||
try:
|
||||
import questionary as _questionary # type: ignore[import-untyped]
|
||||
|
||||
HAS_QUESTIONARY = True
|
||||
except ImportError as _qe:
|
||||
logger.info("[init_flow] questionary not installed, using numbered-list menus: %s", _qe)
|
||||
_questionary = None # type: ignore[assignment]
|
||||
HAS_QUESTIONARY = False
|
||||
|
||||
COMMAND = "init"
|
||||
TOTAL_STAGES = 12
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _get_local_json_path() -> Path:
|
||||
"""Always resolve .trinity/local.json from CWD (user's project)."""
|
||||
return Path.cwd() / ".trinity" / "local.json"
|
||||
|
||||
|
||||
CLI_CHOICES = ["claude", "codex", "gemini", "other"]
|
||||
# Flag variants for CLI launch — these are user-facing config values, not code-level flags
|
||||
FLAG_CHOICES = ["default", "skip-permissions"]
|
||||
STYLE_CHOICES = ["building-my-own-project", "improving-aipass", "just-exploring"]
|
||||
|
||||
|
||||
# --- LOCAL JSON HELPERS ---
|
||||
def _read_local_json() -> dict:
|
||||
"""Read .trinity/local.json, returning empty dict on failure."""
|
||||
local_json = _get_local_json_path()
|
||||
if not local_json.exists() or local_json.stat().st_size == 0:
|
||||
return {}
|
||||
try:
|
||||
with open(local_json, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("[init_flow] local.json read error: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _fire_file_deleted(path: str) -> None:
|
||||
"""Fire trigger event for temp file deletion, ignoring ImportError."""
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
|
||||
trigger.fire("file_deleted", path=path, reason="write_failure_cleanup")
|
||||
except ImportError as exc:
|
||||
logger.warning("[init_flow] trigger unavailable for file_deleted event: %s", exc)
|
||||
|
||||
|
||||
def _write_local_json(data: dict) -> None:
|
||||
"""Write .trinity/local.json atomically via temp-file rename."""
|
||||
local_json = _get_local_json_path()
|
||||
dir_ = local_json.parent
|
||||
dir_.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(dir_), prefix=".local_", suffix=".json.tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp_path, local_json)
|
||||
except OSError as exc:
|
||||
logger.warning("[init_flow] write failed, cleaning up %s: %s", tmp_path, exc)
|
||||
_fire_file_deleted(tmp_path)
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError as _ue:
|
||||
logger.warning("[init_flow] temp file cleanup failed: %s", _ue)
|
||||
raise
|
||||
|
||||
|
||||
def _get_setup_progress() -> dict:
|
||||
"""Return setup_progress section from local.json."""
|
||||
data = _read_local_json()
|
||||
return data.get("setup_progress", {"last_completed_stage": 0, "stages": {}})
|
||||
|
||||
|
||||
def _get_last_completed_stage() -> int:
|
||||
"""Return the last completed stage number (0 if fresh)."""
|
||||
return _get_setup_progress().get("last_completed_stage", 0)
|
||||
|
||||
|
||||
def _save_stage(stage: int, stage_data: dict | None = None, dry_run: bool = False) -> None:
|
||||
"""Persist stage completion to local.json setup_progress.
|
||||
|
||||
When dry_run=True the call is a no-op (pure read flow).
|
||||
"""
|
||||
if dry_run:
|
||||
logger.info("[init_flow] dry-run: skipping _save_stage(%d)", stage)
|
||||
return
|
||||
data = _read_local_json()
|
||||
progress = data.get("setup_progress", {"last_completed_stage": 0, "stages": {}})
|
||||
progress["last_completed_stage"] = stage
|
||||
progress["stages"][str(stage)] = {
|
||||
"status": "done",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
**(stage_data or {}),
|
||||
}
|
||||
data["setup_progress"] = progress
|
||||
_write_local_json(data)
|
||||
|
||||
|
||||
def _prompt(msg: str, default: str = "") -> str:
|
||||
"""Simple input prompt with optional default."""
|
||||
display = f"{msg} [{default}]: " if default else f"{msg}: "
|
||||
try:
|
||||
val = input(display).strip()
|
||||
return val if val else default
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
def _choose(msg: str, choices: List[str], default: str | None = None) -> str:
|
||||
"""Arrow-key menu via questionary, or numbered-list fallback."""
|
||||
if HAS_QUESTIONARY and _questionary is not None:
|
||||
try:
|
||||
result = _questionary.select(msg, choices=choices, default=default).ask()
|
||||
if result is None:
|
||||
raise KeyboardInterrupt
|
||||
return result
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] questionary.select failed, using fallback: %s", exc)
|
||||
|
||||
console.print(f"\n{msg}")
|
||||
for i, choice in enumerate(choices, 1):
|
||||
marker = " [dim](default)[/dim]" if choice == default else ""
|
||||
console.print(f" {i}. {choice}{marker}")
|
||||
default_idx = str(choices.index(default) + 1) if default in choices else "1"
|
||||
while True:
|
||||
raw = _prompt("Choice (number)", default_idx)
|
||||
try:
|
||||
idx = int(raw) - 1
|
||||
if 0 <= idx < len(choices):
|
||||
return choices[idx]
|
||||
except ValueError as exc:
|
||||
logger.info("[init_flow] invalid menu input %r: %s", raw, exc)
|
||||
console.print("[red]Invalid choice.[/red]")
|
||||
|
||||
|
||||
# --- STAGE FUNCTIONS ---
|
||||
def stage_1_welcome(dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Print welcome banner and greeting."""
|
||||
console.print()
|
||||
console.print("[bold cyan] █████╗ ██╗██████╗ █████╗ ███████╗███████╗[/bold cyan]")
|
||||
console.print("[bold cyan] ██╔══██╗██║██╔══██╗██╔══██╗██╔════╝██╔════╝[/bold cyan]")
|
||||
console.print("[bold cyan] ███████║██║██████╔╝███████║███████╗███████╗ [/bold cyan]")
|
||||
console.print("[bold cyan] ██╔══██║██║██╔═══╝ ██╔══██║╚════██║╚════██║[/bold cyan]")
|
||||
console.print("[bold cyan] ██║ ██║██║██║ ██║ ██║███████║███████║[/bold cyan]")
|
||||
console.print("[bold cyan] ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[bold]Hi, I am AIPass — your AI passport and front door to the ecosystem.[/bold]")
|
||||
console.print("Let's walk through setup together. This takes about 5 minutes.")
|
||||
if shutil.which("drone"):
|
||||
console.print()
|
||||
console.print(
|
||||
"[dim]Tip: Open another terminal and run [cyan]drone @prax monitor run[/cyan] to watch activity live.[/dim]"
|
||||
)
|
||||
if dry_run:
|
||||
console.print("[yellow]\\[dry-run][/yellow] No state will be written, no subprocesses launched.")
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 1/12[/bold cyan] — Welcome")
|
||||
_save_stage(1, dry_run=dry_run)
|
||||
return {}
|
||||
|
||||
|
||||
def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Detect OS, Python, shell, RAM, CPU, install method, and optional tools."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 2/12[/bold cyan] — System detection")
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
py = detect_python()
|
||||
git = detect_git()
|
||||
sh = detect_shell()
|
||||
os_info = detect_os()
|
||||
ram = detect_ram()
|
||||
cpu = detect_cpu()
|
||||
install = detect_install_method()
|
||||
has_tmux = detect_tmux()
|
||||
has_wt = detect_wt()
|
||||
has_docker = detect_docker()
|
||||
|
||||
table = Table(show_header=False, box=None)
|
||||
table.add_column("key", style="cyan")
|
||||
table.add_column("value")
|
||||
table.add_row("OS", f"{os_info['os_name']} {os_info['release']}")
|
||||
table.add_row("Python", py["version"])
|
||||
table.add_row("shell", sh["name"])
|
||||
table.add_row("RAM", f"{ram['total_gb']} GB")
|
||||
table.add_row("CPU", f"{cpu['count']} cores")
|
||||
table.add_row("install", install)
|
||||
table.add_row("git", git["version"] if git["found"] else "not found")
|
||||
table.add_row("tmux", "yes" if has_tmux else "no")
|
||||
if sys.platform == "win32":
|
||||
table.add_row("wt.exe", "yes" if has_wt else "no")
|
||||
table.add_row("docker", "yes" if has_docker else "no")
|
||||
console.print(table)
|
||||
|
||||
console.print()
|
||||
console.print(f"You are on [cyan]{os_info['os_name']}[/cyan] with Python [cyan]{py['version']}[/cyan].")
|
||||
install_labels = {"dev": "development (editable source)", "pip": "pip", "clone": "git clone", "unknown": "unknown"}
|
||||
console.print(f"Install type: [cyan]{install_labels.get(install, install)}[/cyan]")
|
||||
|
||||
system_data: Dict[str, Any] = {
|
||||
"os": os_info["os_name"],
|
||||
"python": py["version"],
|
||||
"shell": sh["name"],
|
||||
"ram_gb": ram["total_gb"],
|
||||
"install": install,
|
||||
"has_docker": has_docker,
|
||||
"has_tmux": has_tmux,
|
||||
}
|
||||
_save_stage(2, system_data, dry_run=dry_run)
|
||||
return system_data
|
||||
|
||||
|
||||
def stage_3_doctor(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Run aipass doctor health checks inline."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 3/12[/bold cyan] — System health check")
|
||||
|
||||
error_count = 0
|
||||
provider_gaps: Dict[str, Any] = {}
|
||||
try:
|
||||
from aipass.aipass.apps.modules import doctor
|
||||
|
||||
error_count = doctor.run_doctor()
|
||||
try:
|
||||
for r in doctor._check_provider_manifest():
|
||||
if r.glyph != doctor.GLYPH_PASS:
|
||||
provider_gaps[r.label] = r.detail
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] provider manifest check failed: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] doctor run failed: %s", exc)
|
||||
warning(f"Doctor check skipped: {exc}")
|
||||
|
||||
if error_count > 0:
|
||||
warning(f"{error_count} issue(s) found above — review when convenient.")
|
||||
else:
|
||||
console.print("[green]✓[/green] Health check passed.")
|
||||
|
||||
_save_stage(3, {"doctor_errors": error_count}, dry_run=dry_run)
|
||||
return {"doctor_errors": error_count, "provider_gaps": provider_gaps}
|
||||
|
||||
|
||||
def stage_4_user_profile(
|
||||
non_interactive: bool = False,
|
||||
name_override: str | None = None,
|
||||
system_data: dict | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Collect user name and OS, save to profile."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 4/12[/bold cyan] — User profile")
|
||||
|
||||
from aipass.aipass.apps.modules import profile as profile_mod
|
||||
|
||||
if name_override:
|
||||
name = name_override
|
||||
elif non_interactive:
|
||||
name = "User"
|
||||
else:
|
||||
name = _prompt("What's your name?", "User")
|
||||
|
||||
os_name = (system_data or {}).get("os") or detect_os()["os_name"]
|
||||
|
||||
existing = profile_mod.get_user_profile()
|
||||
existing.update(
|
||||
{
|
||||
"name": name,
|
||||
"os": os_name,
|
||||
"shell": (system_data or {}).get("shell"),
|
||||
"install_method": (system_data or {}).get("install"),
|
||||
"first_seen": existing.get("first_seen") or datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would save profile: {existing}")
|
||||
else:
|
||||
profile_mod.save_profile(existing)
|
||||
|
||||
console.print(f"[green]✓[/green] Hello, {name}!")
|
||||
_save_stage(4, {"name": name}, dry_run=dry_run)
|
||||
return {"name": name}
|
||||
|
||||
|
||||
def stage_5_style_questions(
|
||||
non_interactive: bool = False,
|
||||
style_override: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Ask what the user wants to do — routes tone of later stages."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 5/12[/bold cyan] — What brings you here?")
|
||||
|
||||
if style_override and style_override in STYLE_CHOICES:
|
||||
style = style_override
|
||||
elif non_interactive:
|
||||
style = STYLE_CHOICES[0]
|
||||
else:
|
||||
style = _choose("What are you looking to do?", STYLE_CHOICES, default=STYLE_CHOICES[0])
|
||||
|
||||
console.print(f"[green]✓[/green] Got it: {style}")
|
||||
_save_stage(5, {"style": style}, dry_run=dry_run)
|
||||
return {"style": style}
|
||||
|
||||
|
||||
def stage_6_tool_choice(
|
||||
non_interactive: bool = False,
|
||||
cli_override: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Choose CLI tool and launch flag variant."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 6/12[/bold cyan] — CLI tool choice")
|
||||
|
||||
if cli_override and cli_override in CLI_CHOICES:
|
||||
cli_choice = cli_override
|
||||
elif non_interactive:
|
||||
cli_choice = "claude"
|
||||
else:
|
||||
cli_choice = _choose("Which CLI tool do you use?", CLI_CHOICES, default="claude")
|
||||
|
||||
if non_interactive:
|
||||
flag_variant = "default"
|
||||
else:
|
||||
flag_variant = _choose(
|
||||
f"How should I launch {cli_choice}?",
|
||||
FLAG_CHOICES,
|
||||
default="default",
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] {cli_choice} ({flag_variant})")
|
||||
_save_stage(6, {"cli": cli_choice, "flag_variant": flag_variant}, dry_run=dry_run)
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would save preferred_cli={cli_choice} to profile")
|
||||
else:
|
||||
try:
|
||||
from aipass.aipass.apps.modules import profile as profile_mod
|
||||
|
||||
p = profile_mod.get_user_profile()
|
||||
p["preferred_cli"] = cli_choice
|
||||
profile_mod.save_profile(p)
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] could not persist cli to profile: %s", exc)
|
||||
|
||||
return {"cli": cli_choice, "flag_variant": flag_variant}
|
||||
|
||||
|
||||
def stage_7_docker_offer(
|
||||
non_interactive: bool = False,
|
||||
no_docker: bool = False,
|
||||
has_docker: bool | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Offer Docker sandbox test if Docker is detected."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 7/12[/bold cyan] — Docker")
|
||||
|
||||
if has_docker is None:
|
||||
has_docker = detect_docker()
|
||||
|
||||
if not has_docker or no_docker or non_interactive:
|
||||
reason = "not detected" if not has_docker else ("--no-docker" if no_docker else "non-interactive")
|
||||
console.print(f"[dim]Docker offer skipped ({reason}).[/dim]")
|
||||
_save_stage(7, {"docker": "skipped"}, dry_run=dry_run)
|
||||
return {"docker": "skipped"}
|
||||
|
||||
raw = _prompt("Test in a Docker sandbox? [y/N]", "N")
|
||||
use_docker = raw.lower() in ("y", "yes")
|
||||
result = "yes" if use_docker else "no"
|
||||
console.print(f"[green]✓[/green] Docker: {result}")
|
||||
_save_stage(7, {"docker": result}, dry_run=dry_run)
|
||||
return {"docker": result}
|
||||
|
||||
|
||||
def stage_8_first_agent(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Create the user's first AI agent via drone @spawn."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 8/12[/bold cyan] — Create your first agent")
|
||||
console.print("Let's create your first AI agent (citizen).")
|
||||
|
||||
if non_interactive:
|
||||
agent_name = "my-agent"
|
||||
else:
|
||||
agent_name = _prompt("Agent name (letters, hyphens, no spaces)", "my-agent") or "my-agent"
|
||||
|
||||
agent_path = f"src/{agent_name}"
|
||||
console.print(f"Running: [cyan]drone @spawn create {agent_path}[/cyan]")
|
||||
|
||||
success = False
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would run: drone @spawn create {agent_path}")
|
||||
success = True
|
||||
else:
|
||||
try:
|
||||
proc = subprocess.run(["drone", "@spawn", "create", agent_path], timeout=60)
|
||||
success = proc.returncode == 0
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[init_flow] drone not found in stage 8: %s", exc)
|
||||
warning("drone not found — skipping agent creation.")
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[init_flow] spawn timed out in stage 8: %s", exc)
|
||||
warning("spawn timed out — agent may still be created.")
|
||||
|
||||
if success:
|
||||
console.print(f"[green]✓[/green] Agent created at {agent_path}")
|
||||
|
||||
_save_stage(8, {"agent_name": agent_name, "agent_path": agent_path, "success": success}, dry_run=dry_run)
|
||||
return {"agent_name": agent_name, "agent_path": agent_path}
|
||||
|
||||
|
||||
def stage_9_ping_sweep(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Ping all registered branches via test-convention emails."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 9/12[/bold cyan] — Pinging agents")
|
||||
|
||||
from aipass.aipass.apps.handlers import ping_sweep
|
||||
|
||||
branches = ping_sweep._discover_branches()
|
||||
if not branches:
|
||||
console.print("[dim] No branches registered yet — skipping ping sweep.[/dim]")
|
||||
_save_stage(9, {"results": {}, "skipped": True}, dry_run=dry_run)
|
||||
return {"ping_results": {}}
|
||||
|
||||
# Standalone projects can't ping agents via drone (drone only knows AIPass's registry).
|
||||
# Only attempt ping if we're inside the AIPass source tree.
|
||||
aipass_registry = _BRANCH_ROOT.parent / "AIPASS_REGISTRY.json"
|
||||
if not aipass_registry.exists():
|
||||
if len(branches) == 1:
|
||||
console.print("[dim] 1 agent registered. Ping skipped — ping is for multi-agent projects.[/dim]")
|
||||
else:
|
||||
console.print(f"[dim] Found {len(branches)} agent(s) in this project.[/dim]")
|
||||
console.print("[dim] Ping skipped — agents will be reachable after handoff (next step).[/dim]")
|
||||
_save_stage(9, {"results": {}, "skipped_standalone": True}, dry_run=dry_run)
|
||||
return {"ping_results": {}}
|
||||
|
||||
console.print(f"[dim] Found {len(branches)} agent(s). Checking reachability...[/dim]")
|
||||
console.print(
|
||||
"[dim] (Agents with a running session will auto-ack; new agents will time out — that's normal.)[/dim]"
|
||||
)
|
||||
|
||||
results = ping_sweep.sweep_all_branches(timeout=10)
|
||||
|
||||
for branch, status in results.items():
|
||||
if status == "ack":
|
||||
glyph = "[green]✓[/green]"
|
||||
elif status == "timeout":
|
||||
glyph = "[yellow]—[/yellow]"
|
||||
else:
|
||||
glyph = "[red]✗[/red]"
|
||||
label = "reachable" if status == "ack" else "not running" if status == "timeout" else "error"
|
||||
console.print(f" {glyph} @{branch}: {label}")
|
||||
|
||||
summary = ping_sweep.sweep_summary(results)
|
||||
console.print(f" {summary}")
|
||||
_save_stage(9, {"results": results}, dry_run=dry_run)
|
||||
return {"ping_results": results}
|
||||
|
||||
|
||||
def stage_10_smoke_test(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Verify drone and aipass binaries are on PATH."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 10/12[/bold cyan] — Smoke test")
|
||||
|
||||
drone_bin = shutil.which("drone")
|
||||
aipass_bin = shutil.which("aipass")
|
||||
|
||||
if drone_bin:
|
||||
console.print(f"[green]✓[/green] drone: {drone_bin}")
|
||||
else:
|
||||
warning("drone not on PATH — run: pip install -e .")
|
||||
|
||||
if aipass_bin:
|
||||
console.print(f"[green]✓[/green] aipass: {aipass_bin}")
|
||||
else:
|
||||
warning("aipass not on PATH — run: pip install -e .")
|
||||
|
||||
_save_stage(10, {"drone": drone_bin, "aipass": aipass_bin}, dry_run=dry_run)
|
||||
return {"drone": drone_bin, "aipass": aipass_bin}
|
||||
|
||||
|
||||
def stage_11_handoff(
|
||||
cli_choice: str = "claude",
|
||||
flag_variant: str = "default",
|
||||
agent_path: str = "src/my-agent",
|
||||
non_interactive: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Launch user's chosen CLI in a new session via handoff module."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 11/12[/bold cyan] — Handoff")
|
||||
|
||||
init_prompt = "I just completed aipass init. I am ready to start. What should I do first?"
|
||||
|
||||
console.print()
|
||||
console.print(" Your agent is ready. The next step opens an interactive session with it.")
|
||||
console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]")
|
||||
|
||||
if dry_run:
|
||||
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
|
||||
|
||||
command = build_manual_command(cli_choice, init_prompt, agent_path, flag_variant)
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would launch handoff: {command}")
|
||||
launched = False
|
||||
elif non_interactive:
|
||||
from aipass.aipass.apps.modules import handoff as handoff_mod
|
||||
|
||||
launched = handoff_mod.do_handoff(
|
||||
cli=cli_choice,
|
||||
prompt=init_prompt,
|
||||
cwd=agent_path,
|
||||
flag_variant=flag_variant,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
|
||||
|
||||
command = build_manual_command(cli_choice, init_prompt, agent_path, flag_variant)
|
||||
else:
|
||||
console.print()
|
||||
input(" Press Enter to chat with your agent...")
|
||||
from aipass.aipass.apps.modules import handoff as handoff_mod
|
||||
|
||||
launched = handoff_mod.do_handoff(
|
||||
cli=cli_choice,
|
||||
prompt=init_prompt,
|
||||
cwd=agent_path,
|
||||
flag_variant=flag_variant,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
|
||||
|
||||
command = build_manual_command(cli_choice, init_prompt, agent_path, flag_variant)
|
||||
|
||||
_save_stage(11, {"command": command, "launched": launched}, dry_run=dry_run)
|
||||
return {"handoff_command": command, "launched": launched}
|
||||
|
||||
|
||||
def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bool = False) -> None:
|
||||
"""Drop init_report.json into the agent's dropbox."""
|
||||
if dry_run or not agent_path:
|
||||
return
|
||||
dropbox = Path(agent_path) / "dropbox"
|
||||
dropbox.mkdir(parents=True, exist_ok=True)
|
||||
system_data = {k: accumulated.get(k) for k in ("os", "python", "shell", "ram_gb", "install") if accumulated.get(k)}
|
||||
report = {
|
||||
"created": datetime.now(timezone.utc).isoformat(),
|
||||
"project_name": Path.cwd().name,
|
||||
"project_path": str(Path.cwd()),
|
||||
"agent_name": accumulated.get("agent_name", Path(agent_path).name.upper()),
|
||||
"agent_number": 1,
|
||||
"is_orchestrator": True,
|
||||
"install_method": accumulated.get("install", "unknown"),
|
||||
"cli_choice": accumulated.get("cli", "claude"),
|
||||
"total_agents": 1,
|
||||
"system": system_data,
|
||||
"note": "You are the first agent created in this project. You are the orchestrator. After dispatching work to other agents, monitor them with: drone @devpulse watchdog agent @target",
|
||||
}
|
||||
provider_gaps = accumulated.get("provider_gaps", {})
|
||||
if provider_gaps:
|
||||
report["provider_gaps"] = provider_gaps
|
||||
report["provider_action"] = "Provider settings need configuring. Tell the user what is missing and point them to provider_manifest.json for details."
|
||||
report_path = dropbox / "init_report.json"
|
||||
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
logger.info("[init_flow] init report written to %s", report_path)
|
||||
|
||||
|
||||
def stage_12_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Print completion summary and drop init report."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 12/12[/bold cyan] — Done!")
|
||||
console.print()
|
||||
console.print("[bold green]✓ Setup complete![/bold green]")
|
||||
console.print()
|
||||
console.print(" [cyan]aipass help[/cyan] [dim]# Ask any question[/dim]")
|
||||
console.print(" [cyan]aipass doctor[/cyan] [dim]# Check system health[/dim]")
|
||||
console.print(" [cyan]aipass profile[/cyan] [dim]# View your profile[/dim]")
|
||||
console.print()
|
||||
if accumulated:
|
||||
_write_init_report(accumulated.get("agent_path", ""), accumulated, dry_run=dry_run)
|
||||
_save_stage(12, dry_run=dry_run)
|
||||
return {}
|
||||
|
||||
|
||||
# --- MAIN RUNNER ---
|
||||
def _preflight_check() -> str | None:
|
||||
"""Return an error message if CWD is unsafe for init, else None."""
|
||||
cwd = Path.cwd()
|
||||
# Block if inside an agent directory
|
||||
if (cwd / ".trinity" / "passport.json").is_file():
|
||||
return (
|
||||
"This directory is an agent branch (has .trinity/passport.json).\n"
|
||||
"Agents are managed by 'drone @spawn', not 'aipass init'."
|
||||
)
|
||||
# Block if inside an existing AIPass project (registry above us)
|
||||
for parent in [cwd] + list(cwd.parents):
|
||||
for f in parent.iterdir():
|
||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||
return (
|
||||
f"Already inside an AIPass project (found {f.name} at {parent}).\n"
|
||||
"Use 'aipass init update' to upgrade an existing project."
|
||||
)
|
||||
if parent == parent.parent:
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def run_init(
|
||||
non_interactive: bool = False,
|
||||
name: str | None = None,
|
||||
cli: str | None = None,
|
||||
style: str | None = None,
|
||||
no_docker: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> int:
|
||||
"""Run the 12-stage init flow. Returns 0 on success."""
|
||||
# Pre-flight: refuse to run inside existing projects or agent dirs
|
||||
err = _preflight_check()
|
||||
if err:
|
||||
console.print(f"[red]✗[/red] {err}")
|
||||
return 1
|
||||
|
||||
# Ensure scaffold exists (creates registry, .trinity, etc. if missing)
|
||||
cwd = Path.cwd()
|
||||
if not list(cwd.glob("*_REGISTRY.json")):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import init_project
|
||||
|
||||
if not dry_run:
|
||||
init_project(cwd)
|
||||
else:
|
||||
console.print("[yellow]\\[dry-run][/yellow] would create project scaffold")
|
||||
|
||||
# In dry-run we ignore on-disk progress so the full flow always walks.
|
||||
last_done = 0 if dry_run else _get_last_completed_stage()
|
||||
|
||||
if last_done >= TOTAL_STAGES:
|
||||
console.print("[green]✓[/green] Setup already complete.")
|
||||
console.print("[dim]Run 'aipass doctor' to check status.[/dim]")
|
||||
return 0
|
||||
|
||||
if last_done > 0:
|
||||
warning(f"Resuming from stage {last_done + 1}...")
|
||||
|
||||
accumulated: Dict[str, Any] = {}
|
||||
|
||||
stage_fns = [
|
||||
(1, lambda: stage_1_welcome(dry_run=dry_run)),
|
||||
(2, lambda: stage_2_system_detect(non_interactive, dry_run=dry_run)),
|
||||
(3, lambda: stage_3_doctor(non_interactive, dry_run=dry_run)),
|
||||
(4, lambda: stage_4_user_profile(non_interactive, name, accumulated, dry_run=dry_run)),
|
||||
(5, lambda: stage_5_style_questions(non_interactive, style, dry_run=dry_run)),
|
||||
(6, lambda: stage_6_tool_choice(non_interactive, cli, dry_run=dry_run)),
|
||||
(7, lambda: stage_7_docker_offer(non_interactive, no_docker, accumulated.get("has_docker"), dry_run=dry_run)),
|
||||
(8, lambda: stage_8_first_agent(non_interactive, dry_run=dry_run)),
|
||||
(9, lambda: stage_9_ping_sweep(non_interactive, dry_run=dry_run)),
|
||||
(10, lambda: stage_10_smoke_test(non_interactive, dry_run=dry_run)),
|
||||
(
|
||||
11,
|
||||
lambda: stage_11_handoff(
|
||||
accumulated.get("cli", "claude"),
|
||||
accumulated.get("flag_variant", "default"),
|
||||
accumulated.get("agent_path", "src/my-agent"),
|
||||
non_interactive,
|
||||
dry_run=dry_run,
|
||||
),
|
||||
),
|
||||
(12, lambda: stage_12_done(accumulated=accumulated, dry_run=dry_run)),
|
||||
]
|
||||
|
||||
for stage_num, fn in stage_fns:
|
||||
if stage_num <= last_done:
|
||||
continue
|
||||
try:
|
||||
result = fn() or {}
|
||||
accumulated.update(result)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("[init_flow] init paused at stage %d by user", stage_num)
|
||||
warning(f"Paused at stage {stage_num}. Run 'aipass init run' to resume.")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] stage %d error: %s", stage_num, exc)
|
||||
warning(f"Stage {stage_num} error: {exc} — continuing.")
|
||||
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# --- INTROSPECTION + HELP ---
|
||||
def print_introspection() -> None:
|
||||
"""Show module info and current setup progress."""
|
||||
progress = _get_setup_progress()
|
||||
last = progress.get("last_completed_stage", 0)
|
||||
console.print()
|
||||
console.print("[bold cyan]init_flow Module[/bold cyan]")
|
||||
console.print("12-stage guided first-run setup, resumable")
|
||||
console.print()
|
||||
if last == 0:
|
||||
console.print("[dim]Setup not started. Run: aipass init run[/dim]")
|
||||
elif last >= TOTAL_STAGES:
|
||||
console.print("[green]✓[/green] Setup complete.")
|
||||
else:
|
||||
console.print(f"[yellow]In progress:[/yellow] stage {last}/{TOTAL_STAGES} completed.")
|
||||
console.print(f"[dim]Run 'aipass init run' to resume from stage {last + 1}.[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the init command."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass init[/bold cyan] — guided first-run setup")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass init run[/green] [dim]# interactive[/dim]")
|
||||
console.print(" [green]aipass init run --non-interactive[/green] [dim]# CI/headless[/dim]")
|
||||
console.print(" [green]aipass init run --name Patrick[/green] [dim]# pre-fill name[/dim]")
|
||||
console.print(" [green]aipass init run --cli claude[/green] [dim]# pre-fill CLI[/dim]")
|
||||
console.print(" [green]aipass init run --no-docker[/green] [dim]# skip docker offer[/dim]")
|
||||
console.print(" [green]aipass init run --dry-run[/green] [dim]# walk all stages, no writes[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]STAGES:[/yellow] 12 stages, each saved — resume on ctrl-C")
|
||||
console.print()
|
||||
|
||||
|
||||
# --- COMMAND HANDLER ---
|
||||
def _handle_init_scaffold(args: list[str]) -> int:
|
||||
"""Handle `aipass init [target] [name]` — instant project scaffold."""
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import init_project
|
||||
|
||||
target = Path(args[0]) if args else Path.cwd()
|
||||
project_name = args[1] if len(args) > 1 else None
|
||||
try:
|
||||
result = init_project(target, project_name)
|
||||
console.print(f"[green]✓[/green] Project initialized at {target}")
|
||||
json_handler.log_operation("aipass_init", {"target": str(target), "result": result})
|
||||
return 0
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] scaffold failed: %s", exc)
|
||||
console.print(f"[red]✗[/red] Init failed: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
def _handle_init_update(args: list[str]) -> int:
|
||||
"""Handle `aipass init update [target]` — refresh managed scaffold files."""
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import update_project
|
||||
|
||||
target = Path(args[0]) if args else Path.cwd()
|
||||
try:
|
||||
result = update_project(target)
|
||||
console.print(f"[green]✓[/green] Project updated at {target}")
|
||||
json_handler.log_operation("aipass_init_update", {"target": str(target), "result": result})
|
||||
return 0
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] update failed: %s", exc)
|
||||
console.print(f"[red]✗[/red] Update failed: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
def _handle_init_agent(args: list[str]) -> int:
|
||||
"""Handle `aipass init agent <name>` — create a new agent via spawn."""
|
||||
if not args:
|
||||
console.print("[red]✗[/red] Usage: aipass init agent <name>")
|
||||
return 1
|
||||
agent_name = args[0]
|
||||
import subprocess as _sp
|
||||
|
||||
cmd = ["drone", "@spawn", "create", f"src/{agent_name}"]
|
||||
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
|
||||
result = _sp.run(cmd, capture_output=False)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route init subcommands. Returns True if handled, False otherwise."""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
err = _preflight_check()
|
||||
if err:
|
||||
console.print(f"[red]✗[/red] {err}")
|
||||
sys.exit(1)
|
||||
sys.exit(_handle_init_scaffold([]))
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] == "agent":
|
||||
sys.exit(_handle_init_agent(args[1:]))
|
||||
return True
|
||||
|
||||
if args[0] == "update":
|
||||
sys.exit(_handle_init_update(args[1:]))
|
||||
return True
|
||||
|
||||
if args[0] == "run" or args[0].startswith("--"):
|
||||
run_args = args[1:] if args[0] == "run" else args
|
||||
non_interactive = "--non-interactive" in run_args
|
||||
|
||||
def _flag_value(flag: str) -> str | None:
|
||||
"""Extract the value after a named flag, or None if absent."""
|
||||
if flag not in run_args:
|
||||
return None
|
||||
idx = run_args.index(flag)
|
||||
return run_args[idx + 1] if idx + 1 < len(run_args) else None
|
||||
|
||||
name = _flag_value("--name")
|
||||
cli = _flag_value("--cli")
|
||||
style = _flag_value("--style")
|
||||
no_docker = "--no-docker" in run_args
|
||||
dry_run = "--dry-run" in run_args
|
||||
|
||||
result = run_init(
|
||||
non_interactive=non_interactive,
|
||||
name=name,
|
||||
cli=cli,
|
||||
style=style,
|
||||
no_docker=no_docker,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
json_handler.log_operation(
|
||||
"init_run",
|
||||
{"non_interactive": non_interactive, "dry_run": dry_run, "exit": result},
|
||||
)
|
||||
sys.exit(result)
|
||||
return True
|
||||
|
||||
# Positional args = target path and/or project name for scaffold
|
||||
err = _preflight_check()
|
||||
if err:
|
||||
console.print(f"[red]✗[/red] {err}")
|
||||
sys.exit(1)
|
||||
sys.exit(_handle_init_scaffold(args))
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
handle_command("init", sys.argv[1:])
|
||||
@@ -0,0 +1,184 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: profile.py
|
||||
# Description: User profile read/write — aipass profile command
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass profile — show/edit what aipass remembers about the user
|
||||
|
||||
Reads and writes the `user` section of .trinity/local.json.
|
||||
Commands:
|
||||
aipass profile — pretty-print current profile
|
||||
aipass profile set <f> <v> — update a field
|
||||
aipass profile clear — reset (confirm required)
|
||||
aipass profile clear --yes — reset without confirmation (dev/CI)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
COMMAND = "profile"
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||
_LOCAL_JSON = _BRANCH_ROOT / ".trinity" / "local.json"
|
||||
|
||||
USER_FIELDS = ["name", "os", "shell", "preferred_cli", "install_method", "first_seen"]
|
||||
|
||||
|
||||
def _read_local_json() -> dict:
|
||||
if not _LOCAL_JSON.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(_LOCAL_JSON, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("[profile] local.json read error: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _fire_file_deleted(path: str) -> None:
|
||||
"""Fire trigger event for temp file deletion, ignoring ImportError."""
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
|
||||
trigger.fire("file_deleted", path=path, reason="write_failure_cleanup")
|
||||
except ImportError as exc:
|
||||
logger.warning("[profile] trigger unavailable for file_deleted event: %s", exc)
|
||||
|
||||
|
||||
def _write_local_json(data: dict) -> None:
|
||||
dir_ = _LOCAL_JSON.parent
|
||||
dir_.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(dir_), prefix=".local_", suffix=".json.tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp_path, _LOCAL_JSON)
|
||||
except OSError as exc:
|
||||
logger.warning("[profile] write failed, cleaning up temp file: %s", tmp_path)
|
||||
_fire_file_deleted(tmp_path)
|
||||
os.unlink(tmp_path)
|
||||
logger.warning("[profile] local.json write error: %s", exc)
|
||||
raise
|
||||
|
||||
|
||||
def get_user_profile() -> dict:
|
||||
"""Return user section from local.json, creating defaults if absent."""
|
||||
data = _read_local_json()
|
||||
if "user" not in data:
|
||||
data["user"] = {f: None for f in USER_FIELDS}
|
||||
_write_local_json(data)
|
||||
return data.get("user", {})
|
||||
|
||||
|
||||
def save_profile(profile: dict) -> None:
|
||||
"""Write user section to local.json, preserving all other sections."""
|
||||
data = _read_local_json()
|
||||
data["user"] = profile
|
||||
_write_local_json(data)
|
||||
json_handler.log_operation("profile_save", {"fields": list(profile.keys())})
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display current user profile."""
|
||||
from rich.table import Table
|
||||
|
||||
profile = get_user_profile()
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass profile[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
table = Table(show_header=True, header_style="bold yellow")
|
||||
table.add_column("Field", style="cyan", width=20)
|
||||
table.add_column("Value")
|
||||
for field in USER_FIELDS:
|
||||
value = profile.get(field)
|
||||
display = str(value) if value is not None else "[dim]—[/dim]"
|
||||
table.add_row(field, display)
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("[dim]Use 'aipass profile set <field> <value>' to update.[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the profile command."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass profile[/bold cyan] — user memory read/write")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass profile[/green] [dim]# Show current profile[/dim]")
|
||||
console.print(" [green]aipass profile set <field> <value>[/green] [dim]# Update a field[/dim]")
|
||||
console.print(" [green]aipass profile clear[/green] [dim]# Reset profile (interactive confirm)[/dim]")
|
||||
console.print(" [green]aipass profile clear --yes[/green] [dim]# Reset profile (no confirm)[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]FIELDS:[/yellow] " + ", ".join(USER_FIELDS))
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route profile subcommands: show, set <field> <value>, clear, help.
|
||||
|
||||
Returns True if handled, False if command does not match.
|
||||
"""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] == "set":
|
||||
if len(args) < 3:
|
||||
error("Usage: aipass profile set <field> <value>")
|
||||
return True
|
||||
field, value = args[1], args[2]
|
||||
if field not in USER_FIELDS:
|
||||
console.print(f"[red]Unknown field: {field}[/red]")
|
||||
console.print("[dim]Valid fields: " + ", ".join(USER_FIELDS) + "[/dim]")
|
||||
return True
|
||||
profile = get_user_profile()
|
||||
profile[field] = value
|
||||
save_profile(profile)
|
||||
console.print(f"[green]✓[/green] {field} = {value}")
|
||||
return True
|
||||
|
||||
if args[0] == "clear":
|
||||
# --yes / -y skips the interactive confirmation (CI, dev resets,
|
||||
# piped invocations where stdin isn't a TTY).
|
||||
skip_confirm = any(a in ("--yes", "-y") for a in args[1:])
|
||||
if skip_confirm:
|
||||
save_profile({f: None for f in USER_FIELDS})
|
||||
console.print("[green]✓[/green] Profile cleared.")
|
||||
return True
|
||||
warning("Type 'aipass' to confirm clearing your profile (ctrl-C to cancel):")
|
||||
try:
|
||||
confirm = input("> ").strip()
|
||||
except (KeyboardInterrupt, EOFError) as exc:
|
||||
logger.info("[profile] clear input interrupted: %s", exc)
|
||||
console.print("\n[yellow]Cancelled.[/yellow]")
|
||||
return True
|
||||
if confirm == "aipass":
|
||||
save_profile({f: None for f in USER_FIELDS})
|
||||
console.print("[green]✓[/green] Profile cleared.")
|
||||
else:
|
||||
console.print("[yellow]Cancelled.[/yellow]")
|
||||
return True
|
||||
|
||||
print_help()
|
||||
return True
|
||||
@@ -0,0 +1,5 @@
|
||||
# Plugins
|
||||
|
||||
Scheduled tasks and extensions for `AIPASS`.
|
||||
|
||||
Plugins are standalone units of work that can be scheduled via the daemon. Each plugin handles one specific recurring task.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Docs
|
||||
|
||||
Documentation files for the `AIPASS` branch.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user