From 71e5198d4c344af0dd6f799c051d0ab422b9042a Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Sat, 18 Jul 2026 15:28:44 -0700 Subject: [PATCH] =?UTF-8?q?feat(onboarding):=20install=20ends=20in=20a=20c?= =?UTF-8?q?onversation=20=E2=80=94=20TDPLAN-0014=20chain=20complete=20+=20?= =?UTF-8?q?v2.7.3=20bump.=20Dead-end=20kills:=20empty-template=20init=20ru?= =?UTF-8?q?ns=20handoff+report=20(default=20path=20reaches=20the=20convers?= =?UTF-8?q?ation),=20non-interactive=20completes=20with=20defaults=20exit?= =?UTF-8?q?=200=20(was=20EOFError=20crash=20=E2=80=94=20caught=20by=20live?= =?UTF-8?q?=20door-test=20after=20green=20suites),=20aipass=20new=20TTY=20?= =?UTF-8?q?auto-launch=20into=20the=20manager=20w/=20printed=20fallback=20?= =?UTF-8?q?+=20escape=20line.=20Install-to-chat=20handoff:=20launch=5Finli?= =?UTF-8?q?ne=20@aipass=20with=20authored=20first=20prompt=20+=20install?= =?UTF-8?q?=20report=20context,=20ONE=20unified=20INIT=5FPROMPT,=20headles?= =?UTF-8?q?s=20print-only.=20Welcome=20Mode=20branch=20prompt=20(opener=20?= =?UTF-8?q?spec,=20name-ask,=20turn-5=20triage,=20hooks-first=20via=20real?= =?UTF-8?q?=20dispatch,=20WSL=20beat,=20exact-command=20principle)=20behav?= =?UTF-8?q?iorally=20door-tested=20over=20a=20live=20multi-turn=20run=20in?= =?UTF-8?q?cl=20second-session=20momentum.=20Feedback=20pulse=20(@hooks):?= =?UTF-8?q?=2010-turn=20one-liner,=20aipass=20feedback=20on/off=20alias,?= =?UTF-8?q?=20disabled=20on=20host,=20live-proven=20cadence+persistence.?= =?UTF-8?q?=20README:=20install-ends-in-conversation=20story,=20aipass=20n?= =?UTF-8?q?ew=20documented,=20non-interactive=20truth.=20CHANGELOG=20+=20v?= =?UTF-8?q?ersion=202.7.3=20both=20files.=20seedgo=2017/17=20100%,=20local?= =?UTF-8?q?=20CI=20gate=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .aipass/hooks.json | 5 + .claude/provider_manifest.json | 6 + CHANGELOG.md | 47 ++- README.md | 23 +- pyproject.toml | 2 +- src/aipass/__init__.py | 2 +- .../aipass/.aipass/aipass_local_prompt.md | 29 +- src/aipass/aipass/README.md | 6 +- src/aipass/aipass/apps/aipass.py | 1 + src/aipass/aipass/apps/modules/feedback.py | 107 ++++++ src/aipass/aipass/apps/modules/handoff.py | 4 +- src/aipass/aipass/apps/modules/init_flow.py | 44 ++- src/aipass/aipass/apps/modules/install.py | 39 ++- src/aipass/aipass/apps/modules/new_project.py | 29 +- src/aipass/aipass/tests/test_feedback.py | 76 ++++ src/aipass/aipass/tests/test_init_flow.py | 44 ++- src/aipass/aipass/tests/test_install.py | 90 ++++- src/aipass/aipass/tests/test_new_project.py | 98 ++++++ src/aipass/hooks/.seedgo/bypass.json | 5 + src/aipass/hooks/README.md | 7 +- .../apps/handlers/prompt/feedback_pulse.py | 97 +++++ src/aipass/hooks/apps/modules/feedback.py | 96 +++++ src/aipass/hooks/tests/test_feedback_pulse.py | 331 ++++++++++++++++++ 23 files changed, 1117 insertions(+), 71 deletions(-) create mode 100644 src/aipass/aipass/apps/modules/feedback.py create mode 100644 src/aipass/aipass/tests/test_feedback.py create mode 100644 src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py create mode 100644 src/aipass/hooks/apps/modules/feedback.py create mode 100644 src/aipass/hooks/tests/test_feedback_pulse.py diff --git a/.aipass/hooks.json b/.aipass/hooks.json index a88cf9e9..566a6c17 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -44,6 +44,11 @@ "matcher": "", "max_per_session": 10 }, + "feedback_pulse": { + "enabled": false, + "handler": "aipass.hooks.apps.handlers.prompt.feedback_pulse.handle", + "matcher": "" + }, "auto_process": { "enabled": true, "handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle", diff --git a/.claude/provider_manifest.json b/.claude/provider_manifest.json index 49a72ec4..8e3eda28 100644 --- a/.claude/provider_manifest.json +++ b/.claude/provider_manifest.json @@ -4,11 +4,17 @@ "cli": { "claude": { "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: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: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:compass_recall", "event": "UserPromptSubmit"}, + {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"}, + {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"}, + {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"}, {"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py 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"}, diff --git a/CHANGELOG.md b/CHANGELOG.md index cef61a15..334c0490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,54 @@ PyPI version — not the changelog header. --- +## [2026-07-18] + +**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** — `aipass new` and the front-door overhaul. The `projects/` +**v2.7.3 (first pass)** — `aipass new` and the front-door overhaul. The `projects/` directory is now a first-class playground: `aipass new ` creates a fully isolated project — own registry, own git repo with a birth commit, born deployable — with a full framework resident agent that answers diff --git a/README.md b/README.md index 2879eab0..0739c9ec 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ 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. +One command does it all: builds the environment, puts `aipass` + `drone` on your PATH, 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 and it picks up exactly where you left off. That's the whole interface: say "hi". This is the base framework. It gives your agents the infrastructure to persist, communicate, and organize — everything else you build on top. @@ -85,21 +85,32 @@ cd AIPass ./aipass install # Creates venv, installs, puts `aipass` + `drone` on your PATH, bootstraps 17 agents ``` -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 ` 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. +On an interactive terminal, install chains into `aipass init run` and ends in a live conversation with the AIPass concierge — one command takes you from clone to talking with an agent that knows your machine. Pass `--no-init` to skip the chain, `--project ` to pick where the 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. `./aipass` is a thin repo-root launcher over `setup.sh`; after setup it simply forwards to the installed `aipass` binary. ### 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 -cd ~ && mkdir my-project && cd my-project -aipass init run # Guided setup — project, first agent, terminal handoff +aipass new my-project --template python # Project + resident manager agent + git birth commit ``` -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. All through `drone @branch command`. ```bash aipass init # Just the scaffold (no guided setup) aipass init agent my_agent # Add another agent 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. @@ -151,7 +162,7 @@ drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than **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. +- **Your own project:** `aipass new ` builds a project around a resident manager agent, or `aipass init run` sets one up in a directory you bring. 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. --- diff --git a/pyproject.toml b/pyproject.toml index d790a3bf..4f4a4471 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aipass" -version = "2.7.2" +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" readme = "README.md" license = "MIT" diff --git a/src/aipass/__init__.py b/src/aipass/__init__.py index ab100156..73fd772e 100644 --- a/src/aipass/__init__.py +++ b/src/aipass/__init__.py @@ -3,4 +3,4 @@ git clone + ./setup.sh — https://github.com/AIOSAI/AIPass """ -__version__ = "2.7.2" +__version__ = "2.7.3" diff --git a/src/aipass/aipass/.aipass/aipass_local_prompt.md b/src/aipass/aipass/.aipass/aipass_local_prompt.md index 083f0139..dc6e511c 100644 --- a/src/aipass/aipass/.aipass/aipass_local_prompt.md +++ b/src/aipass/aipass/.aipass/aipass_local_prompt.md @@ -77,8 +77,33 @@ apps/ - **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. +## 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 -- **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 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. +- **`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`. - **Test-convention tokens need buy-in.** Core agents don't yet recognize `[AIPASS-TEST — ...]`. Coordinating @ai_mail before pinging anyone. diff --git a/src/aipass/aipass/README.md b/src/aipass/aipass/README.md index 3801501e..898c6ed4 100644 --- a/src/aipass/aipass/README.md +++ b/src/aipass/aipass/README.md @@ -35,7 +35,8 @@ aipass/ │ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init) │ │ ├── new_project.py # aipass new — create projects inside the installation │ │ ├── profile.py # User profile read/write -│ │ └── trust.py # Trust registry — aipass trust / aipass revoke +│ │ ├── trust.py # Trust registry — aipass trust / aipass revoke +│ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off │ ├── handlers/ │ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record │ │ ├── handoff_platform/ # Platform-specific handoff detection @@ -49,7 +50,7 @@ aipass/ │ │ ├── system_detect/ # OS, shell, Python, RAM, CPU │ │ └── ui/ # Progress bars, menus, banners │ └── plugins/ -├── tests/ # 723 passing +├── tests/ # 756 passing ├── requirements.project.txt # Project-specific Python dependencies ├── .trinity/ # Identity + session history + observations └── README.md @@ -75,6 +76,7 @@ aipass/ | `aipass new --no-agent` | Create without resident agent | | `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry | | `aipass revoke ` | Remove a project from the trust registry | +| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) | | `aipass --version` | Version | ## Integration Points diff --git a/src/aipass/aipass/apps/aipass.py b/src/aipass/aipass/apps/aipass.py index c3e41a4d..0f22bbd5 100644 --- a/src/aipass/aipass/apps/aipass.py +++ b/src/aipass/aipass/apps/aipass.py @@ -50,6 +50,7 @@ _PUBLIC_COMMANDS = { "new": "Create a project inside AIPass", "profile": "Show/edit user profile", "trust": "Trust registry — enroll/revoke projects", + "feedback": "Toggle the feedback reminder on/off", } # ============================================================================= diff --git a/src/aipass/aipass/apps/modules/feedback.py b/src/aipass/aipass/apps/modules/feedback.py new file mode 100644 index 00000000..d75eba71 --- /dev/null +++ b/src/aipass/aipass/apps/modules/feedback.py @@ -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 diff --git a/src/aipass/aipass/apps/modules/handoff.py b/src/aipass/aipass/apps/modules/handoff.py index 00f831f5..32519376 100644 --- a/src/aipass/aipass/apps/modules/handoff.py +++ b/src/aipass/aipass/apps/modules/handoff.py @@ -28,7 +28,7 @@ from aipass.aipass.apps.handlers.json import json_handler COMMAND = "handoff" -_INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?" +INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?" CLI_CHOICES = ["claude", "codex"] FLAG_CHOICES = ["default", "skip-permissions"] @@ -48,7 +48,7 @@ def _get_stored_profile() -> dict: def do_handoff( cli: str = "claude", - prompt: str = _INIT_PROMPT, + prompt: str = INIT_PROMPT, cwd: str = ".", flag_variant: str = "default", ) -> bool: diff --git a/src/aipass/aipass/apps/modules/init_flow.py b/src/aipass/aipass/apps/modules/init_flow.py index 4c0c1ba3..04046f7c 100644 --- a/src/aipass/aipass/apps/modules/init_flow.py +++ b/src/aipass/aipass/apps/modules/init_flow.py @@ -96,7 +96,7 @@ TEMPLATE_EMPTY = "empty project" TEMPLATE_AIPASS = "aipass_framework" TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS] # 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 --- @@ -603,29 +603,35 @@ def stage_9_handoff( console.print() 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(" Your agent is ready.") - console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]") + if _template == TEMPLATE_AIPASS: + 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 - 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 if dry_run: console.print(f"[yellow]\\[dry-run][/yellow] would launch handoff: {command}") launched = False elif non_interactive: - from aipass.aipass.apps.modules import handoff as handoff_mod - - launched = handoff_mod.do_handoff( - cli=cli_choice, - prompt=init_prompt, - cwd=agent_path, - flag_variant=flag_variant, - ) + console.print() + console.print(" [dim]Next step (run manually):[/dim]") + console.print(f" [cyan]{command}[/cyan]") + console.print() + launched = False else: console.print() console.print(" [bold]1.[/bold] Stay here — launch agent in this terminal") @@ -784,6 +790,9 @@ def run_init( template: str | None = None, ) -> int: """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 err = _preflight_check() if err: @@ -825,6 +834,8 @@ def run_init( warning(f"Resuming from stage {last_done + 1}...") accumulated: Dict[str, Any] = {"template": template} + if template != TEMPLATE_AIPASS: + accumulated["agent_path"] = "." stage_fns = [ (1, lambda: stage_1_welcome(dry_run=dry_run)), @@ -854,6 +865,8 @@ def run_init( continue if stage_num in AIPASS_SPECIFIC_STAGES and template != TEMPLATE_AIPASS: 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 try: result = fn() or {} @@ -867,11 +880,6 @@ def run_init( warning(f"Stage {stage_num} error: {exc} — continuing.") _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 ' to add an agent.[/dim]") - return 0 diff --git a/src/aipass/aipass/apps/modules/install.py b/src/aipass/aipass/apps/modules/install.py index 939a62b9..f4d20d72 100644 --- a/src/aipass/aipass/apps/modules/install.py +++ b/src/aipass/aipass/apps/modules/install.py @@ -179,17 +179,21 @@ def _verify_binaries(home: Path) -> Dict[str, str | None]: return {"drone": drone, "aipass": aipass} -def _should_run_init(non_interactive: bool, with_init: bool, no_init: bool) -> bool: - """Decide whether to auto-launch init. --no-init wins; --with-init forces on. +def _build_install_prompt(home: Path, bins: dict) -> str: + """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). - """ - if no_init: - return False - if with_init: - return True - return not non_interactive + +def _should_run_init(no_init: bool) -> bool: + """Decide whether to auto-launch init. --no-init skips; default = always chain.""" + return not no_init def _handoff_to_init( @@ -357,13 +361,26 @@ def run_install( # Step 4 — hand off into init (or print next steps) console.print() 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) + # Log BEFORE exec — launch_inline replaces the process and never returns json_handler.log_operation( "aipass_install", {"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 diff --git a/src/aipass/aipass/apps/modules/new_project.py b/src/aipass/aipass/apps/modules/new_project.py index 145f3459..02c577d3 100644 --- a/src/aipass/aipass/apps/modules/new_project.py +++ b/src/aipass/aipass/apps/modules/new_project.py @@ -15,6 +15,7 @@ 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 @@ -180,7 +181,7 @@ def handle_command(command: str, args: list[str]) -> bool: except (RuntimeError, ValueError) as e: logger.warning("[AIPASS] new project failed: %s", e) error(str(e)) - return True + sys.exit(1) console.print() success(f"Project '{name}' created at {result['target']}") @@ -191,6 +192,26 @@ def handle_command(command: str, args: list[str]) -> bool: 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"]: @@ -199,10 +220,4 @@ def handle_command(command: str, args: list[str]) -> bool: else: console.print(f" [cyan]cd {result['target']}[/cyan]") console.print() - - 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"]) return True diff --git a/src/aipass/aipass/tests/test_feedback.py b/src/aipass/aipass/tests/test_feedback.py new file mode 100644 index 00000000..25e1923c --- /dev/null +++ b/src/aipass/aipass/tests/test_feedback.py @@ -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() diff --git a/src/aipass/aipass/tests/test_init_flow.py b/src/aipass/aipass/tests/test_init_flow.py index 3fc2520a..52a3802e 100644 --- a/src/aipass/aipass/tests/test_init_flow.py +++ b/src/aipass/aipass/tests/test_init_flow.py @@ -586,6 +586,17 @@ class TestStages: result = stage_9_handoff(agent_path="src/mybot", non_interactive=True) 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: """stage_10_done returns {} and marks stage 10 complete.""" with patch(f"{_MOD}.console"): @@ -785,8 +796,8 @@ class TestTemplateSelector: ] return {name: MagicMock(return_value={}) for name in stage_names} - def test_empty_project_default_skips_scaffold(self, tmp_local_json) -> None: - """empty project (default) = no scaffold; framework-only stages 6,7,9,10 skipped.""" + def test_empty_project_default_skips_agent_and_ping(self, tmp_local_json) -> None: + """empty project (default) = no scaffold; framework-only stages 6,7 skipped; 9,10 run.""" mocks = self._stage_patches() with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks): result = run_init(non_interactive=True, template=TEMPLATE_EMPTY) @@ -798,9 +809,11 @@ class TestTemplateSelector: "stage_4_style_questions", "stage_5_tool_choice", "stage_8_smoke_test", + "stage_9_handoff", + "stage_10_done", ): 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" def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None: @@ -862,6 +875,27 @@ class TestTemplateSelector: assert "setup.sh" 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: - """AIPASS_SPECIFIC_STAGES contains exactly {6, 7, 9, 10}.""" - assert AIPASS_SPECIFIC_STAGES == {6, 7, 9, 10} + """AIPASS_SPECIFIC_STAGES contains exactly {6, 7} — stages 9/10 run for ALL templates.""" + assert AIPASS_SPECIFIC_STAGES == {6, 7} diff --git a/src/aipass/aipass/tests/test_install.py b/src/aipass/aipass/tests/test_install.py index ce7b1449..4c3586aa 100644 --- a/src/aipass/aipass/tests/test_install.py +++ b/src/aipass/aipass/tests/test_install.py @@ -17,6 +17,7 @@ from aipass.aipass.apps.modules.install import ( DEFAULT_HOME, DEFAULT_PROJECT, TOTAL_STEPS, + _build_install_prompt, _clone_repo, _handoff_to_init, _looks_like_aipass_tree, @@ -215,21 +216,13 @@ class TestRunInstall: class TestShouldRunInit: """Deciding whether the install chains into init.""" - def test_no_init_wins(self) -> None: - """--no-init disables the handoff even alongside --with-init.""" - assert _should_run_init(non_interactive=False, with_init=True, no_init=True) is False + def test_no_init_skips(self) -> None: + """--no-init disables the handoff.""" + assert _should_run_init(no_init=True) is False - def test_with_init_forces_headless(self) -> None: - """--with-init runs init even when the install was headless.""" - assert _should_run_init(non_interactive=True, with_init=True, 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 + def test_default_chains(self) -> None: + """Default: always chain into init.""" + assert _should_run_init(no_init=False) is True class TestResolveProjectDir: @@ -327,6 +320,75 @@ class TestHandleCommand: 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: """Help/introspection render and constants hold.""" diff --git a/src/aipass/aipass/tests/test_new_project.py b/src/aipass/aipass/tests/test_new_project.py index c67428a8..ab4edc90 100644 --- a/src/aipass/aipass/tests/test_new_project.py +++ b/src/aipass/aipass/tests/test_new_project.py @@ -623,6 +623,104 @@ def test_prompt_agent_eof(): assert _prompt_agent() is False +# --------------------------------------------------------------------------- +# TTY auto-launch (FIX 3: aipass new auto-launches on TTY) +# --------------------------------------------------------------------------- + + +def test_tty_auto_launches_agent(host_env, monkeypatch): + """On a TTY with an agent created, launch_inline is called.""" + from aipass.aipass.apps.modules.new_project import handle_command + + monkeypatch.chdir(host_env) + spawn_ok = { + "success": True, + "branch_name": "LAUNCH", + "path": str(host_env / "projects" / "launch"), + "files_copied": 12, + "registry_updated": True, + "validation_issues": [], + } + with ( + patch("subprocess.run", side_effect=_mock_git_run), + patch("builtins.input", return_value=""), + patch( + "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + return_value=None, + ), + patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), + patch( + "aipass.aipass.apps.handlers.new_project.spawn_agent", + return_value=spawn_ok, + ), + patch("aipass.aipass.apps.modules.new_project.console"), + patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys, + patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch, + ): + mock_sys.stdin.isatty.return_value = True + handle_command("new", ["launch", "--template", "empty"]) + mock_launch.assert_called_once() + assert "launch" in mock_launch.call_args[0][2] + + +def test_no_tty_skips_auto_launch(host_env, monkeypatch): + """On a non-TTY, launch_inline is NOT called — fallback to printed instructions.""" + from aipass.aipass.apps.modules.new_project import handle_command + + monkeypatch.chdir(host_env) + spawn_ok = { + "success": True, + "branch_name": "PIPED", + "path": str(host_env / "projects" / "piped"), + "files_copied": 12, + "registry_updated": True, + "validation_issues": [], + } + with ( + patch("subprocess.run", side_effect=_mock_git_run), + patch("builtins.input", return_value=""), + patch( + "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + return_value=None, + ), + patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), + patch( + "aipass.aipass.apps.handlers.new_project.spawn_agent", + return_value=spawn_ok, + ), + patch("aipass.aipass.apps.modules.new_project.console") as mock_con, + patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys, + patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch, + ): + mock_sys.stdin.isatty.return_value = False + handle_command("new", ["piped", "--template", "empty"]) + mock_launch.assert_not_called() + printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0]) + assert "cd" in printed + assert "claude" in printed + + +def test_no_agent_skips_auto_launch(host_env, monkeypatch): + """With --no-agent, launch_inline is not called even on TTY.""" + from aipass.aipass.apps.modules.new_project import handle_command + + monkeypatch.chdir(host_env) + with ( + patch("subprocess.run", side_effect=_mock_git_run), + patch( + "aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home", + return_value=None, + ), + patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"), + patch("aipass.aipass.apps.modules.new_project.console"), + patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys, + patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch, + ): + mock_sys.stdin.isatty.return_value = True + handle_command("new", ["nolaunch", "--template", "empty", "--no-agent"]) + mock_launch.assert_not_called() + + # --------------------------------------------------------------------------- # aipass.py entry point help (cli_ux) # --------------------------------------------------------------------------- diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index db381bd7..712caaa2 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -446,6 +446,11 @@ "standard": "trigger", "reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle \u2014 not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'." }, + { + "file": "apps/modules/feedback.py", + "standard": "trigger", + "reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file \u2014 not a tracked resource. Deliberate user action via 'drone @hooks feedback on'." + }, { "file": "apps/modules/hookstatus.py", "standard": "json_structure", diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index e212799a..ff7fe010 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -35,6 +35,9 @@ drone @hooks --help # Full help reference | `drone @hooks hooksound` | Show current sound mute status | | `drone @hooks hooksound off` | Mute all hook sounds | | `drone @hooks hooksound on` | Unmute all hook sounds | +| `drone @hooks feedback` | Show feedback pulse status (enabled/disabled) | +| `drone @hooks feedback off` | Disable feedback pulse for this project | +| `drone @hooks feedback on` | Enable feedback pulse for this project | | `drone @hooks dismiss ` | Remove an alert from `.aipass/alerts.json` | | `drone @hooks cadence` | Show prompt injection cadence config and state | | `drone @hooks verify` | Cross-check provider settings vs project hook config | @@ -66,6 +69,7 @@ src/aipass/hooks/ │ │ ├── hook_test.py # Portable test runner (drone @hooks test) │ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/.json) │ │ ├── engine.py # Core dispatch — routes events to handlers +│ │ ├── feedback.py # Feedback pulse toggle (drone @hooks feedback on/off) │ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off) │ │ ├── hookstatus.py # Config viewer (drone @hooks status) │ │ ├── alert_dismiss.py # Dismiss alerts (drone @hooks dismiss ) @@ -81,6 +85,7 @@ src/aipass/hooks/ │ │ │ ├── tier0_kernel.py # Injects tier0 kernel prompt (every turn) │ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic) │ │ │ ├── identity.py # Injects passport identity block +│ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable) │ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json │ │ ├── security/ # Enforcement hooks │ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics) @@ -127,7 +132,7 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im | Event | Hooks | Description | |---|---|---| -| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + auto-process + TG mirror | +| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + auto-process + TG mirror | | PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound | | PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog | | SubagentStop | subagent_gate | Seedgo validation | diff --git a/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py b/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py new file mode 100644 index 00000000..170f81fa --- /dev/null +++ b/src/aipass/hooks/apps/handlers/prompt/feedback_pulse.py @@ -0,0 +1,97 @@ +# =================== AIPass ==================== +# Name: feedback_pulse.py +# Version: 1.0.0 +# Description: Periodic feedback ask — one ignorable line every ~10 turns +# Branch: hooks +# Layer: apps/handlers/prompt +# Created: 2026-07-18 +# Modified: 2026-07-18 +# ============================================= + +"""Periodic feedback pulse — surfaces a one-line feedback ask every ~10 turns. + +Scoped to external user projects (not the AIPass host). Toggle via +drone @hooks feedback on/off. State persists across session restarts +as a .aipass/feedback_off sentinel file per project.""" + +import json +import os +import tempfile +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.hooks.apps.handlers.json import json_handler + +_STATE_DIR = Path(tempfile.gettempdir()) +_PERIOD = 10 +_FEEDBACK_URL = "https://github.com/AIOSAI/AIPass/issues" +_FEEDBACK_LINE = f"How are we doing? Your feedback is hugely appreciated → {_FEEDBACK_URL}" + + +def _state_path(hook_data: dict) -> Path | None: + session_id = hook_data.get("session_id", "") + if not session_id: + session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "") + if not session_id: + return None + return _STATE_DIR / f"aipass-feedback-pulse-{session_id}.json" + + +def _load_and_increment(path: Path) -> int: + """Load turn counter, increment, and persist. Returns the new turn number.""" + try: + if path.exists(): + data = json.loads(path.read_text(encoding="utf-8")) + turn = data.get("turn", 0) + 1 + else: + turn = 0 + path.write_text(json.dumps({"turn": turn}), encoding="utf-8") + return turn + except (json.JSONDecodeError, OSError) as exc: + logger.info("[HOOKS] feedback_pulse: state access failed: %s", exc) + return 0 + + +def _find_aipass_dir(cwd: str | None = None) -> Path | None: + """Walk up from CWD to find the nearest .aipass/ directory.""" + start = Path(cwd) if cwd else Path.cwd() + for parent in [start, *start.parents]: + candidate = parent / ".aipass" + if candidate.is_dir(): + return candidate + if parent == parent.parent: + break + return None + + +def _is_disabled(cwd: str | None = None) -> bool: + """Check if feedback pulse is toggled off for this project.""" + aipass_dir = _find_aipass_dir(cwd) + if aipass_dir is None: + return True + sentinel = aipass_dir / "feedback_off" + return sentinel.exists() + + +def handle(hook_data: dict) -> dict: + """Inject feedback pulse line on cadence (~every 10 turns, skipping early turns).""" + try: + path = _state_path(hook_data) + if path is None: + return {"stdout": "", "exit_code": 0} + + turn = _load_and_increment(path) + + if turn < _PERIOD or turn % _PERIOD != 0: + return {"stdout": "", "exit_code": 0} + + cwd = hook_data.get("cwd", "") + if _is_disabled(cwd or None): + return {"stdout": "", "exit_code": 0} + + json_handler.log_operation("feedback_pulse", {"turn": turn}) + return {"stdout": _FEEDBACK_LINE, "exit_code": 0} + + except Exception as exc: + logger.info("[HOOKS] feedback_pulse: unexpected error: %s", exc) + return {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/modules/feedback.py b/src/aipass/hooks/apps/modules/feedback.py new file mode 100644 index 00000000..602a65d3 --- /dev/null +++ b/src/aipass/hooks/apps/modules/feedback.py @@ -0,0 +1,96 @@ +# =================== AIPass ==================== +# Name: feedback.py +# Version: 1.0.0 +# Description: Feedback pulse toggle — on/off control for periodic feedback ask +# Branch: hooks +# Layer: apps/modules +# Created: 2026-07-18 +# Modified: 2026-07-18 +# ============================================= + +"""Feedback pulse toggle — on/off control for the periodic feedback ask via drone @hooks feedback.""" + +from pathlib import Path + +from aipass.cli.apps.modules import err_console +from aipass.hooks.apps.handlers.json import json_handler +from aipass.prax.apps.modules.logger import system_logger as logger # noqa: F401 + +CONSOLE = err_console + +HELP_COMMANDS = [ + ("feedback on", "Enable feedback pulse (default)"), + ("feedback off", "Disable feedback pulse"), + ("feedback", "Show current feedback pulse status"), +] + + +def _find_aipass_dir() -> Path | None: + """Walk up from CWD to find the nearest .aipass/ directory.""" + cwd = Path.cwd() + for parent in [cwd, *cwd.parents]: + candidate = parent / ".aipass" + if candidate.is_dir(): + return candidate + if parent == parent.parent: + break + return None + + +def _sentinel() -> Path | None: + """Return the sentinel file path, or None if no .aipass/ dir found.""" + aipass_dir = _find_aipass_dir() + if aipass_dir is None: + return None + return aipass_dir / "feedback_off" + + +def print_introspection() -> None: + """Print module structure for drone routing.""" + sentinel = _sentinel() + if sentinel is None: + status = "NO PROJECT" + else: + status = "DISABLED" if sentinel.exists() else "ENABLED" + CONSOLE.print(f"[bold cyan]feedback[/bold cyan] — Feedback pulse ({status})") + + +def handle_command(command: str, args: list) -> bool: + """Route feedback commands from drone @hooks.""" + if command == "feedback": + if not args: + print_introspection() + return True + + sub = args[0] + + if sub in ("--help", "-h", "help"): + CONSOLE.print("[bold cyan]feedback[/bold cyan] — Toggle the periodic feedback pulse") + CONSOLE.print() + CONSOLE.print(" drone @hooks feedback Show current status") + CONSOLE.print(" drone @hooks feedback on Enable feedback pulse (default)") + CONSOLE.print(" drone @hooks feedback off Disable feedback pulse") + return True + + if sub == "off": + sentinel = _sentinel() + if sentinel is None: + CONSOLE.print("[yellow]No .aipass/ directory found[/yellow]") + return True + sentinel.touch() + json_handler.log_operation("feedback_toggle", {"state": "off"}) + CONSOLE.print("[yellow]Feedback pulse DISABLED[/yellow]") + return True + + if sub == "on": + sentinel = _sentinel() + if sentinel is None: + CONSOLE.print("[yellow]No .aipass/ directory found[/yellow]") + return True + if sentinel.exists(): + sentinel.unlink() + json_handler.log_operation("feedback_toggle", {"state": "on"}) + CONSOLE.print("[green]Feedback pulse ENABLED[/green]") + return True + + return False diff --git a/src/aipass/hooks/tests/test_feedback_pulse.py b/src/aipass/hooks/tests/test_feedback_pulse.py new file mode 100644 index 00000000..8531c4f1 --- /dev/null +++ b/src/aipass/hooks/tests/test_feedback_pulse.py @@ -0,0 +1,331 @@ +# =================== AIPass ==================== +# Name: test_feedback_pulse.py +# Version: 1.0.0 +# Description: Tests for feedback pulse handler and toggle module +# Branch: hooks +# Layer: tests +# Created: 2026-07-18 +# Modified: 2026-07-18 +# ============================================= + +"""Tests for feedback_pulse handler and feedback toggle module.""" + +import json +from pathlib import Path +from unittest.mock import patch + + +class TestFeedbackPulseHandler: + """Tests for the feedback_pulse prompt handler.""" + + def _handler(self): + from aipass.hooks.apps.handlers.prompt.feedback_pulse import handle + + return handle + + def test_no_session_id_returns_empty(self): + result = self._handler()({"session_id": ""}) + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_early_turns_return_empty(self, tmp_path): + with patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ): + for i in range(10): + result = self._handler()({"session_id": "test-session"}) + assert result["stdout"] == "", f"Turn {i} should not fire" + + def test_fires_on_turn_10(self, tmp_path): + result = {"stdout": "", "exit_code": 0} + with ( + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ), + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled", + return_value=False, + ), + ): + for i in range(11): + result = self._handler()({"session_id": "test-fire"}) + + assert "feedback" in result["stdout"].lower() + assert "github.com" in result["stdout"] + + def test_fires_on_turn_20(self, tmp_path): + result = {"stdout": "", "exit_code": 0} + with ( + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ), + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled", + return_value=False, + ), + ): + for i in range(21): + result = self._handler()({"session_id": "test-fire-20"}) + + assert "feedback" in result["stdout"].lower() + + def test_skips_turn_11_through_19(self, tmp_path): + with ( + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ), + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled", + return_value=False, + ), + ): + for i in range(11): + self._handler()({"session_id": "test-skip"}) + + for i in range(9): + result = self._handler()({"session_id": "test-skip"}) + assert result["stdout"] == "", f"Turn {11 + i} should not fire" + + def test_disabled_returns_empty(self, tmp_path): + result = {"stdout": "", "exit_code": 0} + with ( + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ), + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled", + return_value=True, + ), + ): + for i in range(11): + result = self._handler()({"session_id": "test-disabled"}) + + assert result["stdout"] == "" + + def test_output_is_one_line(self, tmp_path): + result = {"stdout": "", "exit_code": 0} + with ( + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ), + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled", + return_value=False, + ), + ): + for i in range(11): + result = self._handler()({"session_id": "test-oneline"}) + + assert "\n" not in result["stdout"] + + def test_state_file_persists(self, tmp_path): + state_file = tmp_path / "aipass-feedback-pulse-test-persist.json" + with patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ): + self._handler()({"session_id": "test-persist"}) + + assert state_file.exists() + data = json.loads(state_file.read_text()) + assert data["turn"] == 0 + + def test_state_increments_across_calls(self, tmp_path): + with patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ): + for i in range(5): + self._handler()({"session_id": "test-incr"}) + + state_file = tmp_path / "aipass-feedback-pulse-test-incr.json" + data = json.loads(state_file.read_text()) + assert data["turn"] == 4 + + def test_corrupted_state_recovers(self, tmp_path): + state_file = tmp_path / "aipass-feedback-pulse-test-corrupt.json" + state_file.write_text("not json") + with patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ): + result = self._handler()({"session_id": "test-corrupt"}) + + assert result["exit_code"] == 0 + + def test_handler_exception_returns_safe(self): + with patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._state_path", + side_effect=RuntimeError("boom"), + ): + result = self._handler()({"session_id": "test-crash"}) + + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_session_id_from_env(self, tmp_path): + with ( + patch( + "aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR", + tmp_path, + ), + patch.dict( + "os.environ", + {"CLAUDE_CODE_SESSION_ID": "env-session"}, + ), + ): + self._handler()({"session_id": ""}) + + state_file = tmp_path / "aipass-feedback-pulse-env-session.json" + assert state_file.exists() + + +class TestFeedbackPulseToggle: + """Tests for the _is_disabled toggle and sentinel file.""" + + def test_no_aipass_dir_is_disabled(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.feedback_pulse import _is_disabled + + assert _is_disabled(str(tmp_path)) is True + + def test_aipass_dir_no_sentinel_is_enabled(self, tmp_path): + (tmp_path / ".aipass").mkdir() + from aipass.hooks.apps.handlers.prompt.feedback_pulse import _is_disabled + + assert _is_disabled(str(tmp_path)) is False + + def test_sentinel_exists_is_disabled(self, tmp_path): + aipass_dir = tmp_path / ".aipass" + aipass_dir.mkdir() + (aipass_dir / "feedback_off").touch() + from aipass.hooks.apps.handlers.prompt.feedback_pulse import _is_disabled + + assert _is_disabled(str(tmp_path)) is True + + +class TestFeedbackToggleModule: + """Tests for the feedback toggle CLI module (drone @hooks feedback).""" + + def test_handle_command_feedback_shows_status(self, capsys): + from aipass.hooks.apps.modules.feedback import handle_command + + with patch( + "aipass.hooks.apps.modules.feedback._sentinel", + return_value=Path("/nonexistent/sentinel"), + ): + assert handle_command("feedback", []) is True + + captured = capsys.readouterr() + assert "ENABLED" in captured.err + + def test_handle_command_feedback_disabled(self, capsys, tmp_path): + sentinel = tmp_path / "feedback_off" + sentinel.touch() + from aipass.hooks.apps.modules.feedback import handle_command + + with patch( + "aipass.hooks.apps.modules.feedback._sentinel", + return_value=sentinel, + ): + assert handle_command("feedback", []) is True + + captured = capsys.readouterr() + assert "DISABLED" in captured.err + + def test_handle_command_feedback_off(self, tmp_path): + sentinel = tmp_path / "feedback_off" + from aipass.hooks.apps.modules.feedback import handle_command + + with patch( + "aipass.hooks.apps.modules.feedback._sentinel", + return_value=sentinel, + ): + assert handle_command("feedback", ["off"]) is True + + assert sentinel.exists() + + def test_handle_command_feedback_on(self, tmp_path): + sentinel = tmp_path / "feedback_off" + sentinel.touch() + from aipass.hooks.apps.modules.feedback import handle_command + + with patch( + "aipass.hooks.apps.modules.feedback._sentinel", + return_value=sentinel, + ): + assert handle_command("feedback", ["on"]) is True + + assert not sentinel.exists() + + def test_handle_command_feedback_on_no_sentinel(self, tmp_path): + sentinel = tmp_path / "feedback_off" + from aipass.hooks.apps.modules.feedback import handle_command + + with patch( + "aipass.hooks.apps.modules.feedback._sentinel", + return_value=sentinel, + ): + assert handle_command("feedback", ["on"]) is True + + def test_handle_command_feedback_help(self, capsys): + from aipass.hooks.apps.modules.feedback import handle_command + + assert handle_command("feedback", ["--help"]) is True + captured = capsys.readouterr() + assert "drone @hooks feedback" in captured.err + + def test_handle_command_no_aipass_dir(self, capsys): + from aipass.hooks.apps.modules.feedback import handle_command + + with patch( + "aipass.hooks.apps.modules.feedback._sentinel", + return_value=None, + ): + assert handle_command("feedback", []) is True + + captured = capsys.readouterr() + assert "NO PROJECT" in captured.err + + def test_handle_command_off_no_aipass_dir(self, capsys): + from aipass.hooks.apps.modules.feedback import handle_command + + with patch( + "aipass.hooks.apps.modules.feedback._sentinel", + return_value=None, + ): + assert handle_command("feedback", ["off"]) is True + + captured = capsys.readouterr() + assert "No .aipass/" in captured.err + + def test_unrelated_command_returns_false(self): + from aipass.hooks.apps.modules.feedback import handle_command + + assert handle_command("other", []) is False + + def test_state_survives_session_restart(self, tmp_path): + """Toggle state persists on disk — survives session restarts.""" + sentinel = tmp_path / "feedback_off" + from aipass.hooks.apps.modules.feedback import handle_command + + with patch( + "aipass.hooks.apps.modules.feedback._sentinel", + return_value=sentinel, + ): + handle_command("feedback", ["off"]) + + assert sentinel.exists() + + with patch( + "aipass.hooks.apps.modules.feedback._sentinel", + return_value=sentinel, + ): + handle_command("feedback", ["on"]) + + assert not sentinel.exists()