feat(onboarding): install ends in a conversation — TDPLAN-0014 chain complete + v2.7.3 bump. Dead-end kills: empty-template init runs handoff+report (default path reaches the conversation), non-interactive completes with defaults exit 0 (was EOFError crash — caught by live door-test after green suites), aipass new TTY auto-launch into the manager w/ printed fallback + escape line. Install-to-chat handoff: launch_inline @aipass with authored first prompt + install report context, ONE unified INIT_PROMPT, headless print-only. Welcome Mode branch prompt (opener spec, name-ask, turn-5 triage, hooks-first via real dispatch, WSL beat, exact-command principle) behaviorally door-tested over a live multi-turn run incl second-session momentum. Feedback pulse (@hooks): 10-turn one-liner, aipass feedback on/off alias, disabled on host, live-proven cadence+persistence. README: install-ends-in-conversation story, aipass new documented, non-interactive truth. CHANGELOG + version 2.7.3 both files. seedgo 17/17 100%, local CI gate green
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
+46
-1
@@ -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 <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
|
||||
|
||||
@@ -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 <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.
|
||||
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 <dir>` 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 <name>` 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.
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
|
||||
"""
|
||||
|
||||
__version__ = "2.7.2"
|
||||
__version__ = "2.7.3"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <name> --no-agent` | Create without resident agent |
|
||||
| `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry |
|
||||
| `aipass revoke <path>` | Remove a project from the trust registry |
|
||||
| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) |
|
||||
| `aipass --version` | Version |
|
||||
|
||||
## Integration Points
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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"
|
||||
|
||||
_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:
|
||||
|
||||
@@ -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 <name>' to add an agent.[/dim]")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
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}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <alert-id>` | 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/<pid>.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 <id>)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,96 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: feedback.py
|
||||
# Version: 1.0.0
|
||||
# Description: Feedback pulse toggle — on/off control for periodic feedback ask
|
||||
# Branch: hooks
|
||||
# Layer: apps/modules
|
||||
# Created: 2026-07-18
|
||||
# Modified: 2026-07-18
|
||||
# =============================================
|
||||
|
||||
"""Feedback pulse toggle — on/off control for the periodic feedback ask via drone @hooks feedback."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import err_console
|
||||
from aipass.hooks.apps.handlers.json import json_handler
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: F401
|
||||
|
||||
CONSOLE = err_console
|
||||
|
||||
HELP_COMMANDS = [
|
||||
("feedback on", "Enable feedback pulse (default)"),
|
||||
("feedback off", "Disable feedback pulse"),
|
||||
("feedback", "Show current feedback pulse status"),
|
||||
]
|
||||
|
||||
|
||||
def _find_aipass_dir() -> Path | None:
|
||||
"""Walk up from CWD to find the nearest .aipass/ directory."""
|
||||
cwd = Path.cwd()
|
||||
for parent in [cwd, *cwd.parents]:
|
||||
candidate = parent / ".aipass"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
if parent == parent.parent:
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _sentinel() -> Path | None:
|
||||
"""Return the sentinel file path, or None if no .aipass/ dir found."""
|
||||
aipass_dir = _find_aipass_dir()
|
||||
if aipass_dir is None:
|
||||
return None
|
||||
return aipass_dir / "feedback_off"
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Print module structure for drone routing."""
|
||||
sentinel = _sentinel()
|
||||
if sentinel is None:
|
||||
status = "NO PROJECT"
|
||||
else:
|
||||
status = "DISABLED" if sentinel.exists() else "ENABLED"
|
||||
CONSOLE.print(f"[bold cyan]feedback[/bold cyan] — Feedback pulse ({status})")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Route feedback commands from drone @hooks."""
|
||||
if command == "feedback":
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
sub = args[0]
|
||||
|
||||
if sub in ("--help", "-h", "help"):
|
||||
CONSOLE.print("[bold cyan]feedback[/bold cyan] — Toggle the periodic feedback pulse")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(" drone @hooks feedback Show current status")
|
||||
CONSOLE.print(" drone @hooks feedback on Enable feedback pulse (default)")
|
||||
CONSOLE.print(" drone @hooks feedback off Disable feedback pulse")
|
||||
return True
|
||||
|
||||
if sub == "off":
|
||||
sentinel = _sentinel()
|
||||
if sentinel is None:
|
||||
CONSOLE.print("[yellow]No .aipass/ directory found[/yellow]")
|
||||
return True
|
||||
sentinel.touch()
|
||||
json_handler.log_operation("feedback_toggle", {"state": "off"})
|
||||
CONSOLE.print("[yellow]Feedback pulse DISABLED[/yellow]")
|
||||
return True
|
||||
|
||||
if sub == "on":
|
||||
sentinel = _sentinel()
|
||||
if sentinel is None:
|
||||
CONSOLE.print("[yellow]No .aipass/ directory found[/yellow]")
|
||||
return True
|
||||
if sentinel.exists():
|
||||
sentinel.unlink()
|
||||
json_handler.log_operation("feedback_toggle", {"state": "on"})
|
||||
CONSOLE.print("[green]Feedback pulse ENABLED[/green]")
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,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()
|
||||
Reference in New Issue
Block a user