Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb710fdb59 | ||
|
|
5b476e0dc3 | ||
|
|
1c6abe9b59 | ||
|
|
4169d085e3 | ||
|
|
37a26ea86c | ||
|
|
42f56f2cc4 | ||
|
|
7a2700d649 | ||
|
|
d1a6c874e8 | ||
|
|
76ce13830d | ||
|
|
57285740f2 | ||
|
|
73baa0005f | ||
|
|
c71f389409 | ||
|
|
47bef92162 | ||
|
|
e2157e118c | ||
|
|
1ac0cecb9a | ||
|
|
583a792515 | ||
|
|
33a1510cbb | ||
|
|
d1facd8bdc | ||
|
|
0529a4e7c4 | ||
|
|
efb530e0b2 | ||
|
|
a7108e8504 | ||
|
|
f09d42a862 | ||
|
|
c91cfd5166 | ||
|
|
8cb3895264 | ||
|
|
d631fd8d54 | ||
|
|
48e25ae46e | ||
|
|
800acd079a | ||
|
|
defee1f513 | ||
|
|
7811381d54 | ||
|
|
bdb128d904 | ||
|
|
9b85a95552 | ||
|
|
2213251756 | ||
|
|
a057cdf488 | ||
|
|
251b2729c2 | ||
|
|
06c1a3a1be | ||
|
|
74bf6eef04 | ||
|
|
28e8028a02 | ||
|
|
71e5198d4c | ||
|
|
ef38f3be4c | ||
|
|
db9643eb58 | ||
|
|
193336967b | ||
|
|
f6d31285ea | ||
|
|
d4b265ad45 | ||
|
|
53a695580f | ||
|
|
6163202a93 | ||
|
|
4912d96f58 | ||
|
|
87bfccd55b | ||
|
|
a89ddb30bd | ||
|
|
e412cfed14 | ||
|
|
b8f6fb8dad | ||
|
|
6b0dcdccef | ||
|
|
1902775812 | ||
|
|
03dfce20b4 | ||
|
|
b3bb529a6a | ||
|
|
9a61d237fa | ||
|
|
702e335cb8 | ||
|
|
73e9ededd4 | ||
|
|
bad08e9b03 | ||
|
|
851988abbc | ||
|
|
222a9c8382 | ||
|
|
a8b1ce6658 | ||
|
|
807924241f |
+28
-5
@@ -38,12 +38,34 @@
|
|||||||
"handler": "aipass.hooks.apps.handlers.prompt.navmap.handle",
|
"handler": "aipass.hooks.apps.handlers.prompt.navmap.handle",
|
||||||
"matcher": ""
|
"matcher": ""
|
||||||
},
|
},
|
||||||
|
"compass_recall": {
|
||||||
|
"enabled": true,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.prompt.compass_recall.handle",
|
||||||
|
"matcher": "",
|
||||||
|
"max_per_session": 10
|
||||||
|
},
|
||||||
|
"feedback_pulse": {
|
||||||
|
"enabled": false,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.prompt.feedback_pulse.handle",
|
||||||
|
"matcher": ""
|
||||||
|
},
|
||||||
"auto_process": {
|
"auto_process": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
||||||
"matcher": "",
|
"matcher": "",
|
||||||
"timeout": 120
|
"timeout": 120
|
||||||
},
|
},
|
||||||
|
"context_gauge": {
|
||||||
|
"enabled": true,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.prompt.context_gauge.handle",
|
||||||
|
"matcher": "",
|
||||||
|
"timeout": 30
|
||||||
|
},
|
||||||
|
"temporal": {
|
||||||
|
"enabled": true,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.prompt.temporal.handle",
|
||||||
|
"matcher": ""
|
||||||
|
},
|
||||||
"user_message_relay": {
|
"user_message_relay": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle",
|
"handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle",
|
||||||
@@ -76,11 +98,6 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"handler": "aipass.hooks.apps.handlers.security.registry_gate.handle",
|
"handler": "aipass.hooks.apps.handlers.security.registry_gate.handle",
|
||||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
|
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
|
||||||
},
|
|
||||||
"engine_test_sound": {
|
|
||||||
"enabled": false,
|
|
||||||
"command": "python3 $AIPASS_HOME/.claude/hooks/engine_test_sound.py",
|
|
||||||
"matcher": "WebSearch"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -152,6 +169,12 @@
|
|||||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
||||||
"matcher": "",
|
"matcher": "",
|
||||||
"timeout": 120
|
"timeout": 120
|
||||||
|
},
|
||||||
|
"pre_compact_prep": {
|
||||||
|
"enabled": true,
|
||||||
|
"handler": "aipass.hooks.apps.handlers.lifecycle.pre_compact_prep.handle",
|
||||||
|
"matcher": "",
|
||||||
|
"timeout": 30
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<!-- Tier 1 — injected on cadence 5, at session start, and post-compaction. Kernel = tier0_kernel.md, every turn. Cap: ~8,000 chars per fire (hook truncates near 10k). Format: PROMPT_STYLE.md -->
|
<!-- Tier 1 — injected on cadence 5, at session start, and post-compaction. Kernel = tier0_kernel.md, every turn. Cap: ~8,000 chars per fire (hook truncates near 10k). Format: PROMPT_STYLE.md -->
|
||||||
|
|
||||||
AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`.
|
AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`. **AIPass is open source** — public repo on GitHub. Strangers read, clone, and scan this code; treat external findings as contributions.
|
||||||
|
|
||||||
# Finding your way
|
# Finding your way
|
||||||
|
|
||||||
@@ -110,6 +110,6 @@ Your continuity across sessions. Save proactively — after milestones, decision
|
|||||||
|
|
||||||
# House rules
|
# House rules
|
||||||
|
|
||||||
- Cross-platform, no hardcoded paths. Public repo — `pathlib`, never `/home/...`.
|
- Public repo — write as if it ships, because it does. No secrets in the tree, no hardcoded paths (`pathlib`, never `/home/...`), cross-platform.
|
||||||
- No bare imports — always `from aipass.<agent>.apps...`.
|
- No bare imports — always `from aipass.<agent>.apps...`.
|
||||||
- State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts; memories record; registries catalog.
|
- State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts; memories record; registries catalog.
|
||||||
|
|||||||
@@ -54,8 +54,14 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
|
|||||||
|
|
||||||
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
|
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
|
||||||
- Close any that were already processed but not formally closed
|
- Close any that were already processed but not formally closed
|
||||||
|
- Run `drone @devpulse feedback 2>/dev/null` — report unread cross-project feedback; view/reply/clear anything NEW (S304 F46: this box sat unread for 3 months because nothing invoked it)
|
||||||
|
|
||||||
## 5. Loose Ends
|
## 5. Compass Review (Devpulse only)
|
||||||
|
|
||||||
|
- Run ONE `drone @devpulse compass review` — it serves the oldest-unreviewed entry. Judge it: still true → confirm; superseded → archive it and note what replaced it; wrong → fix or archive.
|
||||||
|
- One entry per prep, every prep. This is the curation cadence — review only works if it actually runs (DPLAN-0246: all 127 entries sat unreviewed because nothing invoked it).
|
||||||
|
|
||||||
|
## 6. Loose Ends
|
||||||
|
|
||||||
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
|
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
|
||||||
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to local.json todos[]
|
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to local.json todos[]
|
||||||
@@ -71,5 +77,6 @@ Prep complete:
|
|||||||
- Plans: [which ones updated]
|
- Plans: [which ones updated]
|
||||||
- Git: [branch, uncommitted count, suggestion]
|
- Git: [branch, uncommitted count, suggestion]
|
||||||
- Inbox: [count, action taken]
|
- Inbox: [count, action taken]
|
||||||
|
- Compass: [entry #N reviewed — verdict]
|
||||||
- Loose ends: [any flagged]
|
- Loose ends: [any flagged]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,11 +4,19 @@
|
|||||||
"cli": {
|
"cli": {
|
||||||
"claude": {
|
"claude": {
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:presence_gate", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:persistent_alert", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:tier0_kernel", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:tier0_kernel", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:navmap", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:navmap", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification", "event": "UserPromptSubmit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:temporal", "event": "UserPromptSubmit"},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop", "event": "SubagentStop"},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop", "event": "SubagentStop"},
|
||||||
@@ -17,7 +25,9 @@
|
|||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "manual", "timeout": 60},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "manual", "timeout": 60},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "auto", "timeout": 60},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "auto", "timeout": 60},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120},
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120},
|
||||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120}
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "manual", "timeout": 30},
|
||||||
|
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "auto", "timeout": 30}
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"AIPASS_HOME": "{{REPO_ROOT}}",
|
"AIPASS_HOME": "{{REPO_ROOT}}",
|
||||||
|
|||||||
@@ -41,6 +41,6 @@ jobs:
|
|||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
- name: "Upload to code-scanning"
|
- name: "Upload to code-scanning"
|
||||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
- uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||||
|
|||||||
+10
@@ -124,6 +124,12 @@ src/aipass/*/apps/integrations/**
|
|||||||
src/aipass/hooks/tools/*
|
src/aipass/hooks/tools/*
|
||||||
!src/aipass/hooks/tools/install_boot_shim.sh
|
!src/aipass/hooks/tools/install_boot_shim.sh
|
||||||
|
|
||||||
|
# User/sample projects — each is its own git repo (git init on create).
|
||||||
|
# Contents never belong to the AIPass repo; only the catalog README is tracked
|
||||||
|
# so the public repo can point at the standalone project repos.
|
||||||
|
projects/*
|
||||||
|
!projects/README.md
|
||||||
|
|
||||||
# CI artifacts
|
# CI artifacts
|
||||||
windows-pytest-results/
|
windows-pytest-results/
|
||||||
|
|
||||||
@@ -139,3 +145,7 @@ README_ORIGINAL_DISABLED.md
|
|||||||
*.bak
|
*.bak
|
||||||
test/
|
test/
|
||||||
sandbox_test/
|
sandbox_test/
|
||||||
|
|
||||||
|
# SQLite WAL sidecars (runtime artifacts — commons.db ro-feed etc.)
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|||||||
+518
@@ -9,8 +9,526 @@ PyPI version — not the changelog header.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2026-07-21]
|
||||||
|
|
||||||
|
**feat(prax)** — Commons live social feed in the monitor (DPLAN-0257, Patrick
|
||||||
|
ask verbatim): `drone @prax monitor run commons` now streams The Commons'
|
||||||
|
chatter — posts, comments, votes, reactions — room-tagged with mood coloring,
|
||||||
|
monitor-style. ~10-event backfill on open, then 1.5s id-cursor polling.
|
||||||
|
Read-only by construction (`mode=ro` sqlite URI — write attempt refused,
|
||||||
|
verified live); commons stays the only writer, zero commons-side changes.
|
||||||
|
Branch-log tail still reachable via `monitor run commons --logs`; mixed branch
|
||||||
|
lists unchanged. `--relay` rides the existing Telegram relay path.
|
||||||
|
33 new tests, prax suite 1065 green, audit 100% (52 files). Door-tested live:
|
||||||
|
devpulse posted/replied/reacted while the feed streamed every event.
|
||||||
|
Built by @prax.
|
||||||
|
|
||||||
|
**fix(hooks)** — two DPLAN-0253 backlog hardenings (DPLAN-0256 clear):
|
||||||
|
engine handler timeout + presence_gate PID-reuse defense. `_run_handler` now
|
||||||
|
runs handler-type hooks on a daemon thread joined with the hooks.json
|
||||||
|
`timeout` field (default 30s) — a hung handler returns TIMEOUT loud
|
||||||
|
(engine.jsonl + sound) and the event moves on; daemon thread chosen over
|
||||||
|
ThreadPoolExecutor so a stuck orphan can never hang interpreter exit.
|
||||||
|
presence_gate occupancy no longer trusts `os.kill(pid, 0)` alone:
|
||||||
|
`procStart` (CC session file) is matched against `/proc/<pid>/stat` field 22
|
||||||
|
so a kernel-recycled PID can't impersonate a dead session — closes the gap
|
||||||
|
before observe-only ever flips to enforcement. Missing procStart / non-Linux
|
||||||
|
falls back to liveness-only, logged. 15 new tests, suite 1272 green,
|
||||||
|
seedgo 31/31 both files. Built by @hooks.
|
||||||
|
|
||||||
|
**fix(trigger)** — runaway-log alerts get the 24h TTL every other mute already
|
||||||
|
had (DPLAN-0256 backlog clear): `_write_alert()` hardcoded `expires_at: None`,
|
||||||
|
so alerts.json entries nagged forever while medic branch mutes self-expired.
|
||||||
|
New `DEFAULT_ALERT_TTL_SECONDS = 86400` (matches medic_state's
|
||||||
|
`DEFAULT_MUTE_SECONDS`) with a `forever` escape hatch threaded through
|
||||||
|
`handle_runaway_log_detected()`. 2 new tests, trigger suite 621 green,
|
||||||
|
audit 100%. Built by @trigger.
|
||||||
|
|
||||||
|
**feat(drone)** — joint-decision gate on `drone @git merge` (DPLAN-0256,
|
||||||
|
Patrick ruling S330: merges are always done together, never accidental).
|
||||||
|
The gate sits in `_handle_merge` before the plugin import — `merge_pr()` is
|
||||||
|
unreachable without confirmation. A real terminal gets an interactive y/N
|
||||||
|
prompt; headless callers (agent Bash) are refused unless `--confirm` is
|
||||||
|
passed explicitly. Every gate decision (confirm / tty-yes / tty-abort /
|
||||||
|
headless-refused) is logged via json_handler. 6 new tests (86 green),
|
||||||
|
live-fired refusal verified, seedgo 31/31.
|
||||||
|
|
||||||
|
**feat(skills)** — telegram user_message_relay joins the sound layer: relay
|
||||||
|
events now carry their own sound key so an inbound user message is audible
|
||||||
|
like every other hook event (59/59 + 252 green).
|
||||||
|
|
||||||
|
**fix(devpulse)** — watchdog stall threshold 120s → 300s: the 120s
|
||||||
|
no-JSONL-activity heuristic fired `[watchdog.stall]` on healthy agents doing
|
||||||
|
long tool calls; 300s matches observed real-stall behavior (verified live
|
||||||
|
S330). Branch `.claude/settings.local.json` carries the devpulse
|
||||||
|
`autoCompactWindow: 350000` dial (Patrick ruling S326 — devpulse compacts
|
||||||
|
~292k, dispatched agents stay pinned at 200k).
|
||||||
|
|
||||||
|
**fix(seedgo)** — checker accuracy arc (S330): AST-based import analysis
|
||||||
|
lands in the checkers (dead_code, encapsulation, handlers, readme,
|
||||||
|
test_quality, unused_function) — 13 false positives eliminated fleet-wide,
|
||||||
|
2 real hooks imports that legitimately bypass the pattern documented instead
|
||||||
|
of suppressed. branch_audit, checklist and ignore_handler aligned; provider
|
||||||
|
hooks snapshot fixture refreshed; stale bypass entries for deleted tools
|
||||||
|
purged across branches (devpulse, memory, seedgo, hooks). Fleet audit 100%.
|
||||||
|
|
||||||
|
**feat(hooks)** — hook sound layer + temporal grounding. Sounds now mirror
|
||||||
|
the log across the hook fleet (prompt, lifecycle, notification, security
|
||||||
|
handlers) — audible liveness for the whole layer, verified live (2465 green,
|
||||||
|
audit 100). New `prompt/temporal.py`: tiny always-on UserPromptSubmit handler
|
||||||
|
injecting one line of local date/time/weekday/part-of-day every turn — live
|
||||||
|
clock each fire, host timezone via `astimezone()` (clones see their own local
|
||||||
|
time). Wired on both wires (`.aipass/hooks.json` + provider manifest).
|
||||||
|
|
||||||
|
**feat(aipass)** — `aipass adopt` + shared scaffold refactor: adopt turns an
|
||||||
|
existing `projects/` directory into a full AIPass project (registry, resident
|
||||||
|
agent, `.aipass`/`.claude` scaffold) — every write additive, nothing existing
|
||||||
|
overwritten; unlike `aipass new` it starts from a directory with its own
|
||||||
|
content and git history. New `shared/` package (`project_home.py`,
|
||||||
|
`scaffold_content.py`) gives init/new/adopt one source of truth per helper —
|
||||||
|
`handlers/init/scaffold_content.py` moved there, no per-command copies to
|
||||||
|
drift. Proven live adopting aipass-site (doctor 31/0). Spawn template registry
|
||||||
|
synced (template bug chain me→spawn→aipass, fixed S329). 786 tests green,
|
||||||
|
audit-clean.
|
||||||
|
|
||||||
|
## [2026-07-20]
|
||||||
|
|
||||||
|
**docs(projects)** — `projects/README.md`: the projects section now ships in
|
||||||
|
the repo (the `!projects/README.md` gitignore whitelist existed since the
|
||||||
|
aipass-new design but the file was never written). Explains the project model:
|
||||||
|
**private by default** — each project is its own local git repo, fully ignored
|
||||||
|
by the AIPass repo, and publishing is an explicit opt-in step (Patrick ruling
|
||||||
|
2026-07-20). Opens the public roster with **Earmark**
|
||||||
|
([AIOSAI/earmark](https://github.com/AIOSAI/earmark)), the first public AIPass
|
||||||
|
project — a VS Code read-aloud extension with local Piper TTS and true
|
||||||
|
pause/resume, born, built, and published 2026-07-20.
|
||||||
|
|
||||||
|
**fix(hooks)** — persistent_alert dedup + loud trust-break banner (5-agent
|
||||||
|
trace round follow-ups, DPLAN-0253 tail):
|
||||||
|
|
||||||
|
- persistent_alert's once-per-session sound dedup lived in a module-global set,
|
||||||
|
but every bridge call is a fresh process — TTS would have announced on every
|
||||||
|
prompt while any alert was active. Replaced with session+alert-keyed tempdir
|
||||||
|
guard files (context_gauge idiom); banner capped at 10 alerts with an
|
||||||
|
"...and N more" note.
|
||||||
|
- Trust-registry breaks are now LOUD: any `.aipass/hooks.json` change breaks
|
||||||
|
the enrolled hash and silently disabled the entire hook layer (bit us live
|
||||||
|
for 2+ hours — tier prompts, security gates, everything dark, one log-file
|
||||||
|
WARNING as the only signal). New `is_hash_mismatch()` distinguishes a
|
||||||
|
genuine break from never-enrolled; `trust_break_banner()` does a
|
||||||
|
config-independent walk+hash check; the engine emits a full-width banner
|
||||||
|
once per prompt via the presence_gate bridge call. No auto-heal —
|
||||||
|
re-enrollment stays a deliberate human checkpoint. Live-fired: hash broken →
|
||||||
|
banner; restored → healthy. 16 new tests, suite 1206 green, seedgo 100%.
|
||||||
|
- Go-live day for the whole handler roster: 11/12 manifest entries wired into
|
||||||
|
provider settings by devpulse with Patrick accepting (user_message_relay
|
||||||
|
held: synchronous Telegram call + full prompt text off-machine — needs a
|
||||||
|
background send and an explicit call first). @hooks branch prompt corrected
|
||||||
|
and hardened: two-wires checklist + mandatory provider-wire flag in every
|
||||||
|
build reply.
|
||||||
|
|
||||||
|
**feat(hooks)** — auto-compact prep: context gauge + mechanical snapshot
|
||||||
|
(DPLAN-0253, built by @hooks, two rounds):
|
||||||
|
|
||||||
|
- `context_gauge` (UserPromptSubmit) — reads live context fill from the session
|
||||||
|
transcript every prompt (cheap 50KB tail), resolves the branch's compact
|
||||||
|
window (env > branch `settings.local.json` `autoCompactWindow` > 200k), and
|
||||||
|
injects a "run /prep NOW" nudge at 80% of the compact trigger, escalating at
|
||||||
|
95% — once per threshold per session. Memory prep happens before auto-compact
|
||||||
|
takes the choice away, on every branch including dispatched agents.
|
||||||
|
- `pre_compact_prep` (PreCompact) — stamps a mechanical AUTO-COMPACT SNAPSHOT
|
||||||
|
session entry into the compacting branch's `.trinity/local.json`: context
|
||||||
|
fill %, active dispatch locks, open plans, git state, inbox unread. Templated
|
||||||
|
from live state, defensive (malformed memory = log + skip, never raises).
|
||||||
|
- Shared `context_window` module: bounded transcript tail reader + per-branch
|
||||||
|
window resolver. 36 new tests; suite 1190 green; seedgo 100%.
|
||||||
|
- Round 2 root-cause fix: handlers wired only in `.aipass/hooks.json` never
|
||||||
|
fire on name-scoped events — UserPromptSubmit and PreCompact invoke the
|
||||||
|
bridge per-handler from provider settings. Both handlers now have
|
||||||
|
`provider_manifest.json` entries; @hooks' branch prompt corrected (it taught
|
||||||
|
the old one-entry-per-event model) with a "new handler? check the provider
|
||||||
|
wire" reminder. Go-live needs the user's `~/.claude/settings.json` synced
|
||||||
|
from the manifest + fresh sessions.
|
||||||
|
|
||||||
|
## [2026-07-19]
|
||||||
|
|
||||||
|
**feat(ai_mail)** — dispatched agents default to Sonnet 5 (Patrick ruling S326):
|
||||||
|
|
||||||
|
- wake.py model resolution passes bare aliases (`sonnet`/`opus`/`haiku`)
|
||||||
|
straight to the Claude CLI, which resolves latest-in-class — the pinned-ID
|
||||||
|
MODEL_MAP is gone and can never go stale again. Default flips opus → sonnet.
|
||||||
|
- dispatch_monitor pins `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000` on every
|
||||||
|
spawned agent — Sonnet 5 is 1M-context native, and without the pin every
|
||||||
|
dispatched agent would silently inherit a 1M window. E2E-proven: a live
|
||||||
|
dispatched probe reported `claude-sonnet-5` + `WINDOW=200000` from inside.
|
||||||
|
- Follow-up landed same morning: the "daemon gap" was a name collision —
|
||||||
|
the unpatched `spawn_agent()` was ai_mail's own inbox-poller
|
||||||
|
(`handlers/dispatch/daemon.py`), not the @daemon branch. It now passes
|
||||||
|
`--model DEFAULT_MODEL` (imported from wake.py, single source); the 200k pin
|
||||||
|
was already covered via the shared dispatch_monitor wrapper. @daemon's
|
||||||
|
scheduled wakes import `wake_branch` directly and were covered from the start.
|
||||||
|
|
||||||
|
**fix(skills)** — Telegram poll 5xx now triggers network backoff (medic loop,
|
||||||
|
autonomous @skills fix): `HTTPError` ≥500 in `poll_updates` raises
|
||||||
|
`_NetworkPollError` instead of falling through to rapid-fire retry; 4xx still
|
||||||
|
logs and returns. Three new tests (502/503 backoff, 429 stays out).
|
||||||
|
|
||||||
|
**fix(spawn, commons, prax, hooks)** — S304 audit fix campaign, Track A
|
||||||
|
(DPLAN-0250, four owner dispatches verified + committed by devpulse):
|
||||||
|
|
||||||
|
- **spawn** — shared `is_protected()` (infrastructure floor / registry owner /
|
||||||
|
active passport) now guards both pollution repair and branch delete; repair no
|
||||||
|
longer flags the live `src/aipass/aipass` branch, and deleting a protected or
|
||||||
|
actively-passported branch is refused with the reason.
|
||||||
|
- **commons** — branch-name resolution lowercased across all five ops sites
|
||||||
|
(trade/artifact/profile/welcome/search) to match identity normalization;
|
||||||
|
gifting and trading work again (guards had never matched since mid-June).
|
||||||
|
- **prax** — pytest detection now also checks `_pytest` in `sys.modules`, so
|
||||||
|
`patch.dict(os.environ, clear=True)` in test suites can no longer strip the
|
||||||
|
guard and freeze prod-path log handlers into the setup cache.
|
||||||
|
- **hooks** — the two static-path JSONL writers (engine diagnostics, telegram
|
||||||
|
delivery log) resolve their path per-write and route to the tmp test dir
|
||||||
|
under pytest; a full 1154-test suite run now adds zero lines to prod logs
|
||||||
|
(marker-bounded proof).
|
||||||
|
|
||||||
|
Also: `/prep` now checks the cross-project feedback box every run — the S304
|
||||||
|
"unread since April" backlog (F46) is processed to zero and can't silently
|
||||||
|
rot again.
|
||||||
|
|
||||||
|
**fix(aipass)** — `aipass doctor` no longer invents errors on a healthy repo
|
||||||
|
(S304 F14-17): `.backup` and spawn `templates` dirs excluded from the agent
|
||||||
|
scan, relative registry paths anchored against the project root instead of the
|
||||||
|
caller's CWD (running doctor from inside a branch dir showed 5 fake
|
||||||
|
missing-branch errors), and info-severity findings now render as warnings
|
||||||
|
instead of pass checkmarks. Remaining findings on this repo are genuine.
|
||||||
|
|
||||||
|
**fix(flow)** — registry aggregate writes are lock+atomic (S304 F85 residual):
|
||||||
|
`save_branch_registry` and `save_central` were bare `open`+`json.dump`; both now
|
||||||
|
use the O_EXCL lockfile + temp-file + `os.replace` pattern from the earlier
|
||||||
|
`save_registry.py` fix. Proven with a 6-process concurrent-write hammer. Also
|
||||||
|
investigated (N1): FPLAN-0313/0314 closed blank because
|
||||||
|
`is_template_content()`'s line-count threshold fires before its bracket-marker
|
||||||
|
check on default templates — guard fix queued, registry annotation is the
|
||||||
|
maintainer's call.
|
||||||
|
|
||||||
|
## [2026-07-18]
|
||||||
|
|
||||||
|
**fix(tests)** — the immortal `MagicMock/LOG_FILE/` directory is dead: a hooks
|
||||||
|
engine test patched `diagnostics.LOG_FILE` with a bare Mock, and prax's
|
||||||
|
`append_jsonl` turned the mock's fspath into a real `mkdir` — every test run
|
||||||
|
re-minted an empty `MagicMock/LOG_FILE/` in the runner's CWD (found in repo
|
||||||
|
root, devpulse, and hooks). Test now patches a real tmp path; 100/100 green,
|
||||||
|
clean-CWD run verified to mint nothing.
|
||||||
|
|
||||||
|
**docs** — merge playbook gains a site drift-check step (DPLAN-0249 follow-on):
|
||||||
|
every merge run now asks whether install commands, onboarding flow, agent count,
|
||||||
|
or the platform/CLI story changed — if yes, aipass.ai must be updated the same
|
||||||
|
day. Codifies the S323 ruling that the site is a projection of the README, never
|
||||||
|
its own source of facts. Template edit by @flow, one checklist line in the
|
||||||
|
post-merge section.
|
||||||
|
|
||||||
|
**docs** — root README v3 restructure (DPLAN-0249): single-funnel story with
|
||||||
|
zero duplicated commands (install, `aipass new`/`init run`, trees, drone
|
||||||
|
examples each taught exactly once), hero link line to aipass.ai/PyPI/r/AIPass,
|
||||||
|
three reserved gif slots. Positioning ruling: the README tells only the
|
||||||
|
Claude Code on Linux/WSL story — Codex/macOS/Windows mentions and the Roadmap
|
||||||
|
section removed (code support unchanged; Docker distribution will serve those
|
||||||
|
users later). Earlier same day: stale demo.gif embed dropped (#701) and
|
||||||
|
aipass.ai realigned to the v2.7.3 front door.
|
||||||
|
|
||||||
|
**v2.7.3** — the onboarding chain: from `git clone` to a conversation with an
|
||||||
|
agent that remembers you. Install's three dead-ends are gone — the default
|
||||||
|
`init` path, headless runs, and `aipass new` all now end where they should:
|
||||||
|
`install` chains through the guided init and **opens a live conversation with
|
||||||
|
the AIPass concierge**, first prompt authored with the install report in its
|
||||||
|
context. The concierge's Welcome Mode (research-backed opener, one name-ask,
|
||||||
|
deferred setup triage, hooks-first health check via a real @hooks dispatch,
|
||||||
|
every suggestion with its exact command) was proven in a live multi-turn
|
||||||
|
door-test — including the second-session payoff: relaunch, and it picks up
|
||||||
|
mid-task where you left off. Plus `aipass new` and the front-door overhaul below.
|
||||||
|
|
||||||
|
### Added (onboarding chain — TDPLAN-0014)
|
||||||
|
|
||||||
|
- **Install→chat handoff**: after init returns, install `launch_inline`s the
|
||||||
|
concierge with an authored first prompt (fresh-install recognition + binary
|
||||||
|
report). TTY-only; headless returns cleanly.
|
||||||
|
- **Welcome Mode** in the concierge branch prompt: capability opener with 3–5
|
||||||
|
concrete starters, single graceful name-ask, ~turn-5 setup push ("every
|
||||||
|
machine is different"), hooks-first verification incl. trust-registry
|
||||||
|
enrollment, setup plan seeded from the cross-OS checklist, Windows→WSL
|
||||||
|
recommendation, prax-monitor + hooksound tips, exact copy-paste command with
|
||||||
|
every suggestion.
|
||||||
|
- **Feedback pulse** (@hooks): one ignorable line every ~10 turns with the repo
|
||||||
|
feedback link — `aipass feedback on/off` (alias for `drone @hooks feedback`)
|
||||||
|
turns it off. Registered disabled for the AIPass host itself. 25 tests.
|
||||||
|
- **Dead-end kills**: empty-template init now runs handoff + report stages
|
||||||
|
(default path ends in the conversation); non-interactive installs complete
|
||||||
|
with defaults and exit 0 (headless stage 9 prints the launch command instead
|
||||||
|
of spawning); `aipass new` auto-launches into the new project's manager agent
|
||||||
|
on a TTY with a printed fallback and Ctrl-C escape line.
|
||||||
|
- **Unified handoff prompt**: one `INIT_PROMPT` constant (was two drifting
|
||||||
|
strings in init_flow vs handoff).
|
||||||
|
|
||||||
|
### Fixed (onboarding chain)
|
||||||
|
|
||||||
|
- Non-TTY `aipass init run` crashed with EOFError at the first prompt (caught
|
||||||
|
in a live door-test after unit suites ran green — the prompt layer now
|
||||||
|
auto-detects non-TTY and takes defaults).
|
||||||
|
- `aipass new` outside an AIPass environment now exits 1 instead of 0.
|
||||||
|
- Empty-template handoff messaging no longer claims an agent exists
|
||||||
|
("Your project is ready", resolved absolute path instead of `cd .`).
|
||||||
|
- Stage numbering shows a skip notice instead of silently jumping 5→8.
|
||||||
|
|
||||||
|
## [2026-07-17]
|
||||||
|
|
||||||
|
**v2.7.3 (first pass)** — `aipass new` and the front-door overhaul. The `projects/`
|
||||||
|
directory is now a first-class playground: `aipass new <name>` creates a fully
|
||||||
|
isolated project — own registry, own git repo with a birth commit, born
|
||||||
|
deployable — with a full framework resident agent that answers
|
||||||
|
`drone @<name>` from inside the project while staying invisible to the host
|
||||||
|
roster. ai_mail enforces the project boundary (cross-project mail is refused
|
||||||
|
with a pointer to the feedback channel). A live door-test of the aipass CLI
|
||||||
|
exposed a blind spot in the audit — perfect structural scores on an unusable
|
||||||
|
front door — so seedgo grew two user-facing-quality standards (`cli_ux`,
|
||||||
|
`readme_quality`) and a fleet-100 campaign brought every branch's help output
|
||||||
|
and README to the house pattern: 17/17 branches at 100%.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`aipass new <name>`** (module + handler): creates `projects/<name>` with
|
||||||
|
registry-first credential linkage (`registry.metadata.id ==
|
||||||
|
passport.citizenship.registry_id`), empty/python templates, interactive
|
||||||
|
template + agent prompts (flags for scripted use), a full framework agent
|
||||||
|
(entry point, modules/handlers skeleton, trinity set, mailbox, tier files),
|
||||||
|
git init + birth commit, and next-step output. 49 tests.
|
||||||
|
- **seedgo standards 41–42**: `cli_ux` (8 AST checks — two-tier help, Rich
|
||||||
|
console, styled title, purpose line, --help pointer, Usage, Examples, no
|
||||||
|
exposed internal plumbing) and `readme_quality` (Quick Start with runnable
|
||||||
|
code block, stranger accessibility, invoke/entry-point match, early
|
||||||
|
what-description). 36 tests + case-resolution regression tests.
|
||||||
|
- **ai_mail cross-project boundary**: sender and recipient project roots
|
||||||
|
compared on delivery; cross-project sends refused with a feedback-channel
|
||||||
|
pointer. Fail-open for internal/unregistered sends. 13 tests.
|
||||||
|
- **Root `.gitignore`**: `projects/*` ignored (each project is its own repo);
|
||||||
|
only the future catalog README stays trackable.
|
||||||
|
|
||||||
|
### Added (second pass — the agent becomes a real citizen)
|
||||||
|
|
||||||
|
- **`aipass new` agents are now spawn-issued full citizens** (FPLAN-0334): the
|
||||||
|
hand-rolled scaffold in the new_project handler is retired for a
|
||||||
|
`spawn_agent()` call against @spawn's new `project_agent` template — branch
|
||||||
|
prompt, structured mailbox, birth certificate, trinity trio, dashboard,
|
||||||
|
house-pattern entry point, and a branch-style README. One authority issues
|
||||||
|
citizens; project agents inherit template evolution for free.
|
||||||
|
- **Agent home = `src/<project>/<agent>/`**, mirroring the host's
|
||||||
|
`src/aipass/<branch>` layout (door-test ruling: the project root is never an
|
||||||
|
agent home). Seat paths are relative like host seats; the registry walk stops
|
||||||
|
at the first project registry. The first agent is the project's **manager**
|
||||||
|
(`citizen_class: manager` — its devpulse), named after the project.
|
||||||
|
- **Birth-commit hygiene**: the `.venv` symlink (absolute host path) and the
|
||||||
|
registry lock file are no longer tracked in new projects' birth commits.
|
||||||
|
- **Boundary verified live in all four directions**: host↔project email and
|
||||||
|
dispatch all refused — project→host lands on the ai_mail cross-project check
|
||||||
|
with its feedback-channel pointer, closing the leak found in the S319
|
||||||
|
prototype probes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **aipass front door rebuilt**: `--help` now follows the house pattern with a
|
||||||
|
curated command list, usage, and examples (internal plumbing hidden —
|
||||||
|
`doctor_fix`/`doctor_wire` renamed underscore-private); `aipass help` shows
|
||||||
|
the Q&A screen instead of falling through to the module dump; README
|
||||||
|
rewritten to pass the stranger test with a Quick Start and the correct
|
||||||
|
invocation.
|
||||||
|
- **Fleet-100 sweep**: 15 branches gained Quick Start READMEs and/or
|
||||||
|
Usage/Examples help sections — each owner fixed their own front against the
|
||||||
|
new gate.
|
||||||
|
- **Debug_Print detector hardened**: the regex-based checker matched `print(`
|
||||||
|
inside string literals (flagging cli_ux_check's own error messages — the
|
||||||
|
auditor was the last branch under 100%). String content is now stripped
|
||||||
|
before matching, with a regression test; plus a depth-5 nesting refactor in
|
||||||
|
the same file.
|
||||||
|
|
||||||
|
**v2.7.2** — everything merged since v2.7.1, headlined by the compass decision
|
||||||
|
engine v2: curation with supersedes links + write-time conflict advisories
|
||||||
|
(Track 1), and ambient recall — rated past decisions now surface verbatim into
|
||||||
|
live sessions on matching prompts, governed by session caps and spacing
|
||||||
|
(Track 2). Also in this release: the plan close pipeline completes itself
|
||||||
|
(auto-vectorization + crash-safe registry writes), drone's 3-layer subprocess
|
||||||
|
timeout policy with a collision-free `--drone-timeout` flag, plan-number memory
|
||||||
|
search that pins the exact plan, a fleet-wide seedgo 100% restoration, and
|
||||||
|
SSH-signed commits now verifying on GitHub. Details in the sections below
|
||||||
|
(2026-07-16 carries the full stories).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Release cadence ruling: every dev→main merge ships a PATCH bump + tag by
|
||||||
|
default.** PyPI tracks main, always current; version numbers carry no
|
||||||
|
significance during beta — the big jump is reserved for beta exit.
|
||||||
|
|
||||||
|
- **Merge playbook SOP refined from live run PPLAN-0010.** The raw
|
||||||
|
`git fetch origin main:main` step (now blocked by the git gate) is replaced
|
||||||
|
with `drone @git sync` in both places it appeared, and the template opens
|
||||||
|
with the exact create command (`drone @flow create . "Merge summary" merge
|
||||||
|
pplan` — template name before type), closing the trap where the wrong arg
|
||||||
|
order silently stamps the default template.
|
||||||
|
|
||||||
|
## [2026-07-16]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Compass ambient recall — Track 2 (DPLAN-0246/FPLAN-0332): rated decisions
|
||||||
|
surface unprompted.** On every user prompt, a new hooks handler
|
||||||
|
(`compass_recall`, registered in `.aipass/hooks.json` only) queries compass
|
||||||
|
FTS with the prompt text and injects matching rulings VERBATIM —
|
||||||
|
`[BAD] #56: <decision text>` — tidbits, never vibes. Three branches, one
|
||||||
|
pipeline, each piece behind a modules/-boundary API: devpulse's
|
||||||
|
`recall_decisions()` (side-effect-free scored candidates; rare-token
|
||||||
|
evidence scoring + a query-side stopword filter so greeting/filler words
|
||||||
|
can't fake relevance) + `mark_surfaced()` (counts only real injections);
|
||||||
|
@memory's pure `should_surface()` governance (promoted from the dormant
|
||||||
|
symbolic engine: threshold, 5/session cap, 10-message spacing — first
|
||||||
|
surface exempt, 300s cooldown, dedup; state-in/state-out, caller persists);
|
||||||
|
@hooks' 90-line handler + engine per-handler budget (errors never block a
|
||||||
|
prompt — `compass_recall_unreachable` log signature for @trigger's watcher).
|
||||||
|
Live acceptance matrix through the real bridge: topic-with-history prompts
|
||||||
|
recall the right ruling (a CI prompt surfaced the red-CI-never-parked
|
||||||
|
ruling), small talk and greetings stay silent, repeat prompts gate on
|
||||||
|
spacing. Review caught and fixed pre-ship: wrong payload key (`userInput` →
|
||||||
|
`prompt`), phantom `CLAUDE_CODE_SESSION_ID` env (session id is
|
||||||
|
stdin-payload-only), spacing gate blocking the first surface, and a trust
|
||||||
|
registry re-enrollment gap that silently disabled ALL project hooks for 20
|
||||||
|
minutes after the hooks.json edit. 446 devpulse + 1011 memory + 1129 hooks
|
||||||
|
tests green; seedgo 31/31 on every touched module.
|
||||||
|
|
||||||
|
- **Compass curation v2 Track 1 (DPLAN-0246/FPLAN-0331): supersedes links +
|
||||||
|
write-time conflict check.** A correcting compass entry now archives and
|
||||||
|
links what it replaces in one transaction (`compass add --supersedes N`);
|
||||||
|
query renders both directions ("supersedes #N" / "ARCHIVED — superseded by
|
||||||
|
#M") so a retracted decision can never masquerade as current truth. Every
|
||||||
|
`compass add` FTS-checks the new text against active entries and prints a
|
||||||
|
non-blocking "possible conflict with #X" advisory — flag-and-ask, no LLM, no
|
||||||
|
auto-resolve (boardroom ruling). New `compass note <id>` command (FTS
|
||||||
|
re-index proven by test), `--include-archived` query flag (the avoid-list is
|
||||||
|
finally searchable), dead `score` column removed from all code surfaces
|
||||||
|
(kept inert on disk — zero migration risk). Idempotent PRAGMA-checked
|
||||||
|
migration ran clean on the production store (128 rows, no loss); the four
|
||||||
|
fresh-eyes-audit archive pairs got their links backfilled. /prep now runs
|
||||||
|
one `compass review` per session — curation living in a path that already
|
||||||
|
runs, the lesson of all three compass eras. 435 devpulse tests green,
|
||||||
|
seedgo 31/31 on both touched modules.
|
||||||
|
|
||||||
|
- **Close pipeline completes itself (DPLAN-0245): auto-vectorization +
|
||||||
|
crash-safe registry writes + drone timeout policy.** Closing a plan now
|
||||||
|
produces all side effects from one command — `post_close_runner` invokes
|
||||||
|
@memory's plan intake directly after archival (detached, loud on failure,
|
||||||
|
drains any backlog it finds), so plans can no longer silently pile up
|
||||||
|
unvectorized. Plan registry saves (@flow `save_registry` + mbank
|
||||||
|
`save_flow_registry`) now use the O_EXCL lockfile + atomic
|
||||||
|
tempfile-and-replace pattern, closing the same lost-update race class fixed
|
||||||
|
earlier in CLOSED_PLANS. @drone gained a 3-layer timeout policy: per-command
|
||||||
|
overrides (`@memory process-plans` 120s, `@flow close` 90s), a `--timeout N`
|
||||||
|
flag, 30s default — replacing the flat 30s guillotine that killed legitimate
|
||||||
|
long commands mid-pipeline; the timeout error now says how to override.
|
||||||
|
Proven end-to-end live: one `drone @flow close` on a throwaway plan yielded
|
||||||
|
archive + vectors + ledger + registry with zero manual steps, and the
|
||||||
|
auto-trigger swept a pre-existing backlog file on its first run. 730 flow +
|
||||||
|
878 drone tests green, seedgo 100%.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **CI seedgo gate back to 100% across all 17 branches.** The Track 2 compass
|
||||||
|
recall code left three branches at 99%: @hooks' compass_recall handler was
|
||||||
|
missing json_handler operation logging and had two silent catches (now
|
||||||
|
logged); @memory's governance module held its implementation in modules/
|
||||||
|
(moved to handlers/governance/engine.py with modules/governance.py as the
|
||||||
|
thin re-export — the cross-branch import path is unchanged and live-E2E
|
||||||
|
verified through the real bridge); devpulse's README test count had drifted
|
||||||
|
(309 → 348). Audits re-run per branch: 100% overall, all suites green.
|
||||||
|
|
||||||
|
- **Plan-number memory search hits the exact plan.** Searching a plan ID
|
||||||
|
('DPLAN-0244', 'fplan 0332' — any case, dash or space) now pins the exact
|
||||||
|
plan as the top result at 100%, via a metadata lookup on the vector store's
|
||||||
|
source-file field instead of embedding similarity (which treats all plan IDs
|
||||||
|
as near-identical strings and never surfaced the target). Patrick ruling:
|
||||||
|
searching a plan number must return that plan first. Semantic search quality
|
||||||
|
for normal queries is unchanged. Also purged 193 junk vectors — throwaway
|
||||||
|
probe/flaky test plans from scratchpad sessions (dv4 batch, probe_test_plan,
|
||||||
|
throwaway_e2e_proof) that had leaked into the store. 1011 memory tests green.
|
||||||
|
|
||||||
|
- **drone --timeout collision: router flag swallowed module flags.** The
|
||||||
|
DPLAN-0245 subprocess-timeout flag consumed the first `--timeout` token
|
||||||
|
anywhere in argv, so module-level flags silently vanished — watchdog's
|
||||||
|
`--timeout 1800` never arrived and long watches died at the 600s default
|
||||||
|
(live repro x2). Drone's flag is now namespaced `--drone-timeout`; plain
|
||||||
|
`--timeout` passes through untouched to the target module, with a regression
|
||||||
|
test pinning the passthrough. Per-command overrides intact. 879 drone tests
|
||||||
|
green, seedgo 100%.
|
||||||
|
|
||||||
|
- **@memory command routing eaten by the new governance module.** The
|
||||||
|
governance module shipped in Track 2 had the wrong `handle_command`
|
||||||
|
signature (`args: list` instead of `command: str, args: list`) and always
|
||||||
|
returned True, so auto-discovery routed EVERY @memory command through it
|
||||||
|
first — `drone @memory search` answered "governance: unknown command 's'".
|
||||||
|
Fixed to the standard signature returning False for commands not its own;
|
||||||
|
search verified live (135 results). Library modules must decline commands
|
||||||
|
they don't own or they silently hijack the whole CLI. 1011 memory tests
|
||||||
|
green, seedgo 31/31.
|
||||||
|
|
||||||
## [2026-07-15]
|
## [2026-07-15]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Plan vectorization pipeline unwedged (DPLAN-0245): 57 closed plans were
|
||||||
|
silently missing from semantic memory since mid-June.** Vector IDs were pure
|
||||||
|
content hashes, so identical template boilerplate across different plans
|
||||||
|
produced duplicate IDs within one ChromaDB upsert — the store rejected the
|
||||||
|
entire batch, and the all-or-nothing intake retried the same failing batch
|
||||||
|
forever. Fixed in @memory: IDs are now salted with the source filename when
|
||||||
|
present (rollover hashes unchanged — no re-vectorization churn), in-batch
|
||||||
|
dedup as a safety net, and `process_plans()` now runs per-file with the
|
||||||
|
manifest saved after each success so a poison file can never wedge the queue
|
||||||
|
again. Backlog drained and verified: 229/229 archived plans vectorized, 1112
|
||||||
|
chunks, formerly-lost plans answering semantic queries at 85%+ similarity.
|
||||||
|
990 memory tests green.
|
||||||
|
|
||||||
|
- **CLOSED_PLANS ledger append race (@flow): concurrent plan closes lost
|
||||||
|
entries.** `append_to_closed_plans` was an unlocked read-modify-write; the
|
||||||
|
S314 bulk sweep lost 18 of 21 entries to it (reconciled by hand). Now guarded
|
||||||
|
by an `O_CREAT|O_EXCL` lockfile with retry/backoff, and the previously
|
||||||
|
silent append failure is surfaced in close output and logs. 730 flow tests
|
||||||
|
green.
|
||||||
|
|
||||||
|
- **Telegram routine read-timeouts no longer logged as errors (@skills,
|
||||||
|
Patrick ruling): ends the medic wake-loop.** A routine long-poll read
|
||||||
|
timeout (`socket.timeout` — an `OSError` subclass) slipped past the earlier
|
||||||
|
`URLError`-only guard into the network-outage path, logging ERROR once per
|
||||||
|
episode (~576 lines/30h) and waking @trigger's medic each time. The
|
||||||
|
`_is_routine_read_timeout` guard now covers the `OSError` handler too, and
|
||||||
|
the genuine-outage episode-start line is demoted ERROR→WARNING (backoff
|
||||||
|
self-heals; recovery already logs INFO; medic only fires on ERROR/CRITICAL).
|
||||||
|
Real failures still log ERROR. 825 telegram tests green.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Hook config trust model hardening (DPLAN-0244): closes a zero-interaction
|
||||||
|
RCE from untrusted `.aipass/hooks.json`.** The hook loader walked up from CWD
|
||||||
|
and trusted any `.aipass/hooks.json` it found; since the bridge is wired
|
||||||
|
globally in provider settings, a hostile repo shipping a `command`-type hook
|
||||||
|
could execute arbitrary shell on `SessionStart` with no user interaction.
|
||||||
|
Fixed with defense-in-depth. **Layer A (engine):** per-project configs may no
|
||||||
|
longer run `command`-type hooks (refused via an unconditionally-stamped
|
||||||
|
`_source` provenance flag), and handler paths are gated to the `aipass.*`
|
||||||
|
namespace. **Layer B (loader + CLI):** a trusted-project registry
|
||||||
|
(`~/.aipass/trusted_projects.json`, path + sha256) that the loader checks
|
||||||
|
fail-closed; on upgrade it bootstraps **only** the `$AIPASS_HOME` install
|
||||||
|
(never trust-on-first-use of an arbitrary directory); `aipass init`/`init
|
||||||
|
update` auto-enroll, and new `aipass trust`/`revoke` commands manage
|
||||||
|
enrollment. Both gates proven to block the attack independently via a live
|
||||||
|
acceptance test driving the real bridge with a real payload. 1105 hooks +
|
||||||
|
133 aipass tests green. Origin: external scan (false positive at
|
||||||
|
`engine.py:37`) whose triage surfaced the real adjacent hole.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Supply-chain hardening pass (DPLAN-0243): commit signing + hash-pinned CI
|
- **Supply-chain hardening pass (DPLAN-0243): commit signing + hash-pinned CI
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ These steps are sequential and dependent — run each ONCE, wait for the result,
|
|||||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
|
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
|
||||||
- Refresh: `drone @prax dashboard refresh @<self>` — where `<self>` is your branch name (CWD directory name)
|
- Refresh: `drone @prax dashboard refresh @<self>` — where `<self>` is your branch name (CWD directory name)
|
||||||
- Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance.
|
- Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance.
|
||||||
|
- announce ur current (PID)
|
||||||
|
|
||||||
|
|
||||||
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
|
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,15 @@
|
|||||||
</p>
|
</p>
|
||||||
<p align="center"><strong>Persistent Agent Workspace</strong></p>
|
<p align="center"><strong>Persistent Agent Workspace</strong></p>
|
||||||
<p align="center"><em>AI agents that remember, collaborate, and never start from zero.</em></p>
|
<p align="center"><em>AI agents that remember, collaborate, and never start from zero.</em></p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://aipass.ai">aipass.ai</a> ·
|
||||||
|
<a href="https://pypi.org/project/aipass/">PyPI</a> ·
|
||||||
|
<a href="https://reddit.com/r/AIPass">r/AIPass</a> ·
|
||||||
|
<a href="https://github.com/AIOSAI/AIPass/discussions">Discussions</a>
|
||||||
|
</p>
|
||||||
|
|
||||||

|
<!-- GIF SLOT 1 — hero (~20s): clone → ./aipass install → live conversation with the concierge.
|
||||||
|
 -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,51 +34,15 @@ That's not a team. That's a room full of people wearing headphones.
|
|||||||
|
|
||||||
## What AIPass Does
|
## What AIPass Does
|
||||||
|
|
||||||
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard. You work in your terminal.
|
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard, no cloud. Everything is plain files on your machine; delete the directory and it's gone.
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/AIOSAI/AIPass.git
|
|
||||||
cd AIPass
|
|
||||||
./aipass install # installs everything, then walks you into your first project
|
|
||||||
```
|
|
||||||
|
|
||||||
One command does it all: builds the environment, puts `aipass` + `drone` on your PATH, then chains straight into a guided init that creates your project, your first agent, and opens a terminal where that agent is already running. Say "hi" — it knows who it is. Come back tomorrow — it remembers.
|
|
||||||
|
|
||||||
This is the base framework. It gives your agents the infrastructure to persist, communicate, and organize — everything else you build on top.
|
|
||||||
|
|
||||||
Here's what lands in your project:
|
|
||||||
|
|
||||||
```
|
|
||||||
my-project/
|
|
||||||
├── .aipass/ # Project config + prompts
|
|
||||||
├── .claude/ # Hooks (injected automatically)
|
|
||||||
├── src/my_project/
|
|
||||||
│ └── my_agent/
|
|
||||||
│ ├── .trinity/ # Identity + memory (3 JSON files)
|
|
||||||
│ ├── .ai_mail.local/ # Local mailbox
|
|
||||||
│ ├── apps/ # Your agent's code
|
|
||||||
│ └── README.md # Domain knowledge
|
|
||||||
├── CLAUDE.md # Project instructions
|
|
||||||
└── MY-PROJECT_REGISTRY.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Everything is plain files. No daemon, no hidden state. Delete the directory and it's gone.
|
|
||||||
|
|
||||||
**Start with one agent.** Add more when you need them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
|
||||||
```
|
|
||||||
|
|
||||||
**What makes this different:**
|
|
||||||
|
|
||||||
- **Agents are persistent.** They remember across sessions. Expertise develops over time. Nobody starts from zero.
|
- **Agents are persistent.** They remember across sessions. Expertise develops over time. Nobody starts from zero.
|
||||||
- **Bring your own project.** AIPass adds agent infrastructure to whatever you're building. It's a scaffold, not a product — you shape it.
|
- **Bring your own project.** AIPass adds agent infrastructure to whatever you're building. It's a scaffold, not a product — you shape it.
|
||||||
- **Everything is local.** Memory is JSON files. Communication is local mailbox files. No cloud, no external APIs.
|
- **Everything is local.** Memory is JSON files. Communication is local mailbox files. No cloud, no external APIs.
|
||||||
- **Shared workspace.** All agents work on the same filesystem, same project, same time. No sandboxes.
|
- **Shared workspace.** All agents work on the same filesystem, same project, same time. No sandboxes.
|
||||||
- **One command for everything.** AIPass ships with `drone`, a CLI router — `drone @agent command` reaches any agent. Learn it once, use it everywhere.
|
- **One command for everything.** `drone @agent command` reaches any agent. Learn it once, use it everywhere.
|
||||||
|
|
||||||
**Runs on your existing CLI subscription.** Claude Pro/Max or Codex — AIPass uses the same CLI binary you already run. No extra API keys, no extra costs for core functionality.
|
**Runs on your existing Claude subscription.** AIPass drives the same [Claude Code](https://code.claude.com/docs) binary you already run — Pro or Max. No extra API keys, no extra costs for core functionality.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,31 +53,47 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/AIOSAI/AIPass.git
|
git clone https://github.com/AIOSAI/AIPass.git
|
||||||
cd AIPass
|
cd AIPass
|
||||||
./aipass install # Creates venv, installs, puts `aipass` + `drone` on your PATH, bootstraps 17 agents
|
./aipass install
|
||||||
```
|
```
|
||||||
|
|
||||||
On an interactive terminal, install ends by chaining into `aipass init run` — one command takes you from clone to a working first project. Pass `--no-init` to skip the chain, `--project <dir>` to pick where the project lands (CI and piped shells skip automatically). `./aipass` is a thin repo-root launcher over `setup.sh`; after setup it simply forwards to the installed `aipass` binary.
|
One command does it all: builds the environment, puts `aipass` + `drone` on your PATH, bootstraps the 17-agent reference fleet, then walks you through a guided init — and ends **in a conversation**. The AIPass concierge opens right in your terminal with your install report in hand: it welcomes you, asks your name once, shows you around, and checks what your machine still needs — every machine is different.
|
||||||
|
|
||||||
|
Come back tomorrow, say "hi", and it picks up exactly where you left off. That's the whole interface.
|
||||||
|
|
||||||
|
<!-- GIF SLOT 2 — memory payoff (~15s): close the terminal, reopen, "hi", the agent recalls yesterday.
|
||||||
|
 -->
|
||||||
|
|
||||||
|
Options: `--no-init` skips the guided chain, `--project <dir>` picks where your project lands. Non-interactive shells (CI, pipes) complete with defaults and exit 0 — no prompts, no spawned sessions; the handoff prints as a next-step command instead. The installer wires Claude Code hooks automatically — merging with any hooks you've already configured, never overwriting them. `./aipass` is a thin repo-root launcher over `setup.sh`; after setup it forwards to the installed `aipass` binary.
|
||||||
|
|
||||||
### 2. Your own project (if you skipped the chain)
|
### 2. Your own project (if you skipped the chain)
|
||||||
|
|
||||||
|
Two ways in. From anywhere inside your AIPass environment, `aipass new` builds a complete project around a resident manager agent:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~ && mkdir my-project && cd my-project
|
aipass new my-project --template python # Project + resident manager agent + git birth commit
|
||||||
aipass init run # Guided setup — project, first agent, terminal handoff
|
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it. Your agent has identity, memory, a mailbox, and access to every AIPass service — planning, quality audits, dispatch, real-time monitoring. All through `drone @branch command`.
|
It mints the project registry, spawns a full citizen (identity, memory, mailbox, birth certificate) at `src/my_project/my_project`, makes the first commit — and drops you straight into a conversation with your new manager.
|
||||||
|
|
||||||
|
Or bring your own directory, anywhere on disk:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~ && mkdir my-project && cd my-project
|
||||||
|
aipass init run # Guided setup — project, first agent, ends in the conversation
|
||||||
|
```
|
||||||
|
|
||||||
|
Either way your agent has identity, memory, a mailbox, and access to every AIPass service — planning, quality audits, dispatch, real-time monitoring.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
aipass init # Just the scaffold (no guided setup)
|
aipass init # Just the scaffold (no guided setup)
|
||||||
aipass init agent my_agent # Add another agent
|
aipass init agent my_agent # Add another agent
|
||||||
aipass doctor # Check system health
|
aipass doctor # Check system health
|
||||||
|
aipass feedback off # Silence the occasional how-are-we-doing ask
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
|
### 3. Meet the fleet
|
||||||
|
|
||||||
### 3. Explore the full framework
|
The clone already includes all 17 agents working together — the reference implementation that maintains AIPass itself:
|
||||||
|
|
||||||
The clone above already includes all 17 agents working together — the reference implementation:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src/aipass/devpulse
|
cd src/aipass/devpulse
|
||||||
@@ -114,45 +101,33 @@ claude # Talk to the orchestrator
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Things you can do:
|
drone @seedgo audit aipass # Quality checks across all agents
|
||||||
aipass doctor # Check system health
|
drone @flow create . "Add user auth" # Create a work plan
|
||||||
drone @seedgo audit aipass # Run automated quality checks across all agents
|
drone @ai_mail dispatch @agent "Subject" "Body" # Send a task + wake an agent
|
||||||
drone @flow create . "Add user auth" # Create a work plan
|
|
||||||
drone @ai_mail dispatch @agent "Sub" "Body" # Send task + wake an agent
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
**One agent:** Run `aipass init run` and in 5 minutes you have a project with an agent that reads `.trinity/` on startup and picks up where it left off. Memory starts as plain JSON files — no setup required. When they fill up, older entries automatically archive into ChromaDB for long-term search. Nothing is lost.
|
**Memory.** Every agent owns a `.trinity/` directory — identity, session history, learnings — read on startup, updated as it works. Memory starts as plain JSON, no setup required. When files fill up, older entries automatically archive into ChromaDB for long-term semantic search. Nothing is lost.
|
||||||
|
|
||||||
**A team:** When one agent isn't enough, every agent shares the same structure:
|
**One structure.** Every agent — yours and the reference fleet — shares the same layout. If you know one agent, you know all of them:
|
||||||
|
|
||||||
```
|
```
|
||||||
src/my-project/<agent>/
|
src/my_project/<agent>/
|
||||||
├── .trinity/ # Identity + memory (persists across sessions)
|
├── .trinity/ # Identity + memory (persists across sessions)
|
||||||
├── .ai_mail.local/ # Mailbox (receives tasks, sends results)
|
├── .ai_mail.local/ # Mailbox (receives tasks, sends results)
|
||||||
├── apps/ # Entry point → modules → handlers
|
├── apps/ # Entry point → modules → handlers
|
||||||
└── README.md # Domain knowledge (the agent reads this on startup)
|
└── README.md # Domain knowledge (read on startup)
|
||||||
```
|
```
|
||||||
|
|
||||||
Identical layout everywhere. If you know one agent, you know all of them. `drone` is the single command that routes to any agent:
|
**One router.** `drone @branch command [args]` reaches any agent — routing, access tiers, and @agent resolution handled for you. Agents use the same commands to reach each other: they dispatch work, share findings, and wake whoever they're waiting on.
|
||||||
|
|
||||||
```bash
|
<!-- GIF SLOT 3 — team (~20s): dispatch a task to an agent, watchdog wake-back, result lands.
|
||||||
drone @branch command [args] # Every agent, every task. Drone handles routing.
|
 -->
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
drone @seedgo audit aipass # Run quality checks on everything
|
|
||||||
drone @flow create . "Refactor auth module" # Create a work plan
|
|
||||||
drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Two ways to use AIPass:**
|
|
||||||
|
|
||||||
- **Your own project:** `aipass init run` sets up a new project with your first agent. Add more agents as you need them. Your first agent is the orchestrator — it coordinates the others.
|
|
||||||
- **The full framework:** Clone the repo to work with all 17 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,8 +155,6 @@ devpulse (orchestrator)
|
|||||||
└── commons — the social space — post, comment, vote, gather
|
└── commons — the social space — post, comment, vote, gather
|
||||||
```
|
```
|
||||||
|
|
||||||
These agents work on the **same filesystem, same project, same time** — no sandboxes, no worktrees. This is the pattern your projects inherit.
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Agent details</summary>
|
<summary>Agent details</summary>
|
||||||
|
|
||||||
@@ -222,19 +195,6 @@ These agents work on the **same filesystem, same project, same time** — no san
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CLI Support
|
|
||||||
|
|
||||||
AIPass is built and tested with **Claude Code** on Linux/WSL.
|
|
||||||
|
|
||||||
| CLI | Autonomous Mode | Status |
|
|
||||||
|-----|----------------|--------|
|
|
||||||
| [Claude Code](https://code.claude.com/docs) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
|
||||||
| [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental |
|
|
||||||
|
|
||||||
The installer (`./aipass install`, powered by setup.sh) auto-detects which CLIs are installed and configures hooks for each — merging with any hooks you've already wired, never overwriting them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**Beta.** Actively developed by a solo developer working with the AI agents themselves — every PR, every test, every fix is human-AI collaboration.
|
**Beta.** Actively developed by a solo developer working with the AI agents themselves — every PR, every test, every fix is human-AI collaboration.
|
||||||
@@ -249,25 +209,14 @@ The installer (`./aipass install`, powered by setup.sh) auto-detects which CLIs
|
|||||||
|
|
||||||
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
|
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- [Claude Code](https://code.claude.com/docs)
|
- [Claude Code](https://code.claude.com/docs)
|
||||||
- Linux, macOS, or WSL (all CI-tested)
|
- Linux or WSL
|
||||||
- `sudo` access optional (for `/usr/local/bin` symlinks — falls back to `~/.local/bin` without sudo)
|
- `sudo` access optional (for `/usr/local/bin` symlinks — falls back to `~/.local/bin` without sudo)
|
||||||
- API keys optional (OpenRouter/OpenAI — for optional add-on agents)
|
- API keys optional (OpenRouter/OpenAI — for optional add-on agents)
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
These items have partial work done and are under ongoing testing:
|
|
||||||
|
|
||||||
- **macOS support** — CI green, full test suite passing ([#360](https://github.com/AIOSAI/AIPass/issues/360))
|
|
||||||
- **Windows native** — CI green, full test suite passing
|
|
||||||
- **Codex CLI** — hooks and AGENTS.md wired, needs end-to-end testing
|
|
||||||
- **Fork contributor workflow** — improved error handling for fork-based PRs ([#329](https://github.com/AIOSAI/AIPass/issues/329))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -305,9 +254,9 @@ This archives the agent's directory and removes it from the registry.
|
|||||||
|
|
||||||
### Use your existing subscription
|
### Use your existing subscription
|
||||||
|
|
||||||
AIPass runs on your **existing CLI subscription** — Claude Pro/Max or Codex. No API keys required for core functionality. No extra costs beyond your existing subscription.
|
AIPass runs on your **existing Claude subscription** — Pro or Max. No API keys required for core functionality. No extra costs beyond your existing subscription.
|
||||||
|
|
||||||
This works because AIPass runs each CLI as an **official subprocess** — the same binary you'd run yourself in a terminal. It doesn't extract credentials, proxy API calls, or intercept tokens. Your subscription stays within the provider's infrastructure at all times.
|
This works because AIPass runs Claude Code as an **official subprocess** — the same binary you'd run yourself in a terminal. It doesn't extract credentials, proxy API calls, or intercept tokens. Your subscription stays within the provider's infrastructure at all times.
|
||||||
|
|
||||||
### What AIPass does NOT do
|
### What AIPass does NOT do
|
||||||
|
|
||||||
@@ -316,7 +265,7 @@ This works because AIPass runs each CLI as an **official subprocess** — the sa
|
|||||||
- Bypass rate limits or prompt caching
|
- Bypass rate limits or prompt caching
|
||||||
- Impersonate official CLI clients
|
- Impersonate official CLI clients
|
||||||
|
|
||||||
Claude Code is proprietary but officially supports hooks and subprocess usage. Codex CLI is open source (Apache 2.0).
|
Claude Code is proprietary but officially supports hooks and subprocess usage.
|
||||||
|
|
||||||
> API keys are only needed for optional add-on agents (OpenRouter/OpenAI). For server/automated deployments, API key authentication is recommended per [Anthropic's guidance](https://code.claude.com/docs/en/legal-and-compliance).
|
> API keys are only needed for optional add-on agents (OpenRouter/OpenAI). For server/automated deployments, API key authentication is recommended per [Anthropic's guidance](https://code.claude.com/docs/en/legal-and-compliance).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
[← Back to AIPass](../README.md)
|
||||||
|
|
||||||
|
# Projects
|
||||||
|
|
||||||
|
> Your projects, built with AIPass. **Private by default** — published only if and when you choose.
|
||||||
|
|
||||||
|
This folder is where your own projects live. Create one with `aipass new <name>` and build whatever you want here — a tool, an app, an experiment, something nobody else will ever see. Each project is **its own separate git repository**, and the AIPass repo ignores everything in this folder (only this README ships). Nothing you build here can leak into the AIPass repo, appear in its history, or end up in anyone's pull request.
|
||||||
|
|
||||||
|
## What a project is
|
||||||
|
|
||||||
|
Every project is born complete:
|
||||||
|
|
||||||
|
- **Own git repo** — initialized locally at creation. Local means local: no remote, no publishing, nothing leaves your machine.
|
||||||
|
- **Own registry** — a sealed `*_REGISTRY.json` seating the project's owner.
|
||||||
|
- **Resident agent** — a full AIPass citizen living at `src/<name>/<name>/`, with identity, memory, and a mailbox, ready to work the project.
|
||||||
|
- **AIPass scaffold** — the same `.aipass/` prompt structure and conventions as the main ecosystem, so any agent (or human) knows their way around immediately.
|
||||||
|
|
||||||
|
Projects use AIPass; they don't live inside its repo. The ecosystem is the workshop — what you build in it is yours.
|
||||||
|
|
||||||
|
## Going public — optional, deliberate
|
||||||
|
|
||||||
|
If a project is ready for the world, publishing is an explicit choice: create a GitHub repository for it and push. Until you do that, it exists only on your machine.
|
||||||
|
|
||||||
|
Projects from the AIPass family that chose to go public:
|
||||||
|
|
||||||
|
| Project | What it is | Repo |
|
||||||
|
|---|---|---|
|
||||||
|
| **Earmark** | Read code and docs aloud in VS Code with local Piper TTS — pause, resume, pick up where you left off. Ear + bookmark: the plan is notes anchored to where you paused. First public AIPass project. | [AIOSAI/earmark](https://github.com/AIOSAI/earmark) |
|
||||||
|
| **aipass-site** | The [aipass.ai](https://aipass.ai) website — AIPass's front door on the web. | [AIOSAI/aipass-site](https://github.com/AIOSAI/aipass-site) |
|
||||||
|
|
||||||
|
Projects in residence (private, not yet published):
|
||||||
|
|
||||||
|
| Project | What it is |
|
||||||
|
|---|---|
|
||||||
|
| **Speakeasy** | System-wide voice-to-text: press a hotkey, speak, text lands at your cursor in any app. Local Whisper (faster-whisper), VAD, zero cloud. The voice-IN half of the loop Earmark's voice-OUT completes. Repo moved from ~/Projects/Speakeasy 2026-07-21, own git history intact. |
|
||||||
|
|
||||||
|
## Creating one
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aipass new <name> # empty template + resident agent
|
||||||
|
aipass new <name> --template python
|
||||||
|
aipass new <name> --no-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
The project lands in `projects/<name>`, git-initialized, registry sealed, agent seated — and private until you decide otherwise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Back to AIPass](../README.md)
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "aipass"
|
name = "aipass"
|
||||||
version = "2.7.1"
|
version = "2.7.3"
|
||||||
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
|
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
|
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.7.1"
|
__version__ = "2.7.3"
|
||||||
|
|||||||
@@ -11,6 +11,25 @@
|
|||||||
|
|
||||||
**Status:** Operational | **Seedgo:** 100% (34/34) | **Tests:** 712 pass | **Battle Tested:** S62
|
**Status:** Operational | **Seedgo:** 100% (34/34) | **Tests:** 712 pass | **Battle Tested:** S62
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check your inbox
|
||||||
|
drone @ai_mail inbox
|
||||||
|
|
||||||
|
# View a message
|
||||||
|
drone @ai_mail view <id>
|
||||||
|
|
||||||
|
# Reply and close
|
||||||
|
drone @ai_mail reply <id> "your message"
|
||||||
|
|
||||||
|
# Send mail to another branch
|
||||||
|
drone @ai_mail email @target "Subject" "Body"
|
||||||
|
|
||||||
|
# Dispatch (send + wake target agent)
|
||||||
|
drone @ai_mail dispatch @target "Subject" "Body"
|
||||||
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from aipass.ai_mail.apps.handlers.json import json_handler
|
|||||||
from aipass.ai_mail.apps.handlers.dispatch.status import log_dispatch
|
from aipass.ai_mail.apps.handlers.dispatch.status import log_dispatch
|
||||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||||
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
|
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
|
||||||
|
from aipass.ai_mail.apps.handlers.dispatch.wake import DEFAULT_MODEL
|
||||||
|
|
||||||
|
|
||||||
# Infrastructure paths
|
# Infrastructure paths
|
||||||
@@ -408,6 +409,8 @@ def spawn_agent(
|
|||||||
"-c",
|
"-c",
|
||||||
"-p",
|
"-p",
|
||||||
prompt,
|
prompt,
|
||||||
|
"--model",
|
||||||
|
DEFAULT_MODEL,
|
||||||
"--max-turns",
|
"--max-turns",
|
||||||
str(max_turns),
|
str(max_turns),
|
||||||
"--permission-mode",
|
"--permission-mode",
|
||||||
|
|||||||
@@ -449,6 +449,9 @@ def main():
|
|||||||
for key in list(spawn_env.keys()):
|
for key in list(spawn_env.keys()):
|
||||||
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID":
|
||||||
spawn_env.pop(key)
|
spawn_env.pop(key)
|
||||||
|
# Pin agent context window to 200k (Sonnet 5 is 1M native; without this,
|
||||||
|
# agents inherit 1M which causes cost + runaway risk).
|
||||||
|
spawn_env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = "200000"
|
||||||
# Strip caller identity vars to prevent dispatch context leakage.
|
# Strip caller identity vars to prevent dispatch context leakage.
|
||||||
spawn_env.pop("AIPASS_CALLER_BRANCH", None)
|
spawn_env.pop("AIPASS_CALLER_BRANCH", None)
|
||||||
spawn_env.pop("AIPASS_CALLER_CWD", None)
|
spawn_env.pop("AIPASS_CALLER_CWD", None)
|
||||||
|
|||||||
@@ -63,13 +63,9 @@ MONITOR_SCRIPT = Path(__file__).parent / "dispatch_monitor.py"
|
|||||||
# Default prompt when no custom message provided
|
# Default prompt when no custom message provided
|
||||||
DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done."
|
DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done."
|
||||||
|
|
||||||
# Model shorthand mapping
|
# Model aliases — passed directly to claude CLI which resolves latest-in-class.
|
||||||
MODEL_MAP = {
|
KNOWN_MODEL_ALIASES: frozenset = frozenset({"sonnet", "opus", "haiku"})
|
||||||
"sonnet": "claude-sonnet-4-6",
|
DEFAULT_MODEL = "sonnet"
|
||||||
"opus": "claude-opus-4-6",
|
|
||||||
"haiku": "claude-haiku-4-5-20251001",
|
|
||||||
}
|
|
||||||
DEFAULT_MODEL = "opus"
|
|
||||||
|
|
||||||
# Branches that cannot be woken manually by cross-branch drone commands.
|
# Branches that cannot be woken manually by cross-branch drone commands.
|
||||||
# Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check.
|
# Dispatch-send path (dispatch.py._orchestrate_dispatch_send) bypasses this check.
|
||||||
@@ -591,8 +587,8 @@ def wake_branch(
|
|||||||
config = _load_config()
|
config = _load_config()
|
||||||
max_turns = config.get("max_turns_per_wake", 100)
|
max_turns = config.get("max_turns_per_wake", 100)
|
||||||
|
|
||||||
# Resolve model: shorthand -> full ID, or pass through if already a full ID
|
# Pass model directly to CLI — aliases resolve latest-in-class automatically
|
||||||
resolved_model = MODEL_MAP.get(model or DEFAULT_MODEL, model or MODEL_MAP[DEFAULT_MODEL])
|
resolved_model = model or DEFAULT_MODEL
|
||||||
|
|
||||||
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
|
lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock")
|
||||||
if custom_message:
|
if custom_message:
|
||||||
@@ -781,7 +777,7 @@ if __name__ == "__main__":
|
|||||||
print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)")
|
print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)")
|
||||||
print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.")
|
print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.")
|
||||||
print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)")
|
print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)")
|
||||||
print(" --model NAME Model to use: opus (default), sonnet, haiku, or full model ID")
|
print(" --model NAME Model to use: sonnet (default), opus, haiku, or full model ID")
|
||||||
print()
|
print()
|
||||||
print("Output: Step-by-step status of the dispatch pipeline:")
|
print("Output: Step-by-step status of the dispatch pipeline:")
|
||||||
print(" ✅ resolve → @branch found at /path/to/branch")
|
print(" ✅ resolve → @branch found at /path/to/branch")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from typing import Dict, Tuple, List, Optional, Callable
|
|||||||
|
|
||||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
from aipass.ai_mail.apps.handlers.paths import find_repo_root, find_project_root
|
||||||
from aipass.ai_mail.apps.handlers.registry.read import get_all_branches
|
from aipass.ai_mail.apps.handlers.registry.read import get_all_branches
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@@ -213,6 +213,44 @@ def _resolve_reply_path() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _check_cross_project_boundary(recipient_path: Path, sender_email: str) -> Tuple[bool, str]:
|
||||||
|
"""Refuse mail when sender and recipient are in different projects.
|
||||||
|
|
||||||
|
Compares project roots (first *_REGISTRY.json found walking up) for the
|
||||||
|
sender (from AIPASS_CALLER_CWD) and recipient (from resolved branch path).
|
||||||
|
Same-project and host-to-host mail passes through unchanged.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(True, error_message) to refuse, (False, "") to allow.
|
||||||
|
"""
|
||||||
|
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", "")
|
||||||
|
if not caller_cwd:
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
sender_root = find_project_root(Path(caller_cwd))
|
||||||
|
if sender_root is None:
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
recipient_root = find_project_root(recipient_path)
|
||||||
|
if recipient_root is None:
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
if sender_root == recipient_root:
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
sender_name = sender_email or os.environ.get("AIPASS_CALLER_BRANCH", "unknown")
|
||||||
|
logger.warning(
|
||||||
|
"[delivery] cross-project mail refused: sender root %s != recipient root %s",
|
||||||
|
sender_root,
|
||||||
|
recipient_root,
|
||||||
|
)
|
||||||
|
return True, (
|
||||||
|
f"Cross-project mail refused: {sender_name} (project: {sender_root.name}) "
|
||||||
|
f"cannot send to this branch (project: {recipient_root.name}). "
|
||||||
|
f"Use the feedback channel for cross-project communication."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def deliver_email_to_branch(
|
def deliver_email_to_branch(
|
||||||
to_branch: str, email_data: Dict, on_delivered: Optional[Callable] = None
|
to_branch: str, email_data: Dict, on_delivered: Optional[Callable] = None
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
@@ -283,6 +321,11 @@ def deliver_email_to_branch(
|
|||||||
if not branch_path.is_absolute():
|
if not branch_path.is_absolute():
|
||||||
branch_path = (_REPO_ROOT / branch_path).resolve()
|
branch_path = (_REPO_ROOT / branch_path).resolve()
|
||||||
|
|
||||||
|
# Cross-project boundary: refuse mail when sender and recipient are in different projects
|
||||||
|
refused, refusal_msg = _check_cross_project_boundary(branch_path, sender_email)
|
||||||
|
if refused:
|
||||||
|
return False, refusal_msg
|
||||||
|
|
||||||
# Find the branch's .ai_mail.local/inbox.json file
|
# Find the branch's .ai_mail.local/inbox.json file
|
||||||
if branch_path == Path("/") or branch_path == _REPO_ROOT:
|
if branch_path == Path("/") or branch_path == _REPO_ROOT:
|
||||||
inbox_file = _REPO_ROOT / ".ai_mail.local" / "inbox.json"
|
inbox_file = _REPO_ROOT / ".ai_mail.local" / "inbox.json"
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ Consolidated from 8 identical copies per DPLAN-0036 audit.
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@@ -36,6 +38,23 @@ def find_repo_root() -> Path:
|
|||||||
return Path.cwd()
|
return Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def find_project_root(start: Path) -> Optional[Path]:
|
||||||
|
"""Walk up from *start* to find the first *_REGISTRY.json (project root).
|
||||||
|
|
||||||
|
Returns the directory containing the registry, or None if not found.
|
||||||
|
Stops at filesystem root.
|
||||||
|
"""
|
||||||
|
current = start.resolve()
|
||||||
|
for candidate in [current] + list(current.parents):
|
||||||
|
try:
|
||||||
|
if any(candidate.glob("*_REGISTRY.json")):
|
||||||
|
return candidate
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[paths] find_project_root: glob failed at %s: %s", candidate, exc)
|
||||||
|
break
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from aipass.cli.apps.modules import console
|
from aipass.cli.apps.modules import console
|
||||||
|
|
||||||
|
|||||||
@@ -1317,6 +1317,48 @@ def test_spawn_agent_strips_claude_env_vars(tmp_path, monkeypatch):
|
|||||||
assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon"
|
assert captured_env.get("AIPASS_SESSION_TYPE") == "daemon"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_agent_claude_cmd_includes_model_flag(tmp_path):
|
||||||
|
"""Poller-triggered spawn passes --model DEFAULT_MODEL to the claude invocation."""
|
||||||
|
branch_path = tmp_path / "branch"
|
||||||
|
branch_path.mkdir()
|
||||||
|
(branch_path / "logs").mkdir()
|
||||||
|
|
||||||
|
message = {"from": "@devpulse", "id": "msg1", "subject": "Test task"}
|
||||||
|
config = {"max_turns_per_wake": 50}
|
||||||
|
state = {"daily_counts": {}, "session_cycles": {}}
|
||||||
|
|
||||||
|
captured_args = []
|
||||||
|
|
||||||
|
def capture_popen(*args, **kwargs):
|
||||||
|
captured_args.append(args[0] if args else kwargs.get("args"))
|
||||||
|
mock_proc = MagicMock()
|
||||||
|
mock_proc.pid = 22222
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = spawn_agent(branch_path, "@testbranch", message, config, state)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
monitor_cmd = captured_args[0]
|
||||||
|
assert "--model" in monitor_cmd
|
||||||
|
model_idx = monitor_cmd.index("--model")
|
||||||
|
assert monitor_cmd[model_idx + 1] == daemon_mod.DEFAULT_MODEL
|
||||||
|
|
||||||
|
|
||||||
def test_spawn_agent_prompt_includes_reply_id(tmp_path):
|
def test_spawn_agent_prompt_includes_reply_id(tmp_path):
|
||||||
"""Prompt includes explicit reply command with the dispatch email ID."""
|
"""Prompt includes explicit reply command with the dispatch email ID."""
|
||||||
branch_path = tmp_path / "branch"
|
branch_path = tmp_path / "branch"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from unittest.mock import patch, MagicMock
|
|||||||
|
|
||||||
import aipass.ai_mail.apps.handlers.email.delivery as delivery_mod
|
import aipass.ai_mail.apps.handlers.email.delivery as delivery_mod
|
||||||
from aipass.ai_mail.apps.handlers.email.delivery import (
|
from aipass.ai_mail.apps.handlers.email.delivery import (
|
||||||
|
_check_cross_project_boundary,
|
||||||
_migrate_inbox_format,
|
_migrate_inbox_format,
|
||||||
_is_private_branch_email,
|
_is_private_branch_email,
|
||||||
_resolve_reply_path,
|
_resolve_reply_path,
|
||||||
@@ -493,3 +494,121 @@ def test_deliver_stores_reply_path_from_env(tmp_path, repo_root, noop_inbox_lock
|
|||||||
msg = inbox["messages"][0]
|
msg = inbox["messages"][0]
|
||||||
assert "reply_path" in msg
|
assert "reply_path" in msg
|
||||||
assert msg["reply_path"] == str(inbox_file)
|
assert msg["reply_path"] == str(inbox_file)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- _check_cross_project_boundary() tests ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_no_caller_cwd_allows(tmp_path, monkeypatch):
|
||||||
|
"""No AIPASS_CALLER_CWD → host-internal, always allowed."""
|
||||||
|
monkeypatch.delenv("AIPASS_CALLER_CWD", raising=False)
|
||||||
|
refused, _ = _check_cross_project_boundary(tmp_path, "@sender")
|
||||||
|
assert refused is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_same_root_allows(tmp_path, monkeypatch):
|
||||||
|
"""Sender and recipient in the same project → allowed."""
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
sender_dir = tmp_path / "src" / "branch_a"
|
||||||
|
sender_dir.mkdir(parents=True)
|
||||||
|
recipient_dir = tmp_path / "src" / "branch_b"
|
||||||
|
recipient_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||||
|
refused, _ = _check_cross_project_boundary(recipient_dir, "@branch_b")
|
||||||
|
assert refused is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_different_roots_refuses(tmp_path, monkeypatch):
|
||||||
|
"""Sender in nested project, recipient in host → refused."""
|
||||||
|
host = tmp_path / "host"
|
||||||
|
host.mkdir()
|
||||||
|
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
recipient_dir = host / "src" / "devpulse"
|
||||||
|
recipient_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
project = host / "projects" / "myproj"
|
||||||
|
project.mkdir(parents=True)
|
||||||
|
(project / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
sender_dir = project / "src"
|
||||||
|
sender_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||||
|
refused, msg = _check_cross_project_boundary(recipient_dir, "@proj_agent")
|
||||||
|
assert refused is True
|
||||||
|
assert "Cross-project mail refused" in msg
|
||||||
|
assert "feedback channel" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_sender_no_registry_allows(tmp_path, monkeypatch):
|
||||||
|
"""Sender in dir with no registry → cannot determine boundary, allow."""
|
||||||
|
isolated = tmp_path / "nowhere"
|
||||||
|
isolated.mkdir()
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(isolated))
|
||||||
|
|
||||||
|
recipient = tmp_path / "host" / "branch"
|
||||||
|
recipient.mkdir(parents=True)
|
||||||
|
(tmp_path / "host" / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
refused, _ = _check_cross_project_boundary(recipient, "@branch")
|
||||||
|
assert refused is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_recipient_no_registry_allows(tmp_path, monkeypatch):
|
||||||
|
"""Recipient in dir with no registry → cannot determine boundary, allow."""
|
||||||
|
sender_dir = tmp_path / "host" / "src"
|
||||||
|
sender_dir.mkdir(parents=True)
|
||||||
|
(tmp_path / "host" / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||||
|
|
||||||
|
recipient = tmp_path / "orphan"
|
||||||
|
recipient.mkdir()
|
||||||
|
|
||||||
|
refused, _ = _check_cross_project_boundary(recipient, "@orphan")
|
||||||
|
assert refused is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_delivery_e2e_refused(tmp_path, repo_root, noop_inbox_lock, monkeypatch):
|
||||||
|
"""End-to-end: delivery from nested project to host branch is refused."""
|
||||||
|
host_root = repo_root
|
||||||
|
(host_root / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
branches = _setup_branch(tmp_path)
|
||||||
|
|
||||||
|
project = host_root / "projects" / "testproj"
|
||||||
|
project.mkdir(parents=True)
|
||||||
|
(project / "TESTPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
sender_cwd = project / "src"
|
||||||
|
sender_cwd.mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_cwd))
|
||||||
|
|
||||||
|
with patch.object(delivery_mod, "get_all_branches", return_value=branches):
|
||||||
|
success, error = deliver_email_to_branch(
|
||||||
|
"@target",
|
||||||
|
_make_email_data(sender="@testproj"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert "Cross-project mail refused" in error
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_project_delivery_same_project_allowed(tmp_path, repo_root, noop_inbox_lock, monkeypatch):
|
||||||
|
"""End-to-end: delivery within the same project is allowed."""
|
||||||
|
(repo_root / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
branches = _setup_branch(tmp_path)
|
||||||
|
|
||||||
|
sender_cwd = tmp_path / "src" / "other_branch"
|
||||||
|
sender_cwd.mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_cwd))
|
||||||
|
|
||||||
|
with patch.object(delivery_mod, "get_all_branches", return_value=branches):
|
||||||
|
success, error = deliver_email_to_branch(
|
||||||
|
"@target",
|
||||||
|
_make_email_data(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert error == ""
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
# Modified: 2026-04-03
|
# Modified: 2026-04-03
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
"""Tests for paths module -- repo root discovery."""
|
"""Tests for paths module -- repo root discovery and project root resolution."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import aipass.ai_mail.apps.handlers.paths as mod
|
import aipass.ai_mail.apps.handlers.paths as mod
|
||||||
|
from aipass.ai_mail.apps.handlers.paths import find_project_root
|
||||||
|
|
||||||
|
|
||||||
# --- Fixtures --------------------------------------------------------
|
# --- Fixtures --------------------------------------------------------
|
||||||
@@ -84,3 +85,53 @@ def test_find_repo_root_finds_registry_in_same_dir(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
result = mod.find_repo_root()
|
result = mod.find_repo_root()
|
||||||
assert result == tmp_path
|
assert result == tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# --- find_project_root tests --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_project_root_finds_registry(tmp_path):
|
||||||
|
"""Returns directory containing *_REGISTRY.json."""
|
||||||
|
project = tmp_path / "projects" / "myproj"
|
||||||
|
deep = project / "src" / "pkg"
|
||||||
|
deep.mkdir(parents=True)
|
||||||
|
(project / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
assert find_project_root(deep) == project
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_project_root_finds_host_registry(tmp_path):
|
||||||
|
"""Returns host repo root when AIPASS_REGISTRY.json is the first hit."""
|
||||||
|
host = tmp_path / "repo"
|
||||||
|
branch = host / "src" / "aipass" / "branch"
|
||||||
|
branch.mkdir(parents=True)
|
||||||
|
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
assert find_project_root(branch) == host
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_project_root_stops_at_first_registry(tmp_path):
|
||||||
|
"""Nested project registry is found before the host registry."""
|
||||||
|
host = tmp_path / "repo"
|
||||||
|
project = host / "projects" / "inner"
|
||||||
|
deep = project / "src"
|
||||||
|
deep.mkdir(parents=True)
|
||||||
|
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
(project / "INNER_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
assert find_project_root(deep) == project
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_project_root_none_when_no_registry(tmp_path):
|
||||||
|
"""Returns None when no *_REGISTRY.json is found anywhere."""
|
||||||
|
deep = tmp_path / "a" / "b" / "c"
|
||||||
|
deep.mkdir(parents=True)
|
||||||
|
|
||||||
|
assert find_project_root(deep) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_project_root_at_start_dir(tmp_path):
|
||||||
|
"""Returns start dir itself when it contains the registry."""
|
||||||
|
(tmp_path / "PROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
assert find_project_root(tmp_path) == tmp_path
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from aipass.ai_mail.apps.handlers.dispatch.wake import (
|
|||||||
_find_claude_bin,
|
_find_claude_bin,
|
||||||
resolve_branch,
|
resolve_branch,
|
||||||
DispatchStatus,
|
DispatchStatus,
|
||||||
MODEL_MAP,
|
KNOWN_MODEL_ALIASES,
|
||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
_acquire_lock,
|
_acquire_lock,
|
||||||
_load_config,
|
_load_config,
|
||||||
@@ -572,24 +572,22 @@ def _fake_open_factory(real_status_path, mapping):
|
|||||||
# --- Model flag tests ---------------------------------------------------
|
# --- Model flag tests ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_model_map_has_expected_entries():
|
def test_known_model_aliases_has_expected_entries():
|
||||||
"""MODEL_MAP should contain sonnet, opus, haiku shorthand mappings."""
|
"""KNOWN_MODEL_ALIASES should contain sonnet, opus, haiku."""
|
||||||
assert "sonnet" in MODEL_MAP
|
assert "sonnet" in KNOWN_MODEL_ALIASES
|
||||||
assert "opus" in MODEL_MAP
|
assert "opus" in KNOWN_MODEL_ALIASES
|
||||||
assert "haiku" in MODEL_MAP
|
assert "haiku" in KNOWN_MODEL_ALIASES
|
||||||
assert "claude-sonnet-4-6" in MODEL_MAP["sonnet"]
|
|
||||||
assert "claude-opus-4-6" in MODEL_MAP["opus"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_model_is_opus():
|
def test_default_model_is_sonnet():
|
||||||
"""Default model should be opus."""
|
"""Default model should be sonnet."""
|
||||||
assert DEFAULT_MODEL == "opus"
|
assert DEFAULT_MODEL == "sonnet"
|
||||||
|
|
||||||
|
|
||||||
def test_model_map_values_are_full_ids():
|
def test_known_model_aliases_are_bare_names():
|
||||||
"""All MODEL_MAP values should be full claude model IDs."""
|
"""All KNOWN_MODEL_ALIASES should be bare alias names (no 'claude-' prefix)."""
|
||||||
for key, value in MODEL_MAP.items():
|
for alias in KNOWN_MODEL_ALIASES:
|
||||||
assert value.startswith("claude-"), f"{key} -> {value} doesn't start with 'claude-'"
|
assert not alias.startswith("claude-"), f"{alias} should be a bare alias"
|
||||||
|
|
||||||
|
|
||||||
# --- _find_claude_bin tests ------------------------------------------
|
# --- _find_claude_bin tests ------------------------------------------
|
||||||
|
|||||||
@@ -77,8 +77,33 @@ apps/
|
|||||||
- **Never pretend.** Don't know → say so, offer find out or ask branch expert.
|
- **Never pretend.** Don't know → say so, offer find out or ask branch expert.
|
||||||
- **Clean handoffs.** Every init stage saves `setup_progress` `.trinity/local.json` — resume works.
|
- **Clean handoffs.** Every init stage saves `setup_progress` `.trinity/local.json` — resume works.
|
||||||
|
|
||||||
|
## Welcome Mode — Fresh Install
|
||||||
|
|
||||||
|
Trigger: first message mentions "Fresh AIPass install" or you detect a fresh install context.
|
||||||
|
|
||||||
|
**Opening — three jobs in one tight block:**
|
||||||
|
1. Say who you are and what you know: "I'm the AIPass concierge — I know this framework, every agent in it, and I'll remember what we set up."
|
||||||
|
2. Show 3-5 concrete starters with exact commands:
|
||||||
|
- `drone systems` — see every agent in the ecosystem
|
||||||
|
- `drone @prax monitor run` — watch the system work live (leave this running in another terminal)
|
||||||
|
- `aipass doctor` — check what's healthy and what needs wiring
|
||||||
|
- `aipass help "how does memory work?"` — ask me anything about the framework
|
||||||
|
- `drone @hooks hooksound` — toggle sound notifications (hear hooks firing as you work, or mute if distracting)
|
||||||
|
3. Ask their name ONCE: "What should I call you? I'll remember it — next time you open this, I'll know who you are. Skip if you'd rather not." Accept skip gracefully. Never re-ask.
|
||||||
|
|
||||||
|
**Deferred triage (~turn 5):** After rapport is built, suggest completing setup. Frame it as "every machine is different — let's see what yours needs" rather than dumping a checklist.
|
||||||
|
|
||||||
|
**Hooks-first verification:** The first real setup task. Dispatch @hooks to investigate and report: `drone @ai_mail dispatch @hooks "Hooks health check" "Check if hooks are wired correctly for this installation. Include trust-registry enrollment status. Report what's green and what needs wiring."` Then check your inbox conversationally: `drone @ai_mail inbox`
|
||||||
|
|
||||||
|
**Setup DPLAN:** When the user is ready for the full setup pass, create a setup plan seeded from the cross-OS checklist: `drone @flow create . "Machine setup — post-install verification"` and reference `aipass doctor --cross-os` for the machine-specific gaps.
|
||||||
|
|
||||||
|
**Windows detected:** If system detection shows Windows (not WSL), recommend WSL: "AIPass works best on Linux/macOS or WSL. Want me to walk you through setting up WSL?" Offer a playbook.
|
||||||
|
|
||||||
|
**Feedback pulse — mention once:** "How's the experience so far? Your feedback is hugely appreciated — this is an open-source project and fresh-machine experience is the data we can't get any other way. https://github.com/AIOSAI/AIPass/issues — or turn reminders off anytime: `aipass feedback off`"
|
||||||
|
|
||||||
|
**Every suggestion ships its exact command.** Never say "you can check the agents" — say "run `drone systems` to see every agent."
|
||||||
|
|
||||||
## Known Gotchas
|
## Known Gotchas
|
||||||
|
|
||||||
- **Status: under construction (DPLAN-0136).** Don't PR / reveal this branch until Phase 8 — that's a *policy*, NOT a gitignore. Only the usual runtime/memory layer is ignored (`.trinity/`, plan files, `*.local`, logs) same as every branch; my code (init_flow, cross_os, tests, README) IS trackable. Committed-or-not = git's call, devpulse's lane.
|
- **`aipass` binary is THIS branch's CLI** — installed on PATH, ships publicly (post-FPLAN-0333). init/install/new/doctor/help/profile/trust/feedback all route here. Citizen creation inside the host framework is still `drone @spawn create`.
|
||||||
- **`aipass` binary currently `cli` branch's `aipass init`** — project bootstrap, not citizen creation. Eventually this CLI entry moves here. Until then, use `drone @spawn create` citizen creation.
|
|
||||||
- **Test-convention tokens need buy-in.** Core agents don't yet recognize `[AIPASS-TEST — ...]`. Coordinating @ai_mail before pinging anyone.
|
- **Test-convention tokens need buy-in.** Core agents don't yet recognize `[AIPASS-TEST — ...]`. Coordinating @ai_mail before pinging anyone.
|
||||||
|
|||||||
@@ -31,20 +31,10 @@
|
|||||||
"standard": "cli",
|
"standard": "cli",
|
||||||
"reason": "Session info must print immediately after tmux spawn — returning data to module layer would lose the timing context. User needs attach/kill instructions right when the session starts."
|
"reason": "Session info must print immediately after tmux spawn — returning data to module layer would lose the timing context. User needs attach/kill instructions right when the session starts."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "apps/aipass.py",
|
|
||||||
"standard": "cli",
|
|
||||||
"reason": "Thin command router — discovers and routes to modules, which own the CLI service layer. Adding console/header imports here couples the bootstrap entry point to Rich for 4 status lines."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "apps/aipass.py",
|
|
||||||
"standard": "debug_print",
|
|
||||||
"reason": "Thin command router uses bare print() for version output and help banner (4 calls). These run before module discovery — importing Rich console for bootstrap output adds startup overhead for minimal benefit."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "apps/aipass.py",
|
"file": "apps/aipass.py",
|
||||||
"standard": "introspection",
|
"standard": "introspection",
|
||||||
"reason": "Thin command router, not a module — it has no domain to introspect. Modules handle their own introspection via --info. No print_introspection() needed."
|
"reason": "Entry point router — introspection is in print_introspection() called from main() on no-args/--help. Not a module with handle_command()."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/doctor.py",
|
"file": "apps/modules/doctor.py",
|
||||||
@@ -257,14 +247,34 @@
|
|||||||
"reason": "aipass is binary-invoked: aipass doctor runs the command; introspection via --info"
|
"reason": "aipass is binary-invoked: aipass doctor runs the command; introspection via --info"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/doctor_fix.py",
|
"file": "apps/modules/_doctor_fix.py",
|
||||||
"standard": "introspection",
|
"standard": "introspection",
|
||||||
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
|
"reason": "Private helper module for doctor.py — not directly invokable, no introspection needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/doctor_wire.py",
|
"file": "apps/modules/_doctor_wire.py",
|
||||||
"standard": "introspection",
|
"standard": "introspection",
|
||||||
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
|
"reason": "Private helper module for doctor.py — not directly invokable, no introspection needed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/_doctor_fix.py",
|
||||||
|
"standard": "naming",
|
||||||
|
"reason": "Leading underscore is intentional — hides from module discovery to pass cli_ux no_internal_modules check."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/_doctor_wire.py",
|
||||||
|
"standard": "naming",
|
||||||
|
"reason": "Leading underscore is intentional — hides from module discovery to pass cli_ux no_internal_modules check."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/_doctor_fix.py",
|
||||||
|
"standard": "meta",
|
||||||
|
"reason": "Private helper module for doctor.py — meta name matches actual filename _doctor_fix.py."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/_doctor_wire.py",
|
||||||
|
"standard": "meta",
|
||||||
|
"reason": "Private helper module for doctor.py — meta name matches actual filename _doctor_wire.py."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "apps/modules/handoff.py",
|
"file": "apps/modules/handoff.py",
|
||||||
@@ -375,6 +385,41 @@
|
|||||||
"file": "shared/json_ops.py",
|
"file": "shared/json_ops.py",
|
||||||
"standard": "unused_function",
|
"standard": "unused_function",
|
||||||
"reason": "backup_json() is consumed by @spawn (cross-branch caller). Appears unused in @aipass-only scan but is a shared API."
|
"reason": "backup_json() is consumed by @spawn (cross-branch caller). Appears unused in @aipass-only scan but is a shared API."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/trust.py",
|
||||||
|
"standard": "encapsulation",
|
||||||
|
"reason": "Imports frozen trust_registry interface (enroll/revoke/is_trusted/read_registry) from @hooks by DPLAN-0244 design. Cross-branch import required — the registry module lives in hooks, consumers live in aipass."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/trust.py",
|
||||||
|
"standard": "json_structure",
|
||||||
|
"reason": "No JSON file operations — trust.py is a thin CLI wrapper that delegates all JSON I/O to the trust_registry module in @hooks. No json_handler needed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/modules/trust.py",
|
||||||
|
"standard": "introspection",
|
||||||
|
"reason": "aipass is binary-invoked: bare 'aipass trust' shows registry table; introspection via --info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/handlers/init/bootstrap.py",
|
||||||
|
"standard": "encapsulation",
|
||||||
|
"reason": "Imports enroll() from @hooks trust_registry (DPLAN-0244 frozen interface). Cross-branch import required — init must enroll projects in the trust registry after writing hooks.json."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "apps/handlers/init/bootstrap.py",
|
||||||
|
"standard": "handlers",
|
||||||
|
"reason": "Imports enroll() from @hooks trust_registry by DPLAN-0244 design. Cross-handler import required — bootstrap auto-enrolls projects after hooks.json creation/merge."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/test_trust.py",
|
||||||
|
"standard": "architecture",
|
||||||
|
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/test_trust.py",
|
||||||
|
"standard": "encapsulation",
|
||||||
|
"reason": "Tests import trust_registry directly to verify enrollment/revocation in isolation with monkeypatched REGISTRY_PATH."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-14
@@ -1,11 +1,23 @@
|
|||||||
# AIPASS
|
# AIPASS
|
||||||
|
|
||||||
Concierge and librarian for AIPass. Greets new users, walks them through setup, answers how-things-work questions, hands off to their chosen CLI.
|
The friendly front door for AIPass. Walks new users through setup, runs system diagnostics, answers documentation questions, and creates projects inside the AIPass environment.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aipass # Show available commands
|
||||||
|
aipass doctor # Check system health
|
||||||
|
aipass help what does drone do # Search branch documentation
|
||||||
|
aipass new myapp --template python # Create a new project
|
||||||
|
aipass adopt myapp --dry-run # Preview adopting an existing projects/ dir
|
||||||
|
aipass init # Guided setup (10 stages, resumable)
|
||||||
|
```
|
||||||
|
|
||||||
## Invoke
|
## Invoke
|
||||||
|
|
||||||
```
|
```
|
||||||
drone @aipass <command>
|
aipass <command> [options]
|
||||||
|
aipass <command> --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -16,26 +28,32 @@ aipass/
|
|||||||
│ ├── aipass.py # Entry point — subcommand dispatch
|
│ ├── aipass.py # Entry point — subcommand dispatch
|
||||||
│ ├── modules/
|
│ ├── modules/
|
||||||
│ │ ├── doctor.py # System health aggregation + cross-OS pre-flight (--cross-os)
|
│ │ ├── doctor.py # System health aggregation + cross-OS pre-flight (--cross-os)
|
||||||
│ │ ├── doctor_fix.py # Remediation report (--fix, --json)
|
│ │ ├── _doctor_fix.py # Remediation report (--fix, --json) [internal]
|
||||||
│ │ ├── doctor_wire.py # Auto-wire provider settings + stale-deny re-export
|
│ │ ├── _doctor_wire.py # Auto-wire provider settings + stale-deny re-export [internal]
|
||||||
│ │ ├── handoff.py # CLI handoff (placeholder)
|
│ │ ├── handoff.py # CLI handoff (placeholder)
|
||||||
│ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler)
|
│ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler)
|
||||||
│ │ ├── init_flow.py # 10-stage guided setup
|
│ │ ├── init_flow.py # 10-stage guided setup
|
||||||
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
||||||
│ │ └── profile.py # User profile read/write
|
│ │ ├── new_project.py # aipass new — create projects inside the installation
|
||||||
|
│ │ ├── adopt.py # aipass adopt — bring an existing projects/ dir into AIPass
|
||||||
|
│ │ ├── profile.py # User profile read/write
|
||||||
|
│ │ ├── trust.py # Trust registry — aipass trust / aipass revoke
|
||||||
|
│ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off
|
||||||
│ ├── handlers/
|
│ ├── handlers/
|
||||||
│ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record
|
│ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record
|
||||||
│ │ ├── handoff_platform/ # Platform-specific handoff detection
|
│ │ ├── handoff_platform/ # Platform-specific handoff detection
|
||||||
│ │ ├── init/ # bootstrap.py, scaffold_content.py
|
│ │ ├── init/ # bootstrap.py, scaffold_content.py
|
||||||
|
│ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init)
|
||||||
|
│ │ │ └── adopt.py # Project adoption logic (additive scaffold onto an existing dir)
|
||||||
│ │ ├── json/ # JSON read/write utilities
|
│ │ ├── json/ # JSON read/write utilities
|
||||||
│ │ ├── ping_sweep/ # Branch reachability verification
|
│ │ ├── ping_sweep/ # Branch reachability verification
|
||||||
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
||||||
│ │ ├── readme_map/ # Live file reads + branch routing
|
│ │ ├── readme_map/ # Live file reads + branch routing
|
||||||
│ │ ├── structure_scan/ # Agent placement + pollution detection
|
│ │ ├── structure_scan/ # Agent placement + pollution detection
|
||||||
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
||||||
│ │ └── ui/ # Progress bars, menus, banners
|
│ │ └── ui/ # Progress bars, menus, banners
|
||||||
│ └── plugins/
|
│ └── plugins/
|
||||||
├── tests/ # 609 passing
|
├── tests/ # 785 passing
|
||||||
├── requirements.project.txt # Project-specific Python dependencies
|
├── requirements.project.txt # Project-specific Python dependencies
|
||||||
├── .trinity/ # Identity + session history + observations
|
├── .trinity/ # Identity + session history + observations
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -45,17 +63,26 @@ aipass/
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `aipass` | Help banner |
|
| `aipass` | Show available commands |
|
||||||
| `aipass help [Q]` | README-backed Q&A with branch routing |
|
| `aipass help [Q]` | README-backed Q&A with branch routing |
|
||||||
| `aipass doctor` | System health — structure, registry, hooks, pytest |
|
| `aipass doctor` | System health — structure, registry, hooks, pytest |
|
||||||
| `aipass doctor --fix` | Remediation report with `drone @spawn repair` commands |
|
| `aipass doctor --fix` | Remediation report with `drone @spawn repair` commands |
|
||||||
| `aipass doctor --json` | JSON output for structure scan results |
|
| `aipass doctor --json` | JSON output for structure scan results |
|
||||||
| `aipass doctor --cross-os` | Cross-OS pre-flight (Layer-3-lite, machine) — OS-gap cross-ref + routing/versions/hookstatus |
|
| `aipass doctor --cross-os` | Cross-OS pre-flight — OS-gap cross-ref + routing/versions/hookstatus |
|
||||||
| `aipass doctor --cross-os --e2e` | ...also runs the real Layer-2 e2e wiring suite (heavy, opt-in) |
|
| `aipass doctor --cross-os --e2e` | ...also runs the real e2e wiring suite (heavy, opt-in) |
|
||||||
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human Layer-3 acceptance pass |
|
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human acceptance pass |
|
||||||
| `aipass init` | 10-stage guided setup (resumable) |
|
| `aipass init` | 10-stage guided setup (resumable) |
|
||||||
| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init (`--no-init`/`--with-init`/`--path`/`--here`) |
|
| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init |
|
||||||
| `aipass profile` | Show/edit user profile |
|
| `aipass profile` | Show/edit user profile |
|
||||||
|
| `aipass new <name>` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent |
|
||||||
|
| `aipass new <name> --template python` | Create with Python template (pyproject + src/) |
|
||||||
|
| `aipass new <name> --no-agent` | Create without resident agent |
|
||||||
|
| `aipass adopt <name>` | Turn an existing `projects/<name>` directory into a full project — additive scaffold only |
|
||||||
|
| `aipass adopt <name> --no-agent` | Adopt without a resident agent |
|
||||||
|
| `aipass adopt <name> --dry-run` | Preview what adoption would do, writes nothing |
|
||||||
|
| `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry |
|
||||||
|
| `aipass revoke <path>` | Remove a project from the trust registry |
|
||||||
|
| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) |
|
||||||
| `aipass --version` | Version |
|
| `aipass --version` | Version |
|
||||||
|
|
||||||
## Integration Points
|
## Integration Points
|
||||||
@@ -76,7 +103,7 @@ Humans only. Nothing in AIPass depends on this branch.
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
609 passing — `pytest src/aipass/aipass/tests/`
|
723 passing — `pytest src/aipass/aipass/tests/`
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
||||||
@@ -84,4 +111,4 @@ Humans only. Nothing in AIPass depends on this branch.
|
|||||||
|
|
||||||
## Last Updated
|
## Last Updated
|
||||||
|
|
||||||
Last Updated: 2026-07-05
|
Last Updated: 2026-07-17
|
||||||
|
|||||||
@@ -35,8 +35,25 @@ if sys.platform == "win32":
|
|||||||
if _reconfigure is not None:
|
if _reconfigure is not None:
|
||||||
_reconfigure(encoding="utf-8", errors="replace")
|
_reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
from aipass.cli.apps.modules import console, error
|
||||||
from aipass.prax import logger
|
from aipass.prax import logger
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# COMMANDS — public-facing labels and descriptions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
_PUBLIC_COMMANDS = {
|
||||||
|
"adopt": "Turn an existing projects/ directory into a full project",
|
||||||
|
"doctor": "System health — structure, registry, hooks, tests",
|
||||||
|
"help": "README-backed Q&A — ask about any branch",
|
||||||
|
"init": "Guided setup for new users (10 stages, resumable)",
|
||||||
|
"install": "One-command bootstrap — clone + setup + init",
|
||||||
|
"new": "Create a project inside AIPass",
|
||||||
|
"profile": "Show/edit user profile",
|
||||||
|
"trust": "Trust registry — enroll/revoke projects",
|
||||||
|
"feedback": "Toggle the feedback reminder on/off",
|
||||||
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MODULE DISCOVERY
|
# MODULE DISCOVERY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -72,6 +89,86 @@ def discover_modules() -> List[Any]:
|
|||||||
return modules
|
return modules
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HELP OUTPUT — house pattern (cli_ux)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection(modules: List[Any] | None = None) -> None:
|
||||||
|
"""Bare invocation — title, purpose, public commands, --help pointer."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]AIPASS — Concierge & Setup[/bold cyan]")
|
||||||
|
console.print("[dim]The friendly front door for AIPass. Setup, diagnostics, documentation, project creation.[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if modules is None:
|
||||||
|
modules = discover_modules()
|
||||||
|
|
||||||
|
commands = []
|
||||||
|
for module in modules:
|
||||||
|
name = getattr(module, "COMMAND", None)
|
||||||
|
if name and name in _PUBLIC_COMMANDS:
|
||||||
|
commands.append((name, _PUBLIC_COMMANDS[name]))
|
||||||
|
commands.sort()
|
||||||
|
|
||||||
|
if commands:
|
||||||
|
console.print("[yellow]Commands:[/yellow]")
|
||||||
|
for name, desc in commands:
|
||||||
|
console.print(f" [green]{name:16}[/green] [dim]{desc}[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[dim]Run 'aipass --help' for usage and examples[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_help(modules: List[Any] | None = None) -> None:
|
||||||
|
"""Full help — usage, commands, examples."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]AIPASS — Concierge & Setup[/bold cyan]")
|
||||||
|
console.print("[dim]The friendly front door for AIPass. Setup, diagnostics, documentation, project creation.[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[yellow]Usage:[/yellow]")
|
||||||
|
console.print(" [green]aipass[/green] [dim]<command>[/dim] [dim][options][/dim]")
|
||||||
|
console.print(" [green]aipass[/green] [dim]Show commands[/dim]")
|
||||||
|
console.print(" [green]aipass[/green] [dim]<command>[/dim] [dim]--help[/dim] [dim]Help for a command[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[yellow]Commands:[/yellow]")
|
||||||
|
console.print(
|
||||||
|
" [green]doctor[/green] [dim]System health — structure, registry, hooks, tests[/dim]"
|
||||||
|
)
|
||||||
|
console.print(" [green]doctor --fix[/green] [dim]Remediation report with repair commands[/dim]")
|
||||||
|
console.print(" [green]doctor --json[/green] [dim]JSON output for structure scan[/dim]")
|
||||||
|
console.print(" [green]doctor --cross-os[/green] [dim]Cross-OS pre-flight check[/dim]")
|
||||||
|
console.print(" [green]help <question>[/green] [dim]Search branch documentation (Q&A)[/dim]")
|
||||||
|
console.print(
|
||||||
|
" [green]init[/green] [dim]Guided setup for new users (10 stages, resumable)[/dim]"
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
" [green]install[/green] [dim]One-command bootstrap — clone + setup.sh + hooks[/dim]"
|
||||||
|
)
|
||||||
|
console.print(" [green]new <name>[/green] [dim]Create a project inside AIPass[/dim]")
|
||||||
|
console.print(
|
||||||
|
" [green]adopt <name>[/green] "
|
||||||
|
"[dim]Turn an existing projects/ directory into a full project[/dim]"
|
||||||
|
)
|
||||||
|
console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]")
|
||||||
|
console.print(
|
||||||
|
" [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]"
|
||||||
|
)
|
||||||
|
console.print(" [green]--version[/green] [dim]Show version[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print("[yellow]Examples:[/yellow]")
|
||||||
|
console.print(" [green]aipass doctor[/green] [dim]Check system health[/dim]")
|
||||||
|
console.print(" [green]aipass help what does drone do[/green] [dim]Search documentation[/dim]")
|
||||||
|
console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]")
|
||||||
|
console.print(" [green]aipass adopt myapp --dry-run[/green] [dim]Preview adopting projects/myapp[/dim]")
|
||||||
|
console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||||
"""Route command to appropriate module.
|
"""Route command to appropriate module.
|
||||||
|
|
||||||
@@ -105,17 +202,15 @@ def main():
|
|||||||
except importlib.metadata.PackageNotFoundError:
|
except importlib.metadata.PackageNotFoundError:
|
||||||
logger.info("[AIPASS] Package metadata not found, version unknown")
|
logger.info("[AIPASS] Package metadata not found, version unknown")
|
||||||
version = "unknown"
|
version = "unknown"
|
||||||
print(f"aipass {version}")
|
console.print(f"aipass {version}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
show_root_help = len(args) == 0 or args[0] in ["--help", "-h"] or (args[0] == "help" and len(args) == 1)
|
if not args:
|
||||||
if show_root_help:
|
print_introspection(modules)
|
||||||
print(f"AIPASS - {len(modules)} modules discovered")
|
return 0
|
||||||
for module in modules:
|
|
||||||
stem = module.__name__.split(".")[-1]
|
if args[0] in ("--help", "-h"):
|
||||||
name = getattr(module, "COMMAND", stem)
|
print_help(modules)
|
||||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
|
||||||
print(f" {name:20} {desc}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
command = args[0]
|
command = args[0]
|
||||||
@@ -126,31 +221,31 @@ def main():
|
|||||||
for module in modules:
|
for module in modules:
|
||||||
if module.handle_command(command, ["--help"]):
|
if module.handle_command(command, ["--help"]):
|
||||||
return 0
|
return 0
|
||||||
print(f"Unknown command: {command}")
|
console.print(f"Unknown command: {command}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if route_command(command, remaining, modules):
|
if route_command(command, remaining, modules):
|
||||||
return 0
|
return 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error: '{command}' crashed: {e}")
|
error(f"'{command}' crashed: {e}")
|
||||||
logger.error(f"[AIPASS] '{command}' traceback", exc_info=True)
|
logger.error(f"[AIPASS] '{command}' traceback", exc_info=True)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if command.startswith("@"):
|
if command.startswith("@"):
|
||||||
print(f"{command} is a drone routing target, not an aipass command.")
|
console.print(f"{command} is a drone routing target, not an aipass command.")
|
||||||
print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
|
console.print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
|
||||||
print()
|
console.print()
|
||||||
print(f" Reach an agent: drone {command} ... · drone systems")
|
console.print(f" Reach an agent: drone {command} ... · drone systems")
|
||||||
print(" aipass commands: aipass --help")
|
console.print(" aipass commands: aipass --help")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
for stem, err in _import_failures.items():
|
for stem, err in _import_failures.items():
|
||||||
if command in (stem, stem.replace("_", "")):
|
if command in (stem, stem.replace("_", "")):
|
||||||
print(f"Error: '{command}' failed to load: {err}")
|
error(f"'{command}' failed to load: {err}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print(f"Unknown command: {command}")
|
console.print(f"Unknown command: {command}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||||
_sanitize_name,
|
_sanitize_name,
|
||||||
init_project,
|
init_project,
|
||||||
|
is_projects_child,
|
||||||
update_project,
|
update_project,
|
||||||
)
|
)
|
||||||
from aipass.aipass.apps.handlers.init.scaffold_content import (
|
from aipass.aipass.shared.scaffold_content import (
|
||||||
global_prompt_md,
|
global_prompt_md,
|
||||||
inbox_json,
|
inbox_json,
|
||||||
prep_md,
|
prep_md,
|
||||||
@@ -25,6 +26,7 @@ __all__ = [
|
|||||||
"global_prompt_md",
|
"global_prompt_md",
|
||||||
"inbox_json",
|
"inbox_json",
|
||||||
"init_project",
|
"init_project",
|
||||||
|
"is_projects_child",
|
||||||
"prep_md",
|
"prep_md",
|
||||||
"update_project",
|
"update_project",
|
||||||
"with_source",
|
"with_source",
|
||||||
|
|||||||
@@ -30,18 +30,23 @@ RULES:
|
|||||||
- No hardcoded paths
|
- No hardcoded paths
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
from aipass.aipass.shared import scaffold_content as sc
|
||||||
|
from aipass.aipass.shared.project_home import (
|
||||||
|
_claude_local_settings,
|
||||||
|
_claude_settings,
|
||||||
|
_detect_aipass_home,
|
||||||
|
_enroll_project,
|
||||||
|
is_projects_child,
|
||||||
|
is_throwaway_path,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -50,25 +55,6 @@ _STALE_MANAGED_FILES: list[Path] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def is_throwaway_path(path: str | Path) -> bool:
|
|
||||||
"""True if path is under a temp dir or Claude Code scratchpad."""
|
|
||||||
resolved = str(Path(path).resolve())
|
|
||||||
tmp_roots = [tempfile.gettempdir()]
|
|
||||||
if os.name == "posix":
|
|
||||||
tmp_roots.append("/tmp")
|
|
||||||
for root in tmp_roots:
|
|
||||||
try:
|
|
||||||
r = str(Path(root).resolve())
|
|
||||||
except OSError:
|
|
||||||
logger.info("is_throwaway_path: could not resolve %s", root)
|
|
||||||
continue
|
|
||||||
if resolved == r or resolved.startswith(r + os.sep):
|
|
||||||
return True
|
|
||||||
if "scratchpad" in resolved.lower():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_name(raw: str) -> str:
|
def _sanitize_name(raw: str) -> str:
|
||||||
"""Sanitize a project name for use in filenames.
|
"""Sanitize a project name for use in filenames.
|
||||||
|
|
||||||
@@ -78,23 +64,6 @@ def _sanitize_name(raw: str) -> str:
|
|||||||
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
|
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
|
||||||
|
|
||||||
|
|
||||||
def _detect_aipass_home() -> str | None:
|
|
||||||
"""Detect the AIPass installation root from the aipass package location.
|
|
||||||
|
|
||||||
Returns the parent of the src/ directory (the repo root).
|
|
||||||
Returns None if detection fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
spec = importlib.util.find_spec("aipass")
|
|
||||||
if spec and spec.origin:
|
|
||||||
# aipass/__init__.py lives at src/aipass/__init__.py
|
|
||||||
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
|
|
||||||
return str(Path(spec.origin).resolve().parent.parent.parent)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.info("AIPASS_HOME detection skipped: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _hook_fingerprint(hook_entry: dict) -> str:
|
def _hook_fingerprint(hook_entry: dict) -> str:
|
||||||
"""Extract a comparable fingerprint from a hook entry."""
|
"""Extract a comparable fingerprint from a hook entry."""
|
||||||
commands = []
|
commands = []
|
||||||
@@ -133,10 +102,11 @@ def _merge_settings(existing: dict, generated: dict) -> dict:
|
|||||||
if cleaned_hooks:
|
if cleaned_hooks:
|
||||||
merged["hooks"] = cleaned_hooks
|
merged["hooks"] = cleaned_hooks
|
||||||
|
|
||||||
# Merge env: generated wins for AIPASS_HOME, preserve user additions
|
# env: AIPASS_HOME is machine-local — never tracked (see settings.local.json).
|
||||||
existing_env = existing.get("env", {})
|
# Strip it from any previously-tracked settings.json; preserve other user vars.
|
||||||
generated_env = generated.get("env", {})
|
existing_env = {k: v for k, v in existing.get("env", {}).items() if k != "AIPASS_HOME"}
|
||||||
merged["env"] = {**existing_env, **generated_env}
|
if existing_env:
|
||||||
|
merged["env"] = existing_env
|
||||||
|
|
||||||
# Merge permissions: union deny/ask lists
|
# Merge permissions: union deny/ask lists
|
||||||
existing_perms = existing.get("permissions", {})
|
existing_perms = existing.get("permissions", {})
|
||||||
@@ -210,45 +180,13 @@ def _merge_hooks_json(existing: dict, template: dict) -> dict:
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _claude_settings(aipass_home: str | None = None) -> str:
|
def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None:
|
||||||
"""Generate .claude/settings.json — env and permissions only.
|
|
||||||
|
|
||||||
Hooks are NOT wired at the project level. All AIPass hooks
|
|
||||||
(prompt injection, identity, email, pre-compact, edit gates) fire
|
|
||||||
from provider settings (~/.claude/settings.json), installed by
|
|
||||||
setup.sh. Provider hooks use CWD-walking patterns that work from
|
|
||||||
any directory in any project.
|
|
||||||
|
|
||||||
Project settings only contain:
|
|
||||||
- env.AIPASS_HOME (so hooks can find the AIPass installation)
|
|
||||||
- permissions.deny (basic safety rails)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME.
|
|
||||||
"""
|
|
||||||
data: dict = {}
|
|
||||||
|
|
||||||
data["permissions"] = {
|
|
||||||
"deny": [
|
|
||||||
"Bash(git push --force*)",
|
|
||||||
"Bash(git reset --hard*)",
|
|
||||||
"EnterPlanMode",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if aipass_home and not is_throwaway_path(aipass_home):
|
|
||||||
data["env"] = {"AIPASS_HOME": aipass_home}
|
|
||||||
elif aipass_home:
|
|
||||||
logger.warning(
|
|
||||||
"AIPASS_HOME '%s' is a throwaway path — not writing to settings",
|
|
||||||
aipass_home,
|
|
||||||
)
|
|
||||||
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _guard_init(target: Path) -> None:
|
|
||||||
"""Block init if target is inside an agent branch or existing project.
|
"""Block init if target is inside an agent branch or existing project.
|
||||||
|
|
||||||
|
When *allow_projects_child* is True, the nested-project checks are
|
||||||
|
skipped for targets that are ``<host>/projects/<name>``. This is used
|
||||||
|
by ``aipass new`` to create projects inside the installation.
|
||||||
|
|
||||||
Raises RuntimeError with explanation if init should not proceed.
|
Raises RuntimeError with explanation if init should not proceed.
|
||||||
"""
|
"""
|
||||||
target = target.resolve()
|
target = target.resolve()
|
||||||
@@ -270,6 +208,8 @@ def _guard_init(target: Path) -> None:
|
|||||||
# Block: target already has a registry (is already a project)
|
# Block: target already has a registry (is already a project)
|
||||||
for f in target.iterdir() if target.is_dir() else []:
|
for f in target.iterdir() if target.is_dir() else []:
|
||||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||||
|
if allow_projects_child and is_projects_child(target):
|
||||||
|
break
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"BLOCKED: '{target}' is already an AIPass project (has {f.name}). "
|
f"BLOCKED: '{target}' is already an AIPass project (has {f.name}). "
|
||||||
"Use 'aipass init update' to upgrade an existing project."
|
"Use 'aipass init update' to upgrade an existing project."
|
||||||
@@ -280,6 +220,8 @@ def _guard_init(target: Path) -> None:
|
|||||||
continue
|
continue
|
||||||
for f in parent.iterdir():
|
for f in parent.iterdir():
|
||||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||||
|
if allow_projects_child and is_projects_child(target):
|
||||||
|
return
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"BLOCKED: '{target}' is inside AIPass project at '{parent}' (has {f.name}). "
|
f"BLOCKED: '{target}' is inside AIPass project at '{parent}' (has {f.name}). "
|
||||||
"Cannot create a nested project."
|
"Cannot create a nested project."
|
||||||
@@ -288,12 +230,18 @@ def _guard_init(target: Path) -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
def init_project(target: Path, project_name: str | None = None) -> dict:
|
def init_project(
|
||||||
|
target: Path,
|
||||||
|
project_name: str | None = None,
|
||||||
|
*,
|
||||||
|
allow_projects_child: bool = False,
|
||||||
|
) -> dict:
|
||||||
"""Initialize an AIPass project in the target directory.
|
"""Initialize an AIPass project in the target directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
target: Directory to initialize
|
target: Directory to initialize
|
||||||
project_name: Name for the registry (defaults to directory name)
|
project_name: Name for the registry (defaults to directory name)
|
||||||
|
allow_projects_child: When True, allow init inside ``<host>/projects/<name>``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with registry_id, registry_file, project_name, target, created_files
|
dict with registry_id, registry_file, project_name, target, created_files
|
||||||
@@ -303,7 +251,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
|||||||
RuntimeError: If target is inside an agent branch or existing project
|
RuntimeError: If target is inside an agent branch or existing project
|
||||||
"""
|
"""
|
||||||
target = target.resolve()
|
target = target.resolve()
|
||||||
_guard_init(target)
|
_guard_init(target, allow_projects_child=allow_projects_child)
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
target.mkdir(parents=True)
|
target.mkdir(parents=True)
|
||||||
|
|
||||||
@@ -362,6 +310,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
|||||||
if template.is_file():
|
if template.is_file():
|
||||||
shutil.copy2(str(template), str(hooks_json_path))
|
shutil.copy2(str(template), str(hooks_json_path))
|
||||||
created.append(str(hooks_json_path))
|
created.append(str(hooks_json_path))
|
||||||
|
_enroll_project(target)
|
||||||
else:
|
else:
|
||||||
logger.info("hooks template not found at %s — skipping", template)
|
logger.info("hooks template not found at %s — skipping", template)
|
||||||
|
|
||||||
@@ -399,16 +348,23 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
|||||||
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
|
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
|
||||||
created.append(str(gitignore_path))
|
created.append(str(gitignore_path))
|
||||||
|
|
||||||
# 9. .claude/settings.json
|
# 9. .claude/settings.json — tracked, permissions only (no machine-local paths)
|
||||||
claude_dir = target / ".claude"
|
claude_dir = target / ".claude"
|
||||||
claude_dir.mkdir(exist_ok=True)
|
claude_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
settings_path = claude_dir / "settings.json"
|
settings_path = claude_dir / "settings.json"
|
||||||
if not settings_path.exists():
|
if not settings_path.exists():
|
||||||
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
|
settings_path.write_text(_claude_settings(), encoding="utf-8")
|
||||||
created.append(str(settings_path))
|
created.append(str(settings_path))
|
||||||
|
|
||||||
# 9b. .claude/commands/prep.md — /prep session wrap-up slash command
|
# 9b. .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
|
||||||
|
if aipass_home and not is_throwaway_path(aipass_home):
|
||||||
|
local_settings_path = claude_dir / "settings.local.json"
|
||||||
|
if not local_settings_path.exists():
|
||||||
|
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
|
||||||
|
created.append(str(local_settings_path))
|
||||||
|
|
||||||
|
# 9c. .claude/commands/prep.md — /prep session wrap-up slash command
|
||||||
# Only prep.md here — memo.md belongs at provider level (~/.claude/commands/)
|
# Only prep.md here — memo.md belongs at provider level (~/.claude/commands/)
|
||||||
commands_dir = claude_dir / "commands"
|
commands_dir = claude_dir / "commands"
|
||||||
commands_dir.mkdir(exist_ok=True)
|
commands_dir.mkdir(exist_ok=True)
|
||||||
@@ -532,11 +488,11 @@ def update_project(target: Path) -> dict:
|
|||||||
elif tier_dest.exists():
|
elif tier_dest.exists():
|
||||||
already_current.append(str(tier_dest))
|
already_current.append(str(tier_dest))
|
||||||
|
|
||||||
# settings.json — smart merge: preserve user hooks + env, update AIPass hooks
|
# settings.json — smart merge: preserve user hooks, update AIPass permissions.
|
||||||
|
# AIPASS_HOME never lives here — machine-local paths go in settings.local.json.
|
||||||
settings_path = claude_dir / "settings.json"
|
settings_path = claude_dir / "settings.json"
|
||||||
if not settings_path.exists():
|
if not settings_path.exists():
|
||||||
aipass_home = _detect_aipass_home()
|
settings_path.write_text(_claude_settings(), encoding="utf-8")
|
||||||
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
|
|
||||||
updated.append(str(settings_path))
|
updated.append(str(settings_path))
|
||||||
else:
|
else:
|
||||||
existing_content = settings_path.read_text(encoding="utf-8")
|
existing_content = settings_path.read_text(encoding="utf-8")
|
||||||
@@ -545,9 +501,7 @@ def update_project(target: Path) -> dict:
|
|||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
logger.info("settings.json parse failed, rebuilding: %s", exc)
|
logger.info("settings.json parse failed, rebuilding: %s", exc)
|
||||||
existing = {}
|
existing = {}
|
||||||
existing_env = existing.get("env", {})
|
generated = json.loads(_claude_settings())
|
||||||
aipass_home = existing_env.get("AIPASS_HOME") or _detect_aipass_home()
|
|
||||||
generated = json.loads(_claude_settings(aipass_home))
|
|
||||||
merged = _merge_settings(existing, generated)
|
merged = _merge_settings(existing, generated)
|
||||||
merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
|
merged_content = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
|
||||||
if existing != merged:
|
if existing != merged:
|
||||||
@@ -556,6 +510,15 @@ def update_project(target: Path) -> dict:
|
|||||||
else:
|
else:
|
||||||
already_current.append(str(settings_path))
|
already_current.append(str(settings_path))
|
||||||
|
|
||||||
|
# settings.local.json — machine-local AIPASS_HOME (gitignored, create if missing)
|
||||||
|
if aipass_home and not is_throwaway_path(aipass_home):
|
||||||
|
local_settings_path = claude_dir / "settings.local.json"
|
||||||
|
if not local_settings_path.exists():
|
||||||
|
local_settings_path.write_text(_claude_local_settings(aipass_home), encoding="utf-8")
|
||||||
|
updated.append(str(local_settings_path))
|
||||||
|
else:
|
||||||
|
already_current.append(str(local_settings_path))
|
||||||
|
|
||||||
# hooks.json — union-merge: preserve user enabled, add new hooks from template
|
# hooks.json — union-merge: preserve user enabled, add new hooks from template
|
||||||
hooks_json_path = aipass_dir / "hooks.json"
|
hooks_json_path = aipass_dir / "hooks.json"
|
||||||
hook_home = aipass_home or _detect_aipass_home()
|
hook_home = aipass_home or _detect_aipass_home()
|
||||||
@@ -573,6 +536,7 @@ def update_project(target: Path) -> dict:
|
|||||||
if existing_hooks != merged_hooks:
|
if existing_hooks != merged_hooks:
|
||||||
hooks_json_path.write_text(merged_hooks_content, encoding="utf-8")
|
hooks_json_path.write_text(merged_hooks_content, encoding="utf-8")
|
||||||
updated.append(str(hooks_json_path))
|
updated.append(str(hooks_json_path))
|
||||||
|
_enroll_project(target)
|
||||||
else:
|
else:
|
||||||
already_current.append(str(hooks_json_path))
|
already_current.append(str(hooks_json_path))
|
||||||
else:
|
else:
|
||||||
@@ -581,6 +545,7 @@ def update_project(target: Path) -> dict:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
updated.append(str(hooks_json_path))
|
updated.append(str(hooks_json_path))
|
||||||
|
_enroll_project(target)
|
||||||
elif hooks_json_path.exists():
|
elif hooks_json_path.exists():
|
||||||
already_current.append(str(hooks_json_path))
|
already_current.append(str(hooks_json_path))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: __init__.py
|
||||||
|
# Description: New project handler — create projects inside AIPass
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-17
|
||||||
|
# Modified: 2026-07-17
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
New Project Handler — creates projects inside AIPass installations.
|
||||||
|
|
||||||
|
Business logic for `aipass new`. Creates a project at <host>/projects/<name>
|
||||||
|
with its own git repo, registry, and optional AIPass agent.
|
||||||
|
|
||||||
|
Flow: find host -> validate -> mkdir -> mint registry (FIRST) ->
|
||||||
|
write template -> scaffold AIPass files -> git init -> optional agent.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Registry MUST be minted before any spawn call
|
||||||
|
- git init via subprocess (hooks git gate only intercepts agent Bash)
|
||||||
|
- Cleanup on failure: partial project is worse than no project
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.aipass.shared import scaffold_content as sc
|
||||||
|
from aipass.prax import logger
|
||||||
|
from aipass.spawn import spawn_agent
|
||||||
|
|
||||||
|
TEMPLATES = ("empty", "python")
|
||||||
|
|
||||||
|
|
||||||
|
def find_host_root(start: Path) -> Path | None:
|
||||||
|
"""Walk up from *start* to find the AIPass host installation root.
|
||||||
|
|
||||||
|
Returns the directory containing ``*_REGISTRY.json``, or ``None``.
|
||||||
|
"""
|
||||||
|
for p in [start, *start.parents]:
|
||||||
|
try:
|
||||||
|
entries = list(p.iterdir())
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for f in entries:
|
||||||
|
try:
|
||||||
|
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||||
|
return p
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_name(name: str) -> str:
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Project name cannot be empty")
|
||||||
|
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid project name '{name}'. "
|
||||||
|
"Must start with a letter, contain only letters, digits, hyphens, underscores."
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_name(name: str) -> str:
|
||||||
|
return re.sub(r"[^A-Z0-9_-]", "_", name.upper()).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def _git(args: list[str], cwd: Path) -> str:
|
||||||
|
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"git {' '.join(args)}: {r.stderr.strip()}")
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── registry ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _write_registry(target: Path, name: str) -> tuple[str, str]:
|
||||||
|
"""Mint the project registry. Returns ``(registry_id, filename)``."""
|
||||||
|
registry_id = str(uuid.uuid4())
|
||||||
|
today = date.today().isoformat()
|
||||||
|
reg = _registry_name(name)
|
||||||
|
filename = f"{reg}_REGISTRY.json"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"metadata": {
|
||||||
|
"id": registry_id,
|
||||||
|
"name": reg,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"created": today,
|
||||||
|
"last_updated": today,
|
||||||
|
"total_branches": 0,
|
||||||
|
},
|
||||||
|
"branches": [],
|
||||||
|
}
|
||||||
|
(target / filename).write_text(
|
||||||
|
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return registry_id, filename
|
||||||
|
|
||||||
|
|
||||||
|
# ── templates ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _write_template(target: Path, name: str, template: str) -> list[str]:
|
||||||
|
"""Write template-specific files. Returns relative paths created."""
|
||||||
|
created: list[str] = []
|
||||||
|
|
||||||
|
(target / "README.md").write_text(
|
||||||
|
f"# {name}\n\nCreated with `aipass new`. Template: {template}.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
created.append("README.md")
|
||||||
|
|
||||||
|
(target / ".gitignore").write_text(sc.gitignore(), encoding="utf-8")
|
||||||
|
created.append(".gitignore")
|
||||||
|
|
||||||
|
if template == "python":
|
||||||
|
pkg = name.replace("-", "_").lower()
|
||||||
|
(target / "pyproject.toml").write_text(
|
||||||
|
"[build-system]\n"
|
||||||
|
'requires = ["setuptools>=61.0"]\n'
|
||||||
|
'build-backend = "setuptools.build_meta"\n\n'
|
||||||
|
"[project]\n"
|
||||||
|
f'name = "{name}"\n'
|
||||||
|
'version = "0.1.0"\n'
|
||||||
|
f'description = "{name} — born deployable"\n'
|
||||||
|
'requires-python = ">=3.10"\n\n'
|
||||||
|
"[tool.setuptools.packages.find]\n"
|
||||||
|
'where = ["src"]\n\n'
|
||||||
|
"[tool.pytest.ini_options]\n"
|
||||||
|
'testpaths = ["src"]\n'
|
||||||
|
'pythonpath = ["src"]\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
created.append("pyproject.toml")
|
||||||
|
src = target / "src" / pkg
|
||||||
|
src.mkdir(parents=True)
|
||||||
|
(src / "__init__.py").write_text(
|
||||||
|
f'"""{name} — born deployable."""\n\n__version__ = "0.1.0"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
created.append(f"src/{pkg}/__init__.py")
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
# ── AIPass scaffold ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _scaffold_aipass(target: Path, name: str) -> list[str]:
|
||||||
|
"""Write AIPass scaffold files (tiers, hooks, CLAUDE.md, settings, .venv)."""
|
||||||
|
from aipass.aipass.shared.project_home import (
|
||||||
|
_claude_local_settings,
|
||||||
|
_claude_settings,
|
||||||
|
_detect_aipass_home,
|
||||||
|
_enroll_project,
|
||||||
|
is_throwaway_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
created: list[str] = []
|
||||||
|
aipass_home = _detect_aipass_home()
|
||||||
|
reg = _registry_name(name)
|
||||||
|
|
||||||
|
# .aipass/
|
||||||
|
aipass_dir = target / ".aipass"
|
||||||
|
aipass_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Tier files
|
||||||
|
if aipass_home:
|
||||||
|
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
|
||||||
|
dest = aipass_dir / tier_file
|
||||||
|
src_path = Path(aipass_home) / ".aipass" / tier_file
|
||||||
|
if src_path.is_file():
|
||||||
|
shutil.copy2(str(src_path), str(dest))
|
||||||
|
created.append(f".aipass/{tier_file}")
|
||||||
|
|
||||||
|
# hooks.json + trust enrollment
|
||||||
|
if aipass_home:
|
||||||
|
template = Path(aipass_home) / ".aipass" / "project_hooks.json"
|
||||||
|
if template.is_file():
|
||||||
|
shutil.copy2(str(template), str(aipass_dir / "hooks.json"))
|
||||||
|
created.append(".aipass/hooks.json")
|
||||||
|
_enroll_project(target)
|
||||||
|
|
||||||
|
# CLAUDE.md, AGENTS.md
|
||||||
|
for md_name in ("CLAUDE.md", "AGENTS.md"):
|
||||||
|
dest = target / md_name
|
||||||
|
if dest.exists():
|
||||||
|
continue
|
||||||
|
if aipass_home:
|
||||||
|
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
|
||||||
|
if tmpl.is_file():
|
||||||
|
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
|
||||||
|
dest.write_text(content, encoding="utf-8")
|
||||||
|
created.append(md_name)
|
||||||
|
continue
|
||||||
|
if md_name == "AGENTS.md":
|
||||||
|
dest.write_text(sc.agents_md(reg), encoding="utf-8")
|
||||||
|
created.append(md_name)
|
||||||
|
|
||||||
|
# .claude/settings.json — tracked, permissions only (no machine-local paths)
|
||||||
|
claude_dir = target / ".claude"
|
||||||
|
claude_dir.mkdir(exist_ok=True)
|
||||||
|
(claude_dir / "settings.json").write_text(
|
||||||
|
_claude_settings(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
created.append(".claude/settings.json")
|
||||||
|
|
||||||
|
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
|
||||||
|
if aipass_home and not is_throwaway_path(aipass_home):
|
||||||
|
(claude_dir / "settings.local.json").write_text(
|
||||||
|
_claude_local_settings(aipass_home),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
created.append(".claude/settings.local.json")
|
||||||
|
|
||||||
|
# .claude/commands/prep.md
|
||||||
|
commands_dir = claude_dir / "commands"
|
||||||
|
commands_dir.mkdir(exist_ok=True)
|
||||||
|
(commands_dir / "prep.md").write_text(sc.prep_md(), encoding="utf-8")
|
||||||
|
created.append(".claude/commands/prep.md")
|
||||||
|
|
||||||
|
# .venv symlink
|
||||||
|
if aipass_home:
|
||||||
|
venv = Path(aipass_home) / ".venv"
|
||||||
|
if venv.is_dir():
|
||||||
|
link = target / ".venv"
|
||||||
|
link.symlink_to(venv)
|
||||||
|
created.append(".venv")
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
# ── git ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _git_init(target: Path, name: str, template: str) -> None:
|
||||||
|
"""Initialize git repo with birth commit. Guards against re-init."""
|
||||||
|
if (target / ".git").exists():
|
||||||
|
raise RuntimeError(f"'{target}' already has a .git directory")
|
||||||
|
_git(["init", "-b", "main"], target)
|
||||||
|
_git(["add", "-A"], target)
|
||||||
|
_git(
|
||||||
|
["commit", "-m", f"birth: {name} ({template} template) via aipass new"],
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── agent (via @spawn) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_home(project_root: Path, name: str) -> Path:
|
||||||
|
"""Compute the agent home directory: src/<pkg>/<pkg>/."""
|
||||||
|
pkg = name.replace("-", "_").lower()
|
||||||
|
return project_root / "src" / pkg / pkg
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn_project_agent(project_root: Path, name: str) -> dict:
|
||||||
|
"""Create the project agent via spawn_agent().
|
||||||
|
|
||||||
|
Agent lives at src/<pkg>/<pkg>/ inside the project. Spawn discovers
|
||||||
|
the project-local registry (minted earlier by _write_registry) by
|
||||||
|
walking up from the agent home to the project root.
|
||||||
|
"""
|
||||||
|
home = _agent_home(project_root, name)
|
||||||
|
result = spawn_agent(
|
||||||
|
target_path=str(home),
|
||||||
|
role="project_agent",
|
||||||
|
purpose=f"Resident agent of the {name} project.",
|
||||||
|
citizen_class="project_agent",
|
||||||
|
)
|
||||||
|
if not result.get("success"):
|
||||||
|
raise RuntimeError(f"spawn_agent failed: {result.get('error', 'unknown')}")
|
||||||
|
logger.info(
|
||||||
|
"[aipass new] agent spawned via @spawn: %s (%d files)",
|
||||||
|
result["branch_name"],
|
||||||
|
result["files_copied"],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── public API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def create_project(
|
||||||
|
name: str,
|
||||||
|
template: str = "empty",
|
||||||
|
no_agent: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new project inside the AIPass host installation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with name, template, target, host, registry_id, registry_file,
|
||||||
|
files, agent_spawned.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Invalid name or template.
|
||||||
|
RuntimeError: Not inside AIPass, target exists, git failure.
|
||||||
|
"""
|
||||||
|
_validate_name(name)
|
||||||
|
if template not in TEMPLATES:
|
||||||
|
raise ValueError(f"Unknown template '{template}'. Choose from: {', '.join(TEMPLATES)}")
|
||||||
|
|
||||||
|
host = find_host_root(Path.cwd())
|
||||||
|
if host is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Not inside an AIPass installation (no *_REGISTRY.json found). "
|
||||||
|
"`aipass new` creates projects inside the AIPass environment."
|
||||||
|
)
|
||||||
|
|
||||||
|
target = host / "projects" / name
|
||||||
|
if target.exists():
|
||||||
|
raise RuntimeError(f"Project already exists: {target}")
|
||||||
|
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
registry_id, registry_file = _write_registry(target, name)
|
||||||
|
logger.info("[aipass new] registry minted: %s (%s)", registry_file, registry_id)
|
||||||
|
|
||||||
|
template_files = _write_template(target, name, template)
|
||||||
|
scaffold_files = _scaffold_aipass(target, name)
|
||||||
|
|
||||||
|
spawn_result = None
|
||||||
|
if not no_agent:
|
||||||
|
spawn_result = _spawn_project_agent(target, name)
|
||||||
|
|
||||||
|
_git_init(target, name, template)
|
||||||
|
logger.info("[aipass new] git repo initialized with birth commit")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"template": template,
|
||||||
|
"target": str(target),
|
||||||
|
"host": str(host),
|
||||||
|
"registry_id": registry_id,
|
||||||
|
"registry_file": registry_file,
|
||||||
|
"files": template_files + scaffold_files,
|
||||||
|
"agent_created": spawn_result is not None,
|
||||||
|
"agent_home": str(_agent_home(target, name)) if spawn_result else None,
|
||||||
|
"spawn_result": spawn_result,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
raise
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: adopt.py
|
||||||
|
# Description: aipass adopt — bring an existing projects/ directory into AIPass
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-20
|
||||||
|
# Modified: 2026-07-20
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
Adopt Handler — PRIVATE implementation
|
||||||
|
|
||||||
|
Business logic for `aipass adopt`. Turns an EXISTING directory at
|
||||||
|
<host>/projects/<name> into a full AIPass project: sealed registry,
|
||||||
|
resident agent, .aipass/.claude scaffold.
|
||||||
|
|
||||||
|
Unlike `aipass new` (which births a brand-new directory), adopt starts
|
||||||
|
from a directory that already has its own content — possibly its own
|
||||||
|
git repo, possibly public. Every write is existence-guarded; nothing
|
||||||
|
already present is ever overwritten. .gitignore is the one file that's
|
||||||
|
patched rather than skipped, since AIPass local state (.trinity/,
|
||||||
|
mailbox, *.local.json) must never leak into a tracked commit.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Additive only — never overwrite a file that already exists
|
||||||
|
- Never touch the target's git history (no git init/add/commit)
|
||||||
|
- gitignore safety runs BEFORE any other AIPass file is written
|
||||||
|
- Registry-first: mint the project registry before spawning the agent
|
||||||
|
- dry_run performs zero filesystem writes and never calls spawn_agent
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.json import json_handler
|
||||||
|
from aipass.aipass.apps.handlers.new_project import (
|
||||||
|
_agent_home,
|
||||||
|
_registry_name,
|
||||||
|
_spawn_project_agent,
|
||||||
|
_write_registry,
|
||||||
|
)
|
||||||
|
from aipass.aipass.shared import scaffold_content as sc
|
||||||
|
from aipass.aipass.shared.project_home import (
|
||||||
|
_claude_local_settings,
|
||||||
|
_claude_settings,
|
||||||
|
_detect_aipass_home,
|
||||||
|
_enroll_project,
|
||||||
|
is_projects_child,
|
||||||
|
is_throwaway_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
GITIGNORE_MARKER = "# AIPass local state"
|
||||||
|
|
||||||
|
|
||||||
|
def _gitignore_safety(target: Path, *, dry_run: bool) -> str:
|
||||||
|
"""Ensure AIPass-managed paths are gitignored. Returns 'created', 'appended', or 'already-safe'."""
|
||||||
|
gitignore_path = target / ".gitignore"
|
||||||
|
if gitignore_path.exists():
|
||||||
|
existing = gitignore_path.read_text(encoding="utf-8")
|
||||||
|
if GITIGNORE_MARKER in existing:
|
||||||
|
return "already-safe"
|
||||||
|
if not dry_run:
|
||||||
|
separator = "" if existing.endswith("\n") else "\n"
|
||||||
|
gitignore_path.write_text(existing + separator + "\n" + sc.gitignore(), encoding="utf-8")
|
||||||
|
return "appended"
|
||||||
|
if not dry_run:
|
||||||
|
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
|
||||||
|
return "created"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_if_missing(path: Path, content: str, *, dry_run: bool, planned: list[str]) -> None:
|
||||||
|
"""Record and (unless dry_run) write *content* to *path*, but only if it doesn't already exist."""
|
||||||
|
if path.exists():
|
||||||
|
return
|
||||||
|
if not dry_run:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
planned.append(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def adopt_project(target: Path, *, no_agent: bool = False, dry_run: bool = False) -> dict:
|
||||||
|
"""Adopt an existing directory at <host>/projects/<name> as a full AIPass project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: Existing directory to adopt — must be <host>/projects/<name>.
|
||||||
|
no_agent: Skip resident-agent creation.
|
||||||
|
dry_run: Report what WOULD happen; performs zero filesystem writes and
|
||||||
|
never calls spawn_agent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with name, target, host, dry_run, registry_id, registry_file,
|
||||||
|
files, gitignore_action, agent_created, agent_home, spawn_result.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: target missing, not a projects/ child, already adopted,
|
||||||
|
or a resident-agent home path collision.
|
||||||
|
"""
|
||||||
|
target = target.resolve()
|
||||||
|
if not target.is_dir():
|
||||||
|
raise RuntimeError(f"'{target}' does not exist. `aipass adopt` brings in an EXISTING directory.")
|
||||||
|
|
||||||
|
if not is_projects_child(target):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"'{target}' is not <host>/projects/<name>. `aipass adopt` only works inside projects/ "
|
||||||
|
"— use `aipass new` to create a fresh project instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_registry = [f for f in target.iterdir() if f.is_file() and f.name.endswith("_REGISTRY.json")]
|
||||||
|
if existing_registry:
|
||||||
|
raise RuntimeError(f"'{target}' is already adopted (has {existing_registry[0].name}).")
|
||||||
|
|
||||||
|
host = target.parent.parent
|
||||||
|
name = target.name
|
||||||
|
reg = _registry_name(name)
|
||||||
|
|
||||||
|
agent_home = _agent_home(target, name)
|
||||||
|
if not no_agent and agent_home.exists() and any(agent_home.iterdir()):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Name collision: '{agent_home}' already exists and is non-empty — "
|
||||||
|
"cannot seat a resident agent there. Retry with --no-agent."
|
||||||
|
)
|
||||||
|
|
||||||
|
aipass_home = _detect_aipass_home()
|
||||||
|
files: list[str] = []
|
||||||
|
|
||||||
|
# gitignore safety FIRST — nothing AIPass-managed gets written before the
|
||||||
|
# target is confirmed to ignore it (target may be a public repo).
|
||||||
|
gitignore_action = _gitignore_safety(target, dry_run=dry_run)
|
||||||
|
|
||||||
|
# Registry — sealed, minted BEFORE spawn (registry-first rule)
|
||||||
|
registry_id = None
|
||||||
|
registry_filename = f"{reg}_REGISTRY.json"
|
||||||
|
if not dry_run:
|
||||||
|
registry_id, registry_filename = _write_registry(target, name)
|
||||||
|
files.append(registry_filename)
|
||||||
|
|
||||||
|
# .aipass/ — tier files, hooks.json, CLAUDE.md/AGENTS.md
|
||||||
|
aipass_dir = target / ".aipass"
|
||||||
|
if aipass_home:
|
||||||
|
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
|
||||||
|
src_path = Path(aipass_home) / ".aipass" / tier_file
|
||||||
|
if src_path.is_file():
|
||||||
|
_write_if_missing(
|
||||||
|
aipass_dir / tier_file,
|
||||||
|
src_path.read_text(encoding="utf-8"),
|
||||||
|
dry_run=dry_run,
|
||||||
|
planned=files,
|
||||||
|
)
|
||||||
|
|
||||||
|
hooks_template = Path(aipass_home) / ".aipass" / "project_hooks.json"
|
||||||
|
if hooks_template.is_file():
|
||||||
|
hooks_dest = aipass_dir / "hooks.json"
|
||||||
|
already_had_hooks = hooks_dest.exists()
|
||||||
|
_write_if_missing(hooks_dest, hooks_template.read_text(encoding="utf-8"), dry_run=dry_run, planned=files)
|
||||||
|
if not already_had_hooks and not dry_run:
|
||||||
|
_enroll_project(target)
|
||||||
|
|
||||||
|
for md_name in ("CLAUDE.md", "AGENTS.md"):
|
||||||
|
dest = target / md_name
|
||||||
|
if dest.exists():
|
||||||
|
continue
|
||||||
|
if aipass_home:
|
||||||
|
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
|
||||||
|
if tmpl.is_file():
|
||||||
|
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
|
||||||
|
_write_if_missing(dest, content, dry_run=dry_run, planned=files)
|
||||||
|
continue
|
||||||
|
if md_name == "AGENTS.md":
|
||||||
|
_write_if_missing(dest, sc.agents_md(reg), dry_run=dry_run, planned=files)
|
||||||
|
|
||||||
|
# .claude/settings.json — tracked, permissions only
|
||||||
|
claude_dir = target / ".claude"
|
||||||
|
_write_if_missing(claude_dir / "settings.json", _claude_settings(), dry_run=dry_run, planned=files)
|
||||||
|
|
||||||
|
# .claude/settings.local.json — machine-local AIPASS_HOME (gitignored)
|
||||||
|
if aipass_home and not is_throwaway_path(aipass_home):
|
||||||
|
_write_if_missing(
|
||||||
|
claude_dir / "settings.local.json",
|
||||||
|
_claude_local_settings(aipass_home),
|
||||||
|
dry_run=dry_run,
|
||||||
|
planned=files,
|
||||||
|
)
|
||||||
|
|
||||||
|
# .claude/commands/prep.md
|
||||||
|
_write_if_missing(claude_dir / "commands" / "prep.md", sc.prep_md(), dry_run=dry_run, planned=files)
|
||||||
|
|
||||||
|
# .venv symlink → AIPass shared runtime
|
||||||
|
if aipass_home:
|
||||||
|
venv = Path(aipass_home) / ".venv"
|
||||||
|
venv_link = target / ".venv"
|
||||||
|
if venv.is_dir() and not venv_link.exists():
|
||||||
|
if not dry_run:
|
||||||
|
venv_link.symlink_to(venv)
|
||||||
|
files.append(str(venv_link))
|
||||||
|
|
||||||
|
# Resident agent — registry MUST exist first (dry_run never spawns)
|
||||||
|
spawn_result = None
|
||||||
|
agent_created = False
|
||||||
|
will_have_agent = not no_agent
|
||||||
|
if will_have_agent and not dry_run:
|
||||||
|
spawn_result = _spawn_project_agent(target, name)
|
||||||
|
agent_created = True
|
||||||
|
elif will_have_agent and dry_run:
|
||||||
|
files.append(f"{agent_home} (resident agent — planned)")
|
||||||
|
|
||||||
|
json_handler.log_operation(
|
||||||
|
"adopt_project",
|
||||||
|
{"name": name, "target": str(target), "dry_run": dry_run},
|
||||||
|
"adopt",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"target": str(target),
|
||||||
|
"host": str(host),
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"registry_id": registry_id,
|
||||||
|
"registry_file": registry_filename,
|
||||||
|
"files": files,
|
||||||
|
"gitignore_action": gitignore_action,
|
||||||
|
"agent_created": agent_created,
|
||||||
|
"agent_home": str(agent_home) if will_have_agent else None,
|
||||||
|
"spawn_result": spawn_result,
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ def find_project_root(start: Path) -> Optional[Path]:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
_SCAN_SKIP_DIRS = {".archive", ".venv", ".git", "__pycache__", "node_modules", ".chroma"}
|
_SCAN_SKIP_DIRS = {".archive", ".backup", ".venv", ".git", "__pycache__", "node_modules", ".chroma", "templates"}
|
||||||
|
|
||||||
|
|
||||||
def scan_agents(project_root: Path) -> List[AgentInfo]:
|
def scan_agents(project_root: Path) -> List[AgentInfo]:
|
||||||
@@ -286,12 +286,15 @@ def detect_pollution(agents: List[AgentInfo]) -> List[PollutionHit]:
|
|||||||
def check_registry_consistency(
|
def check_registry_consistency(
|
||||||
registry_path: Path,
|
registry_path: Path,
|
||||||
agents: List[AgentInfo],
|
agents: List[AgentInfo],
|
||||||
|
project_root: Optional[Path] = None,
|
||||||
) -> List[RegistryIssue]:
|
) -> List[RegistryIssue]:
|
||||||
"""Validate that registry branches[].path entries match actual filesystem.
|
"""Validate that registry branches[].path entries match actual filesystem.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of RegistryIssue for each problem found.
|
List of RegistryIssue for each problem found.
|
||||||
"""
|
"""
|
||||||
|
anchor = (project_root or registry_path.parent).resolve()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||||
except (json.JSONDecodeError, OSError) as exc:
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
@@ -309,7 +312,8 @@ def check_registry_consistency(
|
|||||||
issues.append(RegistryIssue(name, "", "missing"))
|
issues.append(RegistryIssue(name, "", "missing"))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
reg_path = Path(path_str).resolve()
|
p = Path(path_str)
|
||||||
|
reg_path = p.resolve() if p.is_absolute() else (anchor / p).resolve()
|
||||||
if not reg_path.exists():
|
if not reg_path.exists():
|
||||||
issues.append(RegistryIssue(name, path_str, "missing"))
|
issues.append(RegistryIssue(name, path_str, "missing"))
|
||||||
elif str(reg_path) not in agent_paths:
|
elif str(reg_path) not in agent_paths:
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: doctor_fix.py
|
# Name: _doctor_fix.py
|
||||||
# Description: Structure remediation report for aipass doctor --fix
|
# Description: Structure remediation report for aipass doctor --fix
|
||||||
# Version: 1.0.0
|
# Version: 1.0.0
|
||||||
# Created: 2026-05-15
|
# Created: 2026-05-15
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: doctor_wire.py
|
# Name: _doctor_wire.py
|
||||||
# Description: Auto-wire provider settings from manifest into user config
|
# Description: Auto-wire provider settings from manifest into user config
|
||||||
# Version: 1.0.0
|
# Version: 1.0.0
|
||||||
# Created: 2026-05-08
|
# Created: 2026-05-08
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: adopt.py
|
||||||
|
# Description: aipass adopt — bring an existing projects/ directory into AIPass
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-20
|
||||||
|
# Modified: 2026-07-20
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
aipass adopt — turn an existing directory under projects/ into a full
|
||||||
|
AIPass project (registry, resident agent, .aipass/.claude scaffold).
|
||||||
|
|
||||||
|
Unlike `aipass new`, adopt starts from a directory that already has its
|
||||||
|
own content and git history — every write is additive, nothing existing
|
||||||
|
is ever overwritten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.json import json_handler
|
||||||
|
from aipass.cli.apps.modules import console, error, success
|
||||||
|
from aipass.prax import logger
|
||||||
|
|
||||||
|
COMMAND = "adopt"
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection() -> None:
|
||||||
|
"""Bare invocation — usage pointer."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass adopt[/bold cyan] — bring an existing directory into projects/")
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Usage: aipass adopt <name-or-path> [--no-agent] [--dry-run][/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_help() -> None:
|
||||||
|
"""Print usage help for the adopt command."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass adopt[/bold cyan] — adopt an existing directory as an AIPass project")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]USAGE:[/yellow]")
|
||||||
|
console.print(" [green]aipass adopt <name>[/green] [dim]# Adopt <host>/projects/<name>[/dim]")
|
||||||
|
console.print(" [green]aipass adopt <path>[/green] [dim]# Adopt by relative/absolute path[/dim]")
|
||||||
|
console.print(" [green]aipass adopt <name> --no-agent[/green] [dim]# Skip resident agent[/dim]")
|
||||||
|
console.print(" [green]aipass adopt <name> --dry-run[/green] [dim]# Preview, write nothing[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]WHAT IT DOES:[/yellow]")
|
||||||
|
console.print(" Seats a sealed registry, scaffolds .aipass/.claude, and (unless")
|
||||||
|
console.print(" --no-agent) creates a resident agent — all ADDITIVE. Never touches")
|
||||||
|
console.print(" the target's existing git history or tracked files. If the target")
|
||||||
|
console.print(" has no .gitignore covering AIPass local state, one is created or")
|
||||||
|
console.print(" appended to first — the target may be a public repo.")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]REQUIRES:[/yellow]")
|
||||||
|
console.print(" Target must be an existing directory at <host>/projects/<name>.")
|
||||||
|
console.print(" Use 'aipass new' instead to create a brand-new project.")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_command(command: str, args: list[str]) -> bool:
|
||||||
|
"""Route the 'adopt' command. Returns True if handled."""
|
||||||
|
if command != COMMAND:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
json_handler.log_operation("adopt_usage", {"command": command})
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
if args[0] in ("--help", "-h", "help"):
|
||||||
|
json_handler.log_operation("adopt_help", {"command": command})
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
|
||||||
|
name_or_path = args[0]
|
||||||
|
known = {"--no-agent", "--dry-run"}
|
||||||
|
unknown = [a for a in args[1:] if a not in known]
|
||||||
|
if unknown:
|
||||||
|
error(f"Unknown option: {unknown[0]}")
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
no_agent = "--no-agent" in args[1:]
|
||||||
|
dry_run = "--dry-run" in args[1:]
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.new_project import find_host_root
|
||||||
|
from aipass.aipass.apps.handlers.new_project.adopt import adopt_project
|
||||||
|
|
||||||
|
target_path = Path(name_or_path)
|
||||||
|
if not target_path.exists():
|
||||||
|
host = find_host_root(Path.cwd())
|
||||||
|
if host is None:
|
||||||
|
error("Not inside an AIPass installation (no *_REGISTRY.json found).")
|
||||||
|
sys.exit(1)
|
||||||
|
target_path = host / "projects" / name_or_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = adopt_project(target_path, no_agent=no_agent, dry_run=dry_run)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning("[AIPASS] adopt failed: %s", e)
|
||||||
|
error(str(e))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
verb = "Would adopt" if dry_run else "Adopted"
|
||||||
|
success(f"{verb} '{result['name']}' at {result['target']}")
|
||||||
|
console.print()
|
||||||
|
console.print(f" [dim]Registry:[/dim] {result['registry_file']}")
|
||||||
|
console.print(f" [dim]Gitignore:[/dim] {result['gitignore_action']}")
|
||||||
|
if result["agent_home"]:
|
||||||
|
state = "created" if result["agent_created"] else "planned"
|
||||||
|
console.print(f" [dim]Agent:[/dim] {state} ({result['agent_home']})")
|
||||||
|
else:
|
||||||
|
console.print(" [dim]Agent:[/dim] skipped (--no-agent)")
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Files:[/dim]")
|
||||||
|
for f in result["files"]:
|
||||||
|
console.print(f" [dim]{f}[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
json_handler.log_operation(
|
||||||
|
"adopt_project",
|
||||||
|
{"name": result["name"], "target": result["target"], "dry_run": dry_run},
|
||||||
|
)
|
||||||
|
logger.info("[AIPASS] adopt: %s at %s (dry_run=%s)", result["name"], result["target"], dry_run)
|
||||||
|
return True
|
||||||
@@ -54,11 +54,11 @@ from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
|||||||
find_project_root,
|
find_project_root,
|
||||||
scan_agents,
|
scan_agents,
|
||||||
)
|
)
|
||||||
from aipass.aipass.apps.modules.doctor_fix import (
|
from aipass.aipass.apps.modules._doctor_fix import (
|
||||||
print_json_report,
|
print_json_report,
|
||||||
print_remediation_report,
|
print_remediation_report,
|
||||||
)
|
)
|
||||||
from aipass.aipass.apps.modules.doctor_wire import (
|
from aipass.aipass.apps.modules._doctor_wire import (
|
||||||
_auto_wire_provider,
|
_auto_wire_provider,
|
||||||
_prompt_auto_wire as prompt_auto_wire,
|
_prompt_auto_wire as prompt_auto_wire,
|
||||||
check_wire_verify,
|
check_wire_verify,
|
||||||
@@ -81,6 +81,8 @@ from aipass.aipass.apps.handlers.ui.progress import (
|
|||||||
make_doctor_progress,
|
make_doctor_progress,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
COMMAND = "doctor"
|
||||||
|
|
||||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
@@ -668,7 +670,7 @@ def _check_structure() -> List[CheckResult]:
|
|||||||
# Registry consistency
|
# Registry consistency
|
||||||
reg_path = _discover_registry(start_path=project_root)
|
reg_path = _discover_registry(start_path=project_root)
|
||||||
if reg_path and reg_path.exists():
|
if reg_path and reg_path.exists():
|
||||||
reg_issues = check_registry_consistency(reg_path, agents)
|
reg_issues = check_registry_consistency(reg_path, agents, project_root=project_root)
|
||||||
if reg_issues:
|
if reg_issues:
|
||||||
for issue in reg_issues:
|
for issue in reg_issues:
|
||||||
glyph = GLYPH_FAIL if issue.problem == "missing" else GLYPH_WARN
|
glyph = GLYPH_FAIL if issue.problem == "missing" else GLYPH_WARN
|
||||||
@@ -684,7 +686,7 @@ def _check_structure() -> List[CheckResult]:
|
|||||||
root_hits = check_root_artifacts(project_root)
|
root_hits = check_root_artifacts(project_root)
|
||||||
if root_hits:
|
if root_hits:
|
||||||
for hit in root_hits:
|
for hit in root_hits:
|
||||||
glyph = GLYPH_WARN if hit.severity == "warn" else GLYPH_PASS
|
glyph = GLYPH_PASS if hit.severity == "pass" else GLYPH_WARN
|
||||||
results.append(CheckResult(f"root: {hit.name}", glyph, hit.description, ""))
|
results.append(CheckResult(f"root: {hit.name}", glyph, hit.description, ""))
|
||||||
else:
|
else:
|
||||||
results.append(CheckResult("root artifacts", GLYPH_PASS, "none misplaced", ""))
|
results.append(CheckResult("root artifacts", GLYPH_PASS, "none misplaced", ""))
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: feedback.py
|
||||||
|
# Description: aipass feedback — toggle the feedback reminder pulse on/off
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-18
|
||||||
|
# Modified: 2026-07-18
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
aipass feedback — user-facing alias for the @hooks feedback toggle.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
aipass feedback # show current state
|
||||||
|
aipass feedback on # enable feedback reminders
|
||||||
|
aipass feedback off # disable feedback reminders
|
||||||
|
aipass feedback --help
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from aipass.cli.apps.modules import console, error, warning
|
||||||
|
from aipass.prax import logger
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.json import json_handler
|
||||||
|
|
||||||
|
COMMAND = "feedback"
|
||||||
|
|
||||||
|
_DRONE_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _run_hooks_feedback(action: str | None) -> int:
|
||||||
|
"""Delegate to drone @hooks feedback. Returns the subprocess exit code."""
|
||||||
|
cmd = ["drone", "@hooks", "feedback"]
|
||||||
|
if action:
|
||||||
|
cmd.append(action)
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, timeout=_DRONE_TIMEOUT)
|
||||||
|
return proc.returncode
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("[feedback] drone not found on PATH")
|
||||||
|
warning("drone not found on PATH — cannot reach @hooks.")
|
||||||
|
return 1
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("[feedback] drone @hooks feedback timed out")
|
||||||
|
warning("drone @hooks feedback timed out.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def print_help() -> None:
|
||||||
|
"""Print usage help for the feedback command."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass feedback[/bold cyan] — toggle the feedback reminder")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]USAGE:[/yellow]")
|
||||||
|
console.print(" [green]aipass feedback[/green] [dim]# show current state[/dim]")
|
||||||
|
console.print(" [green]aipass feedback on[/green] [dim]# enable feedback reminders[/dim]")
|
||||||
|
console.print(" [green]aipass feedback off[/green] [dim]# disable feedback reminders[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Delegates to: drone @hooks feedback[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection() -> None:
|
||||||
|
"""Show module info for feedback."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]feedback Module[/bold cyan]")
|
||||||
|
console.print("User-facing alias for the @hooks feedback pulse toggle.")
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Delegates to: drone @hooks feedback on/off[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_command(command: str, args: list[str]) -> bool:
|
||||||
|
"""Route the feedback command. Returns True if handled."""
|
||||||
|
if command != COMMAND:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if args and args[0] in ("--help", "-h", "help"):
|
||||||
|
json_handler.log_operation("feedback_help", {"command": command})
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
if args and args[0] in ("--info", "info"):
|
||||||
|
json_handler.log_operation("feedback_info", {"command": command})
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
json_handler.log_operation("feedback_usage", {"command": command})
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
|
||||||
|
action = args[0] if args[0] in ("on", "off") else None
|
||||||
|
if action is None:
|
||||||
|
error(f"Unknown option: {args[0]}. Use 'on' or 'off'.")
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
|
||||||
|
rc = _run_hooks_feedback(action)
|
||||||
|
json_handler.log_operation(
|
||||||
|
"feedback_toggle",
|
||||||
|
{"action": action or "status", "exit": rc},
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
logger.warning("[feedback] drone @hooks feedback exited %d", rc)
|
||||||
|
return True
|
||||||
@@ -28,7 +28,7 @@ from aipass.aipass.apps.handlers.json import json_handler
|
|||||||
|
|
||||||
COMMAND = "handoff"
|
COMMAND = "handoff"
|
||||||
|
|
||||||
_INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?"
|
INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?"
|
||||||
|
|
||||||
CLI_CHOICES = ["claude", "codex"]
|
CLI_CHOICES = ["claude", "codex"]
|
||||||
FLAG_CHOICES = ["default", "skip-permissions"]
|
FLAG_CHOICES = ["default", "skip-permissions"]
|
||||||
@@ -48,7 +48,7 @@ def _get_stored_profile() -> dict:
|
|||||||
|
|
||||||
def do_handoff(
|
def do_handoff(
|
||||||
cli: str = "claude",
|
cli: str = "claude",
|
||||||
prompt: str = _INIT_PROMPT,
|
prompt: str = INIT_PROMPT,
|
||||||
cwd: str = ".",
|
cwd: str = ".",
|
||||||
flag_variant: str = "default",
|
flag_variant: str = "default",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ TEMPLATE_EMPTY = "empty project"
|
|||||||
TEMPLATE_AIPASS = "aipass_framework"
|
TEMPLATE_AIPASS = "aipass_framework"
|
||||||
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
|
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
|
||||||
# first_agent, ping_sweep, handoff, done — skipped for empty (non-framework) projects
|
# first_agent, ping_sweep, handoff, done — skipped for empty (non-framework) projects
|
||||||
AIPASS_SPECIFIC_STAGES = {6, 7, 9, 10}
|
AIPASS_SPECIFIC_STAGES = {6, 7}
|
||||||
|
|
||||||
|
|
||||||
# --- LOCAL JSON HELPERS ---
|
# --- LOCAL JSON HELPERS ---
|
||||||
@@ -603,29 +603,35 @@ def stage_9_handoff(
|
|||||||
console.print()
|
console.print()
|
||||||
console.print(render_step_header(9, TOTAL_STAGES, "Handoff"))
|
console.print(render_step_header(9, TOTAL_STAGES, "Handoff"))
|
||||||
|
|
||||||
init_prompt = "I just completed aipass init. I am ready to start. What should I do first?"
|
from aipass.aipass.apps.modules.handoff import INIT_PROMPT
|
||||||
|
|
||||||
|
init_prompt = INIT_PROMPT
|
||||||
|
|
||||||
|
_template = (accumulated or {}).get("template", TEMPLATE_AIPASS)
|
||||||
console.print()
|
console.print()
|
||||||
console.print(" Your agent is ready.")
|
if _template == TEMPLATE_AIPASS:
|
||||||
console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]")
|
console.print(" Your agent is ready.")
|
||||||
|
console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]")
|
||||||
|
else:
|
||||||
|
_display = str(Path(agent_path).resolve()) if agent_path == "." else agent_path
|
||||||
|
console.print(" Your project is ready — launching your CLI.")
|
||||||
|
console.print(f" [dim]CLI: {cli_choice} | Project: {_display}[/dim]")
|
||||||
|
|
||||||
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
|
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
|
||||||
|
|
||||||
command = build_manual_command(cli_choice, init_prompt, agent_path, flag_variant)
|
display_path = str(Path(agent_path).resolve()) if agent_path == "." else agent_path
|
||||||
|
command = build_manual_command(cli_choice, init_prompt, display_path, flag_variant)
|
||||||
inline = False
|
inline = False
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
console.print(f"[yellow]\\[dry-run][/yellow] would launch handoff: {command}")
|
console.print(f"[yellow]\\[dry-run][/yellow] would launch handoff: {command}")
|
||||||
launched = False
|
launched = False
|
||||||
elif non_interactive:
|
elif non_interactive:
|
||||||
from aipass.aipass.apps.modules import handoff as handoff_mod
|
console.print()
|
||||||
|
console.print(" [dim]Next step (run manually):[/dim]")
|
||||||
launched = handoff_mod.do_handoff(
|
console.print(f" [cyan]{command}[/cyan]")
|
||||||
cli=cli_choice,
|
console.print()
|
||||||
prompt=init_prompt,
|
launched = False
|
||||||
cwd=agent_path,
|
|
||||||
flag_variant=flag_variant,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
console.print()
|
console.print()
|
||||||
console.print(" [bold]1.[/bold] Stay here — launch agent in this terminal")
|
console.print(" [bold]1.[/bold] Stay here — launch agent in this terminal")
|
||||||
@@ -729,8 +735,12 @@ def stage_10_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = Fal
|
|||||||
|
|
||||||
|
|
||||||
# --- MAIN RUNNER ---
|
# --- MAIN RUNNER ---
|
||||||
def _preflight_check() -> str | None:
|
def _preflight_check(*, allow_projects_child: bool = False) -> str | None:
|
||||||
"""Return an error message if CWD is unsafe for init, else None."""
|
"""Return an error message if CWD is unsafe for init, else None.
|
||||||
|
|
||||||
|
When *allow_projects_child* is True, the nested-project check is skipped
|
||||||
|
if CWD is ``<host>/projects/<name>``.
|
||||||
|
"""
|
||||||
cwd = Path.cwd()
|
cwd = Path.cwd()
|
||||||
# Block if inside an agent directory
|
# Block if inside an agent directory
|
||||||
if (cwd / ".trinity" / "passport.json").is_file():
|
if (cwd / ".trinity" / "passport.json").is_file():
|
||||||
@@ -738,6 +748,12 @@ def _preflight_check() -> str | None:
|
|||||||
"This directory is an agent branch (has .trinity/passport.json).\n"
|
"This directory is an agent branch (has .trinity/passport.json).\n"
|
||||||
"Agents are managed by 'drone @spawn', not 'aipass init'."
|
"Agents are managed by 'drone @spawn', not 'aipass init'."
|
||||||
)
|
)
|
||||||
|
# Early exit: if CWD is a valid <host>/projects/<name>, skip nesting check
|
||||||
|
if allow_projects_child:
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||||
|
|
||||||
|
if is_projects_child(cwd):
|
||||||
|
return None
|
||||||
# Block if inside an existing AIPass project (registry above us).
|
# Block if inside an existing AIPass project (registry above us).
|
||||||
# Walking up to the filesystem root can hit ancestors that can't be
|
# Walking up to the filesystem root can hit ancestors that can't be
|
||||||
# enumerated or stat'd — e.g. locked Windows system entries at the drive
|
# enumerated or stat'd — e.g. locked Windows system entries at the drive
|
||||||
@@ -774,6 +790,9 @@ def run_init(
|
|||||||
template: str | None = None,
|
template: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run the 10-stage init flow. Returns 0 on success."""
|
"""Run the 10-stage init flow. Returns 0 on success."""
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
non_interactive = True
|
||||||
|
|
||||||
# Pre-flight: refuse to run inside existing projects or agent dirs
|
# Pre-flight: refuse to run inside existing projects or agent dirs
|
||||||
err = _preflight_check()
|
err = _preflight_check()
|
||||||
if err:
|
if err:
|
||||||
@@ -815,6 +834,8 @@ def run_init(
|
|||||||
warning(f"Resuming from stage {last_done + 1}...")
|
warning(f"Resuming from stage {last_done + 1}...")
|
||||||
|
|
||||||
accumulated: Dict[str, Any] = {"template": template}
|
accumulated: Dict[str, Any] = {"template": template}
|
||||||
|
if template != TEMPLATE_AIPASS:
|
||||||
|
accumulated["agent_path"] = "."
|
||||||
|
|
||||||
stage_fns = [
|
stage_fns = [
|
||||||
(1, lambda: stage_1_welcome(dry_run=dry_run)),
|
(1, lambda: stage_1_welcome(dry_run=dry_run)),
|
||||||
@@ -844,6 +865,8 @@ def run_init(
|
|||||||
continue
|
continue
|
||||||
if stage_num in AIPASS_SPECIFIC_STAGES and template != TEMPLATE_AIPASS:
|
if stage_num in AIPASS_SPECIFIC_STAGES and template != TEMPLATE_AIPASS:
|
||||||
logger.info("[init_flow] skipping stage %d (not aipass_framework)", stage_num)
|
logger.info("[init_flow] skipping stage %d (not aipass_framework)", stage_num)
|
||||||
|
if not non_interactive:
|
||||||
|
console.print(f"\n[dim] (skipping step {stage_num} — framework-only)[/dim]")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
result = fn() or {}
|
result = fn() or {}
|
||||||
@@ -857,11 +880,6 @@ def run_init(
|
|||||||
warning(f"Stage {stage_num} error: {exc} — continuing.")
|
warning(f"Stage {stage_num} error: {exc} — continuing.")
|
||||||
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
|
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
|
||||||
|
|
||||||
if template != TEMPLATE_AIPASS:
|
|
||||||
console.print()
|
|
||||||
success("Project initialized.")
|
|
||||||
console.print("[dim]Run 'aipass init agent <name>' to add an agent.[/dim]")
|
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -179,17 +179,21 @@ def _verify_binaries(home: Path) -> Dict[str, str | None]:
|
|||||||
return {"drone": drone, "aipass": aipass}
|
return {"drone": drone, "aipass": aipass}
|
||||||
|
|
||||||
|
|
||||||
def _should_run_init(non_interactive: bool, with_init: bool, no_init: bool) -> bool:
|
def _build_install_prompt(home: Path, bins: dict) -> str:
|
||||||
"""Decide whether to auto-launch init. --no-init wins; --with-init forces on.
|
"""Compose the authored first prompt for the post-install @aipass chat."""
|
||||||
|
parts = [f"Fresh AIPass install completed at {home}."]
|
||||||
|
for name, path in bins.items():
|
||||||
|
if path:
|
||||||
|
parts.append(f"{name}: {path}.")
|
||||||
|
parts.append(
|
||||||
|
"This is my first time here — what can I do with AIPass? Show me a few things to try, with the exact commands."
|
||||||
|
)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
Default: interactive flows chain into init ("one command, done"); headless
|
|
||||||
flows stop at a wired engine and print the next command (safe for CI/Docker).
|
def _should_run_init(no_init: bool) -> bool:
|
||||||
"""
|
"""Decide whether to auto-launch init. --no-init skips; default = always chain."""
|
||||||
if no_init:
|
return not no_init
|
||||||
return False
|
|
||||||
if with_init:
|
|
||||||
return True
|
|
||||||
return not non_interactive
|
|
||||||
|
|
||||||
|
|
||||||
def _handoff_to_init(
|
def _handoff_to_init(
|
||||||
@@ -357,13 +361,26 @@ def run_install(
|
|||||||
# Step 4 — hand off into init (or print next steps)
|
# Step 4 — hand off into init (or print next steps)
|
||||||
console.print()
|
console.print()
|
||||||
console.print(render_step_header(4, TOTAL_STEPS, "First project"))
|
console.print(render_step_header(4, TOTAL_STEPS, "First project"))
|
||||||
run_it = _should_run_init(non_interactive, with_init, no_init)
|
run_it = _should_run_init(no_init)
|
||||||
_handoff_to_init(home, bins.get("aipass"), non_interactive, dry_run, project, run_it)
|
_handoff_to_init(home, bins.get("aipass"), non_interactive, dry_run, project, run_it)
|
||||||
|
|
||||||
|
# Log BEFORE exec — launch_inline replaces the process and never returns
|
||||||
json_handler.log_operation(
|
json_handler.log_operation(
|
||||||
"aipass_install",
|
"aipass_install",
|
||||||
{"home": str(home), "non_interactive": non_interactive, "dry_run": dry_run, "init": run_it},
|
{"home": str(home), "non_interactive": non_interactive, "dry_run": dry_run, "init": run_it},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Install-to-chat handoff — launch @aipass concierge in same terminal
|
||||||
|
if run_it and not dry_run and sys.stdin.isatty():
|
||||||
|
prompt = _build_install_prompt(home, bins)
|
||||||
|
aipass_branch = str(Path(__file__).resolve().parents[2])
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Launching the AIPass concierge — Ctrl-C to stay in the shell[/dim]")
|
||||||
|
console.print()
|
||||||
|
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
|
||||||
|
|
||||||
|
launch_inline("claude", prompt, aipass_branch)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: new_project.py
|
||||||
|
# Description: aipass new — create projects inside the AIPass installation
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-17
|
||||||
|
# Modified: 2026-07-17
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
aipass new — create projects inside the AIPass installation (DPLAN-0247)
|
||||||
|
|
||||||
|
Creates a project at <host>/projects/<name> with its own git repo,
|
||||||
|
AIPass scaffold, and optional resident agent. Born deployable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.json import json_handler
|
||||||
|
from aipass.cli.apps.modules import console, error, success
|
||||||
|
from aipass.prax import logger
|
||||||
|
|
||||||
|
COMMAND = "new"
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection() -> None:
|
||||||
|
"""List existing projects in the installation."""
|
||||||
|
from aipass.aipass.apps.handlers.new_project import find_host_root
|
||||||
|
|
||||||
|
host = find_host_root(Path.cwd())
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass new[/bold cyan] — project creator")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if host is None:
|
||||||
|
console.print("[dim]Not inside an AIPass installation.[/dim]")
|
||||||
|
console.print()
|
||||||
|
return
|
||||||
|
|
||||||
|
projects_dir = host / "projects"
|
||||||
|
if not projects_dir.is_dir():
|
||||||
|
console.print(f"[dim]No projects/ directory at {host}[/dim]")
|
||||||
|
console.print()
|
||||||
|
return
|
||||||
|
|
||||||
|
projects = [d for d in sorted(projects_dir.iterdir()) if d.is_dir() and not d.name.startswith(".")]
|
||||||
|
if not projects:
|
||||||
|
console.print("[dim]No projects yet. Create one:[/dim]")
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]{len(projects)} project(s):[/yellow]")
|
||||||
|
for p in projects:
|
||||||
|
has_git = (p / ".git").is_dir()
|
||||||
|
has_reg = any(f.name.endswith("_REGISTRY.json") for f in p.iterdir() if f.is_file())
|
||||||
|
markers = []
|
||||||
|
if has_git:
|
||||||
|
markers.append("git")
|
||||||
|
if has_reg:
|
||||||
|
markers.append("registry")
|
||||||
|
info = f" [dim]({', '.join(markers)})[/dim]" if markers else ""
|
||||||
|
console.print(f" [cyan]{p.name}[/cyan]{info}")
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Create: aipass new <name> [--template python] [--no-agent][/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_help() -> None:
|
||||||
|
"""Print usage help for the new command."""
|
||||||
|
from aipass.aipass.apps.handlers.new_project import TEMPLATES
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass new[/bold cyan] — create a project inside AIPass")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]USAGE:[/yellow]")
|
||||||
|
console.print(" [green]aipass new <name>[/green] [dim]# Create with empty template[/dim]")
|
||||||
|
console.print(" [green]aipass new <name> --template python[/green] [dim]# Create with Python template[/dim]")
|
||||||
|
console.print(" [green]aipass new <name> --no-agent[/green] [dim]# Skip agent creation[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]TEMPLATES:[/yellow]")
|
||||||
|
console.print(f" [dim]{', '.join(TEMPLATES)}[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]WHAT IT DOES:[/yellow]")
|
||||||
|
console.print(" Creates projects/<name> with its own git repo, AIPass scaffold,")
|
||||||
|
console.print(" and optional resident agent. Born deployable — repo + packaging")
|
||||||
|
console.print(" from minute one.")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_template(templates: list[str]) -> str:
|
||||||
|
"""Prompt user to choose a template interactively."""
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]Choose a template:[/yellow]")
|
||||||
|
for idx, t in enumerate(templates, 1):
|
||||||
|
console.print(f" [green]{idx}[/green]. {t}")
|
||||||
|
console.print()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = input("Template [1]: ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
logger.info("template prompt interrupted, defaulting to %s", templates[0])
|
||||||
|
return templates[0]
|
||||||
|
if not choice:
|
||||||
|
return templates[0]
|
||||||
|
if choice.isdigit() and 1 <= int(choice) <= len(templates):
|
||||||
|
return templates[int(choice) - 1]
|
||||||
|
if choice in templates:
|
||||||
|
return choice
|
||||||
|
error(f"Invalid choice. Enter 1-{len(templates)} or a template name.")
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_agent() -> bool:
|
||||||
|
"""Prompt user whether to skip agent creation. Returns no_agent flag."""
|
||||||
|
console.print()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = input("Create resident agent? [Y/n]: ").strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
logger.info("agent prompt interrupted, defaulting to create agent")
|
||||||
|
return False
|
||||||
|
if choice in ("", "y", "yes"):
|
||||||
|
return False
|
||||||
|
if choice in ("n", "no"):
|
||||||
|
return True
|
||||||
|
error("Enter y or n.")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_command(command: str, args: list[str]) -> bool:
|
||||||
|
"""Route the 'new' command. Returns True if handled."""
|
||||||
|
if command != COMMAND:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
json_handler.log_operation("new_project_usage", {"command": command})
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
if args[0] in ("--help", "-h", "help"):
|
||||||
|
json_handler.log_operation("new_project_help", {"command": command})
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
if args[0] == "--info":
|
||||||
|
json_handler.log_operation("new_project_info", {"command": command})
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
|
||||||
|
name = args[0]
|
||||||
|
template = None
|
||||||
|
no_agent = None
|
||||||
|
has_template_flag = False
|
||||||
|
has_agent_flag = False
|
||||||
|
|
||||||
|
i = 1
|
||||||
|
while i < len(args):
|
||||||
|
if args[i] == "--template" and i + 1 < len(args):
|
||||||
|
template = args[i + 1]
|
||||||
|
has_template_flag = True
|
||||||
|
i += 2
|
||||||
|
elif args[i] == "--no-agent":
|
||||||
|
no_agent = True
|
||||||
|
has_agent_flag = True
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
error(f"Unknown option: {args[i]}")
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.new_project import TEMPLATES, create_project
|
||||||
|
|
||||||
|
if not has_template_flag:
|
||||||
|
template = _prompt_template(list(TEMPLATES))
|
||||||
|
elif template is None:
|
||||||
|
template = "empty"
|
||||||
|
if not has_agent_flag:
|
||||||
|
no_agent = _prompt_agent()
|
||||||
|
elif no_agent is None:
|
||||||
|
no_agent = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = create_project(name, template, no_agent)
|
||||||
|
except (RuntimeError, ValueError) as e:
|
||||||
|
logger.warning("[AIPASS] new project failed: %s", e)
|
||||||
|
error(str(e))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
success(f"Project '{name}' created at {result['target']}")
|
||||||
|
console.print()
|
||||||
|
console.print(f" [dim]Registry:[/dim] {result['registry_file']}")
|
||||||
|
console.print(f" [dim]Template:[/dim] {result['template']}")
|
||||||
|
if result["agent_created"]:
|
||||||
|
console.print(" [dim]Agent:[/dim] created (full framework agent)")
|
||||||
|
else:
|
||||||
|
console.print(" [dim]Agent:[/dim] skipped (--no-agent)")
|
||||||
|
|
||||||
|
json_handler.log_operation(
|
||||||
|
"new_project_create",
|
||||||
|
{"name": name, "template": template, "target": result["target"]},
|
||||||
|
)
|
||||||
|
logger.info("[AIPASS] new project: %s (%s) at %s", name, template, result["target"])
|
||||||
|
|
||||||
|
if result["agent_created"] and sys.stdin.isatty():
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Launching your manager agent — Ctrl-C to stay in the shell[/dim]")
|
||||||
|
console.print()
|
||||||
|
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
|
||||||
|
|
||||||
|
launch_inline(
|
||||||
|
"claude",
|
||||||
|
"You are the new resident agent of this project."
|
||||||
|
" Read your passport and README, then tell me what you can do.",
|
||||||
|
result["agent_home"],
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]Next steps:[/yellow]")
|
||||||
|
if result["agent_created"]:
|
||||||
|
console.print(f" [cyan]cd {result['agent_home']}[/cyan]")
|
||||||
|
console.print(" [cyan]claude[/cyan] [dim]# meet your project agent[/dim]")
|
||||||
|
else:
|
||||||
|
console.print(f" [cyan]cd {result['target']}[/cyan]")
|
||||||
|
console.print()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: trust.py
|
||||||
|
# Description: Trust management — aipass trust / aipass revoke commands
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-15
|
||||||
|
# Modified: 2026-07-15
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
aipass trust / revoke — manage the trusted-project registry (DPLAN-0244)
|
||||||
|
|
||||||
|
Enrollment controls which projects have their .aipass/hooks.json loaded
|
||||||
|
by the hook engine. Projects created via `aipass init` auto-enroll;
|
||||||
|
these commands handle manual enrollment and revocation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aipass.cli.apps.modules import console, error, success
|
||||||
|
from aipass.hooks.apps.handlers.config.trust_registry import (
|
||||||
|
enroll,
|
||||||
|
read_registry,
|
||||||
|
revoke,
|
||||||
|
)
|
||||||
|
from aipass.prax import logger
|
||||||
|
|
||||||
|
COMMAND = "trust"
|
||||||
|
_COMMAND_REVOKE = "revoke"
|
||||||
|
|
||||||
|
|
||||||
|
def print_introspection() -> None:
|
||||||
|
"""Display the current trusted-project registry."""
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
registry = read_registry()
|
||||||
|
projects = registry.get("projects", {})
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass trust[/bold cyan] — trusted-project registry")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if not projects:
|
||||||
|
console.print("[dim]No projects enrolled.[/dim]")
|
||||||
|
else:
|
||||||
|
table = Table(show_header=True, header_style="bold yellow")
|
||||||
|
table.add_column("Project", style="cyan")
|
||||||
|
table.add_column("Hash", style="dim", max_width=24)
|
||||||
|
table.add_column("Enrolled")
|
||||||
|
for path, entry in projects.items():
|
||||||
|
short_hash = entry.get("config_hash", "")[:18] + "..."
|
||||||
|
table.add_row(path, short_hash, entry.get("enrolled", ""))
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Use 'aipass trust <path>' to enroll or 'aipass revoke <path>' to remove.[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_help() -> None:
|
||||||
|
"""Print usage help for the trust/revoke commands."""
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]aipass trust / revoke[/bold cyan] — trusted-project registry")
|
||||||
|
console.print()
|
||||||
|
console.print("[yellow]USAGE:[/yellow]")
|
||||||
|
console.print(" [green]aipass trust[/green] [dim]# Show enrolled projects[/dim]")
|
||||||
|
console.print(
|
||||||
|
" [green]aipass trust <path>[/green] [dim]# Enroll a project (requires .aipass/hooks.json)[/dim]"
|
||||||
|
)
|
||||||
|
console.print(" [green]aipass revoke <path>[/green] [dim]# Remove a project from the registry[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_trust(args: list[str]) -> bool:
|
||||||
|
"""Execute the trust enrollment for a given path."""
|
||||||
|
target = Path(args[0]).resolve()
|
||||||
|
if not target.is_dir():
|
||||||
|
error(f"Not a directory: {target}")
|
||||||
|
return True
|
||||||
|
hooks_path = target / ".aipass" / "hooks.json"
|
||||||
|
if not hooks_path.is_file():
|
||||||
|
error(f"No .aipass/hooks.json found in {target}")
|
||||||
|
return True
|
||||||
|
if enroll(str(target)):
|
||||||
|
success(f"Enrolled {target}")
|
||||||
|
logger.info("[AIPASS] trust: enrolled %s", target)
|
||||||
|
else:
|
||||||
|
error(f"Failed to enroll {target}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _do_revoke(args: list[str]) -> bool:
|
||||||
|
"""Execute the revocation for a given path."""
|
||||||
|
target = Path(args[0]).resolve()
|
||||||
|
if revoke(str(target)):
|
||||||
|
success(f"Revoked {target}")
|
||||||
|
logger.info("[AIPASS] revoke: removed %s", target)
|
||||||
|
else:
|
||||||
|
console.print(f"[dim]{target} was not in the registry.[/dim]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def handle_command(command: str, args: list[str]) -> bool:
|
||||||
|
"""Route trust/revoke subcommands. Returns True if handled."""
|
||||||
|
if command == COMMAND:
|
||||||
|
if not args:
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
if args[0] in ("--help", "-h", "help"):
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
if args[0] == "--info":
|
||||||
|
print_introspection()
|
||||||
|
return True
|
||||||
|
return _do_trust(args)
|
||||||
|
if command == _COMMAND_REVOKE:
|
||||||
|
if not args or args[0] in ("--help", "-h", "help"):
|
||||||
|
print_help()
|
||||||
|
return True
|
||||||
|
return _do_revoke(args)
|
||||||
|
return False
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: project_home.py
|
||||||
|
# Description: Shared AIPass-home detection, project-path validation, and settings content
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-21
|
||||||
|
# Modified: 2026-07-21
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Project home — AIPASS_HOME detection, path validation, settings content.
|
||||||
|
|
||||||
|
Shared across every command that scaffolds or inspects an AIPass project
|
||||||
|
(`aipass init`, `aipass new`, `aipass adopt`), so there is exactly one
|
||||||
|
source of truth per helper — no per-command copies to drift apart.
|
||||||
|
|
||||||
|
Dependency-free: uses only stdlib. Importable before drone/prax exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_throwaway_path(path: str | Path) -> bool:
|
||||||
|
"""True if path is under a temp dir or Claude Code scratchpad."""
|
||||||
|
resolved = str(Path(path).resolve())
|
||||||
|
tmp_roots = [tempfile.gettempdir()]
|
||||||
|
if os.name == "posix":
|
||||||
|
tmp_roots.append("/tmp")
|
||||||
|
for root in tmp_roots:
|
||||||
|
try:
|
||||||
|
r = str(Path(root).resolve())
|
||||||
|
except OSError:
|
||||||
|
logger.info("is_throwaway_path: could not resolve %s", root)
|
||||||
|
continue
|
||||||
|
if resolved == r or resolved.startswith(r + os.sep):
|
||||||
|
return True
|
||||||
|
if "scratchpad" in resolved.lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_aipass_home() -> str | None:
|
||||||
|
"""Detect the AIPass installation root from the aipass package location.
|
||||||
|
|
||||||
|
Returns the parent of the src/ directory (the repo root).
|
||||||
|
Returns None if detection fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
spec = importlib.util.find_spec("aipass")
|
||||||
|
if spec and spec.origin:
|
||||||
|
# aipass/__init__.py lives at src/aipass/__init__.py
|
||||||
|
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
|
||||||
|
return str(Path(spec.origin).resolve().parent.parent.parent)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("AIPASS_HOME detection skipped: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_settings() -> str:
|
||||||
|
"""Generate .claude/settings.json — permissions only, no machine-local paths.
|
||||||
|
|
||||||
|
Hooks are NOT wired at the project level. All AIPass hooks
|
||||||
|
(prompt injection, identity, email, pre-compact, edit gates) fire
|
||||||
|
from provider settings (~/.claude/settings.json), installed by
|
||||||
|
setup.sh. Provider hooks use CWD-walking patterns that work from
|
||||||
|
any directory in any project.
|
||||||
|
|
||||||
|
AIPASS_HOME is a machine-local absolute path — it never belongs in this
|
||||||
|
tracked file. It goes in .claude/settings.local.json instead, which is
|
||||||
|
gitignored and merged over tracked settings by Claude Code natively.
|
||||||
|
See _claude_local_settings().
|
||||||
|
"""
|
||||||
|
data: dict = {
|
||||||
|
"permissions": {
|
||||||
|
"deny": [
|
||||||
|
"Bash(git push --force*)",
|
||||||
|
"Bash(git reset --hard*)",
|
||||||
|
"EnterPlanMode",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_local_settings(aipass_home: str) -> str:
|
||||||
|
"""Generate .claude/settings.local.json — machine-local env (gitignored)."""
|
||||||
|
return json.dumps({"env": {"AIPASS_HOME": aipass_home}}, indent=2, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _enroll_project(target: Path) -> None:
|
||||||
|
"""Enroll a project in the trusted-project registry (DPLAN-0244).
|
||||||
|
|
||||||
|
Lazy import to keep this module free of prax/module-level deps.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from aipass.hooks.apps.handlers.config.trust_registry import enroll
|
||||||
|
|
||||||
|
if enroll(str(target)):
|
||||||
|
logger.info("Enrolled project in trust registry: %s", target)
|
||||||
|
else:
|
||||||
|
logger.warning("Trust enrollment failed for %s", target)
|
||||||
|
except ImportError as exc:
|
||||||
|
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def is_projects_child(target: Path) -> bool:
|
||||||
|
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
|
||||||
|
|
||||||
|
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
|
||||||
|
of target (i.e. target's parent is named ``projects``).
|
||||||
|
"""
|
||||||
|
resolved = target.resolve()
|
||||||
|
if resolved.parent.name != "projects":
|
||||||
|
return False
|
||||||
|
host = resolved.parent.parent
|
||||||
|
try:
|
||||||
|
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
|
||||||
|
except OSError as exc:
|
||||||
|
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
|
||||||
|
return False
|
||||||
+12
-6
@@ -1,17 +1,20 @@
|
|||||||
# =================== AIPass ====================
|
# =================== AIPass ====================
|
||||||
# Name: scaffold_content.py
|
# Name: scaffold_content.py
|
||||||
# Description: Template content generators for aipass init scaffold
|
# Description: Shared template content generators for project scaffolding
|
||||||
# Version: 1.0.0
|
# Version: 1.0.0
|
||||||
# Created: 2026-04-22
|
# Created: 2026-04-22
|
||||||
# Modified: 2026-04-22
|
# Modified: 2026-07-21
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Scaffold Content — template generators for `aipass init`
|
Scaffold Content — template generators for `aipass init`, `aipass new`, `aipass adopt`
|
||||||
|
|
||||||
Pure string-returning functions that produce the content for each scaffold
|
Pure string-returning functions that produce the content for each scaffold
|
||||||
file (CLAUDE.md, AGENTS.md, etc.). Extracted from bootstrap.py to keep
|
file (CLAUDE.md, AGENTS.md, .gitignore, etc.). Shared across every command
|
||||||
the handler under 700 lines.
|
that mints AIPass project files, so there is exactly one source of truth
|
||||||
|
per template — no per-command copies to drift apart.
|
||||||
|
|
||||||
|
Dependency-free: uses only stdlib. Importable before drone/prax exist.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- Pure Python only (no module/prax/cli imports)
|
- Pure Python only (no module/prax/cli imports)
|
||||||
@@ -251,7 +254,7 @@ def gitignore() -> str:
|
|||||||
"*.egg-info/\n"
|
"*.egg-info/\n"
|
||||||
"dist/\n"
|
"dist/\n"
|
||||||
"build/\n"
|
"build/\n"
|
||||||
".venv/\n"
|
".venv\n"
|
||||||
"venv/\n"
|
"venv/\n"
|
||||||
"\n"
|
"\n"
|
||||||
"# IDE\n"
|
"# IDE\n"
|
||||||
@@ -267,6 +270,9 @@ def gitignore() -> str:
|
|||||||
"\n"
|
"\n"
|
||||||
"# Disabled files\n"
|
"# Disabled files\n"
|
||||||
"*(disabled)*\n"
|
"*(disabled)*\n"
|
||||||
|
"\n"
|
||||||
|
"# Registry lock\n"
|
||||||
|
".*_REGISTRY.lock\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: test_adopt.py
|
||||||
|
# Description: Tests for aipass adopt — project adoption handler
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-20
|
||||||
|
# Modified: 2026-07-20
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Tests for the adopt handler and module.
|
||||||
|
|
||||||
|
All file operations use tmp_path to stay fully isolated from the live
|
||||||
|
filesystem. dry_run tests assert zero filesystem mutation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.new_project.adopt import (
|
||||||
|
GITIGNORE_MARKER,
|
||||||
|
adopt_project,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def host_env(tmp_path):
|
||||||
|
"""Minimal AIPass host installation with an existing (pre-populated) project dir."""
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "host-id"}, "branches": []}))
|
||||||
|
projects = tmp_path / "projects"
|
||||||
|
projects.mkdir()
|
||||||
|
target = projects / "existing-site"
|
||||||
|
target.mkdir()
|
||||||
|
(target / "index.html").write_text("<html></html>\n", encoding="utf-8")
|
||||||
|
(target / "README.md").write_text("# existing-site\n\nReal content.\n", encoding="utf-8")
|
||||||
|
return tmp_path, target
|
||||||
|
|
||||||
|
|
||||||
|
def _no_home():
|
||||||
|
return patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# adopt_project — guards / refusals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_rejects_missing_target(tmp_path):
|
||||||
|
with pytest.raises(RuntimeError, match="does not exist"):
|
||||||
|
adopt_project(tmp_path / "nope")
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_rejects_non_projects_child(tmp_path):
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
outside = tmp_path / "elsewhere" / "myapp"
|
||||||
|
outside.mkdir(parents=True)
|
||||||
|
with pytest.raises(RuntimeError, match="not <host>/projects/<name>"):
|
||||||
|
adopt_project(outside)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_rejects_already_adopted(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
(target / "EXISTING_SITE_REGISTRY.json").write_text("{}")
|
||||||
|
with pytest.raises(RuntimeError, match="already adopted"):
|
||||||
|
adopt_project(target, no_agent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_rejects_agent_home_collision(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
home = target / "src" / "existing_site" / "existing_site"
|
||||||
|
home.mkdir(parents=True)
|
||||||
|
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
|
||||||
|
with _no_home():
|
||||||
|
with pytest.raises(RuntimeError, match="Name collision"):
|
||||||
|
adopt_project(target, no_agent=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_allows_collision_with_no_agent(host_env):
|
||||||
|
"""An occupied agent-home path is fine when --no-agent skips the agent entirely."""
|
||||||
|
_, target = host_env
|
||||||
|
home = target / "src" / "existing_site" / "existing_site"
|
||||||
|
home.mkdir(parents=True)
|
||||||
|
(home / "stray.txt").write_text("junk\n", encoding="utf-8")
|
||||||
|
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||||
|
result = adopt_project(target, no_agent=True)
|
||||||
|
assert result["agent_created"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# adopt_project — gitignore safety
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_creates_gitignore_when_absent(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||||
|
result = adopt_project(target, no_agent=True)
|
||||||
|
assert result["gitignore_action"] == "created"
|
||||||
|
content = (target / ".gitignore").read_text(encoding="utf-8")
|
||||||
|
assert GITIGNORE_MARKER in content
|
||||||
|
assert ".trinity/" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_appends_gitignore_when_marker_absent(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
(target / ".gitignore").write_text("node_modules/\ndist/\n", encoding="utf-8")
|
||||||
|
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||||
|
result = adopt_project(target, no_agent=True)
|
||||||
|
assert result["gitignore_action"] == "appended"
|
||||||
|
content = (target / ".gitignore").read_text(encoding="utf-8")
|
||||||
|
assert "node_modules/" in content
|
||||||
|
assert GITIGNORE_MARKER in content
|
||||||
|
assert ".trinity/" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_skips_gitignore_when_marker_present(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
(target / ".gitignore").write_text(f"{GITIGNORE_MARKER}\n.trinity/\n", encoding="utf-8")
|
||||||
|
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||||
|
result = adopt_project(target, no_agent=True)
|
||||||
|
assert result["gitignore_action"] == "already-safe"
|
||||||
|
content = (target / ".gitignore").read_text(encoding="utf-8")
|
||||||
|
assert content.count(GITIGNORE_MARKER) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_gitignore_covers_symlinked_venv_and_registry_lock(host_env, tmp_path):
|
||||||
|
"""Live-verification regression: a symlinked .venv (not a real dir) and a
|
||||||
|
registry lock file must both be actually ignored by real git, not just
|
||||||
|
present as a string in the .gitignore content."""
|
||||||
|
host, target = host_env
|
||||||
|
aipass_home = tmp_path / "fake_aipass_home"
|
||||||
|
(aipass_home / ".venv").mkdir(parents=True)
|
||||||
|
subprocess.run(["git", "init"], cwd=target, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.adopt._detect_aipass_home",
|
||||||
|
return_value=str(aipass_home),
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||||
|
):
|
||||||
|
adopt_project(target, no_agent=True)
|
||||||
|
|
||||||
|
venv_link = target / ".venv"
|
||||||
|
assert venv_link.is_symlink()
|
||||||
|
lock_file = target / ".EXISTING-SITE_REGISTRY.lock"
|
||||||
|
lock_file.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "check-ignore", ".venv", ".EXISTING-SITE_REGISTRY.lock"],
|
||||||
|
cwd=target,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert ".venv" in result.stdout.split()
|
||||||
|
assert ".EXISTING-SITE_REGISTRY.lock" in result.stdout.split()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# adopt_project — additive scaffold, existing files untouched
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_never_overwrites_existing_readme(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
original = (target / "README.md").read_text(encoding="utf-8")
|
||||||
|
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||||
|
adopt_project(target, no_agent=True)
|
||||||
|
assert (target / "README.md").read_text(encoding="utf-8") == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_never_touches_existing_tracked_files(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
original = (target / "index.html").read_text(encoding="utf-8")
|
||||||
|
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||||
|
adopt_project(target, no_agent=True)
|
||||||
|
assert (target / "index.html").read_text(encoding="utf-8") == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_writes_registry_and_settings(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"):
|
||||||
|
result = adopt_project(target, no_agent=True)
|
||||||
|
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
|
||||||
|
assert (target / "EXISTING-SITE_REGISTRY.json").exists()
|
||||||
|
reg = json.loads((target / "EXISTING-SITE_REGISTRY.json").read_text())
|
||||||
|
assert reg["metadata"]["name"] == "EXISTING-SITE"
|
||||||
|
assert (target / ".claude" / "settings.json").exists()
|
||||||
|
settings = json.loads((target / ".claude" / "settings.json").read_text())
|
||||||
|
assert "env" not in settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_no_agent_skips_spawn(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
with (
|
||||||
|
_no_home(),
|
||||||
|
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||||
|
patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn,
|
||||||
|
):
|
||||||
|
result = adopt_project(target, no_agent=True)
|
||||||
|
mock_spawn.assert_not_called()
|
||||||
|
assert result["agent_created"] is False
|
||||||
|
assert result["agent_home"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_with_agent_spawns(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
spawn_ok = {
|
||||||
|
"success": True,
|
||||||
|
"branch_name": "EXISTING_SITE",
|
||||||
|
"path": str(target / "src" / "existing_site" / "existing_site"),
|
||||||
|
"files_copied": 12,
|
||||||
|
"registry_updated": True,
|
||||||
|
"validation_issues": [],
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
_no_home(),
|
||||||
|
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value=spawn_ok,
|
||||||
|
) as mock_spawn,
|
||||||
|
):
|
||||||
|
result = adopt_project(target, no_agent=False)
|
||||||
|
mock_spawn.assert_called_once()
|
||||||
|
assert result["agent_created"] is True
|
||||||
|
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_registry_minted_before_spawn(host_env):
|
||||||
|
"""Registry-first invariant: registry file exists on disk before spawn_agent is called."""
|
||||||
|
_, target = host_env
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def _check_registry_exists(**kwargs):
|
||||||
|
seen["registry_present"] = (target / "EXISTING-SITE_REGISTRY.json").exists()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"branch_name": "EXISTING_SITE",
|
||||||
|
"path": kwargs["target_path"],
|
||||||
|
"files_copied": 1,
|
||||||
|
"registry_updated": True,
|
||||||
|
"validation_issues": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
_no_home(),
|
||||||
|
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
side_effect=_check_registry_exists,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
adopt_project(target, no_agent=False)
|
||||||
|
assert seen["registry_present"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# adopt_project — dry_run performs zero writes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_dry_run_writes_nothing(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
before = sorted(p.relative_to(target) for p in target.rglob("*"))
|
||||||
|
with _no_home(), patch("aipass.aipass.apps.handlers.new_project.spawn_agent") as mock_spawn:
|
||||||
|
result = adopt_project(target, no_agent=False, dry_run=True)
|
||||||
|
after = sorted(p.relative_to(target) for p in target.rglob("*"))
|
||||||
|
assert before == after
|
||||||
|
mock_spawn.assert_not_called()
|
||||||
|
assert result["dry_run"] is True
|
||||||
|
assert result["registry_id"] is None
|
||||||
|
assert result["agent_created"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_dry_run_reports_planned_registry_and_agent(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
with _no_home():
|
||||||
|
result = adopt_project(target, no_agent=False, dry_run=True)
|
||||||
|
assert result["registry_file"] == "EXISTING-SITE_REGISTRY.json"
|
||||||
|
assert "EXISTING-SITE_REGISTRY.json" in result["files"]
|
||||||
|
assert result["agent_home"] == str(target / "src" / "existing_site" / "existing_site")
|
||||||
|
assert any("resident agent" in f for f in result["files"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_dry_run_no_agent_reports_no_home(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
with _no_home():
|
||||||
|
result = adopt_project(target, no_agent=True, dry_run=True)
|
||||||
|
assert result["agent_home"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_adopt_dry_run_still_reports_gitignore_action(host_env):
|
||||||
|
_, target = host_env
|
||||||
|
with _no_home():
|
||||||
|
result = adopt_project(target, no_agent=True, dry_run=True)
|
||||||
|
assert result["gitignore_action"] == "created"
|
||||||
|
assert not (target / ".gitignore").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module handle_command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_handles_not_mine():
|
||||||
|
from aipass.aipass.apps.modules.adopt import handle_command
|
||||||
|
|
||||||
|
assert handle_command("notmine", []) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_handles_help():
|
||||||
|
from aipass.aipass.apps.modules.adopt import handle_command
|
||||||
|
|
||||||
|
assert handle_command("adopt", ["--help"]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_handles_no_args():
|
||||||
|
from aipass.aipass.apps.modules.adopt import handle_command
|
||||||
|
|
||||||
|
assert handle_command("adopt", []) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_rejects_unknown_option(host_env):
|
||||||
|
from aipass.aipass.apps.modules.adopt import handle_command
|
||||||
|
|
||||||
|
_, target = host_env
|
||||||
|
with patch("aipass.aipass.apps.modules.adopt.error") as mock_error:
|
||||||
|
handle_command("adopt", [str(target), "--bogus"])
|
||||||
|
mock_error.assert_called_once()
|
||||||
|
assert "Unknown option" in mock_error.call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_adopt_by_absolute_path(host_env, monkeypatch):
|
||||||
|
from aipass.aipass.apps.modules.adopt import handle_command
|
||||||
|
|
||||||
|
host, target = host_env
|
||||||
|
monkeypatch.chdir(host)
|
||||||
|
with (
|
||||||
|
_no_home(),
|
||||||
|
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||||
|
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
|
||||||
|
):
|
||||||
|
handle_command("adopt", [str(target), "--no-agent"])
|
||||||
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
|
assert "Registry:" in printed
|
||||||
|
assert "EXISTING-SITE_REGISTRY.json" in printed
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_adopt_by_bare_name(host_env, monkeypatch):
|
||||||
|
from aipass.aipass.apps.modules.adopt import handle_command
|
||||||
|
|
||||||
|
host, target = host_env
|
||||||
|
monkeypatch.chdir(host)
|
||||||
|
with (
|
||||||
|
_no_home(),
|
||||||
|
patch("aipass.aipass.apps.handlers.new_project.adopt._enroll_project"),
|
||||||
|
patch("aipass.aipass.apps.modules.adopt.console") as mock_con,
|
||||||
|
):
|
||||||
|
handle_command("adopt", ["existing-site", "--no-agent"])
|
||||||
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
|
assert "Registry:" in printed
|
||||||
|
assert "EXISTING-SITE_REGISTRY.json" in printed
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_adopt_dry_run_reports_would_adopt(host_env, monkeypatch, capsys):
|
||||||
|
from aipass.aipass.apps.modules.adopt import handle_command
|
||||||
|
|
||||||
|
host, target = host_env
|
||||||
|
monkeypatch.chdir(host)
|
||||||
|
with _no_home():
|
||||||
|
handle_command("adopt", ["existing-site", "--no-agent", "--dry-run"])
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Would adopt" in out
|
||||||
|
assert not (target / "EXISTING-SITE_REGISTRY.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_adopt_missing_target_no_host(tmp_path, monkeypatch):
|
||||||
|
from aipass.aipass.apps.modules.adopt import handle_command
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
with (
|
||||||
|
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
|
||||||
|
pytest.raises(SystemExit) as exc_info,
|
||||||
|
):
|
||||||
|
handle_command("adopt", ["nope"])
|
||||||
|
mock_error.assert_called_once()
|
||||||
|
assert "Not inside an AIPass installation" in mock_error.call_args[0][0]
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_adopt_reports_refusal(host_env, monkeypatch):
|
||||||
|
from aipass.aipass.apps.modules.adopt import handle_command
|
||||||
|
|
||||||
|
host, target = host_env
|
||||||
|
monkeypatch.chdir(host)
|
||||||
|
(target / "EXISTING-SITE_REGISTRY.json").write_text("{}")
|
||||||
|
with (
|
||||||
|
patch("aipass.aipass.apps.modules.adopt.error") as mock_error,
|
||||||
|
pytest.raises(SystemExit) as exc_info,
|
||||||
|
):
|
||||||
|
handle_command("adopt", ["existing-site", "--no-agent"])
|
||||||
|
mock_error.assert_called_once()
|
||||||
|
assert "already adopted" in mock_error.call_args[0][0]
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# aipass.py wiring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_aipass_public_commands_includes_adopt():
|
||||||
|
from aipass.aipass.apps.aipass import _PUBLIC_COMMANDS
|
||||||
|
|
||||||
|
assert "adopt" in _PUBLIC_COMMANDS
|
||||||
@@ -153,10 +153,10 @@ class TestMain:
|
|||||||
"""--version prints real package version and returns 0."""
|
"""--version prints real package version and returns 0."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
printed = mock_print.call_args[0][0]
|
printed = mock_con.print.call_args[0][0]
|
||||||
assert printed.startswith("aipass ")
|
assert printed.startswith("aipass ")
|
||||||
assert printed != "aipass 0.1.0"
|
assert printed != "aipass 0.1.0"
|
||||||
|
|
||||||
@@ -164,10 +164,10 @@ class TestMain:
|
|||||||
"""-V prints real package version and returns 0."""
|
"""-V prints real package version and returns 0."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-V"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-V"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
printed = mock_print.call_args[0][0]
|
printed = mock_con.print.call_args[0][0]
|
||||||
assert printed.startswith("aipass ")
|
assert printed.startswith("aipass ")
|
||||||
|
|
||||||
def test_version_flag_fallback(self) -> None:
|
def test_version_flag_fallback(self) -> None:
|
||||||
@@ -179,76 +179,81 @@ class TestMain:
|
|||||||
"aipass.aipass.apps.aipass.importlib.metadata.version",
|
"aipass.aipass.apps.aipass.importlib.metadata.version",
|
||||||
side_effect=_not_found,
|
side_effect=_not_found,
|
||||||
):
|
):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
mock_print.assert_called_once_with("aipass unknown")
|
mock_con.print.assert_called_once_with("aipass unknown")
|
||||||
|
|
||||||
def test_help_flag_shows_help(self) -> None:
|
def test_help_flag_shows_help(self) -> None:
|
||||||
"""--help shows module list and returns 0."""
|
"""--help calls print_help and returns 0."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--help"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--help"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
mock_print.assert_called()
|
mock_con.print.assert_called()
|
||||||
|
|
||||||
def test_h_flag_shows_help(self) -> None:
|
def test_h_flag_shows_help(self) -> None:
|
||||||
"""-h shows module list and returns 0."""
|
"""-h shows help and returns 0."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-h"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-h"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print"):
|
with patch("aipass.aipass.apps.aipass.console"):
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
|
|
||||||
def test_no_args_shows_help(self) -> None:
|
def test_no_args_shows_help(self) -> None:
|
||||||
"""No arguments shows module list and returns 0."""
|
"""No arguments shows introspection and returns 0."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print"):
|
with patch("aipass.aipass.apps.aipass.console"):
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
|
|
||||||
def test_help_word_shows_help(self) -> None:
|
def test_help_word_routes_to_module(self) -> None:
|
||||||
"""'help' as only arg shows module list and returns 0."""
|
"""'help' as only arg routes to help_chat module, not root help."""
|
||||||
|
mod = MagicMock()
|
||||||
|
mod.handle_command.return_value = True
|
||||||
|
mod.__name__ = "aipass.aipass.apps.modules.help_chat"
|
||||||
|
mod.COMMAND = "help"
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "help"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "help"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||||
with patch("builtins.print"):
|
result = main()
|
||||||
result = main()
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
|
mod.handle_command.assert_called_once_with("help", [])
|
||||||
|
|
||||||
def test_help_shows_module_count(self) -> None:
|
def test_introspection_shows_public_commands(self) -> None:
|
||||||
"""Help output includes discovered module count."""
|
"""Introspection lists modules with COMMAND in _PUBLIC_COMMANDS."""
|
||||||
mod = types.ModuleType("test_mod")
|
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
||||||
mod.__doc__ = "Test module doc"
|
mod.__doc__ = "Help chatbot"
|
||||||
|
mod.COMMAND = "help" # type: ignore[attr-defined]
|
||||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
main()
|
main()
|
||||||
first_call_args = mock_print.call_args_list[0][0][0]
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "1 modules" in first_call_args
|
assert "help" in printed
|
||||||
|
|
||||||
def test_help_shows_module_with_no_doc(self) -> None:
|
def test_introspection_hides_non_public(self) -> None:
|
||||||
"""Module without docstring shows 'No description'."""
|
"""Modules without COMMAND in _PUBLIC_COMMANDS are hidden."""
|
||||||
mod = types.ModuleType("nodoc_mod")
|
mod = types.ModuleType("aipass.aipass.apps.modules.internal")
|
||||||
mod.__doc__ = None
|
mod.__doc__ = "Internal module"
|
||||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
main()
|
main()
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "No description" in printed
|
assert "internal" not in printed
|
||||||
|
|
||||||
def test_unknown_command_returns_1(self) -> None:
|
def test_unknown_command_returns_1(self) -> None:
|
||||||
"""Unknown command prints error and returns 1."""
|
"""Unknown command prints error and returns 1."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "xyzzy"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "xyzzy"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
mock_print.assert_called_with("Unknown command: xyzzy")
|
mock_con.print.assert_called_with("Unknown command: xyzzy")
|
||||||
|
|
||||||
def test_known_command_routes_and_returns_0(self) -> None:
|
def test_known_command_routes_and_returns_0(self) -> None:
|
||||||
"""Known command that gets handled returns 0."""
|
"""Known command that gets handled returns 0."""
|
||||||
@@ -266,10 +271,10 @@ class TestMain:
|
|||||||
"""@drone prints guidance pointing to drone, not 'Unknown command'."""
|
"""@drone prints guidance pointing to drone, not 'Unknown command'."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@drone"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@drone"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "@drone" in printed
|
assert "@drone" in printed
|
||||||
assert "drone routing target" in printed
|
assert "drone routing target" in printed
|
||||||
assert "Unknown command" not in printed
|
assert "Unknown command" not in printed
|
||||||
@@ -278,10 +283,10 @@ class TestMain:
|
|||||||
"""@memory prints guidance with the actual @name the user typed."""
|
"""@memory prints guidance with the actual @name the user typed."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@memory"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@memory"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "@memory" in printed
|
assert "@memory" in printed
|
||||||
assert "drone @memory" in printed
|
assert "drone @memory" in printed
|
||||||
|
|
||||||
@@ -289,10 +294,10 @@ class TestMain:
|
|||||||
"""Non-@ bad command still prints 'Unknown command', not drone guidance."""
|
"""Non-@ bad command still prints 'Unknown command', not drone guidance."""
|
||||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "frobnicate"]):
|
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "frobnicate"]):
|
||||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
mock_print.assert_called_with("Unknown command: frobnicate")
|
mock_con.print.assert_called_with("Unknown command: frobnicate")
|
||||||
|
|
||||||
def test_command_with_remaining_args(self) -> None:
|
def test_command_with_remaining_args(self) -> None:
|
||||||
"""Remaining args are passed to route_command."""
|
"""Remaining args are passed to route_command."""
|
||||||
@@ -306,7 +311,7 @@ class TestMain:
|
|||||||
mod.handle_command.assert_called_once_with("doctor", ["--verbose", "--fix"])
|
mod.handle_command.assert_called_once_with("doctor", ["--verbose", "--fix"])
|
||||||
|
|
||||||
def test_help_shows_command_constant(self) -> None:
|
def test_help_shows_command_constant(self) -> None:
|
||||||
"""Help listing uses module COMMAND constant, not file stem."""
|
"""Introspection uses module COMMAND constant, not file stem."""
|
||||||
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
||||||
mod.__doc__ = "Help chatbot"
|
mod.__doc__ = "Help chatbot"
|
||||||
mod.COMMAND = "help" # type: ignore[attr-defined]
|
mod.COMMAND = "help" # type: ignore[attr-defined]
|
||||||
@@ -316,14 +321,14 @@ class TestMain:
|
|||||||
"aipass.aipass.apps.aipass.discover_modules",
|
"aipass.aipass.apps.aipass.discover_modules",
|
||||||
return_value=[mod],
|
return_value=[mod],
|
||||||
):
|
):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
main()
|
main()
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "help" in printed
|
assert "help" in printed
|
||||||
assert "help_chat" not in printed
|
assert "help_chat" not in printed
|
||||||
|
|
||||||
def test_help_falls_back_to_stem(self) -> None:
|
def test_introspection_skips_no_command_module(self) -> None:
|
||||||
"""Without COMMAND constant, help listing uses file stem."""
|
"""Modules without COMMAND in _PUBLIC_COMMANDS are hidden from introspection."""
|
||||||
mod = types.ModuleType("aipass.aipass.apps.modules.doctor")
|
mod = types.ModuleType("aipass.aipass.apps.modules.doctor")
|
||||||
mod.__doc__ = "Doctor module"
|
mod.__doc__ = "Doctor module"
|
||||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||||
@@ -332,10 +337,14 @@ class TestMain:
|
|||||||
"aipass.aipass.apps.aipass.discover_modules",
|
"aipass.aipass.apps.aipass.discover_modules",
|
||||||
return_value=[mod],
|
return_value=[mod],
|
||||||
):
|
):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||||
main()
|
main()
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
assert "doctor" in printed
|
assert (
|
||||||
|
"Commands:" not in printed or "doctor" not in printed.split("Commands:")[1]
|
||||||
|
if "Commands:" in printed
|
||||||
|
else True
|
||||||
|
)
|
||||||
|
|
||||||
def test_handler_crash_surfaces_error(self) -> None:
|
def test_handler_crash_surfaces_error(self) -> None:
|
||||||
"""Handler crash prints real error, not 'Unknown command'."""
|
"""Handler crash prints real error, not 'Unknown command'."""
|
||||||
@@ -347,12 +356,12 @@ class TestMain:
|
|||||||
"aipass.aipass.apps.aipass.discover_modules",
|
"aipass.aipass.apps.aipass.discover_modules",
|
||||||
return_value=[mod],
|
return_value=[mod],
|
||||||
):
|
):
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console"):
|
||||||
result = main()
|
with patch("aipass.aipass.apps.aipass.error") as mock_err:
|
||||||
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
err_text = " ".join(str(a) for call in mock_err.call_args_list for a in call[0])
|
||||||
assert "db connection failed" in printed
|
assert "db connection failed" in err_text
|
||||||
assert "Unknown command" not in printed
|
|
||||||
|
|
||||||
def test_import_failure_surfaces_on_command(self) -> None:
|
def test_import_failure_surfaces_on_command(self) -> None:
|
||||||
"""Failed module import surfaces when user types that command."""
|
"""Failed module import surfaces when user types that command."""
|
||||||
@@ -365,11 +374,11 @@ class TestMain:
|
|||||||
):
|
):
|
||||||
aipass_mod._import_failures.clear()
|
aipass_mod._import_failures.clear()
|
||||||
aipass_mod._import_failures["broken"] = ImportError("no module")
|
aipass_mod._import_failures["broken"] = ImportError("no module")
|
||||||
with patch("builtins.print") as mock_print:
|
with patch("aipass.aipass.apps.aipass.console"):
|
||||||
result = main()
|
with patch("aipass.aipass.apps.aipass.error") as mock_err:
|
||||||
|
result = main()
|
||||||
assert result == 1
|
assert result == 1
|
||||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
err_text = " ".join(str(a) for call in mock_err.call_args_list for a in call[0])
|
||||||
assert "failed to load" in printed
|
assert "failed to load" in err_text
|
||||||
assert "no module" in printed
|
assert "no module" in err_text
|
||||||
assert "Unknown command" not in printed
|
|
||||||
aipass_mod._import_failures.clear()
|
aipass_mod._import_failures.clear()
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest # pyright: ignore[reportMissingImports]
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
|
||||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||||
_merge_hooks_json,
|
_merge_hooks_json,
|
||||||
_sanitize_name,
|
_sanitize_name,
|
||||||
@@ -28,6 +27,7 @@ from aipass.aipass.apps.handlers.init.bootstrap import (
|
|||||||
init_project,
|
init_project,
|
||||||
update_project,
|
update_project,
|
||||||
)
|
)
|
||||||
|
from aipass.aipass.shared import scaffold_content as sc
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -293,7 +293,7 @@ def test_init_project_settings_no_hooks(tmp_path, monkeypatch):
|
|||||||
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
assert "hooks" not in data, "Project settings should not contain hooks"
|
assert "hooks" not in data, "Project settings should not contain hooks"
|
||||||
assert "env" in data
|
assert "env" not in data, "AIPASS_HOME is machine-local — belongs in settings.local.json"
|
||||||
assert "permissions" in data
|
assert "permissions" in data
|
||||||
|
|
||||||
|
|
||||||
@@ -547,9 +547,10 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
|
|||||||
result = update_project(target)
|
result = update_project(target)
|
||||||
|
|
||||||
assert (target / ".claude" / "settings.json").exists()
|
assert (target / ".claude" / "settings.json").exists()
|
||||||
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap, hooks.json, settings, prep)
|
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap,
|
||||||
|
# hooks.json, settings.json, settings.local.json, prep)
|
||||||
if result["aipass_home"]:
|
if result["aipass_home"]:
|
||||||
assert len(result["updated_files"]) == 5
|
assert len(result["updated_files"]) == 6
|
||||||
else:
|
else:
|
||||||
assert len(result["updated_files"]) == 2
|
assert len(result["updated_files"]) == 2
|
||||||
assert len(result["already_current"]) >= 2
|
assert len(result["already_current"]) >= 2
|
||||||
@@ -584,7 +585,7 @@ def test_init_project_returns_aipass_home(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch):
|
def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch):
|
||||||
"""When AIPASS_HOME is detected, settings.json includes env.AIPASS_HOME."""
|
"""When AIPASS_HOME is detected, settings.local.json (not settings.json) includes env.AIPASS_HOME."""
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||||
lambda _: False,
|
lambda _: False,
|
||||||
@@ -598,8 +599,10 @@ def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypat
|
|||||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||||
|
|
||||||
settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8"))
|
settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8"))
|
||||||
assert "env" in settings
|
assert "env" not in settings
|
||||||
assert settings["env"]["AIPASS_HOME"] == result["aipass_home"]
|
|
||||||
|
local_settings = json.loads((target / ".claude" / "settings.local.json").read_text(encoding="utf-8"))
|
||||||
|
assert local_settings["env"]["AIPASS_HOME"] == result["aipass_home"]
|
||||||
|
|
||||||
|
|
||||||
def test_update_project_returns_aipass_home(tmp_path):
|
def test_update_project_returns_aipass_home(tmp_path):
|
||||||
@@ -615,7 +618,7 @@ def test_update_project_returns_aipass_home(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
|
def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
|
||||||
"""update_project injects AIPASS_HOME into settings.json if env section is absent."""
|
"""update_project recreates settings.local.json with AIPASS_HOME if missing."""
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||||
lambda _: False,
|
lambda _: False,
|
||||||
@@ -624,17 +627,15 @@ def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
|
|||||||
target.mkdir()
|
target.mkdir()
|
||||||
init_project(target, project_name="addenv")
|
init_project(target, project_name="addenv")
|
||||||
|
|
||||||
settings_path = target / ".claude" / "settings.json"
|
local_settings_path = target / ".claude" / "settings.local.json"
|
||||||
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
local_settings_path.unlink(missing_ok=True)
|
||||||
data.pop("env", None)
|
|
||||||
settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
||||||
|
|
||||||
result = update_project(target)
|
result = update_project(target)
|
||||||
|
|
||||||
if result["aipass_home"] is not None:
|
if result["aipass_home"] is not None:
|
||||||
new_data = json.loads(settings_path.read_text(encoding="utf-8"))
|
new_data = json.loads(local_settings_path.read_text(encoding="utf-8"))
|
||||||
assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"]
|
assert new_data.get("env", {}).get("AIPASS_HOME") == result["aipass_home"]
|
||||||
assert str(settings_path) in result["updated_files"]
|
assert str(local_settings_path) in result["updated_files"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1256,10 +1257,49 @@ def test_throwaway_path_allows_project():
|
|||||||
assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp"))
|
assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp"))
|
||||||
|
|
||||||
|
|
||||||
def test_settings_omits_throwaway_aipass_home(tmp_path):
|
def test_settings_omits_throwaway_aipass_home(tmp_path, monkeypatch):
|
||||||
"""_claude_settings refuses to write AIPASS_HOME when it's a throwaway path."""
|
"""init_project skips settings.local.json when detected AIPASS_HOME is a throwaway path."""
|
||||||
from aipass.aipass.apps.handlers.init.bootstrap import _claude_settings
|
from aipass.aipass.apps.handlers.init import bootstrap
|
||||||
|
|
||||||
content = _claude_settings(str(tmp_path))
|
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: str(tmp_path))
|
||||||
data = json.loads(content)
|
|
||||||
assert "AIPASS_HOME" not in data.get("env", {})
|
target = tmp_path / "proj"
|
||||||
|
target.mkdir()
|
||||||
|
bootstrap.init_project(target, project_name="alpha")
|
||||||
|
|
||||||
|
assert not (target / ".claude" / "settings.local.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# Directory/file-name fragments that .gitignore excludes from git tracking —
|
||||||
|
# mirrors scaffold_content.gitignore(). Tracked-file scans must skip these.
|
||||||
|
_GITIGNORED_PARTS = (".trinity", ".ai_mail.local", "logs", ".venv", "venv", ".git")
|
||||||
|
|
||||||
|
|
||||||
|
def test_minted_tracked_files_have_no_absolute_paths(tmp_path, monkeypatch):
|
||||||
|
"""No file init_project writes into a TRACKED path may contain the machine-local AIPASS_HOME.
|
||||||
|
|
||||||
|
Regression guard for the settings.json bug: AIPASS_HOME is an absolute,
|
||||||
|
machine-local path and must only ever land in gitignored *.local.json
|
||||||
|
files. This walks every minted file that would actually be committed
|
||||||
|
and greps it for the (fake) AIPASS_HOME value.
|
||||||
|
"""
|
||||||
|
from aipass.aipass.apps.handlers.init import bootstrap
|
||||||
|
|
||||||
|
fake_home = str(tmp_path / f"fake_aipass_home_{uuid.uuid4().hex}")
|
||||||
|
monkeypatch.setattr(bootstrap, "_detect_aipass_home", lambda: fake_home)
|
||||||
|
monkeypatch.setattr(bootstrap, "is_throwaway_path", lambda _: False)
|
||||||
|
|
||||||
|
target = tmp_path / "proj"
|
||||||
|
target.mkdir()
|
||||||
|
bootstrap.init_project(target, project_name="pathcheck")
|
||||||
|
|
||||||
|
for path in target.rglob("*"):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(target)
|
||||||
|
if any(part in _GITIGNORED_PARTS for part in rel.parts):
|
||||||
|
continue
|
||||||
|
if ".local." in rel.name:
|
||||||
|
continue
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
assert fake_home not in content, f"{rel} leaks machine-local AIPASS_HOME"
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_no_settings_file_returns_empty(self, tmp_path) -> None:
|
def test_no_settings_file_returns_empty(self, tmp_path) -> None:
|
||||||
"""Missing settings.json returns no results."""
|
"""Missing settings.json returns no results."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||||
results = reconcile_stale_deny(fix=False)
|
results = reconcile_stale_deny(fix=False)
|
||||||
@@ -664,7 +664,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_no_stale_rules_returns_pass(self, tmp_path) -> None:
|
def test_no_stale_rules_returns_pass(self, tmp_path) -> None:
|
||||||
"""Settings with no stale rm rules returns PASS."""
|
"""Settings with no stale rm rules returns PASS."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -680,7 +680,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_stale_rules_detected_without_fix(self, tmp_path) -> None:
|
def test_stale_rules_detected_without_fix(self, tmp_path) -> None:
|
||||||
"""Stale rm rules present returns WARN when fix=False."""
|
"""Stale rm rules present returns WARN when fix=False."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -697,7 +697,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_fix_removes_stale_rules(self, tmp_path) -> None:
|
def test_fix_removes_stale_rules(self, tmp_path) -> None:
|
||||||
"""fix=True removes stale rules and preserves others."""
|
"""fix=True removes stale rules and preserves others."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -719,7 +719,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_fix_single_stale_rule(self, tmp_path) -> None:
|
def test_fix_single_stale_rule(self, tmp_path) -> None:
|
||||||
"""fix=True works when only one of two stale rules is present."""
|
"""fix=True works when only one of two stale rules is present."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -736,7 +736,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_fix_idempotent(self, tmp_path) -> None:
|
def test_fix_idempotent(self, tmp_path) -> None:
|
||||||
"""Running fix twice is safe — second run returns PASS with no stale rules."""
|
"""Running fix twice is safe — second run returns PASS with no stale rules."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -753,7 +753,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_empty_deny_list_returns_pass(self, tmp_path) -> None:
|
def test_empty_deny_list_returns_pass(self, tmp_path) -> None:
|
||||||
"""Empty deny list returns PASS."""
|
"""Empty deny list returns PASS."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -765,7 +765,7 @@ class TestReconcileStaleDeny:
|
|||||||
|
|
||||||
def test_no_permissions_key_returns_pass(self, tmp_path) -> None:
|
def test_no_permissions_key_returns_pass(self, tmp_path) -> None:
|
||||||
"""Settings without permissions key returns PASS."""
|
"""Settings without permissions key returns PASS."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||||
|
|
||||||
settings = tmp_path / ".claude" / "settings.json"
|
settings = tmp_path / ".claude" / "settings.json"
|
||||||
settings.parent.mkdir(parents=True)
|
settings.parent.mkdir(parents=True)
|
||||||
@@ -781,10 +781,10 @@ class TestCheckWireVerify:
|
|||||||
|
|
||||||
def test_pass_on_zero_exit(self) -> None:
|
def test_pass_on_zero_exit(self) -> None:
|
||||||
"""Exit 0 from drone @hooks verify produces a PASS row."""
|
"""Exit 0 from drone @hooks verify produces a PASS row."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||||
|
|
||||||
fake = MagicMock(returncode=0, stdout="✓ Wire check passed\n\n0 errors, 0 warnings\n")
|
fake = MagicMock(returncode=0, stdout="✓ Wire check passed\n\n0 errors, 0 warnings\n")
|
||||||
with patch("aipass.aipass.apps.modules.doctor_wire.subprocess.run", return_value=fake):
|
with patch("aipass.aipass.apps.modules._doctor_wire.subprocess.run", return_value=fake):
|
||||||
results = check_wire_verify()
|
results = check_wire_verify()
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0].label == "wire verify"
|
assert results[0].label == "wire verify"
|
||||||
@@ -792,10 +792,10 @@ class TestCheckWireVerify:
|
|||||||
|
|
||||||
def test_fail_on_nonzero_exit(self) -> None:
|
def test_fail_on_nonzero_exit(self) -> None:
|
||||||
"""Non-zero exit from drone @hooks verify produces a FAIL row."""
|
"""Non-zero exit from drone @hooks verify produces a FAIL row."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||||
|
|
||||||
fake = MagicMock(returncode=1, stdout="ERROR empty array\n2 errors, 0 warnings\n")
|
fake = MagicMock(returncode=1, stdout="ERROR empty array\n2 errors, 0 warnings\n")
|
||||||
with patch("aipass.aipass.apps.modules.doctor_wire.subprocess.run", return_value=fake):
|
with patch("aipass.aipass.apps.modules._doctor_wire.subprocess.run", return_value=fake):
|
||||||
results = check_wire_verify()
|
results = check_wire_verify()
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
assert results[0].glyph == "[red]✗[/red]"
|
assert results[0].glyph == "[red]✗[/red]"
|
||||||
@@ -803,10 +803,10 @@ class TestCheckWireVerify:
|
|||||||
|
|
||||||
def test_warn_on_drone_not_found(self) -> None:
|
def test_warn_on_drone_not_found(self) -> None:
|
||||||
"""FileNotFoundError (drone missing) produces a WARN row."""
|
"""FileNotFoundError (drone missing) produces a WARN row."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
|
"aipass.aipass.apps.modules._doctor_wire.subprocess.run",
|
||||||
side_effect=FileNotFoundError("drone"),
|
side_effect=FileNotFoundError("drone"),
|
||||||
):
|
):
|
||||||
results = check_wire_verify()
|
results = check_wire_verify()
|
||||||
@@ -817,10 +817,10 @@ class TestCheckWireVerify:
|
|||||||
"""TimeoutExpired produces a WARN row."""
|
"""TimeoutExpired produces a WARN row."""
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
|
|
||||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
|
"aipass.aipass.apps.modules._doctor_wire.subprocess.run",
|
||||||
side_effect=sp.TimeoutExpired(cmd="drone", timeout=10),
|
side_effect=sp.TimeoutExpired(cmd="drone", timeout=10),
|
||||||
):
|
):
|
||||||
results = check_wire_verify()
|
results = check_wire_verify()
|
||||||
@@ -849,15 +849,15 @@ class TestPromptAutoWireIsatty:
|
|||||||
|
|
||||||
def test_non_tty_stdin_skips_prompt_and_declines(self) -> None:
|
def test_non_tty_stdin_skips_prompt_and_declines(self) -> None:
|
||||||
"""Non-tty stdin must NOT call input() — it declines and warns instead."""
|
"""Non-tty stdin must NOT call input() — it declines and warns instead."""
|
||||||
from aipass.aipass.apps.modules import doctor_wire
|
from aipass.aipass.apps.modules import _doctor_wire
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||||
patch("builtins.input") as mock_input,
|
patch("builtins.input") as mock_input,
|
||||||
patch.object(doctor_wire, "_print_manual_wire_warning") as mock_warn,
|
patch.object(_doctor_wire, "_print_manual_wire_warning") as mock_warn,
|
||||||
):
|
):
|
||||||
mock_stdin.isatty.return_value = False
|
mock_stdin.isatty.return_value = False
|
||||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
mock_input.assert_not_called()
|
mock_input.assert_not_called()
|
||||||
@@ -865,30 +865,30 @@ class TestPromptAutoWireIsatty:
|
|||||||
|
|
||||||
def test_tty_stdin_prompts_and_respects_decline(self) -> None:
|
def test_tty_stdin_prompts_and_respects_decline(self) -> None:
|
||||||
"""Tty stdin still prompts; a 'n' answer declines."""
|
"""Tty stdin still prompts; a 'n' answer declines."""
|
||||||
from aipass.aipass.apps.modules import doctor_wire
|
from aipass.aipass.apps.modules import _doctor_wire
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||||
patch("builtins.input", return_value="n") as mock_input,
|
patch("builtins.input", return_value="n") as mock_input,
|
||||||
patch.object(doctor_wire, "_print_manual_wire_warning"),
|
patch.object(_doctor_wire, "_print_manual_wire_warning"),
|
||||||
):
|
):
|
||||||
mock_stdin.isatty.return_value = True
|
mock_stdin.isatty.return_value = True
|
||||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
mock_input.assert_called_once()
|
mock_input.assert_called_once()
|
||||||
|
|
||||||
def test_tty_stdin_accepts_and_wires(self) -> None:
|
def test_tty_stdin_accepts_and_wires(self) -> None:
|
||||||
"""Tty stdin with a 'y' answer runs the wire and returns True."""
|
"""Tty stdin with a 'y' answer runs the wire and returns True."""
|
||||||
from aipass.aipass.apps.modules import doctor_wire
|
from aipass.aipass.apps.modules import _doctor_wire
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||||
patch("builtins.input", return_value="y"),
|
patch("builtins.input", return_value="y"),
|
||||||
patch.object(doctor_wire, "_auto_wire_provider", return_value=["wired hook"]) as mock_wire,
|
patch.object(_doctor_wire, "_auto_wire_provider", return_value=["wired hook"]) as mock_wire,
|
||||||
):
|
):
|
||||||
mock_stdin.isatty.return_value = True
|
mock_stdin.isatty.return_value = True
|
||||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_wire.assert_called_once()
|
mock_wire.assert_called_once()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from aipass.aipass.apps.modules.doctor_fix import (
|
from aipass.aipass.apps.modules._doctor_fix import (
|
||||||
RemediationItem,
|
RemediationItem,
|
||||||
detect_project_name,
|
detect_project_name,
|
||||||
format_json_report,
|
format_json_report,
|
||||||
@@ -68,7 +68,7 @@ class TestDetectProjectName:
|
|||||||
no_reg = tmp_path / "empty_project"
|
no_reg = tmp_path / "empty_project"
|
||||||
no_reg.mkdir()
|
no_reg.mkdir()
|
||||||
with patch(
|
with patch(
|
||||||
"aipass.aipass.apps.modules.doctor_fix._discover_registry",
|
"aipass.aipass.apps.modules._doctor_fix._discover_registry",
|
||||||
return_value=no_reg / "MISSING_REGISTRY.json",
|
return_value=no_reg / "MISSING_REGISTRY.json",
|
||||||
):
|
):
|
||||||
result = detect_project_name(no_reg)
|
result = detect_project_name(no_reg)
|
||||||
@@ -368,16 +368,16 @@ class TestPrintFunctions:
|
|||||||
class TestDoctorFixHandleCommand:
|
class TestDoctorFixHandleCommand:
|
||||||
def test_wrong_command(self) -> None:
|
def test_wrong_command(self) -> None:
|
||||||
"""Non-doctor_fix commands are not handled."""
|
"""Non-doctor_fix commands are not handled."""
|
||||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||||
|
|
||||||
assert handle_command("doctor", []) is False
|
assert handle_command("doctor", []) is False
|
||||||
assert handle_command("help", []) is False
|
assert handle_command("help", []) is False
|
||||||
|
|
||||||
def test_no_args_shows_usage(self) -> None:
|
def test_no_args_shows_usage(self) -> None:
|
||||||
"""No args shows usage message (not introspection banner)."""
|
"""No args shows usage message (not introspection banner)."""
|
||||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||||
|
|
||||||
with patch("aipass.aipass.apps.modules.doctor_fix.console") as mock_console:
|
with patch("aipass.aipass.apps.modules._doctor_fix.console") as mock_console:
|
||||||
result = handle_command("doctor_fix", [])
|
result = handle_command("doctor_fix", [])
|
||||||
assert result is True
|
assert result is True
|
||||||
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
|
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
|
||||||
@@ -385,9 +385,9 @@ class TestDoctorFixHandleCommand:
|
|||||||
|
|
||||||
def test_info_flag(self) -> None:
|
def test_info_flag(self) -> None:
|
||||||
"""--info triggers print_introspection."""
|
"""--info triggers print_introspection."""
|
||||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||||
|
|
||||||
with patch("aipass.aipass.apps.modules.doctor_fix.print_introspection") as mock:
|
with patch("aipass.aipass.apps.modules._doctor_fix.print_introspection") as mock:
|
||||||
result = handle_command("doctor_fix", ["--info"])
|
result = handle_command("doctor_fix", ["--info"])
|
||||||
assert result is True
|
assert result is True
|
||||||
mock.assert_called_once()
|
mock.assert_called_once()
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: test_feedback.py
|
||||||
|
# Description: Tests for aipass feedback — toggle alias for @hooks feedback pulse
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-18
|
||||||
|
# Modified: 2026-07-18
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Tests for the aipass feedback module."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from aipass.aipass.apps.modules.feedback import handle_command, print_help, print_introspection
|
||||||
|
|
||||||
|
_MOD = "aipass.aipass.apps.modules.feedback"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleCommand:
|
||||||
|
"""Command routing for aipass feedback."""
|
||||||
|
|
||||||
|
def test_ignores_other_commands(self) -> None:
|
||||||
|
"""A non-feedback command is not handled."""
|
||||||
|
assert handle_command("doctor", []) is False
|
||||||
|
|
||||||
|
def test_help(self) -> None:
|
||||||
|
"""--help is handled."""
|
||||||
|
assert handle_command("feedback", ["--help"]) is True
|
||||||
|
|
||||||
|
def test_info(self) -> None:
|
||||||
|
"""--info is handled."""
|
||||||
|
assert handle_command("feedback", ["--info"]) is True
|
||||||
|
|
||||||
|
def test_unknown_arg_shows_error(self) -> None:
|
||||||
|
"""An unknown argument shows an error and help."""
|
||||||
|
with patch(f"{_MOD}.error") as mock_err:
|
||||||
|
assert handle_command("feedback", ["banana"]) is True
|
||||||
|
mock_err.assert_called_once()
|
||||||
|
|
||||||
|
def test_on_delegates_to_hooks(self) -> None:
|
||||||
|
"""'on' delegates to drone @hooks feedback on."""
|
||||||
|
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||||
|
handle_command("feedback", ["on"])
|
||||||
|
cmd = run.call_args[0][0]
|
||||||
|
assert cmd == ["drone", "@hooks", "feedback", "on"]
|
||||||
|
|
||||||
|
def test_off_delegates_to_hooks(self) -> None:
|
||||||
|
"""'off' delegates to drone @hooks feedback off."""
|
||||||
|
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||||
|
handle_command("feedback", ["off"])
|
||||||
|
cmd = run.call_args[0][0]
|
||||||
|
assert cmd == ["drone", "@hooks", "feedback", "off"]
|
||||||
|
|
||||||
|
def test_no_args_shows_introspection(self) -> None:
|
||||||
|
"""No args shows module introspection."""
|
||||||
|
with patch(f"{_MOD}.subprocess.run") as run:
|
||||||
|
assert handle_command("feedback", []) is True
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_drone_not_found(self) -> None:
|
||||||
|
"""Missing drone warns cleanly, no crash."""
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}.subprocess.run", side_effect=FileNotFoundError("drone")),
|
||||||
|
patch(f"{_MOD}.warning") as warn,
|
||||||
|
):
|
||||||
|
handle_command("feedback", ["on"])
|
||||||
|
warn.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmoke:
|
||||||
|
"""Help/introspection render without error."""
|
||||||
|
|
||||||
|
def test_print_help_runs(self) -> None:
|
||||||
|
print_help()
|
||||||
|
|
||||||
|
def test_print_introspection_runs(self) -> None:
|
||||||
|
print_introspection()
|
||||||
@@ -586,6 +586,17 @@ class TestStages:
|
|||||||
result = stage_9_handoff(agent_path="src/mybot", non_interactive=True)
|
result = stage_9_handoff(agent_path="src/mybot", non_interactive=True)
|
||||||
assert "src/mybot" in result["handoff_command"]
|
assert "src/mybot" in result["handoff_command"]
|
||||||
|
|
||||||
|
def test_stage_9_non_interactive_no_spawn(self, tmp_local_json) -> None:
|
||||||
|
"""Non-interactive stage 9 prints the command but never spawns a session."""
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}.console"),
|
||||||
|
patch("aipass.aipass.apps.modules.handoff.do_handoff") as mock_handoff,
|
||||||
|
):
|
||||||
|
result = stage_9_handoff(non_interactive=True)
|
||||||
|
mock_handoff.assert_not_called()
|
||||||
|
assert result["launched"] is False
|
||||||
|
assert result["handoff_command"]
|
||||||
|
|
||||||
def test_stage_10_done_returns_empty(self, tmp_local_json) -> None:
|
def test_stage_10_done_returns_empty(self, tmp_local_json) -> None:
|
||||||
"""stage_10_done returns {} and marks stage 10 complete."""
|
"""stage_10_done returns {} and marks stage 10 complete."""
|
||||||
with patch(f"{_MOD}.console"):
|
with patch(f"{_MOD}.console"):
|
||||||
@@ -785,8 +796,8 @@ class TestTemplateSelector:
|
|||||||
]
|
]
|
||||||
return {name: MagicMock(return_value={}) for name in stage_names}
|
return {name: MagicMock(return_value={}) for name in stage_names}
|
||||||
|
|
||||||
def test_empty_project_default_skips_scaffold(self, tmp_local_json) -> None:
|
def test_empty_project_default_skips_agent_and_ping(self, tmp_local_json) -> None:
|
||||||
"""empty project (default) = no scaffold; framework-only stages 6,7,9,10 skipped."""
|
"""empty project (default) = no scaffold; framework-only stages 6,7 skipped; 9,10 run."""
|
||||||
mocks = self._stage_patches()
|
mocks = self._stage_patches()
|
||||||
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||||
result = run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
result = run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
||||||
@@ -798,9 +809,11 @@ class TestTemplateSelector:
|
|||||||
"stage_4_style_questions",
|
"stage_4_style_questions",
|
||||||
"stage_5_tool_choice",
|
"stage_5_tool_choice",
|
||||||
"stage_8_smoke_test",
|
"stage_8_smoke_test",
|
||||||
|
"stage_9_handoff",
|
||||||
|
"stage_10_done",
|
||||||
):
|
):
|
||||||
assert mocks[name].called, f"{name} should have been called"
|
assert mocks[name].called, f"{name} should have been called"
|
||||||
for name in ("stage_6_first_agent", "stage_7_ping_sweep", "stage_9_handoff", "stage_10_done"):
|
for name in ("stage_6_first_agent", "stage_7_ping_sweep"):
|
||||||
assert not mocks[name].called, f"{name} should NOT have been called"
|
assert not mocks[name].called, f"{name} should NOT have been called"
|
||||||
|
|
||||||
def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None:
|
def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None:
|
||||||
@@ -862,6 +875,27 @@ class TestTemplateSelector:
|
|||||||
assert "setup.sh" in msg
|
assert "setup.sh" in msg
|
||||||
assert "pip" not in msg
|
assert "pip" not in msg
|
||||||
|
|
||||||
|
def test_empty_template_stage9_gets_cwd_as_agent_path(self, tmp_local_json) -> None:
|
||||||
|
"""Empty template sets agent_path='.' so stage 9 hands off from CWD."""
|
||||||
|
mocks = self._stage_patches()
|
||||||
|
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||||
|
run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
||||||
|
stage_9_call = mocks["stage_9_handoff"].call_args
|
||||||
|
assert stage_9_call is not None
|
||||||
|
agent_path_arg = stage_9_call[0][2] if len(stage_9_call[0]) > 2 else stage_9_call[1].get("agent_path", "")
|
||||||
|
assert agent_path_arg == "."
|
||||||
|
|
||||||
|
def test_non_tty_forces_non_interactive(self, tmp_local_json) -> None:
|
||||||
|
"""When stdin is not a TTY, run_init auto-forces non_interactive (no crash)."""
|
||||||
|
mocks = self._stage_patches()
|
||||||
|
with (
|
||||||
|
patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks),
|
||||||
|
patch("sys.stdin") as mock_stdin,
|
||||||
|
):
|
||||||
|
mock_stdin.isatty.return_value = False
|
||||||
|
rc = run_init(non_interactive=False, template=TEMPLATE_EMPTY)
|
||||||
|
assert rc == 0
|
||||||
|
|
||||||
def test_aipass_specific_stages_constant(self) -> None:
|
def test_aipass_specific_stages_constant(self) -> None:
|
||||||
"""AIPASS_SPECIFIC_STAGES contains exactly {6, 7, 9, 10}."""
|
"""AIPASS_SPECIFIC_STAGES contains exactly {6, 7} — stages 9/10 run for ALL templates."""
|
||||||
assert AIPASS_SPECIFIC_STAGES == {6, 7, 9, 10}
|
assert AIPASS_SPECIFIC_STAGES == {6, 7}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from aipass.aipass.apps.modules.install import (
|
|||||||
DEFAULT_HOME,
|
DEFAULT_HOME,
|
||||||
DEFAULT_PROJECT,
|
DEFAULT_PROJECT,
|
||||||
TOTAL_STEPS,
|
TOTAL_STEPS,
|
||||||
|
_build_install_prompt,
|
||||||
_clone_repo,
|
_clone_repo,
|
||||||
_handoff_to_init,
|
_handoff_to_init,
|
||||||
_looks_like_aipass_tree,
|
_looks_like_aipass_tree,
|
||||||
@@ -215,21 +216,13 @@ class TestRunInstall:
|
|||||||
class TestShouldRunInit:
|
class TestShouldRunInit:
|
||||||
"""Deciding whether the install chains into init."""
|
"""Deciding whether the install chains into init."""
|
||||||
|
|
||||||
def test_no_init_wins(self) -> None:
|
def test_no_init_skips(self) -> None:
|
||||||
"""--no-init disables the handoff even alongside --with-init."""
|
"""--no-init disables the handoff."""
|
||||||
assert _should_run_init(non_interactive=False, with_init=True, no_init=True) is False
|
assert _should_run_init(no_init=True) is False
|
||||||
|
|
||||||
def test_with_init_forces_headless(self) -> None:
|
def test_default_chains(self) -> None:
|
||||||
"""--with-init runs init even when the install was headless."""
|
"""Default: always chain into init."""
|
||||||
assert _should_run_init(non_interactive=True, with_init=True, no_init=False) is True
|
assert _should_run_init(no_init=False) is True
|
||||||
|
|
||||||
def test_headless_defaults_off(self) -> None:
|
|
||||||
"""A plain headless install stops before init."""
|
|
||||||
assert _should_run_init(non_interactive=True, with_init=False, no_init=False) is False
|
|
||||||
|
|
||||||
def test_interactive_defaults_on(self) -> None:
|
|
||||||
"""A plain interactive install chains into init."""
|
|
||||||
assert _should_run_init(non_interactive=False, with_init=False, no_init=False) is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveProjectDir:
|
class TestResolveProjectDir:
|
||||||
@@ -327,6 +320,75 @@ class TestHandleCommand:
|
|||||||
assert kwargs["project"] == "/x/proj"
|
assert kwargs["project"] == "/x/proj"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildInstallPrompt:
|
||||||
|
"""Authored first prompt for the post-install @aipass chat."""
|
||||||
|
|
||||||
|
def test_includes_home(self, tmp_path: Path) -> None:
|
||||||
|
"""Prompt mentions the install home directory."""
|
||||||
|
prompt = _build_install_prompt(tmp_path, {"drone": "/x/drone", "aipass": "/x/aipass"})
|
||||||
|
assert str(tmp_path) in prompt
|
||||||
|
|
||||||
|
def test_includes_verified_bins(self) -> None:
|
||||||
|
"""Verified binary paths appear in the prompt."""
|
||||||
|
prompt = _build_install_prompt(Path("/h"), {"drone": "/x/drone", "aipass": "/x/aipass"})
|
||||||
|
assert "/x/drone" in prompt
|
||||||
|
assert "/x/aipass" in prompt
|
||||||
|
|
||||||
|
def test_omits_none_bins(self) -> None:
|
||||||
|
"""Binaries that weren't found are omitted, not shown as None."""
|
||||||
|
prompt = _build_install_prompt(Path("/h"), {"drone": None, "aipass": "/x/aipass"})
|
||||||
|
assert "None" not in prompt
|
||||||
|
assert "/x/aipass" in prompt
|
||||||
|
|
||||||
|
def test_ends_with_question(self) -> None:
|
||||||
|
"""Prompt ends by asking what to explore."""
|
||||||
|
prompt = _build_install_prompt(Path("/h"), {})
|
||||||
|
assert "?" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstallChatHandoff:
|
||||||
|
"""Install-to-chat handoff launches @aipass after init on TTY."""
|
||||||
|
|
||||||
|
def test_tty_launches_inline(self) -> None:
|
||||||
|
"""Interactive TTY install launches the @aipass concierge after init."""
|
||||||
|
home = Path("/fake/AIPass")
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||||
|
patch(f"{_MOD}.is_throwaway_path", return_value=False),
|
||||||
|
patch(f"{_MOD}._clone_repo", return_value=True),
|
||||||
|
patch(f"{_MOD}._run_setup", return_value=True),
|
||||||
|
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
|
||||||
|
patch(f"{_MOD}._check_and_fix_owner"),
|
||||||
|
patch(f"{_MOD}._handoff_to_init"),
|
||||||
|
patch(f"{_MOD}.sys.stdin") as mock_stdin,
|
||||||
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
):
|
||||||
|
mock_stdin.isatty.return_value = True
|
||||||
|
run_install(non_interactive=False, dry_run=False)
|
||||||
|
mock_launch.assert_called_once()
|
||||||
|
prompt_arg = mock_launch.call_args[0][1]
|
||||||
|
assert "Fresh AIPass install" in prompt_arg
|
||||||
|
|
||||||
|
def test_no_tty_skips_launch(self) -> None:
|
||||||
|
"""Non-TTY install skips the chat handoff."""
|
||||||
|
home = Path("/fake/AIPass")
|
||||||
|
with (
|
||||||
|
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||||
|
patch(f"{_MOD}.is_throwaway_path", return_value=False),
|
||||||
|
patch(f"{_MOD}._clone_repo", return_value=True),
|
||||||
|
patch(f"{_MOD}._run_setup", return_value=True),
|
||||||
|
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
|
||||||
|
patch(f"{_MOD}._check_and_fix_owner"),
|
||||||
|
patch(f"{_MOD}._handoff_to_init"),
|
||||||
|
patch(f"{_MOD}.sys.stdin") as mock_stdin,
|
||||||
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
):
|
||||||
|
mock_stdin.isatty.return_value = False
|
||||||
|
rc = run_install(non_interactive=True, dry_run=False)
|
||||||
|
mock_launch.assert_not_called()
|
||||||
|
assert rc == 0
|
||||||
|
|
||||||
|
|
||||||
class TestSmoke:
|
class TestSmoke:
|
||||||
"""Help/introspection render and constants hold."""
|
"""Help/introspection render and constants hold."""
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ class TestReturnTypeContracts:
|
|||||||
|
|
||||||
def test_doctor_wire_handle_command_returns_bool(self):
|
def test_doctor_wire_handle_command_returns_bool(self):
|
||||||
"""Doctor wire handle_command returns True for match, False otherwise."""
|
"""Doctor wire handle_command returns True for match, False otherwise."""
|
||||||
from aipass.aipass.apps.modules.doctor_wire import handle_command as wire_cmd
|
from aipass.aipass.apps.modules._doctor_wire import handle_command as wire_cmd
|
||||||
|
|
||||||
assert wire_cmd("doctor_wire", []) is True
|
assert wire_cmd("doctor_wire", []) is True
|
||||||
assert wire_cmd("not_wire", []) is False
|
assert wire_cmd("not_wire", []) is False
|
||||||
|
|||||||
@@ -0,0 +1,738 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: test_new_project.py
|
||||||
|
# Description: Tests for aipass new — project creation handler
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-17
|
||||||
|
# Modified: 2026-07-17
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Tests for the new_project handler and module.
|
||||||
|
|
||||||
|
All file operations use tmp_path to stay fully isolated from the live
|
||||||
|
filesystem. Tests mock subprocess calls to avoid real git/drone invocations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
|
from aipass.aipass.apps.handlers.new_project import (
|
||||||
|
_agent_home,
|
||||||
|
_registry_name,
|
||||||
|
_spawn_project_agent,
|
||||||
|
_validate_name,
|
||||||
|
_write_registry,
|
||||||
|
_write_template,
|
||||||
|
create_project,
|
||||||
|
find_host_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# find_host_root
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_host_root_finds_registry(tmp_path):
|
||||||
|
"""Finds directory containing *_REGISTRY.json."""
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
sub = tmp_path / "projects" / "myapp"
|
||||||
|
sub.mkdir(parents=True)
|
||||||
|
assert find_host_root(sub) == tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_host_root_returns_none_without_registry(tmp_path):
|
||||||
|
"""Returns None when no registry exists above start."""
|
||||||
|
assert find_host_root(tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_host_root_finds_closest_registry(tmp_path):
|
||||||
|
"""Walks up and finds the closest *_REGISTRY.json."""
|
||||||
|
(tmp_path / "HOST_REGISTRY.json").write_text("{}")
|
||||||
|
sub = tmp_path / "a" / "b"
|
||||||
|
sub.mkdir(parents=True)
|
||||||
|
assert find_host_root(sub) == tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _validate_name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_accepts_valid():
|
||||||
|
assert _validate_name("myapp") == "myapp"
|
||||||
|
assert _validate_name("My-App_2") == "My-App_2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_empty():
|
||||||
|
with pytest.raises(ValueError, match="cannot be empty"):
|
||||||
|
_validate_name("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_leading_digit():
|
||||||
|
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||||
|
_validate_name("2fast")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_special_chars():
|
||||||
|
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||||
|
_validate_name("my app!")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _registry_name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_name_uppercases():
|
||||||
|
assert _registry_name("myapp") == "MYAPP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_name_replaces_special():
|
||||||
|
assert _registry_name("my.app") == "MY_APP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_name_preserves_hyphens():
|
||||||
|
assert _registry_name("my-app") == "MY-APP"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _write_registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_registry_creates_file(tmp_path):
|
||||||
|
rid, fname = _write_registry(tmp_path, "demo")
|
||||||
|
path = tmp_path / fname
|
||||||
|
assert path.exists()
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
assert data["metadata"]["id"] == rid
|
||||||
|
assert data["metadata"]["name"] == "DEMO"
|
||||||
|
assert fname == "DEMO_REGISTRY.json"
|
||||||
|
assert data["branches"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _write_template — empty
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_template_empty(tmp_path):
|
||||||
|
created = _write_template(tmp_path, "demo", "empty")
|
||||||
|
assert "README.md" in created
|
||||||
|
assert ".gitignore" in created
|
||||||
|
assert (tmp_path / "README.md").exists()
|
||||||
|
assert (tmp_path / ".gitignore").exists()
|
||||||
|
assert not (tmp_path / "pyproject.toml").exists()
|
||||||
|
assert not (tmp_path / "src").exists()
|
||||||
|
gitignore = (tmp_path / ".gitignore").read_text()
|
||||||
|
assert ".venv\n" in gitignore
|
||||||
|
assert ".venv/\n" not in gitignore
|
||||||
|
assert "*_REGISTRY.lock" in gitignore
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _write_template — python
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_template_python(tmp_path):
|
||||||
|
created = _write_template(tmp_path, "demo", "python")
|
||||||
|
assert "pyproject.toml" in created
|
||||||
|
assert "src/demo/__init__.py" in created
|
||||||
|
assert (tmp_path / "pyproject.toml").exists()
|
||||||
|
assert (tmp_path / "src" / "demo" / "__init__.py").exists()
|
||||||
|
pyproject = (tmp_path / "pyproject.toml").read_text()
|
||||||
|
assert 'name = "demo"' in pyproject
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_template_python_hyphen_name(tmp_path):
|
||||||
|
_write_template(tmp_path, "my-app", "python")
|
||||||
|
assert (tmp_path / "src" / "my_app" / "__init__.py").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_project — integration (mocked subprocess)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def host_env(tmp_path):
|
||||||
|
"""Set up a minimal AIPass host installation in tmp_path."""
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "host-id"}, "branches": []}))
|
||||||
|
(tmp_path / "projects").mkdir()
|
||||||
|
(tmp_path / ".aipass").mkdir()
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_git_run(args, **kwargs):
|
||||||
|
"""Stub subprocess.run for git commands — always succeeds."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 0
|
||||||
|
result.stdout = ""
|
||||||
|
result.stderr = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_empty_template(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._enroll_project",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = create_project("testproj", template="empty", no_agent=True)
|
||||||
|
|
||||||
|
target = Path(result["target"])
|
||||||
|
assert target.exists()
|
||||||
|
assert result["name"] == "testproj"
|
||||||
|
assert result["template"] == "empty"
|
||||||
|
assert result["registry_file"] == "TESTPROJ_REGISTRY.json"
|
||||||
|
assert (target / "TESTPROJ_REGISTRY.json").exists()
|
||||||
|
assert (target / "README.md").exists()
|
||||||
|
assert (target / ".gitignore").exists()
|
||||||
|
assert not (target / "pyproject.toml").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_python_template(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._enroll_project",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = create_project("pyapp", template="python", no_agent=True)
|
||||||
|
|
||||||
|
target = Path(result["target"])
|
||||||
|
assert (target / "pyproject.toml").exists()
|
||||||
|
assert (target / "src" / "pyapp" / "__init__.py").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_rejects_existing(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
(host_env / "projects" / "taken").mkdir()
|
||||||
|
with pytest.raises(RuntimeError, match="already exists"):
|
||||||
|
create_project("taken", no_agent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_rejects_invalid_name(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||||
|
create_project("123bad", no_agent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_rejects_bad_template(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with pytest.raises(ValueError, match="Unknown template"):
|
||||||
|
create_project("foo", template="rust", no_agent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_no_host(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
with pytest.raises(RuntimeError, match="Not inside an AIPass"):
|
||||||
|
create_project("foo", no_agent=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_cleans_up_on_failure(host_env, monkeypatch):
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
|
||||||
|
def _fail_git(args, **kwargs):
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
result = MagicMock()
|
||||||
|
result.returncode = 1
|
||||||
|
result.stderr = "simulated failure"
|
||||||
|
return result
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_fail_git),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.shared.project_home._enroll_project"),
|
||||||
|
pytest.raises(RuntimeError, match="simulated failure"),
|
||||||
|
):
|
||||||
|
create_project("failproj", no_agent=True)
|
||||||
|
|
||||||
|
assert not (host_env / "projects" / "failproj").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_registry_before_scaffold(host_env, monkeypatch):
|
||||||
|
"""Registry file must exist before scaffold runs (order invariant)."""
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
creation_order = []
|
||||||
|
|
||||||
|
original_write_registry = _write_registry
|
||||||
|
original_write_template = _write_template
|
||||||
|
|
||||||
|
def track_registry(target, name):
|
||||||
|
creation_order.append("registry")
|
||||||
|
return original_write_registry(target, name)
|
||||||
|
|
||||||
|
def track_template(target, name, template):
|
||||||
|
creation_order.append("template")
|
||||||
|
return original_write_template(target, name, template)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project._write_registry",
|
||||||
|
side_effect=track_registry,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project._write_template",
|
||||||
|
side_effect=track_template,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.shared.project_home._enroll_project"),
|
||||||
|
):
|
||||||
|
create_project("ordertest", no_agent=True)
|
||||||
|
|
||||||
|
assert creation_order.index("registry") < creation_order.index("template")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _spawn_project_agent (delegates to spawn_agent)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SPAWN_SUCCESS = {
|
||||||
|
"success": True,
|
||||||
|
"branch_name": "DEMO",
|
||||||
|
"path": "/tmp/demo",
|
||||||
|
"files_copied": 12,
|
||||||
|
"registry_updated": True,
|
||||||
|
"validation_issues": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_home_simple():
|
||||||
|
"""Agent home is src/<pkg>/<pkg>/."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
home = _agent_home(Path("/proj"), "demo")
|
||||||
|
assert home == Path("/proj/src/demo/demo")
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_home_hyphenated():
|
||||||
|
"""Hyphens normalized to underscores, matching python template."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
home = _agent_home(Path("/proj"), "my-app")
|
||||||
|
assert home == Path("/proj/src/my_app/my_app")
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_project_agent_calls_spawn(tmp_path):
|
||||||
|
"""Calls spawn_agent with correct citizen_class, purpose, and agent_home path."""
|
||||||
|
with patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value=_SPAWN_SUCCESS,
|
||||||
|
) as mock_spawn:
|
||||||
|
result = _spawn_project_agent(tmp_path, "demo")
|
||||||
|
expected_home = str(tmp_path / "src" / "demo" / "demo")
|
||||||
|
mock_spawn.assert_called_once_with(
|
||||||
|
target_path=expected_home,
|
||||||
|
role="project_agent",
|
||||||
|
purpose="Resident agent of the demo project.",
|
||||||
|
citizen_class="project_agent",
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["branch_name"] == "DEMO"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_project_agent_raises_on_failure(tmp_path):
|
||||||
|
"""Raises RuntimeError when spawn_agent returns success=False."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value={"success": False, "error": "template missing"},
|
||||||
|
),
|
||||||
|
pytest.raises(RuntimeError, match="spawn_agent failed.*template missing"),
|
||||||
|
):
|
||||||
|
_spawn_project_agent(tmp_path, "broken")
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_project_agent_returns_spawn_result(tmp_path):
|
||||||
|
"""Returns the full result dict from spawn_agent."""
|
||||||
|
with patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value={**_SPAWN_SUCCESS, "citizen_number": 1},
|
||||||
|
):
|
||||||
|
result = _spawn_project_agent(tmp_path, "demo")
|
||||||
|
assert result["files_copied"] == 12
|
||||||
|
assert result["citizen_number"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_project — WITH agent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_with_agent(host_env, monkeypatch):
|
||||||
|
"""WITH-agent path: spawn_agent called, result propagated."""
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
spawn_ok = {
|
||||||
|
"success": True,
|
||||||
|
"branch_name": "WITHAGENT",
|
||||||
|
"path": str(host_env / "projects" / "withagent"),
|
||||||
|
"files_copied": 15,
|
||||||
|
"registry_updated": True,
|
||||||
|
"validation_issues": [],
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.shared.project_home._enroll_project"),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value=spawn_ok,
|
||||||
|
) as mock_spawn,
|
||||||
|
):
|
||||||
|
result = create_project("withagent", template="empty", no_agent=False)
|
||||||
|
|
||||||
|
assert result["agent_created"] is True
|
||||||
|
assert result["spawn_result"] == spawn_ok
|
||||||
|
expected_home = str(host_env / "projects" / "withagent" / "src" / "withagent" / "withagent")
|
||||||
|
assert result["agent_home"] == expected_home
|
||||||
|
mock_spawn.assert_called_once()
|
||||||
|
call_kwargs = mock_spawn.call_args[1]
|
||||||
|
assert call_kwargs["target_path"] == expected_home
|
||||||
|
assert call_kwargs["citizen_class"] == "project_agent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_spawn_failure_cleans_up(host_env, monkeypatch):
|
||||||
|
"""spawn_agent failure triggers cleanup — no partial project left."""
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.shared.project_home._enroll_project"),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value={"success": False, "error": "template missing"},
|
||||||
|
),
|
||||||
|
pytest.raises(RuntimeError, match="spawn_agent failed"),
|
||||||
|
):
|
||||||
|
create_project("failspawn", template="empty", no_agent=False)
|
||||||
|
|
||||||
|
assert not (host_env / "projects" / "failspawn").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_no_agent_next_steps(host_env, monkeypatch):
|
||||||
|
"""no_agent output omits 'meet your project agent' line."""
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.shared.project_home._enroll_project"),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
|
||||||
|
):
|
||||||
|
handle_command("new", ["cosmtest", "--template", "empty", "--no-agent"])
|
||||||
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
|
assert "meet your project agent" not in printed
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_project_no_agent_flag(host_env, monkeypatch):
|
||||||
|
"""no_agent=True skips passport and registry seating."""
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.shared.project_home._enroll_project"),
|
||||||
|
):
|
||||||
|
result = create_project("noagent", template="empty", no_agent=True)
|
||||||
|
|
||||||
|
assert result["agent_created"] is False
|
||||||
|
assert result["agent_home"] is None
|
||||||
|
target = Path(result["target"])
|
||||||
|
assert not (target / "src" / "noagent" / "noagent").exists()
|
||||||
|
reg = json.loads((target / result["registry_file"]).read_text())
|
||||||
|
assert reg["metadata"]["total_branches"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# is_projects_child (guard relaxation)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_projects_child_valid(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||||
|
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
target = tmp_path / "projects" / "myapp"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
assert is_projects_child(target) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_projects_child_not_in_projects(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||||
|
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
target = tmp_path / "elsewhere" / "myapp"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
assert is_projects_child(target) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_projects_child_no_host_registry(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||||
|
|
||||||
|
target = tmp_path / "projects" / "myapp"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
assert is_projects_child(target) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _guard_init relaxation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_init_blocks_nested_by_default(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||||
|
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
target = tmp_path / "projects" / "nested"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
with pytest.raises(RuntimeError, match="inside AIPass project"):
|
||||||
|
_guard_init(target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_init_allows_nested_with_flag(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||||
|
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
target = tmp_path / "projects" / "nested"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
_guard_init(target, allow_projects_child=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_init_still_blocks_non_projects_nested(tmp_path):
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||||
|
|
||||||
|
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||||
|
target = tmp_path / "elsewhere" / "nested"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
with pytest.raises(RuntimeError, match="inside AIPass project"):
|
||||||
|
_guard_init(target, allow_projects_child=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module handle_command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_handles_new_command():
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
assert handle_command("notmine", []) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_handles_help():
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
assert handle_command("new", ["--help"]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_handles_no_args():
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
assert handle_command("new", []) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Interactive prompts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_template_default():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||||
|
|
||||||
|
with patch("builtins.input", return_value=""):
|
||||||
|
assert _prompt_template(["empty", "python"]) == "empty"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_template_by_number():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||||
|
|
||||||
|
with patch("builtins.input", return_value="2"):
|
||||||
|
assert _prompt_template(["empty", "python"]) == "python"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_template_by_name():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||||
|
|
||||||
|
with patch("builtins.input", return_value="python"):
|
||||||
|
assert _prompt_template(["empty", "python"]) == "python"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_template_eof():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||||
|
|
||||||
|
with patch("builtins.input", side_effect=EOFError):
|
||||||
|
assert _prompt_template(["empty", "python"]) == "empty"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_agent_default_yes():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||||
|
|
||||||
|
with patch("builtins.input", return_value=""):
|
||||||
|
assert _prompt_agent() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_agent_no():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||||
|
|
||||||
|
with patch("builtins.input", return_value="n"):
|
||||||
|
assert _prompt_agent() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_agent_eof():
|
||||||
|
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||||
|
|
||||||
|
with patch("builtins.input", side_effect=EOFError):
|
||||||
|
assert _prompt_agent() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TTY auto-launch (FIX 3: aipass new auto-launches on TTY)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tty_auto_launches_agent(host_env, monkeypatch):
|
||||||
|
"""On a TTY with an agent created, launch_inline is called."""
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
spawn_ok = {
|
||||||
|
"success": True,
|
||||||
|
"branch_name": "LAUNCH",
|
||||||
|
"path": str(host_env / "projects" / "launch"),
|
||||||
|
"files_copied": 12,
|
||||||
|
"registry_updated": True,
|
||||||
|
"validation_issues": [],
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch("builtins.input", return_value=""),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.shared.project_home._enroll_project"),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value=spawn_ok,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.console"),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||||
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
):
|
||||||
|
mock_sys.stdin.isatty.return_value = True
|
||||||
|
handle_command("new", ["launch", "--template", "empty"])
|
||||||
|
mock_launch.assert_called_once()
|
||||||
|
assert "launch" in mock_launch.call_args[0][2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_tty_skips_auto_launch(host_env, monkeypatch):
|
||||||
|
"""On a non-TTY, launch_inline is NOT called — fallback to printed instructions."""
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
spawn_ok = {
|
||||||
|
"success": True,
|
||||||
|
"branch_name": "PIPED",
|
||||||
|
"path": str(host_env / "projects" / "piped"),
|
||||||
|
"files_copied": 12,
|
||||||
|
"registry_updated": True,
|
||||||
|
"validation_issues": [],
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch("builtins.input", return_value=""),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.shared.project_home._enroll_project"),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||||
|
return_value=spawn_ok,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||||
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
):
|
||||||
|
mock_sys.stdin.isatty.return_value = False
|
||||||
|
handle_command("new", ["piped", "--template", "empty"])
|
||||||
|
mock_launch.assert_not_called()
|
||||||
|
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||||
|
assert "cd" in printed
|
||||||
|
assert "claude" in printed
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_agent_skips_auto_launch(host_env, monkeypatch):
|
||||||
|
"""With --no-agent, launch_inline is not called even on TTY."""
|
||||||
|
from aipass.aipass.apps.modules.new_project import handle_command
|
||||||
|
|
||||||
|
monkeypatch.chdir(host_env)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=_mock_git_run),
|
||||||
|
patch(
|
||||||
|
"aipass.aipass.shared.project_home._detect_aipass_home",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch("aipass.aipass.shared.project_home._enroll_project"),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.console"),
|
||||||
|
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||||
|
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||||
|
):
|
||||||
|
mock_sys.stdin.isatty.return_value = True
|
||||||
|
handle_command("new", ["nolaunch", "--template", "empty", "--no-agent"])
|
||||||
|
mock_launch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# aipass.py entry point help (cli_ux)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_aipass_print_introspection():
|
||||||
|
from aipass.aipass.apps.aipass import print_introspection
|
||||||
|
|
||||||
|
print_introspection([])
|
||||||
|
|
||||||
|
|
||||||
|
def test_aipass_print_help():
|
||||||
|
from aipass.aipass.apps.aipass import print_help
|
||||||
|
|
||||||
|
print_help([])
|
||||||
@@ -24,7 +24,9 @@ import sys
|
|||||||
|
|
||||||
import aipass.aipass.shared.json_handler
|
import aipass.aipass.shared.json_handler
|
||||||
import aipass.aipass.shared.json_ops
|
import aipass.aipass.shared.json_ops
|
||||||
|
import aipass.aipass.shared.project_home
|
||||||
import aipass.aipass.shared.registry_discovery
|
import aipass.aipass.shared.registry_discovery
|
||||||
|
import aipass.aipass.shared.scaffold_content
|
||||||
|
|
||||||
bad = []
|
bad = []
|
||||||
for name in sorted(sys.modules):
|
for name in sorted(sys.modules):
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# =================== AIPass ====================
|
||||||
|
# Name: test_trust.py
|
||||||
|
# Description: Tests for trust CLI commands and init enrollment (DPLAN-0244)
|
||||||
|
# Version: 1.0.0
|
||||||
|
# Created: 2026-07-15
|
||||||
|
# Modified: 2026-07-15
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
"""Tests for trust/revoke CLI commands and init auto-enrollment.
|
||||||
|
|
||||||
|
All tests use tmp dirs + monkeypatch REGISTRY_PATH so they never
|
||||||
|
touch the real ~/.aipass registry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
|
from aipass.hooks.apps.handlers.config.trust_registry import (
|
||||||
|
enroll,
|
||||||
|
is_trusted,
|
||||||
|
read_registry,
|
||||||
|
revoke,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_registry(tmp_path, monkeypatch):
|
||||||
|
"""Redirect REGISTRY_PATH to a tmp dir so tests never touch ~/.aipass."""
|
||||||
|
fake_registry = tmp_path / "trusted_projects.json"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aipass.hooks.apps.handlers.config.trust_registry.REGISTRY_PATH",
|
||||||
|
fake_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# trust_registry direct tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_project(tmp_path):
|
||||||
|
"""enroll() registers a project with .aipass/hooks.json."""
|
||||||
|
project = tmp_path / "myproject"
|
||||||
|
project.mkdir()
|
||||||
|
hooks_dir = project / ".aipass"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
hooks_file = hooks_dir / "hooks.json"
|
||||||
|
hooks_file.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||||
|
|
||||||
|
assert enroll(str(project)) is True
|
||||||
|
assert is_trusted(str(project)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_no_hooks_json(tmp_path):
|
||||||
|
"""enroll() returns False when .aipass/hooks.json is missing."""
|
||||||
|
project = tmp_path / "empty"
|
||||||
|
project.mkdir()
|
||||||
|
assert enroll(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_project(tmp_path):
|
||||||
|
"""revoke() removes a previously enrolled project."""
|
||||||
|
project = tmp_path / "myproject"
|
||||||
|
project.mkdir()
|
||||||
|
hooks_dir = project / ".aipass"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
hooks_file = hooks_dir / "hooks.json"
|
||||||
|
hooks_file.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||||
|
|
||||||
|
enroll(str(project))
|
||||||
|
assert is_trusted(str(project)) is True
|
||||||
|
|
||||||
|
assert revoke(str(project)) is True
|
||||||
|
assert is_trusted(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_not_enrolled(tmp_path):
|
||||||
|
"""revoke() returns False cleanly for a non-enrolled project."""
|
||||||
|
project = tmp_path / "never_enrolled"
|
||||||
|
project.mkdir()
|
||||||
|
assert revoke(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_trusted_hash_mismatch(tmp_path):
|
||||||
|
"""is_trusted() returns False when hooks.json content changed after enrollment."""
|
||||||
|
project = tmp_path / "myproject"
|
||||||
|
project.mkdir()
|
||||||
|
hooks_dir = project / ".aipass"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
hooks_file = hooks_dir / "hooks.json"
|
||||||
|
hooks_file.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||||
|
|
||||||
|
enroll(str(project))
|
||||||
|
assert is_trusted(str(project)) is True
|
||||||
|
|
||||||
|
hooks_file.write_text('{"hooks_enabled": false, "modified": true}', encoding="utf-8")
|
||||||
|
assert is_trusted(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# trust CLI module tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_command_enrolls(tmp_path):
|
||||||
|
"""aipass trust <path> enrolls the project."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
project = tmp_path / "proj"
|
||||||
|
project.mkdir()
|
||||||
|
hooks_dir = project / ".aipass"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
(hooks_dir / "hooks.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
assert handle_command("trust", [str(project)]) is True
|
||||||
|
assert is_trusted(str(project)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_command_removes(tmp_path):
|
||||||
|
"""aipass revoke <path> removes enrollment."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
project = tmp_path / "proj"
|
||||||
|
project.mkdir()
|
||||||
|
hooks_dir = project / ".aipass"
|
||||||
|
hooks_dir.mkdir()
|
||||||
|
(hooks_dir / "hooks.json").write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
enroll(str(project))
|
||||||
|
assert handle_command("revoke", [str(project)]) is True
|
||||||
|
assert is_trusted(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_command_no_hooks_json(tmp_path):
|
||||||
|
"""aipass trust <path> prints error when hooks.json is missing."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
project = tmp_path / "bare"
|
||||||
|
project.mkdir()
|
||||||
|
assert handle_command("trust", [str(project)]) is True
|
||||||
|
assert is_trusted(str(project)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_command_not_enrolled(tmp_path):
|
||||||
|
"""aipass revoke <path> handles non-enrolled project cleanly."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
project = tmp_path / "ghost"
|
||||||
|
project.mkdir()
|
||||||
|
assert handle_command("revoke", [str(project)]) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_command_help():
|
||||||
|
"""aipass trust --help returns True (handled)."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
assert handle_command("trust", ["--help"]) is True
|
||||||
|
assert handle_command("trust", []) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_ignores_unrelated_command():
|
||||||
|
"""handle_command returns False for unrelated commands."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
assert handle_command("doctor", []) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_not_a_directory(tmp_path):
|
||||||
|
"""aipass trust <file> prints error."""
|
||||||
|
from aipass.aipass.apps.modules.trust import handle_command
|
||||||
|
|
||||||
|
fake = tmp_path / "not_a_dir.txt"
|
||||||
|
fake.write_text("hi", encoding="utf-8")
|
||||||
|
assert handle_command("trust", [str(fake)]) is True
|
||||||
|
assert is_trusted(str(fake)) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# init enrollment tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_project_enrolls(tmp_path, monkeypatch):
|
||||||
|
"""init_project auto-enrolls after copying hooks.json."""
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import init_project
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||||
|
lambda p: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
aipass_home = tmp_path / "aipass_home"
|
||||||
|
aipass_home.mkdir()
|
||||||
|
aipass_dir = aipass_home / ".aipass"
|
||||||
|
aipass_dir.mkdir()
|
||||||
|
template = aipass_dir / "project_hooks.json"
|
||||||
|
template.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||||
|
(aipass_home / "CLAUDE.md").write_text("# Test", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
lambda: str(aipass_home),
|
||||||
|
)
|
||||||
|
|
||||||
|
target = tmp_path / "newproject"
|
||||||
|
target.mkdir()
|
||||||
|
init_project(target, project_name="test")
|
||||||
|
|
||||||
|
assert is_trusted(str(target.resolve())) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_update_rehashes(tmp_path, monkeypatch):
|
||||||
|
"""init update re-enrolls after merging hooks.json (hash tracks new content)."""
|
||||||
|
from aipass.aipass.apps.handlers.init.bootstrap import init_project, update_project
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||||
|
lambda p: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
aipass_home = tmp_path / "aipass_home"
|
||||||
|
aipass_home.mkdir()
|
||||||
|
aipass_dir = aipass_home / ".aipass"
|
||||||
|
aipass_dir.mkdir()
|
||||||
|
template = aipass_dir / "project_hooks.json"
|
||||||
|
template.write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||||
|
(aipass_home / "CLAUDE.md").write_text("# Test", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||||
|
lambda: str(aipass_home),
|
||||||
|
)
|
||||||
|
|
||||||
|
target = tmp_path / "updproj"
|
||||||
|
target.mkdir()
|
||||||
|
init_project(target, project_name="test")
|
||||||
|
assert is_trusted(str(target.resolve())) is True
|
||||||
|
|
||||||
|
old_reg = read_registry()
|
||||||
|
old_hash = old_reg["projects"][str(target.resolve())]["config_hash"]
|
||||||
|
|
||||||
|
new_template = (
|
||||||
|
'{"hooks_enabled": true, "SessionStart": '
|
||||||
|
'{"new_hook": {"handler": "aipass.hooks.apps.handlers.test.handle", "enabled": true}}}'
|
||||||
|
)
|
||||||
|
template.write_text(new_template, encoding="utf-8")
|
||||||
|
update_project(target)
|
||||||
|
|
||||||
|
new_reg = read_registry()
|
||||||
|
new_hash = new_reg["projects"][str(target.resolve())]["config_hash"]
|
||||||
|
assert new_hash != old_hash
|
||||||
|
assert is_trusted(str(target.resolve())) is True
|
||||||
@@ -18,6 +18,27 @@ drone @api <command> [args]
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Validate your API key
|
||||||
|
drone @api validate
|
||||||
|
|
||||||
|
# Test the connection
|
||||||
|
drone @api test
|
||||||
|
|
||||||
|
# List available models
|
||||||
|
drone @api models
|
||||||
|
|
||||||
|
# Make an API call
|
||||||
|
drone @api call "Hello, world" --model anthropic/claude-3.5-sonnet
|
||||||
|
|
||||||
|
# Check usage stats
|
||||||
|
drone @api stats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|||||||
@@ -213,6 +213,24 @@ def print_help():
|
|||||||
console.print("─" * 70)
|
console.print("─" * 70)
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [yellow]Credentials:[/yellow]")
|
||||||
|
console.print(" [dim]drone @api get-key[/dim] [dim]# Show OpenRouter key[/dim]")
|
||||||
|
console.print(" [dim]drone @api validate[/dim] [dim]# Validate OpenRouter key[/dim]")
|
||||||
|
console.print(" [dim]drone @api validate google[/dim] [dim]# Validate Google OAuth2[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [yellow]Models & calls:[/yellow]")
|
||||||
|
console.print(" [dim]drone @api models --all[/dim] [dim]# List all models[/dim]")
|
||||||
|
console.print(' [dim]drone @api call "Summarize this" --model anthropic/claude-3.5-sonnet[/dim]')
|
||||||
|
console.print()
|
||||||
|
console.print(" [yellow]Usage tracking:[/yellow]")
|
||||||
|
console.print(" [dim]drone @api stats[/dim] [dim]# Overall usage stats[/dim]")
|
||||||
|
console.print(" [dim]drone @api caller-usage flow[/dim] [dim]# Usage by caller[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("─" * 70)
|
||||||
|
console.print()
|
||||||
|
|
||||||
console.print(
|
console.print(
|
||||||
"[dim]Commands: get-key, get-secret, validate, test, models, status, call,"
|
"[dim]Commands: get-key, get-secret, validate, test, models, status, call,"
|
||||||
" list-providers, init, track, stats, session, caller-usage, cleanup[/dim]"
|
" list-providers, init, track, stats, session, caller-usage, cleanup[/dim]"
|
||||||
|
|||||||
@@ -79,6 +79,27 @@ All 11 commands are auto-discovered by the entry point router.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register a project for backup
|
||||||
|
drone @backup register /path/to/project --name myapp
|
||||||
|
|
||||||
|
# Full mirror snapshot
|
||||||
|
drone @backup snapshot @myapp
|
||||||
|
|
||||||
|
# Incremental timestamped backup
|
||||||
|
drone @backup versioned @myapp
|
||||||
|
|
||||||
|
# Check backup status
|
||||||
|
drone @backup status @myapp
|
||||||
|
|
||||||
|
# List available versions of a file
|
||||||
|
drone @backup restore @myapp list src/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `.backup/` Store Structure
|
## `.backup/` Store Structure
|
||||||
|
|
||||||
Each registered project gets a `.backup/` directory at its root:
|
Each registered project gets a `.backup/` directory at its root:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def print_introspection(modules: list[Any]) -> None:
|
|||||||
console.print()
|
console.print()
|
||||||
console.print(f"[bold cyan]BACKUP[/bold cyan] v{VERSION} — project backup & drive sync")
|
console.print(f"[bold cyan]BACKUP[/bold cyan] v{VERSION} — project backup & drive sync")
|
||||||
console.print()
|
console.print()
|
||||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
console.print(f"[bold dim]Discovered Modules:[/bold dim] {len(modules)}")
|
||||||
console.print()
|
console.print()
|
||||||
for module in modules:
|
for module in modules:
|
||||||
name = module.__name__.split(".")[-1]
|
name = module.__name__.split(".")[-1]
|
||||||
@@ -83,6 +83,26 @@ def print_help() -> None:
|
|||||||
console.print(" [green]share[/green] Upload a single file to Drive + get a shareable link")
|
console.print(" [green]share[/green] Upload a single file to Drive + get a shareable link")
|
||||||
console.print(" [green]drive_clear[/green] Clear backups from the remote drive")
|
console.print(" [green]drive_clear[/green] Clear backups from the remote drive")
|
||||||
console.print()
|
console.print()
|
||||||
|
console.print("-" * 70)
|
||||||
|
console.print()
|
||||||
|
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [bold dim]Register & back up:[/bold dim]")
|
||||||
|
console.print(" [dim]drone @backup register /path/to/project --name myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup snapshot @myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup versioned @myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup all @myapp[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [bold dim]Status & restore:[/bold dim]")
|
||||||
|
console.print(" [dim]drone @backup status @myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup restore @myapp list src/main.py[/dim]")
|
||||||
|
console.print(" [dim]drone @backup restore @myapp file src/main.py ./restored.py[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [bold dim]Drive sync:[/bold dim]")
|
||||||
|
console.print(" [dim]drone @backup drive_check @myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup drive_sync @myapp[/dim]")
|
||||||
|
console.print(" [dim]drone @backup share report.pdf --public[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def discover_modules() -> list[Any]:
|
def discover_modules() -> list[Any]:
|
||||||
|
|||||||
@@ -7,7 +7,16 @@
|
|||||||
**Version:** 2.1.0
|
**Version:** 2.1.0
|
||||||
**Seedgo:** 99%
|
**Seedgo:** 99%
|
||||||
**Tests:** 127 passing (5 files)
|
**Tests:** 127 passing (5 files)
|
||||||
**Last Updated:** 2026-05-16
|
**Last Updated:** 2026-07-17
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone @cli # Show discovered modules
|
||||||
|
drone @cli display demo # Run display function showcase
|
||||||
|
drone @cli templates demo # Run operation template showcase
|
||||||
|
drone @cli --help # Full usage guide
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
+87
-78
@@ -37,7 +37,7 @@ from rich.panel import Panel
|
|||||||
from rich import box
|
from rich import box
|
||||||
|
|
||||||
# CLI modules (showcasing our own services!)
|
# CLI modules (showcasing our own services!)
|
||||||
from aipass.cli.apps.modules.display import console as CONSOLE, header, error
|
from aipass.cli.apps.modules.display import console, header, error
|
||||||
|
|
||||||
VERSION = "2.1.0"
|
VERSION = "2.1.0"
|
||||||
CLI_ROOT = Path(__file__).parent
|
CLI_ROOT = Path(__file__).parent
|
||||||
@@ -110,37 +110,37 @@ def print_introspection() -> None:
|
|||||||
command_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] not in SERVICE_MODULES]
|
command_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] not in SERVICE_MODULES]
|
||||||
service_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] in SERVICE_MODULES]
|
service_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] in SERVICE_MODULES]
|
||||||
|
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]")
|
console.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]")
|
||||||
CONSOLE.print(f" Version: {VERSION}")
|
console.print(f" Version: {VERSION}")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("[dim]Universal Display & Output Service Provider[/dim]")
|
console.print("[dim]Universal Display & Output Service Provider[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Discovered command modules
|
# Discovered command modules
|
||||||
CONSOLE.print(f"[yellow]Discovered Modules:[/yellow] {len(command_modules)}")
|
console.print(f"[yellow]Discovered Modules:[/yellow] {len(command_modules)}")
|
||||||
for module in command_modules:
|
for module in command_modules:
|
||||||
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
||||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||||
CONSOLE.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
console.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||||
if not command_modules:
|
if not command_modules:
|
||||||
CONSOLE.print(" [dim]No command modules discovered[/dim]")
|
console.print(" [dim]No command modules discovered[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Service modules (import-only, but with utility commands)
|
# Service modules (import-only, but with utility commands)
|
||||||
if service_modules:
|
if service_modules:
|
||||||
CONSOLE.print(f"[yellow]Services:[/yellow] {len(service_modules)}")
|
console.print(f"[yellow]Services:[/yellow] {len(service_modules)}")
|
||||||
for module in service_modules:
|
for module in service_modules:
|
||||||
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
||||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||||
CONSOLE.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
console.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[yellow]Next:[/yellow] Explore a module")
|
console.print("[yellow]Next:[/yellow] Explore a module")
|
||||||
CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
console.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display showcase[/dim]")
|
console.print(" [green]drone @cli display demo[/green] [dim]# Run display showcase[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli --help[/green] [dim]# Full usage guide[/dim]")
|
console.print(" [green]drone @cli --help[/green] [dim]# Full usage guide[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def print_help() -> None:
|
def print_help() -> None:
|
||||||
@@ -149,45 +149,54 @@ def print_help() -> None:
|
|||||||
Shows COMMANDS, EXAMPLES, full reference.
|
Shows COMMANDS, EXAMPLES, full reference.
|
||||||
Follows seedgo help pattern.
|
Follows seedgo help pattern.
|
||||||
"""
|
"""
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
header("CLI - Display & Templates Service Provider")
|
header("CLI - Display & Templates Service Provider")
|
||||||
|
|
||||||
CONSOLE.print("[dim]Universal display and output formatting for all AIPass branches[/dim]")
|
console.print("[dim]Universal display and output formatting for all AIPass branches[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [dim]drone @cli <command> [args...][/dim]")
|
||||||
|
console.print(" [dim]drone @cli --help[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print("\u2500" * 70)
|
||||||
|
console.print()
|
||||||
|
|
||||||
# What is CLI
|
# What is CLI
|
||||||
CONSOLE.print("[bold cyan]WHAT IS CLI?[/bold cyan]")
|
console.print("[bold cyan]WHAT IS CLI?[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("CLI is the [bold]Display & Templates Service[/bold] - like Prax for logging:")
|
console.print("CLI is the [bold]Display & Templates Service[/bold] - like Prax for logging:")
|
||||||
CONSOLE.print(" [green]\u2713[/green] Centralized display formatting (headers, tables, panels)")
|
console.print(" [green]\u2713[/green] Centralized display formatting (headers, tables, panels)")
|
||||||
CONSOLE.print(" [green]\u2713[/green] Reusable templates for common operations")
|
console.print(" [green]\u2713[/green] Reusable templates for common operations")
|
||||||
CONSOLE.print(" [green]\u2713[/green] Rich library integration for beautiful output")
|
console.print(" [green]\u2713[/green] Rich library integration for beautiful output")
|
||||||
CONSOLE.print(" [green]\u2713[/green] Consistent styling across all AIPass branches")
|
console.print(" [green]\u2713[/green] Consistent styling across all AIPass branches")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("Update CLI once \u2192 All branches instantly benefit from improvements")
|
console.print("Update CLI once \u2192 All branches instantly benefit from improvements")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Commands
|
# Commands
|
||||||
CONSOLE.print("[bold cyan]COMMANDS:[/bold cyan]")
|
console.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print(" [green]drone @cli[/green] [dim]# Show discovered modules[/dim]")
|
console.print(" [green]drone @cli[/green] [dim]# Show discovered modules[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
console.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display demo[/dim]")
|
console.print(" [green]drone @cli display demo[/green] [dim]# Run display demo[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli templates[/green] [dim]# Templates module info[/dim]")
|
console.print(" [green]drone @cli templates[/green] [dim]# Templates module info[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli templates demo[/green] [dim]# Run templates demo[/dim]")
|
console.print(" [green]drone @cli templates demo[/green] [dim]# Run templates demo[/dim]")
|
||||||
CONSOLE.print(" [green]drone @cli --help[/green] [dim]# This help message[/dim]")
|
console.print(" [green]drone @cli --help[/green] [dim]# This help message[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Public services
|
# Public services
|
||||||
CONSOLE.print("[bold cyan]PUBLIC SERVICES (apps/modules/):[/bold cyan]")
|
console.print("[bold cyan]PUBLIC SERVICES (apps/modules/):[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
services_table = Table(show_header=True, header_style="bold cyan", border_style="dim")
|
services_table = Table(show_header=True, header_style="bold cyan", border_style="dim")
|
||||||
services_table.add_column("Module", style="green")
|
services_table.add_column("Module", style="green")
|
||||||
@@ -199,34 +208,34 @@ def print_help() -> None:
|
|||||||
)
|
)
|
||||||
services_table.add_row("templates", "operation_start(), operation_complete()", "Standard operation patterns")
|
services_table.add_row("templates", "operation_start(), operation_complete()", "Standard operation patterns")
|
||||||
|
|
||||||
CONSOLE.print(services_table)
|
console.print(services_table)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Import examples
|
# Import examples
|
||||||
CONSOLE.print("[bold cyan]HOW TO IMPORT CLI SERVICES:[/bold cyan]")
|
console.print("[bold cyan]HOW TO IMPORT CLI SERVICES:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[yellow]Display functions:[/yellow]")
|
console.print("[yellow]Display functions:[/yellow]")
|
||||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.display import header, success, error, warning[/dim]")
|
console.print("[dim] from aipass.cli.apps.modules.display import header, success, error, warning[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[yellow]Templates:[/yellow]")
|
console.print("[yellow]Templates:[/yellow]")
|
||||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.templates import operation_start, operation_complete[/dim]")
|
console.print("[dim] from aipass.cli.apps.modules.templates import operation_start, operation_complete[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("[yellow]Rich console:[/yellow]")
|
console.print("[yellow]Rich console:[/yellow]")
|
||||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.display import console[/dim]")
|
console.print("[dim] from aipass.cli.apps.modules.display import console[/dim]")
|
||||||
CONSOLE.print("[dim] console.print('[bold]Hello[/bold]') # Rich formatted output[/dim]")
|
console.print("[dim] console.print('[bold]Hello[/bold]') # Rich formatted output[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
CONSOLE.print("[bold cyan]ARCHITECTURE:[/bold cyan]")
|
console.print("[bold cyan]ARCHITECTURE:[/bold cyan]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
arch_text = """[bold]CLI Branch Structure:[/bold]
|
arch_text = """[bold]CLI Branch Structure:[/bold]
|
||||||
|
|
||||||
@@ -240,19 +249,19 @@ def print_help() -> None:
|
|||||||
[green]\u2713[/green] Rich library = Underlying formatting engine
|
[green]\u2713[/green] Rich library = Underlying formatting engine
|
||||||
- Console, Table, Panel, Columns, Text styling"""
|
- Console, Table, Panel, Columns, Text styling"""
|
||||||
|
|
||||||
CONSOLE.print(Panel(arch_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
|
console.print(Panel(arch_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
CONSOLE.print("\u2500" * 70)
|
console.print("\u2500" * 70)
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
# Drone compliance — commands line
|
# Drone compliance — commands line
|
||||||
CONSOLE.print("[dim]Commands: display, templates, demo, --help[/dim]")
|
console.print("[dim]Commands: display, templates, demo, --help[/dim]")
|
||||||
CONSOLE.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def show_version():
|
def show_version():
|
||||||
"""Print version."""
|
"""Print version."""
|
||||||
CONSOLE.print(f"CLI v{VERSION}")
|
console.print(f"CLI v{VERSION}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -306,9 +315,9 @@ if __name__ == "__main__":
|
|||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.warning("CLI interrupted by user")
|
logger.warning("CLI interrupted by user")
|
||||||
CONSOLE.print("\n[yellow]Operation cancelled[/yellow]")
|
console.print("\n[yellow]Operation cancelled[/yellow]")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"CLI error: {e}", exc_info=True)
|
logger.error(f"CLI error: {e}", exc_info=True)
|
||||||
CONSOLE.print(f"\n[red]Error: {e}[/red]")
|
error(str(e))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class TestMainFlow:
|
|||||||
"""No args shows introspection and returns 0."""
|
"""No args shows introspection and returns 0."""
|
||||||
cons, _get_output = _make_capture_console()
|
cons, _get_output = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch("sys.argv", ["cli"]),
|
patch("sys.argv", ["cli"]),
|
||||||
):
|
):
|
||||||
@@ -63,7 +63,7 @@ class TestMainFlow:
|
|||||||
cons, _get_output = _make_capture_console()
|
cons, _get_output = _make_capture_console()
|
||||||
err_cons, _get_err = _make_capture_console()
|
err_cons, _get_err = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch.object(display, "err_console", err_cons),
|
patch.object(display, "err_console", err_cons),
|
||||||
patch.object(display, "_TRIGGER", None),
|
patch.object(display, "_TRIGGER", None),
|
||||||
@@ -77,7 +77,7 @@ class TestMainFlow:
|
|||||||
"""--version returns 0."""
|
"""--version returns 0."""
|
||||||
cons, get_output = _make_capture_console()
|
cons, get_output = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch("sys.argv", ["cli", "--version"]),
|
patch("sys.argv", ["cli", "--version"]),
|
||||||
):
|
):
|
||||||
@@ -91,7 +91,7 @@ class TestMainFlow:
|
|||||||
cons, _get_output = _make_capture_console()
|
cons, _get_output = _make_capture_console()
|
||||||
err_cons, get_err = _make_capture_console()
|
err_cons, get_err = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch.object(display, "err_console", err_cons),
|
patch.object(display, "err_console", err_cons),
|
||||||
patch("sys.argv", ["cli", "nonexistent_cmd_xyz"]),
|
patch("sys.argv", ["cli", "nonexistent_cmd_xyz"]),
|
||||||
@@ -106,7 +106,7 @@ class TestMainFlow:
|
|||||||
cons, _get_output = _make_capture_console()
|
cons, _get_output = _make_capture_console()
|
||||||
err_cons, _get_err = _make_capture_console()
|
err_cons, _get_err = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch.object(display, "err_console", err_cons),
|
patch.object(display, "err_console", err_cons),
|
||||||
patch.object(display, "_TRIGGER", None),
|
patch.object(display, "_TRIGGER", None),
|
||||||
@@ -125,7 +125,7 @@ class TestMainFlow:
|
|||||||
"""cli_entry() is the console_scripts entry point — verify it's callable."""
|
"""cli_entry() is the console_scripts entry point — verify it's callable."""
|
||||||
cons, _get_output = _make_capture_console()
|
cons, _get_output = _make_capture_console()
|
||||||
with (
|
with (
|
||||||
patch.object(cli_module, "CONSOLE", cons),
|
patch.object(cli_module, "console", cons),
|
||||||
patch.object(display, "CONSOLE", cons),
|
patch.object(display, "CONSOLE", cons),
|
||||||
patch("sys.argv", ["aipass", "--version"]),
|
patch("sys.argv", ["aipass", "--version"]),
|
||||||
pytest.raises(SystemExit) as exc_info,
|
pytest.raises(SystemExit) as exc_info,
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ def _validate_metadata(metadata_str: str) -> Optional[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_branch_name(mention: str) -> Optional[str]:
|
def _resolve_branch_name(mention: str) -> Optional[str]:
|
||||||
"""Resolve a @mention to a branch name."""
|
"""Resolve a @mention to a branch name (lowercase-normalized)."""
|
||||||
name = mention.lstrip("@").upper()
|
name = mention.lstrip("@").lower()
|
||||||
|
|
||||||
if not os.path.exists(BRANCH_REGISTRY_PATH):
|
if not os.path.exists(BRANCH_REGISTRY_PATH):
|
||||||
return None
|
return None
|
||||||
@@ -85,7 +85,7 @@ def _resolve_branch_name(mention: str) -> Optional[str]:
|
|||||||
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
|
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
|
||||||
registry = json.load(f)
|
registry = json.load(f)
|
||||||
for branch in registry.get("branches", []):
|
for branch in registry.get("branches", []):
|
||||||
if branch.get("name") == name:
|
if branch.get("name", "").lower() == name:
|
||||||
return name
|
return name
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ RARITY_COLORS = {
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_branch_name(mention: str) -> Optional[str]:
|
def _resolve_branch_name(mention: str) -> Optional[str]:
|
||||||
"""Resolve a @mention to a branch name."""
|
"""Resolve a @mention to a branch name (lowercase-normalized)."""
|
||||||
name = mention.lstrip("@").upper()
|
name = mention.lstrip("@").lower()
|
||||||
|
|
||||||
if not os.path.exists(BRANCH_REGISTRY_PATH):
|
if not os.path.exists(BRANCH_REGISTRY_PATH):
|
||||||
return None
|
return None
|
||||||
@@ -64,7 +64,7 @@ def _resolve_branch_name(mention: str) -> Optional[str]:
|
|||||||
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
|
with open(BRANCH_REGISTRY_PATH, encoding="utf-8") as f:
|
||||||
registry = json.load(f)
|
registry = json.load(f)
|
||||||
for branch in registry.get("branches", []):
|
for branch in registry.get("branches", []):
|
||||||
if branch.get("name") == name:
|
if branch.get("name", "").lower() == name:
|
||||||
return name
|
return name
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def show_profile(args: List[str]) -> dict:
|
|||||||
|
|
||||||
# Determine which branch to show
|
# Determine which branch to show
|
||||||
if args:
|
if args:
|
||||||
target_branch = args[0].upper()
|
target_branch = args[0].lower()
|
||||||
else:
|
else:
|
||||||
caller = get_caller_branch()
|
caller = get_caller_branch()
|
||||||
if not caller:
|
if not caller:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def _parse_search_args(args: List[str]) -> dict:
|
|||||||
result["room"] = remaining[i + 1].lower()
|
result["room"] = remaining[i + 1].lower()
|
||||||
i += 2
|
i += 2
|
||||||
elif flag == "--author" and i + 1 < len(remaining):
|
elif flag == "--author" and i + 1 < len(remaining):
|
||||||
result["author"] = remaining[i + 1].upper()
|
result["author"] = remaining[i + 1].lower()
|
||||||
i += 2
|
i += 2
|
||||||
elif flag == "--type" and i + 1 < len(remaining):
|
elif flag == "--type" and i + 1 < len(remaining):
|
||||||
search_type = remaining[i + 1].lower()
|
search_type = remaining[i + 1].lower()
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def run_welcome(args: List[str]) -> dict:
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
|
||||||
if filtered_args:
|
if filtered_args:
|
||||||
branch_name = filtered_args[0].upper()
|
branch_name = filtered_args[0].lower()
|
||||||
if dry_run:
|
if dry_run:
|
||||||
already = has_been_welcomed(conn, branch_name)
|
already = has_been_welcomed(conn, branch_name)
|
||||||
close_db(conn)
|
close_db(conn)
|
||||||
@@ -114,7 +114,7 @@ def _welcome_specific(conn, branch_name: str) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with success and welcome result
|
Dict with success and welcome result
|
||||||
"""
|
"""
|
||||||
agent = conn.execute("SELECT branch_name FROM agents WHERE branch_name = ?", (branch_name,)).fetchone()
|
agent = conn.execute("SELECT branch_name FROM agents WHERE LOWER(branch_name) = ?", (branch_name,)).fetchone()
|
||||||
|
|
||||||
if not agent:
|
if not agent:
|
||||||
return {"success": False, "error": f"Branch '{branch_name}' not found in The Commons."}
|
return {"success": False, "error": f"Branch '{branch_name}' not found in The Commons."}
|
||||||
|
|||||||
@@ -993,7 +993,7 @@ def test_run_welcome_dry_run_specific_branch(
|
|||||||
"""run_welcome <branch> --dry-run reports whether the branch would be welcomed."""
|
"""run_welcome <branch> --dry-run reports whether the branch would be welcomed."""
|
||||||
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
|
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
|
||||||
|
|
||||||
_seed_agent(initialized_db, "GAMMA")
|
_seed_agent(initialized_db, "gamma")
|
||||||
|
|
||||||
mock_get_db.return_value = initialized_db
|
mock_get_db.return_value = initialized_db
|
||||||
mock_close.side_effect = lambda c: None
|
mock_close.side_effect = lambda c: None
|
||||||
@@ -1001,7 +1001,7 @@ def test_run_welcome_dry_run_specific_branch(
|
|||||||
result = run_welcome(["gamma", "--dry-run"])
|
result = run_welcome(["gamma", "--dry-run"])
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["dry_run"] is True
|
assert result["dry_run"] is True
|
||||||
assert result["branch"] == "GAMMA"
|
assert result["branch"] == "gamma"
|
||||||
assert result["would_welcome"] is True
|
assert result["would_welcome"] is True
|
||||||
|
|
||||||
|
|
||||||
@@ -1076,7 +1076,7 @@ def test_run_welcome_specific_branch_success(
|
|||||||
"""run_welcome <branch> creates a welcome post for that branch."""
|
"""run_welcome <branch> creates a welcome post for that branch."""
|
||||||
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
|
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
|
||||||
|
|
||||||
_seed_agent(initialized_db, "EPSILON")
|
_seed_agent(initialized_db, "epsilon")
|
||||||
|
|
||||||
mock_get_db.return_value = initialized_db
|
mock_get_db.return_value = initialized_db
|
||||||
mock_close.side_effect = lambda c: None
|
mock_close.side_effect = lambda c: None
|
||||||
@@ -1085,7 +1085,7 @@ def test_run_welcome_specific_branch_success(
|
|||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["action"] == "specific"
|
assert result["action"] == "specific"
|
||||||
assert result["already_welcomed"] is False
|
assert result["already_welcomed"] is False
|
||||||
assert result["branch"] == "EPSILON"
|
assert result["branch"] == "epsilon"
|
||||||
assert result["post_id"] is not None
|
assert result["post_id"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ def test_parse_search_args_room_flag():
|
|||||||
|
|
||||||
|
|
||||||
def test_parse_search_args_author_flag():
|
def test_parse_search_args_author_flag():
|
||||||
"""The --author flag should set the author filter and uppercase it."""
|
"""The --author flag should set the author filter and lowercase it."""
|
||||||
result = _parse_search_args(["test", "--author", "drone"])
|
result = _parse_search_args(["test", "--author", "drone"])
|
||||||
assert result["query"] == "test"
|
assert result["query"] == "test"
|
||||||
assert result["author"] == "DRONE"
|
assert result["author"] == "drone"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_search_args_type_flag_valid():
|
def test_parse_search_args_type_flag_valid():
|
||||||
@@ -94,7 +94,7 @@ def test_parse_search_args_all_flags():
|
|||||||
)
|
)
|
||||||
assert result["query"] == "registry"
|
assert result["query"] == "registry"
|
||||||
assert result["room"] == "dev"
|
assert result["room"] == "dev"
|
||||||
assert result["author"] == "FLOW"
|
assert result["author"] == "flow"
|
||||||
assert result["search_type"] == "posts"
|
assert result["search_type"] == "posts"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,20 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone @daemon # Show discovered modules
|
||||||
|
drone @daemon update # Status digest
|
||||||
|
drone @daemon activity # Quick 24h activity summary
|
||||||
|
drone @daemon queue # View pending scheduled jobs
|
||||||
|
drone @daemon run # Fire all due jobs now
|
||||||
|
drone @daemon branch-health DAEMON # Deep dive on a branch
|
||||||
|
drone @daemon install-timer # Enable systemd 2-min timer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Builder citizen -- full 3-layer architecture with identity and memory. DAEMON serves as the background orchestration branch: it discovers modules at startup, routes CLI commands to them, and provides introspection and help output via Rich console.
|
Builder citizen -- full 3-layer architecture with identity and memory. DAEMON serves as the background orchestration branch: it discovers modules at startup, routes CLI commands to them, and provides introspection and help output via Rich console.
|
||||||
|
|||||||
@@ -162,8 +162,22 @@ def print_help(modules: List[Any]):
|
|||||||
console.print("-" * 70)
|
console.print("-" * 70)
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [yellow]Status & monitoring:[/yellow]")
|
||||||
|
console.print(" [dim]drone @daemon update[/dim] [dim]# Status digest[/dim]")
|
||||||
|
console.print(" [dim]drone @daemon activity[/dim] [dim]# Quick 24h summary[/dim]")
|
||||||
|
console.print(" [dim]drone @daemon activity-report --json[/dim] [dim]# Full report (raw)[/dim]")
|
||||||
|
console.print(" [dim]drone @daemon branch-health DAEMON[/dim] [dim]# Single branch dive[/dim]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [yellow]Scheduler:[/yellow]")
|
||||||
|
console.print(" [dim]drone @daemon queue[/dim] [dim]# View pending jobs[/dim]")
|
||||||
|
console.print(" [dim]drone @daemon run[/dim] [dim]# Fire due jobs now[/dim]")
|
||||||
|
console.print(" [dim]drone @daemon install-timer[/dim] [dim]# Enable systemd timer[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
console.print("[bold]TIP:[/bold] For module-specific help:")
|
console.print("[bold]TIP:[/bold] For module-specific help:")
|
||||||
console.print(" [dim]daemon <command> --help[/dim]")
|
console.print(" [dim]drone @daemon <command> --help[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"autoCompactWindow": 350000,
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [],
|
"allow": [],
|
||||||
"deny": [
|
"deny": [
|
||||||
|
|||||||
@@ -70,71 +70,6 @@
|
|||||||
"file": "tests/test_owner_guard.py",
|
"file": "tests/test_owner_guard.py",
|
||||||
"reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681."
|
"reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"standard": "encapsulation",
|
|
||||||
"file": "tools/spot_check.py",
|
|
||||||
"reason": "Standalone diagnostic tool script, not a handler."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "debug_print",
|
|
||||||
"file": "tools/spot_check.py",
|
|
||||||
"reason": "Standalone CLI tool — print() is the intended output method."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "silent_catch",
|
|
||||||
"file": "tools/hook_engine_poc/engine.py",
|
|
||||||
"reason": "POC hook engine — stdlib only, no prax logger. Uses sys.stderr for error reporting."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "error_handling",
|
|
||||||
"file": "tools/hook_engine_poc/engine.py",
|
|
||||||
"reason": "POC hook engine — exception handlers write to stderr, not prax logger."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "silent_catch",
|
|
||||||
"file": "tools/hook_engine_poc/test_engine.py",
|
|
||||||
"reason": "POC test harness — catches test exceptions to report pass/fail, writes to stderr."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "imports",
|
|
||||||
"file": "tools/hook_engine_poc/test_engine.py",
|
|
||||||
"reason": "POC test harness — sys.path needed to import engine.py from same directory."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "trigger",
|
|
||||||
"file": "tools/hook_engine_poc/test_engine.py",
|
|
||||||
"reason": "POC test harness — .unlink() clears ephemeral JSONL log between tests, not a tracked file."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "help_text",
|
|
||||||
"file": "tools/hook_engine_poc/test_engine.py",
|
|
||||||
"reason": "POC test harness — usage example in docstring."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "debug_print",
|
|
||||||
"file": "tools/rm_shim/redteam_suite.py",
|
|
||||||
"reason": "Standalone red-team diagnostic runner (FPLAN-0250 Phase 6) — print() IS the report output, same as broker_acceptance_test.py."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "encapsulation",
|
|
||||||
"file": "tools/rm_shim/redteam_suite.py",
|
|
||||||
"reason": "Red-team tool imports the real broker daemon/client + sandbox module directly to exercise them under live conditions — that is the point of an integration probe, not a handler."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "imports",
|
|
||||||
"file": "tools/rm_shim/redteam_suite.py",
|
|
||||||
"reason": "Standalone script run via 'python tools/...' — sys.path insert lets it import the production modules it red-teams without being pip-installed."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "help_text",
|
|
||||||
"file": "tools/rm_shim/redteam_suite.py",
|
|
||||||
"reason": "Diagnostic script — docstring shows the 'python tools/...' invocation; it is not a drone-routed module."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"standard": "documentation",
|
|
||||||
"file": "tools/rm_shim/redteam_suite.py",
|
|
||||||
"reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"standard": "encapsulation",
|
"standard": "encapsulation",
|
||||||
"file": "tests/test_compass_store.py",
|
"file": "tests/test_compass_store.py",
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ DevPulse handles the day-to-day: working with the user to plan, design, troubles
|
|||||||
| Active plans | `drone @flow list open` |
|
| Active plans | `drone @flow list open` |
|
||||||
| Branch list | `drone systems` |
|
| Branch list | `drone systems` |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Talk to the hub — it picks up where the last session left off
|
||||||
|
cd src/aipass/devpulse
|
||||||
|
claude
|
||||||
|
|
||||||
|
# Or drive it via drone from anywhere in AIPass
|
||||||
|
drone @devpulse compass query "registry" # search rated decisions
|
||||||
|
drone @devpulse feedback inbox # cross-project feedback
|
||||||
|
```
|
||||||
|
|
||||||
## Invoke
|
## Invoke
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -44,7 +56,7 @@ src/aipass/devpulse/
|
|||||||
│ │ └── watchdog/ # Agent, timer, schedule, registry
|
│ │ └── watchdog/ # Agent, timer, schedule, registry
|
||||||
│ └── plugins/ # Plugin extension point
|
│ └── plugins/ # Plugin extension point
|
||||||
├── devpulse_json/ # JSON handler storage (config, data, logs per module)
|
├── devpulse_json/ # JSON handler storage (config, data, logs per module)
|
||||||
├── tests/ # 309 tests
|
├── tests/ # 348 tests
|
||||||
├── artifacts/ # Birth certificate, reports
|
├── artifacts/ # Birth certificate, reports
|
||||||
├── dropbox/ # Received files, archived plans, install audit
|
├── dropbox/ # Received files, archived plans, install audit
|
||||||
├── docs/ # Transition notes
|
├── docs/ # Transition notes
|
||||||
|
|||||||
@@ -89,11 +89,14 @@ def print_introspection():
|
|||||||
"""Print branch introspection — discovered modules and capabilities."""
|
"""Print branch introspection — discovered modules and capabilities."""
|
||||||
modules = discover_modules()
|
modules = discover_modules()
|
||||||
console.print("[bold cyan]DEVPULSE[/bold cyan] — Orchestration Hub")
|
console.print("[bold cyan]DEVPULSE[/bold cyan] — Orchestration Hub")
|
||||||
|
console.print("[dim]The user's primary collaborator — design, plan, dispatch, track[/dim]")
|
||||||
console.print(f" Modules discovered: {len(modules)}")
|
console.print(f" Modules discovered: {len(modules)}")
|
||||||
for module in modules:
|
for module in modules:
|
||||||
name = module.__name__.split(".")[-1]
|
name = module.__name__.split(".")[-1]
|
||||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||||
console.print(f" {name:20} {desc}")
|
console.print(f" {name:20} {desc}")
|
||||||
|
console.print()
|
||||||
|
console.print("Run 'drone @devpulse --help' for usage information")
|
||||||
|
|
||||||
|
|
||||||
def print_help():
|
def print_help():
|
||||||
@@ -112,6 +115,11 @@ def print_help():
|
|||||||
console.print("[bold]FLAGS:[/bold]")
|
console.print("[bold]FLAGS:[/bold]")
|
||||||
console.print(" --help, -h Show this help message")
|
console.print(" --help, -h Show this help message")
|
||||||
console.print(" --version, -V Show version")
|
console.print(" --version, -V Show version")
|
||||||
|
console.print()
|
||||||
|
console.print("[bold]EXAMPLES:[/bold]")
|
||||||
|
console.print(' drone @devpulse compass query "registry" Search rated decisions')
|
||||||
|
console.print(" drone @devpulse watchdog agent @flow Watch a dispatched agent")
|
||||||
|
console.print(" drone @devpulse feedback inbox Check cross-project feedback")
|
||||||
|
|
||||||
|
|
||||||
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ from aipass.devpulse.apps.handlers.compass.store import (
|
|||||||
VALID_STATUSES,
|
VALID_STATUSES,
|
||||||
add_decision,
|
add_decision,
|
||||||
archive,
|
archive,
|
||||||
|
find_conflicts,
|
||||||
|
mark_surfaced,
|
||||||
query_decisions,
|
query_decisions,
|
||||||
rate,
|
rate,
|
||||||
|
recall_decisions,
|
||||||
review,
|
review,
|
||||||
|
set_note,
|
||||||
stats,
|
stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,8 +40,12 @@ __all__ = [
|
|||||||
"VALID_STATUSES",
|
"VALID_STATUSES",
|
||||||
"add_decision",
|
"add_decision",
|
||||||
"archive",
|
"archive",
|
||||||
|
"find_conflicts",
|
||||||
|
"mark_surfaced",
|
||||||
"query_decisions",
|
"query_decisions",
|
||||||
"rate",
|
"rate",
|
||||||
|
"recall_decisions",
|
||||||
"review",
|
"review",
|
||||||
|
"set_note",
|
||||||
"stats",
|
"stats",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ command, and maintenance UX are later phases.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -57,6 +58,9 @@ VALID_SOURCES = ("devpulse", "user")
|
|||||||
VALID_STATUSES = ("active", "archived")
|
VALID_STATUSES = ("active", "archived")
|
||||||
|
|
||||||
# Columns we return / surface from the decisions table (everything useful).
|
# Columns we return / surface from the decisions table (everything useful).
|
||||||
|
# NOTE: ``score`` is deliberately absent — it is code-invisible (DPLAN-0246
|
||||||
|
# seedgo ruling). The column stays physically on disk as inert NULL, but no
|
||||||
|
# Python surface (SELECTs, returned dicts) touches it.
|
||||||
_DECISION_COLUMNS = (
|
_DECISION_COLUMNS = (
|
||||||
"id",
|
"id",
|
||||||
"created",
|
"created",
|
||||||
@@ -66,10 +70,10 @@ _DECISION_COLUMNS = (
|
|||||||
"note",
|
"note",
|
||||||
"tags",
|
"tags",
|
||||||
"source",
|
"source",
|
||||||
"score",
|
|
||||||
"status",
|
"status",
|
||||||
"last_reviewed",
|
"last_reviewed",
|
||||||
"times_surfaced",
|
"times_surfaced",
|
||||||
|
"supersedes",
|
||||||
)
|
)
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA = """
|
||||||
@@ -85,7 +89,8 @@ CREATE TABLE IF NOT EXISTS decisions (
|
|||||||
score INTEGER,
|
score INTEGER,
|
||||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')),
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')),
|
||||||
last_reviewed TEXT,
|
last_reviewed TEXT,
|
||||||
times_surfaced INTEGER NOT NULL DEFAULT 0
|
times_surfaced INTEGER NOT NULL DEFAULT 0,
|
||||||
|
supersedes INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts USING fts5(
|
||||||
@@ -133,6 +138,42 @@ def _verify_fts5(conn: sqlite3.Connection) -> None:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Apply idempotent schema migrations to an already-open DB.
|
||||||
|
|
||||||
|
Adds the ``supersedes`` column to DBs created before it existed. Guarded by
|
||||||
|
a ``PRAGMA table_info`` pre-check so it is safe to run on every connect —
|
||||||
|
never a blind ``ALTER`` (DPLAN-0246 seedgo ruling: idempotent migration).
|
||||||
|
Fresh DBs already carry the column from ``_SCHEMA``; the pre-check makes
|
||||||
|
this a no-op for them.
|
||||||
|
"""
|
||||||
|
cols = {row["name"] for row in conn.execute("PRAGMA table_info(decisions)")}
|
||||||
|
if "supersedes" not in cols:
|
||||||
|
conn.execute("ALTER TABLE decisions ADD COLUMN supersedes INTEGER")
|
||||||
|
conn.commit()
|
||||||
|
logger.info("[compass] migration: added supersedes column")
|
||||||
|
|
||||||
|
|
||||||
|
# FTS5 MATCH treats characters like " * ( ) : - ^ and the words AND/OR/NOT as
|
||||||
|
# syntax. Untrusted text (a decision's own words) can therefore crash MATCH.
|
||||||
|
# We defuse it by extracting bare word tokens and OR-ing them as quoted string
|
||||||
|
# literals — no operator can survive, and quoting a bareword is exact.
|
||||||
|
_FTS_WORD = re.compile(r"\w+", re.UNICODE)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_fts_query(text: Optional[str]) -> Optional[str]:
|
||||||
|
"""Turn arbitrary text into a safe FTS5 MATCH expression, or None.
|
||||||
|
|
||||||
|
Returns an ``OR`` of the text's word tokens, each quoted as a string
|
||||||
|
literal so FTS5 syntax characters can never reach the parser. Returns None
|
||||||
|
when there are no usable tokens (caller should skip the search).
|
||||||
|
"""
|
||||||
|
tokens = _FTS_WORD.findall(text or "")
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
return " OR ".join(f'"{t}"' for t in tokens)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_db_path(db_path: Optional[Path | str]) -> Path:
|
def _resolve_db_path(db_path: Optional[Path | str]) -> Path:
|
||||||
"""Resolve the effective DB path, defaulting to the branch-root location."""
|
"""Resolve the effective DB path, defaulting to the branch-root location."""
|
||||||
return Path(db_path) if db_path is not None else DEFAULT_DB_PATH
|
return Path(db_path) if db_path is not None else DEFAULT_DB_PATH
|
||||||
@@ -141,8 +182,9 @@ def _resolve_db_path(db_path: Optional[Path | str]) -> Path:
|
|||||||
def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection:
|
def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection:
|
||||||
"""Open (and lazily initialise) the compass DB.
|
"""Open (and lazily initialise) the compass DB.
|
||||||
|
|
||||||
Creates parent directories on first use, verifies FTS5, ensures schema.
|
Creates parent directories on first use, verifies FTS5, ensures schema, and
|
||||||
Rows come back as ``sqlite3.Row`` so we can build clean dicts.
|
runs idempotent migrations. Rows come back as ``sqlite3.Row`` so we can
|
||||||
|
build clean dicts.
|
||||||
"""
|
"""
|
||||||
path = _resolve_db_path(db_path)
|
path = _resolve_db_path(db_path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -151,6 +193,7 @@ def _connect(db_path: Optional[Path | str]) -> sqlite3.Connection:
|
|||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
_verify_fts5(conn)
|
_verify_fts5(conn)
|
||||||
conn.executescript(_SCHEMA)
|
conn.executescript(_SCHEMA)
|
||||||
|
_migrate(conn)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
@@ -168,6 +211,7 @@ def add_decision(
|
|||||||
source: str = "devpulse",
|
source: str = "devpulse",
|
||||||
db_path: Optional[Path | str] = None,
|
db_path: Optional[Path | str] = None,
|
||||||
created: Optional[str] = None,
|
created: Optional[str] = None,
|
||||||
|
supersedes: Optional[int] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Add a rated decision and return its new id.
|
"""Add a rated decision and return its new id.
|
||||||
|
|
||||||
@@ -181,12 +225,16 @@ def add_decision(
|
|||||||
db_path: Optional DB path override (tests pass a temp path).
|
db_path: Optional DB path override (tests pass a temp path).
|
||||||
created: Optional ISO date override; defaults to today. This is the
|
created: Optional ISO date override; defaults to today. This is the
|
||||||
ONLY place a "today" date is stamped.
|
ONLY place a "today" date is stamped.
|
||||||
|
supersedes: Optional id of the decision this entry corrects. When set,
|
||||||
|
the new entry links to it AND that entry is archived — atomically,
|
||||||
|
in one transaction. Errors cleanly (no write) if the id is unknown.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The new row's integer id.
|
The new row's integer id.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: On empty context/decision or invalid rating/source.
|
ValueError: On empty context/decision, invalid rating/source, or a
|
||||||
|
``supersedes`` target that does not exist.
|
||||||
"""
|
"""
|
||||||
if not context or not context.strip():
|
if not context or not context.strip():
|
||||||
raise ValueError("context must be a non-empty string")
|
raise ValueError("context must be a non-empty string")
|
||||||
@@ -201,22 +249,46 @@ def add_decision(
|
|||||||
|
|
||||||
conn = _connect(db_path)
|
conn = _connect(db_path)
|
||||||
try:
|
try:
|
||||||
|
# Validate the supersede target BEFORE any write so a bad id never
|
||||||
|
# leaves a partial insert behind.
|
||||||
|
if supersedes is not None:
|
||||||
|
target = conn.execute("SELECT 1 FROM decisions WHERE id = ?", (supersedes,)).fetchone()
|
||||||
|
if target is None:
|
||||||
|
raise ValueError(f"cannot supersede #{supersedes}: no decision with that id")
|
||||||
|
|
||||||
|
# Insert + archive-the-target in ONE transaction (commit once at the
|
||||||
|
# end); any failure rolls the whole thing back — never half-applied.
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO decisions (created, context, decision, rating, note, tags, source)
|
INSERT INTO decisions (created, context, decision, rating, note, tags, source, supersedes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(stamp, context.strip(), decision.strip(), rating, note, tags, source),
|
(stamp, context.strip(), decision.strip(), rating, note, tags, source, supersedes),
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
if cur.lastrowid is None: # pragma: no cover - sqlite always sets this on INSERT
|
if cur.lastrowid is None: # pragma: no cover - sqlite always sets this on INSERT
|
||||||
raise RuntimeError("compass: INSERT did not return a rowid")
|
raise RuntimeError("compass: INSERT did not return a rowid")
|
||||||
new_id = int(cur.lastrowid)
|
new_id = int(cur.lastrowid)
|
||||||
|
|
||||||
|
if supersedes is not None:
|
||||||
|
conn.execute("UPDATE decisions SET status = 'archived' WHERE id = ?", (supersedes,))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
logger.info("[compass] added decision id=%s rating=%s source=%s", new_id, rating, source)
|
logger.info(
|
||||||
json_handler.log_operation("compass_add", {"id": new_id, "rating": rating, "source": source})
|
"[compass] added decision id=%s rating=%s source=%s supersedes=%s",
|
||||||
|
new_id,
|
||||||
|
rating,
|
||||||
|
source,
|
||||||
|
supersedes,
|
||||||
|
)
|
||||||
|
json_handler.log_operation(
|
||||||
|
"compass_add", {"id": new_id, "rating": rating, "source": source, "supersedes": supersedes}
|
||||||
|
)
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
|
|
||||||
@@ -224,21 +296,28 @@ def query_decisions(
|
|||||||
query: str,
|
query: str,
|
||||||
rating: Optional[str] = None,
|
rating: Optional[str] = None,
|
||||||
limit: int = 5,
|
limit: int = 5,
|
||||||
|
include_archived: bool = False,
|
||||||
db_path: Optional[Path | str] = None,
|
db_path: Optional[Path | str] = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Search active decisions, ranked by FTS5 BM25 relevance.
|
"""Search decisions, ranked by FTS5 BM25 relevance.
|
||||||
|
|
||||||
Increments ``times_surfaced`` for every returned row.
|
Increments ``times_surfaced`` for every returned row. Each result dict
|
||||||
|
carries a computed ``superseded_by`` field: the id of the row that
|
||||||
|
supersedes this one, or None. Combined with the ``supersedes`` column this
|
||||||
|
lets callers render both pointer directions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: FTS5 match query (keywords).
|
query: FTS5 match query (keywords).
|
||||||
rating: Optional exact rating filter (one of VALID_RATINGS).
|
rating: Optional exact rating filter (one of VALID_RATINGS).
|
||||||
limit: Max rows to return (default 5).
|
limit: Max rows to return (default 5).
|
||||||
|
include_archived: When True, lift the ``status = 'active'`` filter so
|
||||||
|
archived rows (the avoid-list) are searchable too. Default False —
|
||||||
|
unchanged active-only behaviour.
|
||||||
db_path: Optional DB path override.
|
db_path: Optional DB path override.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of decision dicts (most relevant first). Each dict includes the
|
A list of decision dicts (most relevant first). Each dict includes the
|
||||||
rating and all useful fields.
|
rating, all useful fields, and the computed ``superseded_by`` pointer.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: On empty query, bad rating filter, or non-positive limit.
|
ValueError: On empty query, bad rating filter, or non-positive limit.
|
||||||
@@ -256,9 +335,10 @@ def query_decisions(
|
|||||||
FROM decisions_fts f
|
FROM decisions_fts f
|
||||||
JOIN decisions d ON d.id = f.rowid
|
JOIN decisions d ON d.id = f.rowid
|
||||||
WHERE decisions_fts MATCH ?
|
WHERE decisions_fts MATCH ?
|
||||||
AND d.status = 'active'
|
|
||||||
"""
|
"""
|
||||||
params: list = [query.strip()]
|
params: list = [query.strip()]
|
||||||
|
if not include_archived:
|
||||||
|
sql += " AND d.status = 'active'"
|
||||||
if rating is not None:
|
if rating is not None:
|
||||||
sql += " AND d.rating = ?"
|
sql += " AND d.rating = ?"
|
||||||
params.append(rating)
|
params.append(rating)
|
||||||
@@ -269,24 +349,119 @@ def query_decisions(
|
|||||||
try:
|
try:
|
||||||
rows = conn.execute(sql, params).fetchall()
|
rows = conn.execute(sql, params).fetchall()
|
||||||
results = [_row_to_dict(r) for r in rows]
|
results = [_row_to_dict(r) for r in rows]
|
||||||
|
for r in results:
|
||||||
|
r["superseded_by"] = None
|
||||||
ids = [r["id"] for r in results]
|
ids = [r["id"] for r in results]
|
||||||
if ids:
|
if ids:
|
||||||
placeholders = ",".join("?" for _ in ids)
|
placeholders = ",".join("?" for _ in ids)
|
||||||
|
# Reverse-lookup: which returned rows are pointed AT by a superseder?
|
||||||
|
successors: dict = {}
|
||||||
|
for row in conn.execute(
|
||||||
|
f"SELECT id, supersedes FROM decisions WHERE supersedes IN ({placeholders})",
|
||||||
|
ids,
|
||||||
|
):
|
||||||
|
successors[row["supersedes"]] = row["id"]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"UPDATE decisions SET times_surfaced = times_surfaced + 1 WHERE id IN ({placeholders})",
|
f"UPDATE decisions SET times_surfaced = times_surfaced + 1 WHERE id IN ({placeholders})",
|
||||||
ids,
|
ids,
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
# Reflect the increment in the returned dicts without a re-query.
|
# Reflect the increment + attach the reverse pointer without re-query.
|
||||||
for r in results:
|
for r in results:
|
||||||
r["times_surfaced"] = (r["times_surfaced"] or 0) + 1
|
r["times_surfaced"] = (r["times_surfaced"] or 0) + 1
|
||||||
|
r["superseded_by"] = successors.get(r["id"])
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
logger.info("[compass] query %r rating=%s -> %d hit(s)", query, rating, len(results))
|
logger.info(
|
||||||
|
"[compass] query %r rating=%s include_archived=%s -> %d hit(s)",
|
||||||
|
query,
|
||||||
|
rating,
|
||||||
|
include_archived,
|
||||||
|
len(results),
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def find_conflicts(
|
||||||
|
context: str,
|
||||||
|
decision: str,
|
||||||
|
limit: int = 3,
|
||||||
|
db_path: Optional[Path | str] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return ACTIVE decisions whose text overlaps a would-be new entry.
|
||||||
|
|
||||||
|
A write-time, side-effect-free advisory helper: it does NOT increment
|
||||||
|
``times_surfaced`` and never writes. The combined ``context + decision``
|
||||||
|
text is sanitised (:func:`_sanitize_fts_query`) so no FTS5 syntax character
|
||||||
|
can crash the MATCH. Returns up to ``limit`` active hits by BM25 relevance,
|
||||||
|
or an empty list when the text has no usable tokens / nothing overlaps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: The would-be new entry's context.
|
||||||
|
decision: The would-be new entry's decision.
|
||||||
|
limit: Max advisory hits to return (default 3).
|
||||||
|
db_path: Optional DB path override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of active decision dicts (most relevant first), possibly empty.
|
||||||
|
"""
|
||||||
|
match = _sanitize_fts_query(f"{context or ''} {decision or ''}")
|
||||||
|
if match is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
select_cols = ", ".join(f"d.{c}" for c in _DECISION_COLUMNS)
|
||||||
|
sql = f"""
|
||||||
|
SELECT {select_cols}
|
||||||
|
FROM decisions_fts f
|
||||||
|
JOIN decisions d ON d.id = f.rowid
|
||||||
|
WHERE decisions_fts MATCH ?
|
||||||
|
AND d.status = 'active'
|
||||||
|
ORDER BY bm25(decisions_fts) ASC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(sql, (match, limit)).fetchall()
|
||||||
|
results = [_row_to_dict(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("[compass] conflict-check -> %d active hit(s)", len(results))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def set_note(
|
||||||
|
decision_id: int,
|
||||||
|
note: Optional[str],
|
||||||
|
db_path: Optional[Path | str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Set (replace) the note on an existing decision.
|
||||||
|
|
||||||
|
The FTS5 external-content ``decisions_au`` trigger re-indexes the row on
|
||||||
|
UPDATE, so the new note is immediately searchable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_id: Target decision id.
|
||||||
|
note: The note text to store (may be empty to clear).
|
||||||
|
db_path: Optional DB path override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was updated, False if no such id.
|
||||||
|
"""
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
cur = conn.execute("UPDATE decisions SET note = ? WHERE id = ?", (note, decision_id))
|
||||||
|
conn.commit()
|
||||||
|
changed = cur.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("[compass] note id=%s (changed=%s)", decision_id, changed)
|
||||||
|
json_handler.log_operation("compass_note", {"id": decision_id, "changed": changed})
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def stats(db_path: Optional[Path | str] = None) -> dict:
|
def stats(db_path: Optional[Path | str] = None) -> dict:
|
||||||
"""Return decision counts by rating, by status, and the total.
|
"""Return decision counts by rating, by status, and the total.
|
||||||
|
|
||||||
@@ -414,3 +589,174 @@ def review(
|
|||||||
logger.info("[compass] review surfaced id=%s stamped=%s", result["id"], stamp)
|
logger.info("[compass] review surfaced id=%s stamped=%s", result["id"], stamp)
|
||||||
json_handler.log_operation("compass_review", {"id": result["id"], "last_reviewed": stamp})
|
json_handler.log_operation("compass_review", {"id": result["id"], "last_reviewed": stamp})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Ambient recall caps the OR-expansion of a raw prompt: beyond this many unique
|
||||||
|
# tokens the extra words add noise, not recall, and the MATCH string balloons.
|
||||||
|
_RECALL_MAX_TOKENS = 64
|
||||||
|
|
||||||
|
# Stopwords never reach the MATCH: in an OR-of-tokens query, high-frequency
|
||||||
|
# filler ("lets keep working on the...") outweighs topic words in BM25 and
|
||||||
|
# surfaces unrelated entries — proven live in the FPLAN-0332 acceptance run.
|
||||||
|
# Three categories, all query-side only (entry text is never filtered):
|
||||||
|
# grammatical stopwords; conversational filler verbs that open most prompts
|
||||||
|
# ("lets keep working on / need to fix"); greeting/small-talk words ("good
|
||||||
|
# morning", "how did it go last night"). In a technical store casual words are
|
||||||
|
# RARE (df 1-2), so rarity scoring alone cannot reject them — they must never
|
||||||
|
# become query tokens at all. The topic space is open; this filler set is
|
||||||
|
# closed and small, which is why filtering here works.
|
||||||
|
_RECALL_STOPWORDS = frozenset(
|
||||||
|
"""a an and about again also are as at back be bit but by can could did do
|
||||||
|
does for from had has have how i if in is it its just me my no not of on
|
||||||
|
or our so still sure than thanks thank that the their then there these
|
||||||
|
they this to too u ur us was way we well were what when where which who
|
||||||
|
why will with would yes you your
|
||||||
|
add check doing done fix get go going keep lets look make need now see
|
||||||
|
should try use want work working write
|
||||||
|
day days good hello hey hi im ive last morning night ok okay please right
|
||||||
|
today tomorrow tonight week yesterday""".split()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recall_decisions(
|
||||||
|
prompt_text: str,
|
||||||
|
limit: int = 3,
|
||||||
|
db_path: Optional[Path | str] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return scored ambient-recall candidates for raw prompt text.
|
||||||
|
|
||||||
|
The Track 2 read path (DPLAN-0246): a hooks handler passes the raw user
|
||||||
|
prompt; governance (@memory's ``should_surface``) judges the candidates.
|
||||||
|
Side-effect-free — ``times_surfaced`` is NOT incremented here, because a
|
||||||
|
candidate is not yet surfaced; the caller reports actual injections via
|
||||||
|
:func:`mark_surfaced` so the counter stays honest.
|
||||||
|
|
||||||
|
The prompt is arbitrary text, never FTS5 syntax: unique word tokens (first
|
||||||
|
``_RECALL_MAX_TOKENS``) are OR-ed as quoted literals, ACTIVE rows only,
|
||||||
|
ranked by BM25. Each result dict carries ``relevance`` — the BM25 magnitude
|
||||||
|
mapped to (0, 1) via ``m / (1 + m)``, higher = more relevant — so
|
||||||
|
governance thresholds live on a bounded scale.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt_text: Raw prompt text (any content, any length).
|
||||||
|
limit: Max candidates to return (default 3).
|
||||||
|
db_path: Optional DB path override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of active decision dicts (most relevant first), each with a
|
||||||
|
``relevance`` float in (0, 1); empty when the prompt has no usable
|
||||||
|
tokens or nothing matches.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: On non-positive limit.
|
||||||
|
"""
|
||||||
|
if limit <= 0:
|
||||||
|
raise ValueError(f"limit must be a positive integer, got {limit!r}")
|
||||||
|
|
||||||
|
tokens = _FTS_WORD.findall(prompt_text or "")
|
||||||
|
unique: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for t in tokens:
|
||||||
|
lowered = t.lower()
|
||||||
|
if lowered not in seen and lowered not in _RECALL_STOPWORDS:
|
||||||
|
seen.add(lowered)
|
||||||
|
unique.append(t)
|
||||||
|
if len(unique) >= _RECALL_MAX_TOKENS:
|
||||||
|
break
|
||||||
|
if not unique:
|
||||||
|
return []
|
||||||
|
match = " OR ".join(f'"{t}"' for t in unique)
|
||||||
|
|
||||||
|
select_cols = ", ".join(f"d.{c}" for c in _DECISION_COLUMNS)
|
||||||
|
sql = f"""
|
||||||
|
SELECT {select_cols}, bm25(decisions_fts) AS rank,
|
||||||
|
(d.context || ' ' || d.decision || ' ' ||
|
||||||
|
COALESCE(d.note, '') || ' ' || COALESCE(d.tags, '')) AS _text
|
||||||
|
FROM decisions_fts f
|
||||||
|
JOIN decisions d ON d.id = f.rowid
|
||||||
|
WHERE decisions_fts MATCH ?
|
||||||
|
AND d.status = 'active'
|
||||||
|
ORDER BY bm25(decisions_fts) ASC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
# Over-fetch: rare-token scoring below reorders, so BM25's top-N alone
|
||||||
|
# would let a filler-heavy row crowd out a topical one.
|
||||||
|
rows = conn.execute(sql, (match, max(limit * 3, 10))).fetchall()
|
||||||
|
|
||||||
|
# Rarity cutoff: a token is evidence only if few entries contain it.
|
||||||
|
active_total = conn.execute("SELECT count(*) FROM decisions WHERE status = 'active'").fetchone()[0]
|
||||||
|
rare_cutoff = max(3, active_total // 10)
|
||||||
|
|
||||||
|
# Document frequency per prompt token, ONE query against the FTS index.
|
||||||
|
df: dict[str, int] = {}
|
||||||
|
for t in unique:
|
||||||
|
df[t.lower()] = conn.execute(
|
||||||
|
"SELECT count(*) FROM decisions_fts f JOIN decisions d ON d.id = f.rowid "
|
||||||
|
"WHERE decisions_fts MATCH ? AND d.status = 'active'",
|
||||||
|
(f'"{t}"',),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
item = _row_to_dict(row)
|
||||||
|
text = row["_text"].lower()
|
||||||
|
# Rare-token evidence: how many DISTINCTIVE prompt words this entry
|
||||||
|
# actually contains. Filler matches score zero — an entry with no
|
||||||
|
# rare-token overlap must not surface (FPLAN-0332 acceptance: a
|
||||||
|
# haiku prompt surfaced an unrelated ruling on BM25 alone).
|
||||||
|
matched_rare = sum(
|
||||||
|
1
|
||||||
|
for t in unique
|
||||||
|
if df[t.lower()] <= rare_cutoff and re.search(rf"\b{re.escape(t)}", text, re.IGNORECASE)
|
||||||
|
)
|
||||||
|
item["relevance"] = matched_rare / (1.0 + matched_rare)
|
||||||
|
item["_bm25"] = row["rank"]
|
||||||
|
results.append(item)
|
||||||
|
|
||||||
|
results.sort(key=lambda r: (-r["relevance"], r["_bm25"]))
|
||||||
|
results = results[:limit]
|
||||||
|
for r in results:
|
||||||
|
del r["_bm25"]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("[compass] recall -> %d candidate(s)", len(results))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def mark_surfaced(
|
||||||
|
decision_ids: list[int],
|
||||||
|
db_path: Optional[Path | str] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Increment ``times_surfaced`` for decisions actually injected.
|
||||||
|
|
||||||
|
The write half of the recall contract: :func:`recall_decisions` returns
|
||||||
|
candidates without side effects; whatever governance approves and the
|
||||||
|
caller truly injects gets counted here — never the merely-considered.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_ids: Ids of the decisions that were injected.
|
||||||
|
db_path: Optional DB path override.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rows updated (0 for an empty list).
|
||||||
|
"""
|
||||||
|
if not decision_ids:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
placeholders = ",".join("?" for _ in decision_ids)
|
||||||
|
cur = conn.execute(
|
||||||
|
f"UPDATE decisions SET times_surfaced = times_surfaced + 1 WHERE id IN ({placeholders})",
|
||||||
|
list(decision_ids),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
updated = cur.rowcount
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("[compass] mark_surfaced -> %d row(s)", updated)
|
||||||
|
return updated
|
||||||
|
|||||||
@@ -423,7 +423,12 @@ class StallTracker:
|
|||||||
(#634 part 2). The verbose trail stays on ``_stderr`` + logger.
|
(#634 part 2). The verbose trail stays on ``_stderr`` + logger.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
STALL_THRESHOLD = 120.0
|
# Quiet spans with zero JSONL output are routine, not stalls: a model composing
|
||||||
|
# a long response writes nothing until the turn completes, and compaction is one
|
||||||
|
# multi-minute silent API call. 120s false-fired on every stall of S329 (8/8);
|
||||||
|
# 300s clears normal turns and most compactions while a wedged agent still
|
||||||
|
# surfaces well inside a long watch.
|
||||||
|
STALL_THRESHOLD = 300.0
|
||||||
# A single tool call held in-flight this long is surfaced as a soft advisory
|
# A single tool call held in-flight this long is surfaced as a soft advisory
|
||||||
# (heavy op or a hung tool). Below the 600s default timeout so long watches
|
# (heavy op or a hung tool). Below the 600s default timeout so long watches
|
||||||
# get a mid-flight heads-up instead of waiting on the timeout.
|
# get a mid-flight heads-up instead of waiting on the timeout.
|
||||||
@@ -516,7 +521,7 @@ def watch_agent(
|
|||||||
poll_interval: Seconds between checks. Default 5.0 — the per-tick work (lock
|
poll_interval: Seconds between checks. Default 5.0 — the per-tick work (lock
|
||||||
stat, PID liveness, one-dir JSONL size scan) is cheap, so a tight cadence
|
stat, PID liveness, one-dir JSONL size scan) is cheap, so a tight cadence
|
||||||
just burns CPU. 5s keeps completion latency invisible on multi-minute
|
just burns CPU. 5s keeps completion latency invisible on multi-minute
|
||||||
dispatches while the 120s stall threshold has ample resolution.
|
dispatches while the 300s stall threshold has ample resolution.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id.
|
dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id.
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ This module is the thin command layer (FPLAN P2). It parses args, calls the
|
|||||||
No business logic lives here — that's the handler's job.
|
No business logic lives here — that's the handler's job.
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]
|
add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..] [--supersedes N]
|
||||||
query "question" [--rating R] [--limit N]
|
query "question" [--rating R] [--limit N] [--include-archived]
|
||||||
stats
|
stats
|
||||||
rate <id> <rating>
|
rate <id> <rating>
|
||||||
archive <id>
|
archive <id>
|
||||||
|
note <id> "text"
|
||||||
review
|
review
|
||||||
|
|
||||||
Every subcommand accepts ``--db PATH`` (passed through as ``db_path=``) for
|
Every subcommand accepts ``--db PATH`` (passed through as ``db_path=``) for
|
||||||
@@ -36,11 +37,19 @@ from typing import List, Optional
|
|||||||
from aipass.prax import logger
|
from aipass.prax import logger
|
||||||
from aipass.cli.apps.modules import err_console, error, warning
|
from aipass.cli.apps.modules import err_console, error, warning
|
||||||
from aipass.devpulse.apps.handlers import compass
|
from aipass.devpulse.apps.handlers import compass
|
||||||
|
from aipass.devpulse.apps.handlers.compass import mark_surfaced, recall_decisions
|
||||||
from aipass.devpulse.apps.handlers.json import json_handler
|
from aipass.devpulse.apps.handlers.json import json_handler
|
||||||
|
|
||||||
|
# Public cross-branch recall API (DPLAN-0246 Track 2). Other branches import
|
||||||
|
# at the modules/ boundary ONLY (seedgo boardroom ruling):
|
||||||
|
# from aipass.devpulse.apps.modules.compass import recall_decisions, mark_surfaced
|
||||||
|
# recall_decisions(prompt_text, limit) -> scored candidates, side-effect-free;
|
||||||
|
# mark_surfaced(ids) counts only what the caller actually injected.
|
||||||
|
__all__ = ["handle_command", "mark_surfaced", "recall_decisions"]
|
||||||
|
|
||||||
console = err_console
|
console = err_console
|
||||||
|
|
||||||
_VALID_SUBCOMMANDS = ("add", "query", "stats", "rate", "archive", "review")
|
_VALID_SUBCOMMANDS = ("add", "query", "stats", "rate", "archive", "note", "review")
|
||||||
|
|
||||||
# Console colour per rating — the rating is the signal, so make it pop.
|
# Console colour per rating — the rating is the signal, so make it pop.
|
||||||
_RATING_STYLE = {
|
_RATING_STYLE = {
|
||||||
@@ -59,6 +68,7 @@ HELP_TEXT = """\
|
|||||||
compass stats Counts by rating/status
|
compass stats Counts by rating/status
|
||||||
compass rate <id> <rating> Re-rate a decision
|
compass rate <id> <rating> Re-rate a decision
|
||||||
compass archive <id> Archive a decision
|
compass archive <id> Archive a decision
|
||||||
|
compass note <id> "text" Set a decision's note
|
||||||
compass review Surface one to review
|
compass review Surface one to review
|
||||||
compass --help Show this help
|
compass --help Show this help
|
||||||
|
|
||||||
@@ -70,19 +80,43 @@ HELP_TEXT = """\
|
|||||||
--note "..." Optional human observation.
|
--note "..." Optional human observation.
|
||||||
--tags a,b,c Optional comma-separated tags.
|
--tags a,b,c Optional comma-separated tags.
|
||||||
--source S Optional. devpulse (default) or user.
|
--source S Optional. devpulse (default) or user.
|
||||||
|
--supersedes N Optional. Archive decision #N and link this entry as its
|
||||||
|
correction (atomic). At add time, overlapping active
|
||||||
|
entries are shown as a non-blocking advisory.
|
||||||
|
|
||||||
|
[bold]Options (query):[/bold]
|
||||||
|
--rating R Optional exact-rating filter.
|
||||||
|
--limit N Optional max results (default 5).
|
||||||
|
--include-archived Also search archived (avoid-list) entries; archived hits
|
||||||
|
show their status + supersession pointer.
|
||||||
|
|
||||||
[bold]Options (all subcommands):[/bold]
|
[bold]Options (all subcommands):[/bold]
|
||||||
--db PATH Use an alternate SQLite store (testing / power use).
|
--db PATH Use an alternate SQLite store (testing / power use).
|
||||||
|
|
||||||
[bold]Examples:[/bold]
|
[bold]Examples:[/bold]
|
||||||
drone @devpulse compass add "auth fork" "chose JWT over sessions" --rating good
|
drone @devpulse compass add "auth fork" "chose JWT over sessions" --rating good
|
||||||
|
drone @devpulse compass add "auth fork" "switch to sessions" --rating good --supersedes 4
|
||||||
drone @devpulse compass query "auth" --rating good --limit 3
|
drone @devpulse compass query "auth" --rating good --limit 3
|
||||||
|
drone @devpulse compass query "auth" --include-archived
|
||||||
drone @devpulse compass stats
|
drone @devpulse compass stats
|
||||||
drone @devpulse compass rate 4 bad
|
drone @devpulse compass rate 4 bad
|
||||||
drone @devpulse compass archive 4
|
drone @devpulse compass archive 4
|
||||||
|
drone @devpulse compass note 4 "revisited — this held up"
|
||||||
drone @devpulse compass review
|
drone @devpulse compass review
|
||||||
|
|
||||||
See DPLAN-0212 (design) and the compass handler (apps/handlers/compass/).
|
See DPLAN-0212 / DPLAN-0246 (design) and the compass handler (apps/handlers/compass/).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_NOTE_HELP_TEXT = """\
|
||||||
|
[bold]compass note[/bold] — set (replace) a decision's note
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
compass note <id> "text" Set the note on decision #<id>
|
||||||
|
compass note --help Show this help
|
||||||
|
|
||||||
|
The note is re-indexed for search immediately — the FTS5 mirror stays in sync,
|
||||||
|
so the new note text is findable by 'compass query' right away.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +127,7 @@ def print_introspection() -> None:
|
|||||||
console.print("[dim]Devpulse rated decision store. The truth-store of choices —[/dim]")
|
console.print("[dim]Devpulse rated decision store. The truth-store of choices —[/dim]")
|
||||||
console.print("[dim]each decision rated; the rating is the signal at a fork.[/dim]")
|
console.print("[dim]each decision rated; the rating is the signal at a fork.[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[yellow]Subcommands:[/yellow] [cyan]add, query, stats, rate, archive, review[/cyan]")
|
console.print("[yellow]Subcommands:[/yellow] [cyan]add, query, stats, rate, archive, note, review[/cyan]")
|
||||||
console.print("[dim]Run 'compass --help' for full usage.[/dim]")
|
console.print("[dim]Run 'compass --help' for full usage.[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
@@ -141,6 +175,8 @@ def handle_command(command: str, args: List[str]) -> bool:
|
|||||||
return _handle_rate(sub_args)
|
return _handle_rate(sub_args)
|
||||||
if subcommand == "archive":
|
if subcommand == "archive":
|
||||||
return _handle_archive(sub_args)
|
return _handle_archive(sub_args)
|
||||||
|
if subcommand == "note":
|
||||||
|
return _handle_note(sub_args)
|
||||||
if subcommand == "review":
|
if subcommand == "review":
|
||||||
return _handle_review(sub_args)
|
return _handle_review(sub_args)
|
||||||
|
|
||||||
@@ -179,6 +215,18 @@ def _extract_db_path(args: List[str]) -> tuple[List[str], Optional[str]]:
|
|||||||
return _extract_flag(args, "--db")
|
return _extract_flag(args, "--db")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bool_flag(args: List[str], flag: str) -> tuple[List[str], bool]:
|
||||||
|
"""Pull a valueless boolean ``--flag`` out of args.
|
||||||
|
|
||||||
|
Returns the remaining args (every occurrence of the flag removed) and True
|
||||||
|
if the flag was present, else False. Unlike ``_extract_flag`` this consumes
|
||||||
|
no following value.
|
||||||
|
"""
|
||||||
|
if flag in args:
|
||||||
|
return [a for a in args if a != flag], True
|
||||||
|
return args, False
|
||||||
|
|
||||||
|
|
||||||
def _rating_tag(rating: str) -> str:
|
def _rating_tag(rating: str) -> str:
|
||||||
"""Render a coloured ``[RATING]`` tag for query/review output."""
|
"""Render a coloured ``[RATING]`` tag for query/review output."""
|
||||||
style = _RATING_STYLE.get(rating, "bold white")
|
style = _RATING_STYLE.get(rating, "bold white")
|
||||||
@@ -198,13 +246,16 @@ def _handle_add(sub_args: List[str]) -> bool:
|
|||||||
rest, note = _extract_flag(rest, "--note")
|
rest, note = _extract_flag(rest, "--note")
|
||||||
rest, tags = _extract_flag(rest, "--tags")
|
rest, tags = _extract_flag(rest, "--tags")
|
||||||
rest, source = _extract_flag(rest, "--source")
|
rest, source = _extract_flag(rest, "--source")
|
||||||
|
rest, supersedes_raw = _extract_flag(rest, "--supersedes")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("[compass] add arg-parse error: %s", exc)
|
logger.warning("[compass] add arg-parse error: %s", exc)
|
||||||
error(str(exc), suggestion="Use 'compass --help' for usage")
|
error(str(exc), suggestion="Use 'compass --help' for usage")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if len(rest) < 2:
|
if len(rest) < 2:
|
||||||
error('Usage: compass add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..]')
|
error(
|
||||||
|
'Usage: compass add "context" "decision" --rating R [--note ..] [--tags a,b] [--source ..] [--supersedes N]'
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
if rating is None:
|
if rating is None:
|
||||||
error("compass add requires --rating", suggestion="One of: good | bad | impressive | interesting")
|
error("compass add requires --rating", suggestion="One of: good | bad | impressive | interesting")
|
||||||
@@ -213,6 +264,34 @@ def _handle_add(sub_args: List[str]) -> bool:
|
|||||||
context = rest[0]
|
context = rest[0]
|
||||||
decision = rest[1]
|
decision = rest[1]
|
||||||
|
|
||||||
|
supersedes: Optional[int] = None
|
||||||
|
if supersedes_raw is not None:
|
||||||
|
try:
|
||||||
|
supersedes = int(supersedes_raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning("[compass] add bad --supersedes %r: %s", supersedes_raw, exc)
|
||||||
|
error(f"--supersedes must be an integer, got {supersedes_raw!r}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Write-time conflict check — a NON-BLOCKING advisory (DPLAN-0246). Skipped
|
||||||
|
# when the writer already chose to supersede, and never allowed to block or
|
||||||
|
# crash the add. Only shown when NOT already superseding.
|
||||||
|
if supersedes is None:
|
||||||
|
try:
|
||||||
|
conflicts = compass.find_conflicts(context, decision, db_path=db_path)
|
||||||
|
except Exception as exc: # advisory must never break a write
|
||||||
|
logger.warning("[compass] conflict-check failed (non-blocking): %s", exc)
|
||||||
|
conflicts = []
|
||||||
|
for c in conflicts:
|
||||||
|
cid = c.get("id")
|
||||||
|
excerpt = (c.get("context") or "").strip()
|
||||||
|
if len(excerpt) > 80:
|
||||||
|
excerpt = excerpt[:77] + "..."
|
||||||
|
console.print(
|
||||||
|
f"[yellow]possible conflict with #{cid}[/yellow]: {excerpt} "
|
||||||
|
f"[dim]— supersede? (--supersedes {cid})[/dim]"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
new_id = compass.add_decision(
|
new_id = compass.add_decision(
|
||||||
context,
|
context,
|
||||||
@@ -222,6 +301,7 @@ def _handle_add(sub_args: List[str]) -> bool:
|
|||||||
tags=tags,
|
tags=tags,
|
||||||
source=source if source is not None else "devpulse",
|
source=source if source is not None else "devpulse",
|
||||||
db_path=db_path,
|
db_path=db_path,
|
||||||
|
supersedes=supersedes,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("[compass] add rejected: %s", exc)
|
logger.warning("[compass] add rejected: %s", exc)
|
||||||
@@ -235,6 +315,8 @@ def _handle_add(sub_args: List[str]) -> bool:
|
|||||||
console.print(f" [cyan]note:[/cyan] {note}")
|
console.print(f" [cyan]note:[/cyan] {note}")
|
||||||
if tags:
|
if tags:
|
||||||
console.print(f" [cyan]tags:[/cyan] {tags}")
|
console.print(f" [cyan]tags:[/cyan] {tags}")
|
||||||
|
if supersedes is not None:
|
||||||
|
console.print(f" [magenta]supersedes #{supersedes}[/magenta] [dim](archived)[/dim]")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -242,6 +324,7 @@ def _handle_query(sub_args: List[str]) -> bool:
|
|||||||
"""Parse and dispatch ``compass query "question" [--rating R] [--limit N]``."""
|
"""Parse and dispatch ``compass query "question" [--rating R] [--limit N]``."""
|
||||||
try:
|
try:
|
||||||
rest, db_path = _extract_db_path(sub_args)
|
rest, db_path = _extract_db_path(sub_args)
|
||||||
|
rest, include_archived = _extract_bool_flag(rest, "--include-archived")
|
||||||
rest, rating = _extract_flag(rest, "--rating")
|
rest, rating = _extract_flag(rest, "--rating")
|
||||||
rest, limit_raw = _extract_flag(rest, "--limit")
|
rest, limit_raw = _extract_flag(rest, "--limit")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -250,7 +333,7 @@ def _handle_query(sub_args: List[str]) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
if not rest:
|
if not rest:
|
||||||
error('Usage: compass query "question" [--rating R] [--limit N]')
|
error('Usage: compass query "question" [--rating R] [--limit N] [--include-archived]')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
query_text = rest[0]
|
query_text = rest[0]
|
||||||
@@ -265,7 +348,13 @@ def _handle_query(sub_args: List[str]) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = compass.query_decisions(query_text, rating=rating, limit=limit, db_path=db_path)
|
results = compass.query_decisions(
|
||||||
|
query_text,
|
||||||
|
rating=rating,
|
||||||
|
limit=limit,
|
||||||
|
include_archived=include_archived,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("[compass] query rejected: %s", exc)
|
logger.warning("[compass] query rejected: %s", exc)
|
||||||
error(str(exc))
|
error(str(exc))
|
||||||
@@ -294,6 +383,16 @@ def _render_query_results(query_text: str, rating: Optional[str], results: List[
|
|||||||
console.print(f" [cyan]note:[/cyan] {r['note']}")
|
console.print(f" [cyan]note:[/cyan] {r['note']}")
|
||||||
if r.get("tags"):
|
if r.get("tags"):
|
||||||
console.print(f" [cyan]tags:[/cyan] {r['tags']}")
|
console.print(f" [cyan]tags:[/cyan] {r['tags']}")
|
||||||
|
# Supersession pointers — an archived hit must never masquerade as
|
||||||
|
# current truth, so flag its status + who replaced it (DPLAN-0246).
|
||||||
|
if r.get("status") == "archived":
|
||||||
|
superseded_by = r.get("superseded_by")
|
||||||
|
if superseded_by:
|
||||||
|
console.print(f" [bold yellow]ARCHIVED[/bold yellow] — superseded by #{superseded_by}")
|
||||||
|
else:
|
||||||
|
console.print(" [bold yellow]ARCHIVED[/bold yellow] (avoid-list)")
|
||||||
|
if r.get("supersedes"):
|
||||||
|
console.print(f" [magenta]supersedes #{r['supersedes']}[/magenta]")
|
||||||
meta = f"source={r.get('source', '?')} status={r.get('status', '?')} surfaced={r.get('times_surfaced', 0)}"
|
meta = f"source={r.get('source', '?')} status={r.get('status', '?')} surfaced={r.get('times_surfaced', 0)}"
|
||||||
console.print(f" [dim]{meta}[/dim]")
|
console.print(f" [dim]{meta}[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
@@ -389,6 +488,44 @@ def _handle_archive(sub_args: List[str]) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_note(sub_args: List[str]) -> bool:
|
||||||
|
"""Dispatch ``compass note <id> "text"`` — set a decision's note.
|
||||||
|
|
||||||
|
Follows the subcommand-help convention: ``compass note --help`` prints the
|
||||||
|
per-subcommand help block; malformed input shows the Usage line.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rest, db_path = _extract_db_path(sub_args)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning("[compass] note arg-parse error: %s", exc)
|
||||||
|
error(str(exc))
|
||||||
|
return True
|
||||||
|
|
||||||
|
if rest and rest[0] in ("--help", "-h", "help"):
|
||||||
|
console.print(_NOTE_HELP_TEXT)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if len(rest) < 2:
|
||||||
|
error('Usage: compass note <id> "text"')
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
decision_id = int(rest[0])
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning("[compass] note bad id %r: %s", rest[0], exc)
|
||||||
|
error(f"<id> must be an integer, got {rest[0]!r}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
note_text = rest[1]
|
||||||
|
changed = compass.set_note(decision_id, note_text, db_path=db_path)
|
||||||
|
if changed:
|
||||||
|
console.print(f"[green]Note set[/green] on [bold]#{decision_id}[/bold]")
|
||||||
|
console.print(f" [cyan]note:[/cyan] {note_text}")
|
||||||
|
else:
|
||||||
|
warning(f"No decision with id {decision_id} — nothing changed.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _handle_review(sub_args: List[str]) -> bool:
|
def _handle_review(sub_args: List[str]) -> bool:
|
||||||
"""Dispatch ``compass review`` — surface one active decision to review."""
|
"""Dispatch ``compass review`` — surface one active decision to review."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<title>S324 Advisory Scan — AIPass</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--paper: #f4f1ea; --surface: #fbf9f4; --surface-2: #eeeae0; --ink: #1a1e27;
|
||||||
|
--ink-soft: #454b58; --ink-faint: #6b7280; --hair: #ddd6c8;
|
||||||
|
--accent: #b57e1f; --accent-soft: #e9d9b4;
|
||||||
|
--crit: #c92a30; --high: #c0741f; --med: #4964a8; --low: #7b8494; --good: #2f8f5b;
|
||||||
|
--crit-bg: #f6dcdc; --high-bg: #f4e6d0; --med-bg: #dde3f2; --low-bg: #e6e8ec; --good-bg: #d8ecdf;
|
||||||
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
--maxw: 940px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--paper: #10131a; --surface: #161a23; --surface-2: #1e2430; --ink: #e7e9ee;
|
||||||
|
--ink-soft: #aab2c0; --ink-faint: #79828f; --hair: #2a3140; --accent: #d9a441;
|
||||||
|
--accent-soft: #4a3d1c; --crit: #f0565b; --high: #e8973f; --med: #7d97d8; --low: #8a93a3; --good: #4cc082;
|
||||||
|
--crit-bg: #3a1c1e; --high-bg: #382a17; --med-bg: #1f2740; --low-bg: #242a35; --good-bg: #16301f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--paper: #f4f1ea; --surface: #fbf9f4; --surface-2: #eeeae0; --ink: #1a1e27;
|
||||||
|
--ink-soft: #454b58; --ink-faint: #6b7280; --hair: #ddd6c8; --accent: #b57e1f;
|
||||||
|
--accent-soft: #e9d9b4; --crit: #c92a30; --high: #c0741f; --med: #4964a8; --low: #7b8494; --good: #2f8f5b;
|
||||||
|
--crit-bg: #f6dcdc; --high-bg: #f4e6d0; --med-bg: #dde3f2; --low-bg: #e6e8ec; --good-bg: #d8ecdf;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--paper: #10131a; --surface: #161a23; --surface-2: #1e2430; --ink: #e7e9ee;
|
||||||
|
--ink-soft: #aab2c0; --ink-faint: #79828f; --hair: #2a3140; --accent: #d9a441;
|
||||||
|
--accent-soft: #4a3d1c; --crit: #f0565b; --high: #e8973f; --med: #7d97d8; --low: #8a93a3; --good: #4cc082;
|
||||||
|
--crit-bg: #3a1c1e; --high-bg: #382a17; --med-bg: #1f2740; --low-bg: #242a35; --good-bg: #16301f;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||||
|
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 22px; }
|
||||||
|
|
||||||
|
header { padding: 54px 0 30px; border-bottom: 1px solid var(--hair); }
|
||||||
|
.eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); margin: 0 0 16px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.eyebrow .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); box-shadow: 0 0 0 3px var(--good-bg); }
|
||||||
|
h1 { font-family: var(--mono); font-weight: 600; font-size: clamp(28px, 5vw, 44px); line-height: 1.06; letter-spacing: -0.02em; margin: 0 0 14px; text-wrap: balance; }
|
||||||
|
h1 .melt { color: var(--accent); }
|
||||||
|
.lede { font-size: 18px; color: var(--ink-soft); max-width: 62ch; margin: 0; }
|
||||||
|
.meta { font-family: var(--mono); font-size: 12.5px; color: var(--ink-faint); margin-top: 22px; display: flex; gap: 8px 20px; flex-wrap: wrap; }
|
||||||
|
.meta b { color: var(--ink-soft); font-weight: 600; }
|
||||||
|
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--hair); border: 1px solid var(--hair); border-radius: 10px; overflow: hidden; margin: 30px 0 8px; }
|
||||||
|
.stat { background: var(--surface); padding: 18px 18px 16px; }
|
||||||
|
.stat .n { font-family: var(--mono); font-size: 30px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1; }
|
||||||
|
.stat .k { font-size: 12.5px; color: var(--ink-faint); margin-top: 7px; }
|
||||||
|
.stat.good .n { color: var(--good); }
|
||||||
|
.stat.med .n { color: var(--med); }
|
||||||
|
@media (max-width: 620px) { .stats { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
|
||||||
|
section { padding: 40px 0; border-bottom: 1px solid var(--hair); }
|
||||||
|
.sec-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 22px; }
|
||||||
|
.sec-head h2 { font-family: var(--mono); font-size: 14px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink); margin: 0; font-weight: 600; }
|
||||||
|
.sec-head .rule { flex: 1; height: 1px; background: var(--hair); }
|
||||||
|
.sec-head .count { font-family: var(--mono); font-size: 12px; color: var(--ink-faint); }
|
||||||
|
|
||||||
|
.findings { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.f { background: var(--surface); border: 1px solid var(--hair); border-radius: 9px; border-left-width: 4px; padding: 16px 18px; }
|
||||||
|
.f.high { border-left-color: var(--high); }
|
||||||
|
.f.med { border-left-color: var(--med); }
|
||||||
|
.f.low { border-left-color: var(--low); }
|
||||||
|
.f.good { border-left-color: var(--good); }
|
||||||
|
.f-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 7px; }
|
||||||
|
.id { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--ink-faint); }
|
||||||
|
.pill { font-family: var(--mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; font-weight: 600; padding: 2px 8px; border-radius: 20px; }
|
||||||
|
.pill.high { color: var(--high); background: var(--high-bg); }
|
||||||
|
.pill.med { color: var(--med); background: var(--med-bg); }
|
||||||
|
.pill.low { color: var(--low); background: var(--low-bg); }
|
||||||
|
.pill.good { color: var(--good); background: var(--good-bg); }
|
||||||
|
.f-title { font-weight: 600; font-size: 15.5px; color: var(--ink); flex: 1 1 100%; margin-top: 2px; }
|
||||||
|
.f-body { font-size: 14px; color: var(--ink-soft); margin: 0; }
|
||||||
|
.f-body code { font-family: var(--mono); font-size: 12.5px; background: var(--surface-2); padding: 1px 5px; border-radius: 4px; color: var(--ink); }
|
||||||
|
|
||||||
|
table.melt { width: 100%; border-collapse: collapse; font-size: 14px; margin-top: 4px; }
|
||||||
|
table.melt th { font-family: var(--mono); font-size: 11.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-faint); text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--hair); }
|
||||||
|
table.melt td { padding: 9px 12px; border-bottom: 1px solid var(--hair); color: var(--ink-soft); }
|
||||||
|
table.melt td.n { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); font-weight: 600; white-space: nowrap; }
|
||||||
|
table.melt td.verdict-good { color: var(--good); font-weight: 600; }
|
||||||
|
table.melt td.verdict-open { color: var(--med); font-weight: 600; }
|
||||||
|
.tblwrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
.callout { background: var(--surface-2); border: 1px solid var(--hair); border-radius: 9px; padding: 18px 20px; margin-top: 18px; }
|
||||||
|
.callout h3 { font-family: var(--mono); font-size: 13px; letter-spacing: .06em; text-transform: uppercase; margin: 0 0 8px; color: var(--accent); }
|
||||||
|
.callout p { margin: 0; font-size: 14px; color: var(--ink-soft); }
|
||||||
|
|
||||||
|
footer { padding: 30px 0 60px; font-family: var(--mono); font-size: 12px; color: var(--ink-faint); }
|
||||||
|
footer .disc { color: var(--good); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow"><span class="dot"></span> Session S324 · Advisory Scan · Read-only</p>
|
||||||
|
<h1>The scanners CI never runs said 1,419 problems. Verification <span class="melt">melted it to a handful.</span></h1>
|
||||||
|
<p class="lede">Patrick's ask: "look in ur tools folder run some random scan see what surfaces." Ran the April-era advisory scanners — the ones deliberately NOT wired into seedgo's audit — across all 17 branches, then read every scary number in context before believing it. The tree is clean where it counts; most of the raw count is archive noise and scanner age.</p>
|
||||||
|
<p class="meta"><span><b>Date</b> 2026-07-18</span><span><b>Agent</b> @devpulse</span><span><b>Scanners</b> fallback · stale-terms · readme-freshness</span><span><b>Branches</b> 17/17</span><span><b>Files changed</b> 0</span></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats" role="list">
|
||||||
|
<div class="stat" role="listitem"><div class="n">1,419</div><div class="k">raw findings, all scanners</div></div>
|
||||||
|
<div class="stat med" role="listitem"><div class="n">16</div><div class="k">fallbacks actually in live code</div></div>
|
||||||
|
<div class="stat good" role="listitem"><div class="n">2/2</div><div class="k">sampled live hits = defensible on read</div></div>
|
||||||
|
<div class="stat" role="listitem"><div class="n">1</div><div class="k">genuine surprise (MagicMock leak)</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- THE MELT -->
|
||||||
|
<section>
|
||||||
|
<div class="sec-head"><h2>How the numbers melt</h2><span class="rule"></span><span class="count">raw → verified</span></div>
|
||||||
|
<div class="tblwrap"><table class="melt">
|
||||||
|
<tr><th>Scanner</th><th>Raw</th><th>After context</th><th>Verdict</th></tr>
|
||||||
|
<tr><td>Silent fallbacks</td><td class="n">61</td><td class="n">16 live · 45 = devpulse <code>dropbox/</code> quarantine copies</td><td class="verdict-good">mostly archive noise</td></tr>
|
||||||
|
<tr><td> ↳ of the 16 live</td><td class="n">16</td><td class="n">@trigger 11 · @cli 2 · @skills 2 (tests) · @spawn 1 (template)</td><td class="verdict-open">@trigger needs owner triage</td></tr>
|
||||||
|
<tr><td>Stale terminology</td><td class="n">1,358</td><td class="n">~600 genuinely dead names · rest = valid <code>.ai_mail.local/</code> refs</td><td class="verdict-open">needs live-code filter</td></tr>
|
||||||
|
<tr><td>README freshness</td><td class="n">15/17 stale</td><td class="n">dates lag the front-door code week</td><td class="verdict-good">informational</td></tr>
|
||||||
|
</table></div>
|
||||||
|
<div class="callout">
|
||||||
|
<h3>Why sampling flipped the verdict</h3>
|
||||||
|
<p>Both @trigger hits I read in full are <b>recursion-safe meta-logging guards</b> — "if logging this warning fails, don't crash the error pipeline" — one even carries a <code># seedgo:bypass meta-logging</code> marker. The @spawn template hit is a two-prefix import <em>probe</em> that surfaces real failures later, not a swallow. The scanner predates the bypass convention, so it re-alarms on code the standards system already acquitted.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FINDINGS -->
|
||||||
|
<section>
|
||||||
|
<div class="sec-head"><h2>What actually surfaced</h2><span class="rule"></span><span class="count">5 findings</span></div>
|
||||||
|
<div class="findings">
|
||||||
|
|
||||||
|
<div class="f med">
|
||||||
|
<div class="f-top"><span class="id">S324-1</span><span class="pill med">Medium · real · unowned</span>
|
||||||
|
<span class="f-title">A test leaked a Mock onto disk: <code>MagicMock/LOG_FILE/</code> sits in devpulse's branch root</span></div>
|
||||||
|
<p class="f-body">Empty, untracked, born <b>Jul 11 03:31</b> (a night-shift test run). Something called mkdir on a path built from an unpatched <code>Mock().LOG_FILE</code>. Four candidate offenders mock a <code>LOG_FILE</code> attribute: ai_mail <code>test_daemon.py</code> / <code>test_dispatch_status.py</code>, hooks <code>test_engine.py</code>, seedgo <code>test_checkers_batch3.py</code>. Cheap hunt: run each suite from a scratch CWD and watch which one mints the dir. Until then it's clutter that will regrow after cleanup.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="f med">
|
||||||
|
<div class="f-top"><span class="id">S324-2</span><span class="pill med">Medium · owner call</span>
|
||||||
|
<span class="f-title">@trigger: 11 bare <code>except Exception: pass</code> in live event handlers — 9 still unread</span></div>
|
||||||
|
<p class="f-body">The error-detection branch swallowing exceptions is worth eyes on principle. The 2 I sampled are legitimate meta-logging guards; the other 9 (in <code>error_detected</code>, <code>runaway_handler</code>, <code>plan_file</code>, <code>pr_status_sync</code>, <code>memory_pool</code>, <code>registry</code>, <code>startup</code>) deserve the same context read by their owner. Concern, not prescription — @trigger designs the fix if any is real.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="f low">
|
||||||
|
<div class="f-top"><span class="id">S324-3</span><span class="pill low">Low · tooling debt</span>
|
||||||
|
<span class="f-title">The advisory scanners are aging out of the conventions they audit</span></div>
|
||||||
|
<p class="f-body">April-era tools: the fallback scanner doesn't honor <code>seedgo:bypass</code> markers (false alarms on acquitted code), the stale scanner counts valid <code>.ai_mail.local/</code> usage in its headline, and none of them exclude <code>dropbox/</code> / <code>.archive/</code> quarantine dirs — which is where 45 of 61 "fallbacks" came from. A bypass-aware, archive-excluding refresh would make raw counts trustworthy again.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="f low">
|
||||||
|
<div class="f-top"><span class="id">S324-4</span><span class="pill low">Low · unfiltered</span>
|
||||||
|
<span class="f-title">~600 dead-name references still in the tree: dev central 243 · branch_registry 211 · ai_central 131 · cortex 13</span></div>
|
||||||
|
<p class="f-body">Not yet split between live code (worth fixing) and historical plans/archives (should keep their old names — never rewrite history). A filtered live-code pass is the prerequisite before this number means anything. Public-repo angle: strangers reading live code shouldn't meet four generations of naming.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="f good">
|
||||||
|
<div class="f-top"><span class="id">S324-5</span><span class="pill good">Verified good</span>
|
||||||
|
<span class="f-title">Where it counts, the tree is clean</span></div>
|
||||||
|
<p class="f-body">Devpulse live code: <b>zero</b> silent fallbacks — all 45 flagged were quarantined pollution captures in <code>dropbox/</code>, doing exactly what quarantine is for. The 10 hard checkers born from this same tools folder already run inside <code>drone @seedgo audit</code>, where all 17 branches sit at 100%. The advisory layer found no criticals, no highs, nothing live-broken.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CONTEXT -->
|
||||||
|
<section>
|
||||||
|
<div class="sec-head"><h2>Report lineage</h2><span class="rule"></span><span class="count">where this fits</span></div>
|
||||||
|
<p class="f-body" style="max-width:70ch">Sibling of <code>docs/discovery/S304_report.html</code> (Jul 12 — the 124-finding autonomous discovery walk; two criticals from it, medic-off and the rollover off-by-one, were since fixed and Patrick-monitored respectively). The other big HTML artifact of the week is <code>artifacts/ux_flow_graph.html</code> (3.5 MB, the v4 install-to-chat UX graph). Same-day context: PR #703 (merge-playbook drift-check step) is CI-green and awaiting Patrick's merge word; gif session prepped in <code>artifacts/gif_recording_plan.md</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span class="disc">read-only scan</span> · zero files changed · scanners: fallback_scanner_v1, stale_scanner_v1, readme_freshness_scanner_v1 · @devpulse S324 · 2026-07-18
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
@@ -275,3 +275,176 @@ def test_flag_without_value_errors(capsys, db):
|
|||||||
assert compass_cmd.handle_command("compass", ["query", "x", "--rating"]) is True
|
assert compass_cmd.handle_command("compass", ["query", "x", "--rating"]) is True
|
||||||
out = _output(capsys).lower()
|
out = _output(capsys).lower()
|
||||||
assert "rating" in out and "value" in out
|
assert "rating" in out and "value" in out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# supersedes — atomic archive + link, both pointer directions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_supersedes_archives_and_links(capsys, db):
|
||||||
|
"""add --supersedes N archives #N, links the new row, shows 'supersedes #N'."""
|
||||||
|
old = _add(capsys, db, "old ctx sessions", "use sessions", "good")
|
||||||
|
capsys.readouterr()
|
||||||
|
assert (
|
||||||
|
compass_cmd.handle_command(
|
||||||
|
"compass",
|
||||||
|
[
|
||||||
|
"add",
|
||||||
|
"new ctx jwt",
|
||||||
|
"switch to jwt",
|
||||||
|
"--rating",
|
||||||
|
"good",
|
||||||
|
"--supersedes",
|
||||||
|
str(old),
|
||||||
|
"--db",
|
||||||
|
db,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
out = _output(capsys)
|
||||||
|
assert f"supersedes #{old}" in out
|
||||||
|
|
||||||
|
# The archived row is gone from the default (active-only) query...
|
||||||
|
q = _query_out(capsys, db, "sessions")
|
||||||
|
assert "0 result(s)" in q
|
||||||
|
|
||||||
|
# ...but --include-archived surfaces it WITH its status + forward pointer.
|
||||||
|
q2 = _query_out(capsys, db, "sessions", "--include-archived")
|
||||||
|
assert "ARCHIVED" in q2.upper()
|
||||||
|
assert "superseded by #" in q2.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_supersedes_bad_id_errors_no_write(capsys, db):
|
||||||
|
"""add --supersedes to a missing id errors and writes nothing."""
|
||||||
|
capsys.readouterr()
|
||||||
|
compass_cmd.handle_command(
|
||||||
|
"compass",
|
||||||
|
["add", "ctx", "dec", "--rating", "good", "--supersedes", "9999", "--db", db],
|
||||||
|
)
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "9999" in out
|
||||||
|
assert "total decisions: 0" in _stats_out(capsys, db).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_supersedes_non_integer_errors(capsys, db):
|
||||||
|
"""A non-integer --supersedes fails loud."""
|
||||||
|
assert (
|
||||||
|
compass_cmd.handle_command(
|
||||||
|
"compass",
|
||||||
|
["add", "ctx", "dec", "--rating", "good", "--supersedes", "abc", "--db", db],
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "supersedes" in out and "integer" in out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# write-time conflict advisory — non-blocking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_conflict_advisory_prints_but_does_not_block(capsys, db):
|
||||||
|
"""An overlapping active row triggers an advisory; the add still succeeds."""
|
||||||
|
_add(capsys, db, "caching layer strategy", "add redis caching", "good")
|
||||||
|
capsys.readouterr()
|
||||||
|
compass_cmd.handle_command(
|
||||||
|
"compass",
|
||||||
|
["add", "caching approach again", "another caching layer", "--rating", "good", "--db", db],
|
||||||
|
)
|
||||||
|
out = _output(capsys)
|
||||||
|
assert "possible conflict" in out.lower()
|
||||||
|
assert "--supersedes" in out # advisory hints the fix
|
||||||
|
assert "Added decision" in out # NON-BLOCKING: still added
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_no_conflict_on_empty_store(capsys, db):
|
||||||
|
"""First add on an empty store prints no advisory."""
|
||||||
|
capsys.readouterr()
|
||||||
|
compass_cmd.handle_command("compass", ["add", "unique ctx", "unique dec", "--rating", "good", "--db", db])
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "possible conflict" not in out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# note — set a note, prove it is immediately searchable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_command_sets_and_is_searchable(capsys, db):
|
||||||
|
"""note <id> "text" sets the note; a later query finds the new note text."""
|
||||||
|
did = _add(capsys, db, "note cmd ctx", "note cmd dec", "good")
|
||||||
|
capsys.readouterr()
|
||||||
|
assert compass_cmd.handle_command("compass", ["note", str(did), "findme pterodactyl", "--db", db]) is True
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "note set" in out
|
||||||
|
|
||||||
|
q = _query_out(capsys, db, "pterodactyl")
|
||||||
|
assert "1 result(s)" in q
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_help(capsys):
|
||||||
|
"""compass note --help prints per-subcommand usage."""
|
||||||
|
assert compass_cmd.handle_command("compass", ["note", "--help"]) is True
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "note" in out and "usage" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_missing_id_warns(capsys, db):
|
||||||
|
"""note on a non-existent id reports nothing changed, does not crash."""
|
||||||
|
assert compass_cmd.handle_command("compass", ["note", "999", "text", "--db", db]) is True
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "999" in out and ("nothing changed" in out or "no decision" in out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_missing_args_shows_usage(capsys, db):
|
||||||
|
"""note with too few args shows usage."""
|
||||||
|
assert compass_cmd.handle_command("compass", ["note", "5", "--db", db]) is True
|
||||||
|
out = _output(capsys).lower()
|
||||||
|
assert "usage" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_and_introspection_list_note(capsys):
|
||||||
|
"""Both --help and bare introspection advertise the note subcommand."""
|
||||||
|
compass_cmd.handle_command("compass", ["--help"])
|
||||||
|
assert "note" in _output(capsys).lower()
|
||||||
|
compass_cmd.handle_command("compass", [])
|
||||||
|
assert "note" in _output(capsys).lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# --include-archived — archived hits must show status + supersession pointer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_include_archived_shows_archived_pointer(capsys, db):
|
||||||
|
"""--include-archived surfaces an archived row flagged with its successor."""
|
||||||
|
old = _add(capsys, db, "archived-visible ctx", "the old choice", "bad")
|
||||||
|
capsys.readouterr()
|
||||||
|
compass_cmd.handle_command(
|
||||||
|
"compass",
|
||||||
|
[
|
||||||
|
"add",
|
||||||
|
"replacement ctx",
|
||||||
|
"the new choice",
|
||||||
|
"--rating",
|
||||||
|
"good",
|
||||||
|
"--supersedes",
|
||||||
|
str(old),
|
||||||
|
"--db",
|
||||||
|
db,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
capsys.readouterr()
|
||||||
|
|
||||||
|
# Default query hides the archived row.
|
||||||
|
q = _query_out(capsys, db, "old choice")
|
||||||
|
assert "0 result(s)" in q
|
||||||
|
|
||||||
|
# With the flag it appears, unmistakably marked archived + superseded.
|
||||||
|
q2 = _query_out(capsys, db, "old choice", "--include-archived")
|
||||||
|
assert "1 result(s)" in q2
|
||||||
|
assert "archived" in q2.lower()
|
||||||
|
assert "superseded by #" in q2.lower()
|
||||||
|
|||||||
@@ -283,3 +283,252 @@ class TestInputValidation:
|
|||||||
compass.add_decision("ctx", "dec", "good", db_path=db)
|
compass.add_decision("ctx", "dec", "good", db_path=db)
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
compass.query_decisions("ctx", limit=0, db_path=db)
|
compass.query_decisions("ctx", limit=0, db_path=db)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSupersedes:
|
||||||
|
"""supersedes column, atomic archive+link, idempotent migration (DPLAN-0246)."""
|
||||||
|
|
||||||
|
def test_add_with_supersedes_archives_and_links(self, db):
|
||||||
|
"""--supersedes links the corrector AND archives the target, atomically."""
|
||||||
|
old = compass.add_decision("old auth ctx", "use sessions", "good", db_path=db)
|
||||||
|
new = compass.add_decision("new auth ctx", "switch to JWT", "good", db_path=db, supersedes=old)
|
||||||
|
# The corrector row links back to what it replaced.
|
||||||
|
hit = compass.query_decisions("JWT", db_path=db)[0]
|
||||||
|
assert hit["supersedes"] == old
|
||||||
|
# The old entry is archived → gone from the active query.
|
||||||
|
assert compass.query_decisions("sessions", db_path=db) == []
|
||||||
|
# With include_archived it reappears, pointing FORWARD to its successor.
|
||||||
|
arch = compass.query_decisions("sessions", include_archived=True, db_path=db)[0]
|
||||||
|
assert arch["status"] == "archived"
|
||||||
|
assert arch["superseded_by"] == new
|
||||||
|
|
||||||
|
def test_supersedes_nonexistent_raises_no_partial_write(self, db):
|
||||||
|
"""A bad --supersedes id errors cleanly and leaves NO partial write."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
compass.add_decision("ctx", "dec", "good", db_path=db, supersedes=9999)
|
||||||
|
assert compass.stats(db_path=db)["total"] == 0
|
||||||
|
|
||||||
|
def test_supersedes_defaults_none(self, db):
|
||||||
|
"""A plain add has supersedes=None and superseded_by=None."""
|
||||||
|
compass.add_decision("plain ctx", "plain dec", "good", db_path=db)
|
||||||
|
hit = compass.query_decisions("plain", db_path=db)[0]
|
||||||
|
assert hit["supersedes"] is None
|
||||||
|
assert hit["superseded_by"] is None
|
||||||
|
|
||||||
|
def test_active_hit_shows_its_supersedes_pointer(self, db):
|
||||||
|
"""An ACTIVE corrector still exposes its supersedes pointer on query."""
|
||||||
|
old = compass.add_decision("legacy topic zzz", "old way", "bad", db_path=db)
|
||||||
|
compass.add_decision("current topic zzz", "new way", "good", db_path=db, supersedes=old)
|
||||||
|
hit = compass.query_decisions("current", db_path=db)[0]
|
||||||
|
assert hit["supersedes"] == old
|
||||||
|
assert hit["superseded_by"] is None # nothing supersedes the corrector
|
||||||
|
|
||||||
|
def test_migration_idempotent_repeated_connects(self, db):
|
||||||
|
"""Re-opening the DB re-runs migration harmlessly; column stays present."""
|
||||||
|
compass.add_decision("ctx one", "dec one", "good", db_path=db)
|
||||||
|
for _ in range(3):
|
||||||
|
conn = store._connect(db)
|
||||||
|
try:
|
||||||
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert "supersedes" in cols
|
||||||
|
|
||||||
|
def test_migration_adds_column_to_legacy_db(self, db):
|
||||||
|
"""A pre-supersedes DB gets the column added in via _connect migration."""
|
||||||
|
# Build a legacy `decisions` table WITHOUT supersedes, as older builds had.
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE decisions ("
|
||||||
|
"id INTEGER PRIMARY KEY, created TEXT, context TEXT NOT NULL, "
|
||||||
|
"decision TEXT NOT NULL, rating TEXT NOT NULL, note TEXT, tags TEXT, "
|
||||||
|
"source TEXT, score INTEGER, status TEXT DEFAULT 'active', "
|
||||||
|
"last_reviewed TEXT, times_surfaced INTEGER DEFAULT 0)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
# Legacy table lacks the column...
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
pre = {r[1] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||||
|
conn.close()
|
||||||
|
assert "supersedes" not in pre
|
||||||
|
# ...a store connect migrates it in (CREATE IF NOT EXISTS is a no-op here).
|
||||||
|
conn = store._connect(db)
|
||||||
|
try:
|
||||||
|
post = {r["name"] for r in conn.execute("PRAGMA table_info(decisions)")}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert "supersedes" in post
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindConflicts:
|
||||||
|
"""Write-time conflict check: FTS over ACTIVE rows, sanitized, side-effect-free."""
|
||||||
|
|
||||||
|
def test_finds_overlapping_active_row(self, db):
|
||||||
|
"""A would-be entry surfaces an existing active row it overlaps."""
|
||||||
|
compass.add_decision("caching strategy for the API", "add a redis caching layer", "good", db_path=db)
|
||||||
|
hits = compass.find_conflicts("caching approach", "use a caching layer", db_path=db)
|
||||||
|
assert len(hits) >= 1
|
||||||
|
assert any("caching" in h["context"] for h in hits)
|
||||||
|
|
||||||
|
def test_ignores_archived_rows(self, db):
|
||||||
|
"""Conflict check searches active rows only — archived never surfaces."""
|
||||||
|
did = compass.add_decision("archived topic xyzzy", "some decision", "good", db_path=db)
|
||||||
|
compass.archive(did, db_path=db)
|
||||||
|
assert compass.find_conflicts("xyzzy topic", "another decision", db_path=db) == []
|
||||||
|
|
||||||
|
def test_no_side_effects_on_times_surfaced(self, db):
|
||||||
|
"""The advisory must NOT bump times_surfaced — it is not a real surface."""
|
||||||
|
compass.add_decision("surfacing guard ctx", "a decision here", "good", db_path=db)
|
||||||
|
compass.find_conflicts("surfacing guard", "a decision", db_path=db)
|
||||||
|
hit = compass.query_decisions("surfacing", db_path=db)[0]
|
||||||
|
assert hit["times_surfaced"] == 1 # only the query above counted
|
||||||
|
|
||||||
|
def test_sanitizes_fts_special_chars(self, db):
|
||||||
|
"""Raw FTS5 syntax characters must not crash MATCH — sanitized to literals."""
|
||||||
|
compass.add_decision("special ctx", "a normal decision", "good", db_path=db)
|
||||||
|
weird = 'broken " ( ) * : query -term AND OR NOT'
|
||||||
|
result = compass.find_conflicts(weird, "more * (text) ^caret", db_path=db)
|
||||||
|
assert isinstance(result, list) # no exception raised
|
||||||
|
|
||||||
|
def test_empty_text_returns_empty(self, db):
|
||||||
|
"""Text with no usable tokens yields no conflicts (and no crash)."""
|
||||||
|
assert compass.find_conflicts(" ", " ", db_path=db) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetNote:
|
||||||
|
"""note edits persist AND re-index immediately via the FTS5 UPDATE trigger."""
|
||||||
|
|
||||||
|
def test_set_note_updates_and_returns_true(self, db):
|
||||||
|
"""set_note stores the note and reports the row was changed."""
|
||||||
|
did = compass.add_decision("note ctx", "note dec", "good", db_path=db)
|
||||||
|
assert compass.set_note(did, "a fresh observation", db_path=db) is True
|
||||||
|
hit = compass.query_decisions("note ctx", db_path=db)[0]
|
||||||
|
assert hit["note"] == "a fresh observation"
|
||||||
|
|
||||||
|
def test_note_edit_is_immediately_fts_searchable(self, db):
|
||||||
|
"""PROOF: the decisions_au trigger re-indexes a note UPDATE for FTS."""
|
||||||
|
did = compass.add_decision("indexing ctx", "indexing dec", "good", db_path=db)
|
||||||
|
# 'zebra' appears nowhere yet.
|
||||||
|
assert compass.query_decisions("zebra", db_path=db) == []
|
||||||
|
compass.set_note(did, "mentions zebra now", db_path=db)
|
||||||
|
# Immediately findable through the freshly re-indexed note column.
|
||||||
|
found = compass.query_decisions("zebra", db_path=db)
|
||||||
|
assert len(found) == 1
|
||||||
|
assert found[0]["id"] == did
|
||||||
|
|
||||||
|
def test_set_note_missing_id_returns_false(self, db):
|
||||||
|
"""set_note on a non-existent id returns False (no silent create)."""
|
||||||
|
assert compass.set_note(9999, "nope", db_path=db) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreRemoved:
|
||||||
|
"""score is gone from every Python surface (DPLAN-0246 seedgo ruling)."""
|
||||||
|
|
||||||
|
def test_score_not_in_decision_columns(self):
|
||||||
|
"""The code-level column list no longer names score."""
|
||||||
|
assert "score" not in store._DECISION_COLUMNS
|
||||||
|
|
||||||
|
def test_score_absent_from_query_dict(self, db):
|
||||||
|
"""Query result dicts carry no score key."""
|
||||||
|
compass.add_decision("score ctx", "score dec", "good", db_path=db)
|
||||||
|
hit = compass.query_decisions("score", db_path=db)[0]
|
||||||
|
assert "score" not in hit
|
||||||
|
|
||||||
|
def test_score_absent_from_review_dict(self, db):
|
||||||
|
"""Review result dicts carry no score key."""
|
||||||
|
compass.add_decision("review score ctx", "dec", "good", db_path=db)
|
||||||
|
result = compass.review(db_path=db)
|
||||||
|
assert result is not None
|
||||||
|
assert "score" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecall:
|
||||||
|
"""recall_decisions/mark_surfaced — the Track 2 ambient-recall read path."""
|
||||||
|
|
||||||
|
def test_recall_returns_scored_active_candidates(self, db):
|
||||||
|
"""Raw prompt text yields active hits, each with relevance in (0, 1)."""
|
||||||
|
compass.add_decision("vectorization pipeline ctx", "salt vector ids", "good", db_path=db)
|
||||||
|
hits = compass.recall_decisions("we are working on the vectorization pipeline", db_path=db)
|
||||||
|
assert hits and hits[0]["decision"] == "salt vector ids"
|
||||||
|
assert 0.0 < hits[0]["relevance"] < 1.0
|
||||||
|
|
||||||
|
def test_recall_is_side_effect_free(self, db):
|
||||||
|
"""A recall does NOT bump times_surfaced — candidates are not surfacings."""
|
||||||
|
compass.add_decision("recall counter ctx", "stay untouched", "good", db_path=db)
|
||||||
|
rid = compass.recall_decisions("recall counter", db_path=db)[0]["id"]
|
||||||
|
compass.recall_decisions("recall counter", db_path=db)
|
||||||
|
hit = compass.query_decisions("untouched", db_path=db)[0]
|
||||||
|
assert hit["id"] == rid
|
||||||
|
# query_decisions itself increments once; recalls added nothing.
|
||||||
|
assert hit["times_surfaced"] == 1
|
||||||
|
|
||||||
|
def test_recall_excludes_archived(self, db):
|
||||||
|
"""Archived rows never come back as ambient candidates."""
|
||||||
|
rid = compass.add_decision("archived recall ctx", "dead ruling", "bad", db_path=db)
|
||||||
|
compass.archive(rid, db_path=db)
|
||||||
|
assert compass.recall_decisions("archived recall dead ruling", db_path=db) == []
|
||||||
|
|
||||||
|
def test_recall_survives_fts_syntax_in_prompt(self, db):
|
||||||
|
"""FTS5 syntax characters in a prompt cannot crash the MATCH."""
|
||||||
|
compass.add_decision("syntax safety ctx", "quote all tokens", "good", db_path=db)
|
||||||
|
hits = compass.recall_decisions('safety AND (tokens) OR "quote" NEAR *:^-', db_path=db)
|
||||||
|
assert hits and hits[0]["decision"] == "quote all tokens"
|
||||||
|
|
||||||
|
def test_recall_empty_prompt_returns_empty(self, db):
|
||||||
|
"""No usable tokens → empty list, no error."""
|
||||||
|
assert compass.recall_decisions("", db_path=db) == []
|
||||||
|
assert compass.recall_decisions("()!@#$", db_path=db) == []
|
||||||
|
|
||||||
|
def test_recall_ranks_most_relevant_first(self, db):
|
||||||
|
"""Denser overlap outranks a single shared token."""
|
||||||
|
compass.add_decision("alpha beta gamma delta", "dense match", "good", db_path=db)
|
||||||
|
compass.add_decision("alpha unrelated topic here", "sparse match", "good", db_path=db)
|
||||||
|
hits = compass.recall_decisions("alpha beta gamma delta", limit=2, db_path=db)
|
||||||
|
assert hits[0]["decision"] == "dense match"
|
||||||
|
assert hits[0]["relevance"] > hits[1]["relevance"]
|
||||||
|
|
||||||
|
def test_recall_invalid_limit_raises(self, db):
|
||||||
|
"""Non-positive limit fails honestly."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
compass.recall_decisions("anything", limit=0, db_path=db)
|
||||||
|
|
||||||
|
def test_mark_surfaced_counts_only_injected(self, db):
|
||||||
|
"""mark_surfaced increments exactly the ids the caller reports."""
|
||||||
|
a = compass.add_decision("mark ctx one", "dec one xyzzy", "good", db_path=db)
|
||||||
|
b = compass.add_decision("mark ctx two", "dec two xyzzy", "good", db_path=db)
|
||||||
|
assert compass.mark_surfaced([a], db_path=db) == 1
|
||||||
|
hits = {h["id"]: h for h in compass.query_decisions("xyzzy", limit=5, db_path=db)}
|
||||||
|
# query bumps both by 1; only a carries the extra mark_surfaced bump.
|
||||||
|
assert hits[a]["times_surfaced"] == 2
|
||||||
|
assert hits[b]["times_surfaced"] == 1
|
||||||
|
|
||||||
|
def test_mark_surfaced_empty_list_is_noop(self, db):
|
||||||
|
"""An empty id list returns 0 and touches nothing."""
|
||||||
|
assert compass.mark_surfaced([], db_path=db) == 0
|
||||||
|
|
||||||
|
def test_recall_stopwords_do_not_drive_ranking(self, db):
|
||||||
|
"""Filler words in the prompt cannot outrank topic words (FPLAN-0332)."""
|
||||||
|
compass.add_decision(
|
||||||
|
"docker sop ctx with the and on for filler heavy text",
|
||||||
|
"push dev first clone in container",
|
||||||
|
"good",
|
||||||
|
db_path=db,
|
||||||
|
)
|
||||||
|
compass.add_decision(
|
||||||
|
"compass curation ctx",
|
||||||
|
"supersedes links archive corrections",
|
||||||
|
"good",
|
||||||
|
db_path=db,
|
||||||
|
)
|
||||||
|
hits = compass.recall_decisions(
|
||||||
|
"lets keep working on the compass supersedes links and curation",
|
||||||
|
limit=2,
|
||||||
|
db_path=db,
|
||||||
|
)
|
||||||
|
assert hits[0]["decision"] == "supersedes links archive corrections"
|
||||||
|
|
||||||
|
def test_recall_all_stopword_prompt_returns_empty(self, db):
|
||||||
|
"""A prompt made only of stopwords yields no candidates, no error."""
|
||||||
|
compass.add_decision("some ctx", "some dec", "good", db_path=db)
|
||||||
|
assert compass.recall_decisions("what is it and how do we", db_path=db) == []
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ def test_stalltracker_inflight_tool_prevents_stall(monkeypatch, capsys):
|
|||||||
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: True)
|
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: True)
|
||||||
t = agent_handler.StallTracker("@x", Path("/nope"), {}, now=0.0, pid=123)
|
t = agent_handler.StallTracker("@x", Path("/nope"), {}, now=0.0, pid=123)
|
||||||
|
|
||||||
for now in (60.0, 120.0, 180.0, 240.0):
|
for now in (120.0, 240.0, 360.0, 480.0):
|
||||||
t.observe(now=now)
|
t.observe(now=now)
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
@@ -391,7 +391,8 @@ def test_watch_agent_surfaces_stall_to_stdout(monkeypatch, tmp_path, capsys):
|
|||||||
monkeypatch.setattr(agent_handler, "_pid_alive", lambda pid: True)
|
monkeypatch.setattr(agent_handler, "_pid_alive", lambda pid: True)
|
||||||
monkeypatch.setattr(agent_handler, "_has_jsonl_activity", lambda *a, **kw: False)
|
monkeypatch.setattr(agent_handler, "_has_jsonl_activity", lambda *a, **kw: False)
|
||||||
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: False)
|
monkeypatch.setattr(agent_handler, "_last_entry_is_inflight_tool", lambda *a, **kw: False)
|
||||||
_fake_clock_sleep(agent_handler, monkeypatch, lock_file)
|
# Lock must outlive STALL_THRESHOLD (300s) or the watch completes stall-free.
|
||||||
|
_fake_clock_sleep(agent_handler, monkeypatch, lock_file, unlink_at=400.0)
|
||||||
|
|
||||||
result = agent_handler.watch_agent("@fakebranch", timeout_seconds=100000, poll_interval=0.01)
|
result = agent_handler.watch_agent("@fakebranch", timeout_seconds=100000, poll_interval=0.01)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
|||||||
@@ -21,6 +21,17 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone systems # See all registered branches
|
||||||
|
drone @seedgo audit aipass # Route a command to a branch
|
||||||
|
drone @flow --help # Show help for any branch
|
||||||
|
drone scan @memory # Discover available commands
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Commands / Usage
|
## Commands / Usage
|
||||||
|
|
||||||
Drone provides a CLI for terminal use and a Python API for programmatic access.
|
Drone provides a CLI for terminal use and a Python API for programmatic access.
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ INTERACTIVE_COMMANDS = ("monitor", "audit", "watchdog", "status")
|
|||||||
INTERACTIVE_BRANCHES = ("cli", "backup")
|
INTERACTIVE_BRANCHES = ("cli", "backup")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_timeout(args: list[str]) -> tuple[list[str], int | None]:
|
||||||
|
"""Extract --drone-timeout N from an arg list. Returns (cleaned_args, timeout_or_None)."""
|
||||||
|
if "--drone-timeout" not in args:
|
||||||
|
return args, None
|
||||||
|
idx = args.index("--drone-timeout")
|
||||||
|
if idx + 1 >= len(args):
|
||||||
|
return args, None
|
||||||
|
try:
|
||||||
|
timeout = int(args[idx + 1])
|
||||||
|
except ValueError:
|
||||||
|
logger.info("--drone-timeout value %r is not an integer, ignoring", args[idx + 1])
|
||||||
|
return args, None
|
||||||
|
return args[:idx] + args[idx + 2 :], timeout
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# AUTO-DISCOVERY
|
# AUTO-DISCOVERY
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -78,7 +93,7 @@ def _discover_modules() -> list[tuple[str, str]]:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def show_help() -> None:
|
def print_help() -> None:
|
||||||
"""Display drone help with Rich formatting."""
|
"""Display drone help with Rich formatting."""
|
||||||
table = Table(show_header=False, box=None, pad_edge=False, show_edge=False)
|
table = Table(show_header=False, box=None, pad_edge=False, show_edge=False)
|
||||||
table.add_column(style="cyan", no_wrap=True)
|
table.add_column(style="cyan", no_wrap=True)
|
||||||
@@ -92,6 +107,7 @@ def show_help() -> None:
|
|||||||
table.add_row("list", "List registered custom commands")
|
table.add_row("list", "List registered custom commands")
|
||||||
table.add_row("remove <name>", "Remove a custom command")
|
table.add_row("remove <name>", "Remove a custom command")
|
||||||
table.add_row("rm <path> [<path>...]", "Contained safe-delete (project + tmp)")
|
table.add_row("rm <path> [<path>...]", "Contained safe-delete (project + tmp)")
|
||||||
|
table.add_row("--drone-timeout <seconds>", "Override subprocess timeout (default 30s)")
|
||||||
table.add_row("--help", "Show this help")
|
table.add_row("--help", "Show this help")
|
||||||
table.add_row("--version", "Show version")
|
table.add_row("--version", "Show version")
|
||||||
|
|
||||||
@@ -100,22 +116,22 @@ def show_help() -> None:
|
|||||||
console.print()
|
console.print()
|
||||||
console.print("[dim]Routes commands to registered AIPass branches and modules.[/dim]")
|
console.print("[dim]Routes commands to registered AIPass branches and modules.[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
|
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
|
console.print(" [dim]drone @<target> <command> [args...][/dim]")
|
||||||
|
console.print(" [dim]drone <built-in> [args...][/dim]")
|
||||||
|
console.print(" [dim]drone --help[/dim]")
|
||||||
|
console.print()
|
||||||
console.print(table)
|
console.print(table)
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[bold]Examples:[/bold]")
|
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||||
|
console.print()
|
||||||
console.print(" [green]drone @seedgo audit aipass[/green]")
|
console.print(" [green]drone @seedgo audit aipass[/green]")
|
||||||
console.print(" [green]drone @flow status[/green]")
|
console.print(" [green]drone @flow status[/green]")
|
||||||
console.print(" [green]drone systems[/green]")
|
console.print(" [green]drone systems[/green]")
|
||||||
console.print(" [green]drone activate @seedgo[/green]")
|
|
||||||
console.print(" [green]drone audit[/green] [dim](custom shortcut)[/dim]")
|
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
def print_help() -> None:
|
|
||||||
"""Alias for seedgo standard compliance (audit expects print_help)."""
|
|
||||||
show_help()
|
|
||||||
|
|
||||||
|
|
||||||
def print_introspection() -> None:
|
def print_introspection() -> None:
|
||||||
"""Display branch overview — auto-discovers modules."""
|
"""Display branch overview — auto-discovers modules."""
|
||||||
console.print()
|
console.print()
|
||||||
@@ -340,6 +356,7 @@ def _handle_custom_command(args: list[str]) -> int:
|
|||||||
target = cmd_data["target"]
|
target = cmd_data["target"]
|
||||||
command = cmd_data["command"]
|
command = cmd_data["command"]
|
||||||
cmd_args = list(cmd_data.get("args", [])) + remaining_args
|
cmd_args = list(cmd_data.get("args", [])) + remaining_args
|
||||||
|
cmd_args, explicit_timeout = _extract_timeout(cmd_args)
|
||||||
module_name = target.lstrip("@").lower()
|
module_name = target.lstrip("@").lower()
|
||||||
|
|
||||||
interactive = command in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES
|
interactive = command in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES
|
||||||
@@ -349,6 +366,7 @@ def _handle_custom_command(args: list[str]) -> int:
|
|||||||
target,
|
target,
|
||||||
command,
|
command,
|
||||||
args=cmd_args if cmd_args else None,
|
args=cmd_args if cmd_args else None,
|
||||||
|
timeout=explicit_timeout,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
)
|
)
|
||||||
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
||||||
@@ -412,6 +430,7 @@ def _handle_target(args: List[str]) -> int:
|
|||||||
"""Handle `drone @target command [args]` or `drone @target --help`."""
|
"""Handle `drone @target command [args]` or `drone @target --help`."""
|
||||||
target = args[0]
|
target = args[0]
|
||||||
rest = args[1:]
|
rest = args[1:]
|
||||||
|
rest, explicit_timeout = _extract_timeout(rest)
|
||||||
module_name = target.lstrip("@").lower()
|
module_name = target.lstrip("@").lower()
|
||||||
|
|
||||||
first_cmd = rest[0] if rest and rest[0] not in ("--help", "-h") else None
|
first_cmd = rest[0] if rest and rest[0] not in ("--help", "-h") else None
|
||||||
@@ -470,6 +489,7 @@ def _handle_target(args: List[str]) -> int:
|
|||||||
target,
|
target,
|
||||||
command,
|
command,
|
||||||
args=cmd_args if cmd_args else None,
|
args=cmd_args if cmd_args else None,
|
||||||
|
timeout=explicit_timeout,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
)
|
)
|
||||||
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
||||||
|
|||||||
@@ -21,6 +21,29 @@ from .exceptions import CommandExecutionError
|
|||||||
from aipass.drone.apps.handlers.json import json_handler
|
from aipass.drone.apps.handlers.json import json_handler
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
TIMEOUT_OVERRIDES: dict[str, dict[str, int]] = {
|
||||||
|
"memory": {"process-plans": 120},
|
||||||
|
"flow": {"close": 90},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_timeout(branch: str, command: str | None, explicit: int | None = None) -> int:
|
||||||
|
"""Resolve subprocess timeout for a branch command.
|
||||||
|
|
||||||
|
Priority: explicit flag > per-command policy > DEFAULT_TIMEOUT.
|
||||||
|
"""
|
||||||
|
if explicit is not None:
|
||||||
|
return explicit
|
||||||
|
branch_key = branch.lstrip("@").lower()
|
||||||
|
if command and branch_key in TIMEOUT_OVERRIDES:
|
||||||
|
cmd_timeout = TIMEOUT_OVERRIDES[branch_key].get(command)
|
||||||
|
if cmd_timeout is not None:
|
||||||
|
return cmd_timeout
|
||||||
|
return DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandResult:
|
class CommandResult:
|
||||||
"""Result of a routed command execution."""
|
"""Result of a routed command execution."""
|
||||||
@@ -82,7 +105,10 @@ def execute_command(
|
|||||||
return CommandResult(stdout="", stderr="", exit_code=130, branch="", command="")
|
return CommandResult(stdout="", stderr="", exit_code=130, branch="", command="")
|
||||||
raise
|
raise
|
||||||
except subprocess.TimeoutExpired as e:
|
except subprocess.TimeoutExpired as e:
|
||||||
raise CommandExecutionError(f"Command timed out after {timeout}s: {' '.join(full_cmd)}") from e
|
raise CommandExecutionError(
|
||||||
|
f"Command timed out after {timeout}s: {' '.join(full_cmd)}\n"
|
||||||
|
f" Override with: drone @<target> <command> --drone-timeout <seconds>"
|
||||||
|
) from e
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise CommandExecutionError(f"Executable not found: {executable!r}") from e
|
raise CommandExecutionError(f"Executable not found: {executable!r}") from e
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aipass.prax import logger
|
from aipass.prax import logger
|
||||||
@@ -302,10 +303,47 @@ def _handle_close_pr(args: list[str]) -> dict:
|
|||||||
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_merge(pr_number: str, caller: str, confirmed: bool) -> dict | None:
|
||||||
|
"""Joint-decision gate: merges must never happen accidentally (DPLAN-0256).
|
||||||
|
|
||||||
|
Returns None when the merge may proceed, or a refusal/abort result dict.
|
||||||
|
Order matters: an explicit --confirm always passes; otherwise a real
|
||||||
|
terminal gets an interactive y/N prompt; headless callers are refused.
|
||||||
|
"""
|
||||||
|
if confirmed:
|
||||||
|
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "--confirm"})
|
||||||
|
return None
|
||||||
|
|
||||||
|
if sys.stdin.isatty():
|
||||||
|
answer = input(f"Merge PR #{pr_number}? Merges are a joint decision. [y/N] ")
|
||||||
|
if answer.strip().lower() in ("y", "yes"):
|
||||||
|
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "tty-yes"})
|
||||||
|
return None
|
||||||
|
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "tty-abort"})
|
||||||
|
return {"stdout": "", "stderr": f"Merge of PR #{pr_number} aborted at prompt.", "exit_code": 1}
|
||||||
|
|
||||||
|
json_handler.log_operation("merge_gate", {"pr_number": pr_number, "caller": caller, "path": "headless-refused"})
|
||||||
|
logger.info("merge gate: refused headless merge of PR #%s by %s (no --confirm)", pr_number, caller)
|
||||||
|
return {
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": (
|
||||||
|
f"Merge of PR #{pr_number} requires explicit confirmation — merges are a joint decision.\n"
|
||||||
|
f"Re-run once agreed: drone @git merge {pr_number} --confirm"
|
||||||
|
),
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _handle_merge(args: list[str], caller: str) -> dict:
|
def _handle_merge(args: list[str], caller: str) -> dict:
|
||||||
"""Handle the merge subcommand (owner-tier, auth pre-checked)."""
|
"""Handle the merge subcommand (owner-tier, auth pre-checked)."""
|
||||||
if not args:
|
confirmed = "--confirm" in args
|
||||||
return {"stdout": "", "stderr": "Usage: drone @git merge <PR#>", "exit_code": 1}
|
pr_args = [a for a in args if not a.startswith("--")]
|
||||||
|
if not pr_args:
|
||||||
|
return {"stdout": "", "stderr": "Usage: drone @git merge <PR#> [--confirm]", "exit_code": 1}
|
||||||
|
|
||||||
|
refusal = _confirm_merge(pr_args[0], caller, confirmed)
|
||||||
|
if refusal is not None:
|
||||||
|
return refusal
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
|
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
|
||||||
@@ -313,7 +351,7 @@ def _handle_merge(args: list[str], caller: str) -> dict:
|
|||||||
logger.error("Failed to import devpulse_ops merge plugin: %s", exc)
|
logger.error("Failed to import devpulse_ops merge plugin: %s", exc)
|
||||||
return {"stdout": "", "stderr": f"devpulse_ops plugin not available: {exc}", "exit_code": 1}
|
return {"stdout": "", "stderr": f"devpulse_ops plugin not available: {exc}", "exit_code": 1}
|
||||||
|
|
||||||
result = merge_pr(args[0], caller)
|
result = merge_pr(pr_args[0], caller)
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
return {"stdout": result["message"], "stderr": "", "exit_code": 0}
|
return {"stdout": result["message"], "stderr": "", "exit_code": 0}
|
||||||
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
return {"stdout": "", "stderr": result["message"], "exit_code": 1}
|
||||||
@@ -637,8 +675,10 @@ def get_help(command: str | None = None) -> str:
|
|||||||
return "git unlock --force — Force-release the PR lock [owner]\n"
|
return "git unlock --force — Force-release the PR lock [owner]\n"
|
||||||
if command == "merge":
|
if command == "merge":
|
||||||
return (
|
return (
|
||||||
"git merge <PR#> — Merge a PR and sync local main [owner]\n"
|
"git merge <PR#> [--confirm] — Merge a PR and sync local main [owner]\n"
|
||||||
" Runs gh pr merge --merge --delete-branch, then git pull --rebase.\n"
|
" Runs gh pr merge --merge --delete-branch, then git pull --rebase.\n"
|
||||||
|
" Joint-decision gate: terminal sessions get a y/N prompt; headless\n"
|
||||||
|
" callers must pass --confirm or the merge is refused.\n"
|
||||||
)
|
)
|
||||||
if command == "smart-sync":
|
if command == "smart-sync":
|
||||||
return "git smart-sync — Fetch origin and rebase if behind [owner]\n"
|
return "git smart-sync — Fetch origin and rebase if behind [owner]\n"
|
||||||
@@ -686,7 +726,7 @@ def get_help(command: str | None = None) -> str:
|
|||||||
" dev-pr <desc> Push dev and create PR to main\n"
|
" dev-pr <desc> Push dev and create PR to main\n"
|
||||||
" delete-branch <name> Delete a remote branch\n"
|
" delete-branch <name> Delete a remote branch\n"
|
||||||
" close-pr <number> Close a PR\n"
|
" close-pr <number> Close a PR\n"
|
||||||
" merge <PR#> Merge a PR\n"
|
" merge <PR#> [--confirm] Merge a PR (gated: y/N prompt or --confirm)\n"
|
||||||
" sync [--autostash] Sync with origin/main (FF on dev)\n"
|
" sync [--autostash] Sync with origin/main (FF on dev)\n"
|
||||||
" smart-sync Fetch + rebase if behind\n"
|
" smart-sync Fetch + rebase if behind\n"
|
||||||
" unlock --force Force-release the PR lock\n"
|
" unlock --force Force-release the PR lock\n"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from aipass.prax.apps.modules.logger import system_logger
|
from aipass.prax.apps.modules.logger import system_logger
|
||||||
from aipass.cli.apps.modules import console
|
from aipass.cli.apps.modules import console
|
||||||
from aipass.drone.apps.handlers.executor import CommandResult
|
from aipass.drone.apps.handlers.executor import CommandResult, resolve_timeout
|
||||||
from aipass.drone.apps.handlers.json import json_handler
|
from aipass.drone.apps.handlers.json import json_handler
|
||||||
from aipass.drone.apps.handlers.router_handler import (
|
from aipass.drone.apps.handlers.router_handler import (
|
||||||
detect_caller_branch_name,
|
detect_caller_branch_name,
|
||||||
@@ -90,28 +90,38 @@ def route_command(
|
|||||||
target: str,
|
target: str,
|
||||||
command: Optional[str] = None,
|
command: Optional[str] = None,
|
||||||
args: Optional[List[str]] = None,
|
args: Optional[List[str]] = None,
|
||||||
timeout: int = 30,
|
timeout: int | None = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Route a command to a branch's entry point.
|
"""Route a command to a branch's entry point.
|
||||||
|
|
||||||
Resolves @target to a path, then delegates to the handler for execution.
|
Resolves @target to a path, then delegates to the handler for execution.
|
||||||
When command is None, runs the branch with no args (introspection).
|
When command is None, runs the branch with no args (introspection).
|
||||||
|
|
||||||
|
Timeout resolution: explicit value > per-command policy > DEFAULT_TIMEOUT.
|
||||||
"""
|
"""
|
||||||
branch_path = resolve_branch(target)
|
branch_path = resolve_branch(target)
|
||||||
branch_name = target.lstrip("@").lower()
|
branch_name = target.lstrip("@").lower()
|
||||||
|
resolved_timeout = resolve_timeout(branch_name, command, timeout)
|
||||||
|
|
||||||
caller = detect_caller_branch_name(Path.cwd())
|
caller = detect_caller_branch_name(Path.cwd())
|
||||||
if not caller:
|
if not caller:
|
||||||
caller = os.environ.get("AIPASS_BRANCH_NAME")
|
caller = os.environ.get("AIPASS_BRANCH_NAME")
|
||||||
caller_tag = f" [CALLER:{caller.upper()}]" if caller else ""
|
caller_tag = f" [CALLER:{caller.upper()}]" if caller else ""
|
||||||
logger.info("Routing @%s%s → %s %s", branch_name, caller_tag, command or "(introspection)", args or [])
|
logger.info(
|
||||||
|
"Routing @%s%s → %s %s (timeout=%ds)",
|
||||||
|
branch_name,
|
||||||
|
caller_tag,
|
||||||
|
command or "(introspection)",
|
||||||
|
args or [],
|
||||||
|
resolved_timeout,
|
||||||
|
)
|
||||||
return execute_branch_command(
|
return execute_branch_command(
|
||||||
branch_path=branch_path,
|
branch_path=branch_path,
|
||||||
branch_name=branch_name,
|
branch_name=branch_name,
|
||||||
command=command,
|
command=command,
|
||||||
args=args,
|
args=args,
|
||||||
timeout=timeout,
|
timeout=resolved_timeout,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -147,7 +157,7 @@ def print_introspection():
|
|||||||
def route_all(
|
def route_all(
|
||||||
command: str,
|
command: str,
|
||||||
args: Optional[List[str]] = None,
|
args: Optional[List[str]] = None,
|
||||||
timeout: int = 30,
|
timeout: int | None = None,
|
||||||
) -> Dict[str, CommandResult]:
|
) -> Dict[str, CommandResult]:
|
||||||
"""Route the same command to ALL active branches in the registry."""
|
"""Route the same command to ALL active branches in the registry."""
|
||||||
if args is None:
|
if args is None:
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ class TestHandleCustomCommand:
|
|||||||
"@seedgo",
|
"@seedgo",
|
||||||
"audit",
|
"audit",
|
||||||
args=["aipass"],
|
args=["aipass"],
|
||||||
|
timeout=None,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -380,6 +381,7 @@ class TestHandleCustomCommand:
|
|||||||
"@seedgo",
|
"@seedgo",
|
||||||
"audit",
|
"audit",
|
||||||
args=["aipass", "@drone"],
|
args=["aipass", "@drone"],
|
||||||
|
timeout=None,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -616,6 +618,7 @@ class TestMainIntegration:
|
|||||||
"@seedgo",
|
"@seedgo",
|
||||||
"audit",
|
"audit",
|
||||||
args=["aipass"],
|
args=["aipass"],
|
||||||
|
timeout=None,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -642,6 +645,7 @@ class TestMainIntegration:
|
|||||||
"@seedgo",
|
"@seedgo",
|
||||||
"audit",
|
"audit",
|
||||||
args=["aipass", "@drone"],
|
args=["aipass", "@drone"],
|
||||||
|
timeout=None,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -714,5 +718,6 @@ class TestMatchCommandIntegration:
|
|||||||
"@flow",
|
"@flow",
|
||||||
"create",
|
"create",
|
||||||
args=["--type=plan", "my-plan"],
|
args=["--type=plan", "my-plan"],
|
||||||
|
timeout=None,
|
||||||
interactive=False,
|
interactive=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -903,3 +903,80 @@ class TestAipassIntercept:
|
|||||||
):
|
):
|
||||||
result = main()
|
result = main()
|
||||||
assert result == 0
|
assert result == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _extract_timeout — --timeout flag parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractTimeout:
|
||||||
|
"""Tests for --drone-timeout flag extraction from arg lists."""
|
||||||
|
|
||||||
|
def test_no_flag(self) -> None:
|
||||||
|
"""Args without --drone-timeout pass through unchanged."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
args = ["close", "FPLAN-0313"]
|
||||||
|
cleaned, timeout = _extract_timeout(args)
|
||||||
|
assert cleaned == ["close", "FPLAN-0313"]
|
||||||
|
assert timeout is None
|
||||||
|
|
||||||
|
def test_flag_at_end(self) -> None:
|
||||||
|
"""--drone-timeout N at end of args is extracted."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
cleaned, timeout = _extract_timeout(["process-plans", "--drone-timeout", "120"])
|
||||||
|
assert cleaned == ["process-plans"]
|
||||||
|
assert timeout == 120
|
||||||
|
|
||||||
|
def test_flag_at_start(self) -> None:
|
||||||
|
"""--drone-timeout N at start of args is extracted."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
cleaned, timeout = _extract_timeout(["--drone-timeout", "90", "close", "FPLAN-0313"])
|
||||||
|
assert cleaned == ["close", "FPLAN-0313"]
|
||||||
|
assert timeout == 90
|
||||||
|
|
||||||
|
def test_flag_in_middle(self) -> None:
|
||||||
|
"""--drone-timeout N in the middle of args is extracted."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
cleaned, timeout = _extract_timeout(["close", "--drone-timeout", "60", "FPLAN-0313"])
|
||||||
|
assert cleaned == ["close", "FPLAN-0313"]
|
||||||
|
assert timeout == 60
|
||||||
|
|
||||||
|
def test_flag_without_value(self) -> None:
|
||||||
|
"""--drone-timeout at end with no value returns None and leaves args."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
args = ["close", "--drone-timeout"]
|
||||||
|
cleaned, timeout = _extract_timeout(args)
|
||||||
|
assert cleaned == args
|
||||||
|
assert timeout is None
|
||||||
|
|
||||||
|
def test_flag_non_integer_value(self) -> None:
|
||||||
|
"""--drone-timeout with non-integer value returns None and leaves args."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
args = ["close", "--drone-timeout", "abc"]
|
||||||
|
cleaned, timeout = _extract_timeout(args)
|
||||||
|
assert cleaned == args
|
||||||
|
assert timeout is None
|
||||||
|
|
||||||
|
def test_empty_args(self) -> None:
|
||||||
|
"""Empty arg list returns empty with None timeout."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
cleaned, timeout = _extract_timeout([])
|
||||||
|
assert cleaned == []
|
||||||
|
assert timeout is None
|
||||||
|
|
||||||
|
def test_plain_timeout_passes_through(self) -> None:
|
||||||
|
"""--timeout (without drone- prefix) is NOT consumed — passes to target."""
|
||||||
|
from aipass.drone.apps.drone import _extract_timeout
|
||||||
|
|
||||||
|
args = ["watchdog", "agent", "@memory", "--timeout", "1800"]
|
||||||
|
cleaned, timeout = _extract_timeout(args)
|
||||||
|
assert cleaned == args
|
||||||
|
assert timeout is None
|
||||||
|
|||||||
@@ -552,7 +552,9 @@ class TestGitModuleRouting:
|
|||||||
proc.stdout = "ok\n"
|
proc.stdout = "ok\n"
|
||||||
mock_run.return_value = proc
|
mock_run.return_value = proc
|
||||||
|
|
||||||
result = handle_command("merge", ["42"])
|
# --confirm satisfies the joint-decision gate (DPLAN-0256); headless
|
||||||
|
# merge without it is refused before routing reaches merge_pr.
|
||||||
|
result = handle_command("merge", ["42", "--confirm"])
|
||||||
assert result["exit_code"] == 0
|
assert result["exit_code"] == 0
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from aipass.drone.apps.handlers.exceptions import CommandExecutionError
|
from aipass.drone.apps.handlers.exceptions import CommandExecutionError
|
||||||
from aipass.drone.apps.handlers.executor import execute_command
|
from aipass.drone.apps.handlers.executor import (
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
TIMEOUT_OVERRIDES,
|
||||||
|
execute_command,
|
||||||
|
resolve_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -389,3 +394,63 @@ class TestShellSecurity:
|
|||||||
# The semicolon is treated as literal text, not a shell separator
|
# The semicolon is treated as literal text, not a shell separator
|
||||||
assert result.stdout.strip() == "hello; echo pwned"
|
assert result.stdout.strip() == "hello; echo pwned"
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 11. resolve_timeout — policy resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveTimeout:
|
||||||
|
"""Timeout resolution: explicit > policy > default."""
|
||||||
|
|
||||||
|
def test_default_timeout(self):
|
||||||
|
"""Unknown branch+command returns DEFAULT_TIMEOUT."""
|
||||||
|
assert resolve_timeout("unknown", "whatever") == DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
def test_policy_override(self):
|
||||||
|
"""Known branch+command returns the policy value."""
|
||||||
|
for branch, cmds in TIMEOUT_OVERRIDES.items():
|
||||||
|
for cmd, expected in cmds.items():
|
||||||
|
assert resolve_timeout(branch, cmd) == expected
|
||||||
|
|
||||||
|
def test_explicit_wins_over_policy(self):
|
||||||
|
"""Explicit timeout overrides the policy map."""
|
||||||
|
branch = next(iter(TIMEOUT_OVERRIDES))
|
||||||
|
cmd = next(iter(TIMEOUT_OVERRIDES[branch]))
|
||||||
|
assert resolve_timeout(branch, cmd, explicit=999) == 999
|
||||||
|
|
||||||
|
def test_explicit_wins_over_default(self):
|
||||||
|
"""Explicit timeout overrides the default."""
|
||||||
|
assert resolve_timeout("unknown", "whatever", explicit=42) == 42
|
||||||
|
|
||||||
|
def test_none_command_returns_default(self):
|
||||||
|
"""None command (introspection) returns default."""
|
||||||
|
assert resolve_timeout("memory", None) == DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
def test_at_prefix_stripped(self):
|
||||||
|
"""Leading @ on branch name is stripped before lookup."""
|
||||||
|
for branch in TIMEOUT_OVERRIDES:
|
||||||
|
cmd = next(iter(TIMEOUT_OVERRIDES[branch]))
|
||||||
|
expected = TIMEOUT_OVERRIDES[branch][cmd]
|
||||||
|
assert resolve_timeout(f"@{branch}", cmd) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12. Timeout error message includes --timeout hint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimeoutErrorMessage:
|
||||||
|
"""Timeout error tells the caller how to override."""
|
||||||
|
|
||||||
|
def test_timeout_error_includes_override_hint(self, temp_test_dir: Path):
|
||||||
|
"""The timeout error message mentions --timeout."""
|
||||||
|
with pytest.raises(CommandExecutionError, match="--drone-timeout") as exc_info:
|
||||||
|
execute_command(
|
||||||
|
sys.executable,
|
||||||
|
["-c", "import time; time.sleep(10)"],
|
||||||
|
cwd=str(temp_test_dir),
|
||||||
|
timeout=1,
|
||||||
|
)
|
||||||
|
assert "--drone-timeout" in str(exc_info.value)
|
||||||
|
|||||||
@@ -1471,3 +1471,91 @@ class TestScopeFooter:
|
|||||||
result = handle_command("diff", ["--all"])
|
result = handle_command("diff", ["--all"])
|
||||||
|
|
||||||
assert "showing drone scope" not in result["stdout"]
|
assert "showing drone scope" not in result["stdout"]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Merge joint-decision gate (DPLAN-0256)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
_MERGE_PR = "aipass.drone.apps.plugins.devpulse_ops.merge_plugin.merge_pr"
|
||||||
|
_MERGE_OK = {
|
||||||
|
"success": True,
|
||||||
|
"pr_number": "123",
|
||||||
|
"title": "t",
|
||||||
|
"merge_commit": "abc123",
|
||||||
|
"message": "PR #123 merged: t (abc123)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeGate:
|
||||||
|
"""merge must never run without explicit confirmation."""
|
||||||
|
|
||||||
|
@patch(_AUTH, return_value="devpulse")
|
||||||
|
def test_headless_without_confirm_refused(self, _mock_auth: MagicMock) -> None:
|
||||||
|
"""Non-TTY caller without --confirm is refused before the plugin loads."""
|
||||||
|
with patch(_MERGE_PR) as mock_merge:
|
||||||
|
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
|
||||||
|
mock_stdin.isatty.return_value = False
|
||||||
|
result = handle_command("merge", ["123"])
|
||||||
|
|
||||||
|
assert result["exit_code"] == 1
|
||||||
|
assert "requires explicit confirmation" in result["stderr"]
|
||||||
|
assert "--confirm" in result["stderr"]
|
||||||
|
mock_merge.assert_not_called()
|
||||||
|
|
||||||
|
@patch(_AUTH, return_value="devpulse")
|
||||||
|
def test_confirm_flag_proceeds(self, _mock_auth: MagicMock) -> None:
|
||||||
|
"""--confirm merges without prompting, even headless."""
|
||||||
|
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
|
||||||
|
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
|
||||||
|
mock_stdin.isatty.return_value = False
|
||||||
|
result = handle_command("merge", ["123", "--confirm"])
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
mock_merge.assert_called_once_with("123", "devpulse")
|
||||||
|
|
||||||
|
@patch(_AUTH, return_value="devpulse")
|
||||||
|
def test_confirm_flag_position_agnostic(self, _mock_auth: MagicMock) -> None:
|
||||||
|
"""--confirm before the PR number still resolves the right PR."""
|
||||||
|
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
|
||||||
|
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
|
||||||
|
mock_stdin.isatty.return_value = False
|
||||||
|
result = handle_command("merge", ["--confirm", "123"])
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
mock_merge.assert_called_once_with("123", "devpulse")
|
||||||
|
|
||||||
|
@patch(_AUTH, return_value="devpulse")
|
||||||
|
def test_tty_yes_proceeds(self, _mock_auth: MagicMock) -> None:
|
||||||
|
"""Interactive terminal answering y merges."""
|
||||||
|
with patch(_MERGE_PR, return_value=dict(_MERGE_OK)) as mock_merge:
|
||||||
|
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
|
||||||
|
mock_stdin.isatty.return_value = True
|
||||||
|
with patch("builtins.input", return_value="y"):
|
||||||
|
result = handle_command("merge", ["123"])
|
||||||
|
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
mock_merge.assert_called_once_with("123", "devpulse")
|
||||||
|
|
||||||
|
@patch(_AUTH, return_value="devpulse")
|
||||||
|
def test_tty_default_aborts(self, _mock_auth: MagicMock) -> None:
|
||||||
|
"""Interactive terminal hitting enter (default N) aborts."""
|
||||||
|
with patch(_MERGE_PR) as mock_merge:
|
||||||
|
with patch(f"{_GIT_MOD}.sys.stdin") as mock_stdin:
|
||||||
|
mock_stdin.isatty.return_value = True
|
||||||
|
with patch("builtins.input", return_value=""):
|
||||||
|
result = handle_command("merge", ["123"])
|
||||||
|
|
||||||
|
assert result["exit_code"] == 1
|
||||||
|
assert "aborted" in result["stderr"]
|
||||||
|
mock_merge.assert_not_called()
|
||||||
|
|
||||||
|
@patch(_AUTH, return_value="devpulse")
|
||||||
|
def test_confirm_without_pr_number_is_usage_error(self, _mock_auth: MagicMock) -> None:
|
||||||
|
"""--confirm alone (no PR number) is a usage error, not a merge."""
|
||||||
|
with patch(_MERGE_PR) as mock_merge:
|
||||||
|
result = handle_command("merge", ["--confirm"])
|
||||||
|
|
||||||
|
assert result["exit_code"] == 1
|
||||||
|
assert "Usage" in result["stderr"]
|
||||||
|
mock_merge.assert_not_called()
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ Flow is AIPass's plan management system. Every branch uses flow to create, track
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
drone @flow create . "My task description" # Create a plan in the current directory
|
||||||
|
drone @flow list open # See all open plans
|
||||||
|
drone @flow close FPLAN-0042 # Close a completed plan
|
||||||
|
drone @flow create . "Design topic" dplan # Create a design plan (DPLAN)
|
||||||
|
drone @flow templates # List available plan types
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ Key Functions:
|
|||||||
- verify_and_heal_orphaned_plans() - Orphan healing logic
|
- verify_and_heal_orphaned_plans() - Orphan healing logic
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa: E402
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_PKG_ROOT = Path(__file__).resolve().parents[4]
|
_PKG_ROOT = Path(__file__).resolve().parents[4]
|
||||||
|
|
||||||
# Standard imports
|
# Standard imports
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
@@ -42,6 +45,35 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
|||||||
FLOW_ROOT = _PKG_ROOT / "flow"
|
FLOW_ROOT = _PKG_ROOT / "flow"
|
||||||
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
||||||
|
|
||||||
|
MODULE_NAME = "mbank_process"
|
||||||
|
_LOCK_RETRIES = 10
|
||||||
|
_LOCK_BACKOFF_BASE = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_lock(lock_path: Path) -> bool:
|
||||||
|
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
||||||
|
for attempt in range(_LOCK_RETRIES):
|
||||||
|
try:
|
||||||
|
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.write(fd, str(os.getpid()).encode())
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
||||||
|
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Lock creation failed for %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _release_lock(lock_path: Path) -> None:
|
||||||
|
"""Remove lockfile, tolerating already-removed."""
|
||||||
|
try:
|
||||||
|
lock_path.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
|
||||||
|
|
||||||
def _find_repo_root() -> Path:
|
def _find_repo_root() -> Path:
|
||||||
"""Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json)."""
|
"""Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json)."""
|
||||||
@@ -97,12 +129,22 @@ def load_flow_registry(registry_file: str | None = None) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def save_flow_registry(registry: Dict[str, Any], registry_file: str | None = None) -> None:
|
def save_flow_registry(registry: Dict[str, Any], registry_file: str | None = None) -> None:
|
||||||
"""Save a plan registry."""
|
"""Save a plan registry with lockfile + atomic write."""
|
||||||
target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE
|
target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE
|
||||||
|
lock_path = target.with_suffix(".lock")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
if not _acquire_lock(lock_path):
|
||||||
with open(target, "w", encoding="utf-8") as f:
|
raise OSError(f"Could not acquire lock for {target}")
|
||||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
|
||||||
|
try:
|
||||||
|
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
tmp_path = target.with_suffix(".tmp")
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(str(tmp_path), str(target))
|
||||||
|
finally:
|
||||||
|
_release_lock(lock_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Failed to save flow registry: {e}")
|
raise Exception(f"Failed to save flow registry: {e}")
|
||||||
|
|
||||||
@@ -142,11 +184,15 @@ def get_closed_plans() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def is_template_content(content: str) -> bool:
|
def is_template_content(content: str) -> bool:
|
||||||
"""Check if plan content is still unedited template (v4.0)
|
"""Check if plan content is still unedited template (v5.0)
|
||||||
|
|
||||||
Uses bracket placeholders only (not section headers) to detect untouched
|
Priority order:
|
||||||
templates. Also checks for user-added content in key sections — if any
|
1. User-content signals (checked checkboxes) — strongest evidence of
|
||||||
real work was added, the plan is NOT a template even if placeholders remain.
|
real work, overrides everything.
|
||||||
|
2. Real content in Notes section — overrides bracket markers.
|
||||||
|
3. Non-boilerplate Execution Log entries — user-written entries
|
||||||
|
override bracket markers; template boilerplate is ignored.
|
||||||
|
4. Bracket-marker check — 3+ template placeholders = template.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: Plan file content
|
content: Plan file content
|
||||||
@@ -154,37 +200,38 @@ def is_template_content(content: str) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
True if plan is essentially an untouched template
|
True if plan is essentially an untouched template
|
||||||
"""
|
"""
|
||||||
# User content indicators — if ANY of these are found, plan has real work
|
import re
|
||||||
|
|
||||||
|
# --- 1. User content signals override everything ---
|
||||||
user_content_signals = [
|
user_content_signals = [
|
||||||
"- [x] Agent deployed", # Checked execution log item
|
"- [x] Agent deployed",
|
||||||
"- [x] Agent completed", # Checked execution log item
|
"- [x] Agent completed",
|
||||||
"- [x] Seedgo checklist", # Checked completion item
|
"- [x] Seedgo checklist",
|
||||||
"- [x] All goals achieved", # Checked completion item
|
"- [x] All goals achieved",
|
||||||
]
|
]
|
||||||
for signal in user_content_signals:
|
for signal in user_content_signals:
|
||||||
if signal in content:
|
if signal in content:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check Notes section for user content (not just the placeholder)
|
# --- 2. Real Notes content → not template ---
|
||||||
import re
|
|
||||||
|
|
||||||
notes_match = re.search(r"## Notes\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
|
notes_match = re.search(r"## Notes\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
|
||||||
if notes_match:
|
if notes_match:
|
||||||
notes_content = notes_match.group(1).strip()
|
notes_content = notes_match.group(1).strip()
|
||||||
# If notes has content beyond the template placeholder, it's real work
|
|
||||||
if notes_content and notes_content != "[Working notes, issues encountered, decisions made]":
|
if notes_content and notes_content != "[Working notes, issues encountered, decisions made]":
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check Execution Log for user-added entries beyond template
|
# --- 3. Non-boilerplate Exec Log entries → not template ---
|
||||||
|
_exec_boilerplate = ["**Log Pattern:**", "[task]", "[outcome]", "[file]"]
|
||||||
exec_match = re.search(r"## Execution Log\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
|
exec_match = re.search(r"## Execution Log\s*\n(.*?)(?=\n---|\n## |\Z)", content, re.DOTALL)
|
||||||
if exec_match:
|
if exec_match:
|
||||||
exec_content = exec_match.group(1).strip()
|
exec_content = exec_match.group(1).strip()
|
||||||
lines = [line.strip() for line in exec_content.split("\n") if line.strip()]
|
is_boilerplate = any(m in exec_content for m in _exec_boilerplate)
|
||||||
# Template has ~6 lines (date header + checkbox items). More = user added content.
|
if not is_boilerplate:
|
||||||
if len(lines) > 8:
|
lines = [line.strip() for line in exec_content.split("\n") if line.strip()]
|
||||||
return False
|
if len(lines) > 8:
|
||||||
|
return False
|
||||||
|
|
||||||
# Bracket placeholders only (no section headers — those persist in real plans)
|
# --- 4. Bracket markers: 3+ from any type = template ---
|
||||||
default_markers = [
|
default_markers = [
|
||||||
"[What do you want to achieve? Specific end state.]",
|
"[What do you want to achieve? Specific end state.]",
|
||||||
"[How will agents tackle this? What instructions will they need?]",
|
"[How will agents tackle this? What instructions will they need?]",
|
||||||
@@ -208,7 +255,6 @@ def is_template_content(content: str) -> bool:
|
|||||||
"[Any other branches, services, or approvals needed?]",
|
"[Any other branches, services, or approvals needed?]",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 3+ bracket placeholders from any type = template
|
|
||||||
for markers in [default_markers, master_markers, proposal_markers]:
|
for markers in [default_markers, master_markers, proposal_markers]:
|
||||||
found = sum(1 for m in markers if m in content)
|
found = sum(1 for m in markers if m in content)
|
||||||
if found >= 3:
|
if found >= 3:
|
||||||
@@ -361,7 +407,10 @@ def is_template_content(content: str) -> bool:
|
|||||||
# today = datetime.now().strftime("%Y%m%d")
|
# today = datetime.now().strftime("%Y%m%d")
|
||||||
# plan_num = plan_path.stem.replace("FPLAN-", "")
|
# plan_num = plan_path.stem.replace("FPLAN-", "")
|
||||||
# template_suffix = "-TEMP" if is_template else ""
|
# template_suffix = "-TEMP" if is_template else ""
|
||||||
# filename = f"{folder_context}-{analysis['type']}-{analysis['category']}-{analysis['action']}-FPLAN-{plan_num}{template_suffix}-{today}.md"
|
# filename = (
|
||||||
|
# f"{folder_context}-{analysis['type']}-{analysis['category']}"
|
||||||
|
# f"-{analysis['action']}-FPLAN-{plan_num}{template_suffix}-{today}.md"
|
||||||
|
# )
|
||||||
#
|
#
|
||||||
# filename = re.sub(r'[<>:"|?*]', '-', filename)
|
# filename = re.sub(r'[<>:"|?*]', '-', filename)
|
||||||
# filename = re.sub(r'-+', '-', filename)
|
# filename = re.sub(r'-+', '-', filename)
|
||||||
@@ -627,7 +676,7 @@ def process_closed_plans() -> Dict[str, Any]:
|
|||||||
|
|
||||||
if archive_success:
|
if archive_success:
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
# Vector intake handled by close_ops.py via drone @memory process-plans
|
# Vector intake triggered by post_close_runner via direct import
|
||||||
results.append({"plan": plan_label, "status": "archived", "correlation_id": correlation_id})
|
results.append({"plan": plan_label, "status": "archived", "correlation_id": correlation_id})
|
||||||
else:
|
else:
|
||||||
error_count += 1
|
error_count += 1
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any, List, Tuple, Optional
|
from typing import Dict, Any, List, Tuple, Optional
|
||||||
@@ -32,6 +34,34 @@ from aipass.flow.apps.handlers.json import json_handler
|
|||||||
|
|
||||||
MODULE_NAME = "aggregate_central"
|
MODULE_NAME = "aggregate_central"
|
||||||
|
|
||||||
|
_LOCK_RETRIES = 10
|
||||||
|
_LOCK_BACKOFF_BASE = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_lock(lock_path: Path) -> bool:
|
||||||
|
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
||||||
|
for attempt in range(_LOCK_RETRIES):
|
||||||
|
try:
|
||||||
|
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.write(fd, str(os.getpid()).encode())
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
||||||
|
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Lock creation failed for %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _release_lock(lock_path: Path) -> None:
|
||||||
|
"""Remove lockfile, tolerating already-removed."""
|
||||||
|
try:
|
||||||
|
lock_path.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
|
||||||
|
|
||||||
# =============================================
|
# =============================================
|
||||||
# HELPER FUNCTIONS
|
# HELPER FUNCTIONS
|
||||||
@@ -101,13 +131,36 @@ def save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
True on success, False on failure
|
True on success, False on failure
|
||||||
"""
|
"""
|
||||||
|
lock_path = registry_path.with_suffix(".lock")
|
||||||
try:
|
try:
|
||||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(registry_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
if not _acquire_lock(lock_path):
|
||||||
|
logger.error(
|
||||||
|
"[%s] Could not acquire lock for %s after %d retries",
|
||||||
|
MODULE_NAME,
|
||||||
|
registry_path,
|
||||||
|
_LOCK_RETRIES,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
tmp_path = registry_path.with_suffix(".tmp")
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(str(tmp_path), str(registry_path))
|
||||||
|
finally:
|
||||||
|
_release_lock(lock_path)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{MODULE_NAME}] Failed to save registry {registry_path}: {e}")
|
logger.error(
|
||||||
|
"[%s] Failed to save registry %s: %s",
|
||||||
|
MODULE_NAME,
|
||||||
|
registry_path,
|
||||||
|
e,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -265,10 +318,27 @@ def save_central(central_file: Path, central_dir: Path, central_data: Dict[str,
|
|||||||
Returns:
|
Returns:
|
||||||
True on success, False on failure
|
True on success, False on failure
|
||||||
"""
|
"""
|
||||||
|
lock_path = central_file.with_suffix(".lock")
|
||||||
try:
|
try:
|
||||||
central_dir.mkdir(parents=True, exist_ok=True)
|
central_dir.mkdir(parents=True, exist_ok=True)
|
||||||
with open(central_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(central_data, f, indent=2, ensure_ascii=False)
|
if not _acquire_lock(lock_path):
|
||||||
|
logger.error(
|
||||||
|
"[%s] Could not acquire lock for %s after %d retries",
|
||||||
|
MODULE_NAME,
|
||||||
|
central_file,
|
||||||
|
_LOCK_RETRIES,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
tmp_path = central_file.with_suffix(".tmp")
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(central_data, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(str(tmp_path), str(central_file))
|
||||||
|
finally:
|
||||||
|
_release_lock(lock_path)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{MODULE_NAME}] Failed to save {central_file}: {e}")
|
logger.error(f"[{MODULE_NAME}] Failed to save {central_file}: {e}")
|
||||||
@@ -392,7 +462,10 @@ def aggregate_central_impl(
|
|||||||
# Save central file
|
# Save central file
|
||||||
if save_central(central_file, central_dir, central_data):
|
if save_central(central_file, central_dir, central_data):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[{MODULE_NAME}] SUCCESS: Aggregation complete: {len(all_active)} active, {len(recently_closed)} recently closed"
|
"[%s] SUCCESS: Aggregation complete: %d active, %d recently closed",
|
||||||
|
MODULE_NAME,
|
||||||
|
len(all_active),
|
||||||
|
len(recently_closed),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fire trigger event
|
# Fire trigger event
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Description: Closed Plans Local Registry Handler
|
# Description: Closed Plans Local Registry Handler
|
||||||
# Version: 0.1.0
|
# Version: 0.1.0
|
||||||
# Created: 2026-03-03
|
# Created: 2026-03-03
|
||||||
# Modified: 2026-03-03
|
# Modified: 2026-07-15
|
||||||
# =============================================
|
# =============================================
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -13,8 +13,11 @@ Appends a closed plan entry to the branch's CLOSED_PLANS.local.json file.
|
|||||||
Creates the file if it doesn't exist.
|
Creates the file if it doesn't exist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ruff: noqa: E402
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# INFRASTRUCTURE IMPORT PATTERN
|
# INFRASTRUCTURE IMPORT PATTERN
|
||||||
@@ -27,6 +30,31 @@ from aipass.flow.apps.handlers.json import json_handler
|
|||||||
MODULE_NAME = "append_closed_plan"
|
MODULE_NAME = "append_closed_plan"
|
||||||
CLOSED_PLANS_FILE = "CLOSED_PLANS.local.json"
|
CLOSED_PLANS_FILE = "CLOSED_PLANS.local.json"
|
||||||
|
|
||||||
|
_LOCK_RETRIES = 10
|
||||||
|
_LOCK_BACKOFF_BASE = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_append_lock(lock_path: Path) -> bool:
|
||||||
|
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
||||||
|
for attempt in range(_LOCK_RETRIES):
|
||||||
|
try:
|
||||||
|
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.write(fd, str(os.getpid()).encode())
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
||||||
|
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _release_append_lock(lock_path: Path) -> None:
|
||||||
|
"""Remove lockfile, tolerating already-removed."""
|
||||||
|
try:
|
||||||
|
lock_path.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
|
||||||
|
|
||||||
def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path) -> bool:
|
def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -61,27 +89,35 @@ def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path)
|
|||||||
"location": plan_info.get("relative_path", ""),
|
"location": plan_info.get("relative_path", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Read existing file or create new structure
|
# Locked read-modify-write to prevent lost updates under concurrent close
|
||||||
closed_plans_path = plan_location / CLOSED_PLANS_FILE
|
closed_plans_path = plan_location / CLOSED_PLANS_FILE
|
||||||
|
lock_path = closed_plans_path.with_suffix(".lock")
|
||||||
|
|
||||||
if closed_plans_path.exists():
|
if not _acquire_append_lock(lock_path):
|
||||||
with open(closed_plans_path, "r", encoding="utf-8") as f:
|
logger.error(
|
||||||
data = json.load(f)
|
f"[{MODULE_NAME}] Could not acquire lock for {closed_plans_path} after {_LOCK_RETRIES} retries"
|
||||||
else:
|
)
|
||||||
data = {"closed_plans": []}
|
return False
|
||||||
|
|
||||||
# Check for duplicate plan_id before appending
|
try:
|
||||||
existing_ids = {p.get("plan_id") for p in data.get("closed_plans", [])}
|
if closed_plans_path.exists():
|
||||||
if plan_id in existing_ids:
|
with open(closed_plans_path, "r", encoding="utf-8") as f:
|
||||||
logger.info(f"[{MODULE_NAME}] {plan_id} already in {CLOSED_PLANS_FILE} at {plan_location}, skipping")
|
data = json.load(f)
|
||||||
return True
|
else:
|
||||||
|
data = {"closed_plans": []}
|
||||||
|
|
||||||
# Append and write
|
existing_ids = {p.get("plan_id") for p in data.get("closed_plans", [])}
|
||||||
data["closed_plans"].append(entry)
|
if plan_id in existing_ids:
|
||||||
|
logger.info(f"[{MODULE_NAME}] {plan_id} already in {CLOSED_PLANS_FILE} at {plan_location}, skipping")
|
||||||
|
return True
|
||||||
|
|
||||||
with open(closed_plans_path, "w", encoding="utf-8") as f:
|
data["closed_plans"].append(entry)
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
f.write("\n")
|
with open(closed_plans_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
finally:
|
||||||
|
_release_append_lock(lock_path)
|
||||||
|
|
||||||
logger.info(f"[{MODULE_NAME}] Appended {plan_id} to {closed_plans_path}")
|
logger.info(f"[{MODULE_NAME}] Appended {plan_id} to {closed_plans_path}")
|
||||||
json_handler.log_operation(
|
json_handler.log_operation(
|
||||||
|
|||||||
@@ -379,9 +379,22 @@ def close_plan_impl(
|
|||||||
try:
|
try:
|
||||||
from aipass.flow.apps.handlers.plan.append_closed_plan import append_to_closed_plans
|
from aipass.flow.apps.handlers.plan.append_closed_plan import append_to_closed_plans
|
||||||
|
|
||||||
append_to_closed_plans(plan_key, plan_info, plan_file.parent)
|
if not append_to_closed_plans(plan_key, plan_info, plan_file.parent):
|
||||||
|
logger.error(f"[{MODULE_NAME}] CLOSED_PLANS append failed for {plan_prefix}-{plan_key}")
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"type": "warning",
|
||||||
|
"text": f" CLOSED_PLANS append failed for {plan_prefix}-{plan_key}",
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[{MODULE_NAME}] CLOSED_PLANS update failed (non-critical): {e}")
|
logger.error(f"[{MODULE_NAME}] CLOSED_PLANS update failed: {e}")
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"type": "warning",
|
||||||
|
"text": f" CLOSED_PLANS update failed: {e}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Fire trigger event for plan closure
|
# Fire trigger event for plan closure
|
||||||
if trigger_fire_fn is not None:
|
if trigger_fire_fn is not None:
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
@@ -44,6 +46,35 @@ MODULE_NAME = "save_registry"
|
|||||||
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
FLOW_JSON_DIR = FLOW_ROOT / "flow_json"
|
||||||
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
|
REGISTRY_FILE = FLOW_JSON_DIR / "fplan_registry.json"
|
||||||
|
|
||||||
|
_LOCK_RETRIES = 10
|
||||||
|
_LOCK_BACKOFF_BASE = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def _acquire_lock(lock_path: Path) -> bool:
|
||||||
|
"""Atomically acquire a lockfile via O_CREAT|O_EXCL with retry+backoff."""
|
||||||
|
for attempt in range(_LOCK_RETRIES):
|
||||||
|
try:
|
||||||
|
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.write(fd, str(os.getpid()).encode())
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
logger.info("[%s] Lock contention on %s, retry %d", MODULE_NAME, lock_path, attempt + 1)
|
||||||
|
time.sleep(_LOCK_BACKOFF_BASE * (2**attempt))
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Lock creation failed for %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _release_lock(lock_path: Path) -> None:
|
||||||
|
"""Remove lockfile, tolerating already-removed."""
|
||||||
|
try:
|
||||||
|
lock_path.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("[%s] Could not release lock %s: %s", MODULE_NAME, lock_path, exc)
|
||||||
|
|
||||||
|
|
||||||
# =============================================
|
# =============================================
|
||||||
# HANDLER FUNCTION
|
# HANDLER FUNCTION
|
||||||
# =============================================
|
# =============================================
|
||||||
@@ -64,15 +95,30 @@ def save_registry(registry: Dict[str, Any], registry_file: str | None = None) ->
|
|||||||
|
|
||||||
Automatically updates the last_updated timestamp before saving.
|
Automatically updates the last_updated timestamp before saving.
|
||||||
Creates the flow_json directory if it doesn't exist.
|
Creates the flow_json directory if it doesn't exist.
|
||||||
|
Uses a lockfile to serialize concurrent writes and atomic
|
||||||
|
tempfile+rename to prevent torn reads.
|
||||||
"""
|
"""
|
||||||
target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE
|
target = FLOW_JSON_DIR / registry_file if registry_file else REGISTRY_FILE
|
||||||
|
lock_path = target.with_suffix(".lock")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
registry["_notice"] = "DO NOT MANUALLY EDIT — managed by flow close pipeline"
|
|
||||||
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
if not _acquire_lock(lock_path):
|
||||||
with open(target, "w", encoding="utf-8") as f:
|
logger.error("[%s] Could not acquire lock for %s after %d retries", MODULE_NAME, target, _LOCK_RETRIES)
|
||||||
json.dump(registry, f, indent=2, ensure_ascii=False)
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
registry["_notice"] = "DO NOT MANUALLY EDIT — managed by flow close pipeline"
|
||||||
|
registry["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
tmp_path = target.with_suffix(".tmp")
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(registry, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(str(tmp_path), str(target))
|
||||||
|
finally:
|
||||||
|
_release_lock(lock_path)
|
||||||
|
|
||||||
json_handler.log_operation(
|
json_handler.log_operation(
|
||||||
"registry_saved",
|
"registry_saved",
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user