From be9aa8c14be8ae748b6138f88d173f6368c4a4f9 Mon Sep 17 00:00:00 2001 From: ychampion Date: Tue, 26 May 2026 16:54:16 +0000 Subject: [PATCH] Make Edgebase install into an automatic work-contract runtime Add host-aware install prompts, active Work Contract state, runtime status and finish gates, warn/strict pre-edit enforcement, broader freshness hooks, Codex skill surfaces, team guidance, and docs so agents can follow the Goal Capsule loop automatically after setup. Constraint: Preserve dirty /root/edgebase work by implementing in the clean feature worktree based on origin/main. Rejected: Rebase the older PR commit directly | main already contained overlapping preflight, radius, hook, and graph work that needed integration instead of replay. Confidence: high Scope-risk: broad Directive: Keep setup host-capability driven and keep default enforcement warn-only unless users opt into --strict. Tested: python3 -m unittest -v; python3 -m compileall -q src tests; git diff --check; edgebase install-prompt --agent all; edgebase status --json; edgebase finish "demo" --test "python3 -m unittest -v: pass" Not-tested: Live Claude Code/Codex hook execution inside their native hosts; verified with simulated hook payloads and generated config tests. Co-authored-by: OmX --- CHANGELOG.md | 17 ++++ README.md | 58 ++++++++----- docs/AGENT_CLIENTS.md | 35 ++++++-- docs/ARCHITECTURE.md | 31 +++++-- docs/VALIDATION.md | 20 ++++- src/edgebase/bootstrap.py | 46 ++++------ src/edgebase/cli.py | 97 +++++++++++++++++++-- src/edgebase/doctor.py | 14 ++++ src/edgebase/goal.py | 16 +++- src/edgebase/hooks.py | 171 ++++++++++++++++++++++++++++++-------- src/edgebase/hosts.py | 62 ++++++++++++++ src/edgebase/preflight.py | 23 +++++ src/edgebase/runtime.py | 146 ++++++++++++++++++++++++++++++++ src/edgebase/setup.py | 162 ++++++++++++++++++++++++++++++------ src/edgebase/team.py | 160 +++++++++++++++++++++++++++++++++++ tests/test_edgebase.py | 148 +++++++++++++++++++++++++++++++-- 16 files changed, 1067 insertions(+), 139 deletions(-) create mode 100644 src/edgebase/hosts.py create mode 100644 src/edgebase/runtime.py create mode 100644 src/edgebase/team.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa196d..840af39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +### Added + +- `edgebase bootstrap` for copy-focused, host-aware onboarding prompts. +- Host capability table covering Claude Code, Codex, Cursor, Gemini CLI, OpenCode, and Windsurf setup surfaces. +- Active Work Contract state at `.edgebase/session/active-goal.json`, written by prompt hooks and `edgebase goal --record`. +- `edgebase status` for active goal, freshness, changed files, stale files, elevated tests, unrecorded required checks, and latest artifacts. +- `edgebase finish` for writing `.edgebase/passports/latest.md` and `.json` without inventing test results. +- Codex global skill surfaces for `/edgebase`, `/edgebase-goal`, `/goal`, and `/edgebase-*` commands when global setup is selected. +- Marker-bounded `edgebase team init optional|required` and `edgebase team disable`. + +### Changed + +- Pre-edit hooks now use the persisted active Work Contract before falling back to preflight state. +- Claude pre-edit checks warn by default and support opt-in strict denial for missing/stale contracts or protected-path edits. +- Git freshness hooks now cover `post-commit`, `post-checkout`, `post-merge`, and `post-rewrite`. +- Patch Passport output now separates inferred required checks from explicitly recorded tests. + ## 0.1.9 - 2026-05-26 ### Added diff --git a/README.md b/README.md index 8aaaff7..0aab358 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- Git-native preflight context for coding agents. + Automatic work-contract runtime for coding agents.

@@ -15,16 +15,19 @@ # Edgebase -Edgebase is a local, git-native preflight layer for coding agents. It records a small, source-backed Goal Capsule before an agent edits, keeps `AGENTS.md` minimal, and serves fresh context through MCP and agent hooks. +Edgebase is a local, git-native work-contract runtime for coding agents. Install it once, and supported agents automatically record a source-backed Goal Capsule before work, check the active Work Contract before edits, refresh context after changes, and finish with a Patch Passport. -Its flagship feature is **Goal Capsules**: short, executable briefs that tell Codex, Claude Code, Cursor-style agents, and human reviewers what to read, what not to touch yet, what tests matter, and what evidence the final patch must include. +Its flagship workflow is **Goal Capsules + active Work Contracts + Patch Passports**: short, executable briefs that tell Codex, Claude Code, Cursor-style agents, and human reviewers what to read, what not to touch yet, what tests matter, and what evidence the final patch must include. ## What It Does -- Records a Goal Capsule before coding agents plan or edit. -- Blocks broad edits when the capsule is missing or stale. +- Prints pasteable `edgebase install-prompt` and `edgebase bootstrap` prompts for Claude Code, Codex, Cursor, Gemini CLI, OpenCode, and Windsurf. +- Records a Goal Capsule and active Work Contract before coding agents plan or edit. +- Warns before unsafe edits by default, with opt-in strict blocking for stale contracts or protected paths. - Shows an advisory Change Blast Radius for likely routes, migrations, tests, downstream modules, and side-effect risks. -- Refreshes the graph after edits, commits, and MCP calls. +- Refreshes the graph after edits, commits, checkouts, merges, rebases, and MCP calls. +- Shows active workflow state with `edgebase status`. +- Writes final Patch Passports with `edgebase finish`. - Keeps generated structure out of `AGENTS.md`. - Runs locally with no Docker, cloud service, graph database, or API key. - Preserves provenance for every edge: path, line, extractor, confidence, commit, and freshness. @@ -86,7 +89,7 @@ edgebase_fork_plan(message, from_id?, branch?, path?, allow_dirty?, budget?) edgebase_resume(snapshot_id?) ``` -The companion context output remains a compact, source-backed capsule: high-signal files, symbols, imports, conservative call edges, inferred tests, owners, churn, freshness, and provenance. Claude Code and Codex project setup also get an automatic preflight gate, so users do not need to remember a special phrase before each task. +The companion context output remains a compact, source-backed capsule: high-signal files, symbols, imports, conservative call edges, inferred tests, owners, churn, freshness, and provenance. Claude Code and Codex project setup also get an automatic work-contract runtime, so users do not need to remember a special phrase before each task. Edgebase is not a vector database, a Neo4j wrapper, or a generic memory product. It is a small local substrate for answering: @@ -117,14 +120,26 @@ Flat instruction files such as `AGENTS.md`, `CLAUDE.md`, and Cursor rules are st Generate the copy/paste setup prompt for a specific agent: ```bash +python3 -m pip install --user --upgrade git+https://github.com/ychampion/edgebase.git +python3 -m edgebase install-prompt --agent all python3 -m edgebase install-prompt --agent claude python3 -m edgebase install-prompt --agent codex ``` -Paste this into Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Windsurf, or any coding agent with shell access: +Paste that prompt into Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Windsurf, or any coding agent with shell access. It tells the agent to install Edgebase, run `edgebase setup --scope both`, run `edgebase doctor --scope both`, and report exactly what became automatic. + +If Edgebase is not installed yet and you want a one-shot prompt to paste directly: ```text -Set up Edgebase in this repo: current working directory. Install it from https://github.com/ychampion/edgebase, run the local setup and doctor checks yourself, preserve existing agent config, do not commit, and report exactly what changed. +Install Edgebase for this repository and verify the automatic work-contract runtime. + +Run: +python3 -m pip install --user --upgrade git+https://github.com/ychampion/edgebase.git +python3 -m edgebase setup --scope both --agents all +python3 -m edgebase doctor --scope both --agents all +python3 -m edgebase status --json + +After setup, report exactly which capabilities became automatic: MCP tools and prompts, prompt-time Goal Capsule creation where hooks are supported, pre-edit Work Contract checks where hooks are supported, post-edit refresh, checkpoints, Patch Passport finish flow, and slash commands or skills installed for this agent host. ``` For stricter setup, paste the full prompt: @@ -186,15 +201,16 @@ EDGEBASE_PREFLIGHT=off | Target | Project file | User file | Behavior | | --- | --- | --- | --- | | Edgebase cache/artifacts | `.edgebase/index.sqlite3`, `.edgebase/graphs/latest.*` | none | Rebuildable local graph cache plus optional visual artifacts, ignored by git | +| Edgebase session state | `.edgebase/session/active-goal.json`, `.edgebase/passports/latest.*` | none | Active Work Contract, recorded checks, latest Patch Passport, ignored by git | | Git ignore | `.git/info/exclude` | none | Locally ignores `.edgebase/` without changing committed ignore files | | Agent instructions | `AGENTS.md` marker block | none | Tells agents to use Edgebase automatically for broad exploration/editing | -| Claude Code | `.mcp.json`, `.claude/settings.json`, `.claude/skills/edgebase*/SKILL.md`, `.claude/skills/goal/SKILL.md` | none by default | MCP server, automatic Goal Capsules, PreToolUse stale-capsule blocking, SessionStart/PostToolUse/PreCompact/SessionEnd hooks, `/edgebase`, `/edgebase-goal`, `/goal`, and `/edgebase-*` command skills | -| Codex | `.codex/config.toml`, `.codex/hooks.json`, `.agents/skills/edgebase*/SKILL.md`, `.agents/skills/goal/SKILL.md` | `~/.codex/config.toml` | MCP server entry, project hook config, project skills including `/edgebase-*`, AGENTS.md routing | +| Claude Code | `.mcp.json`, `.claude/settings.json`, `.claude/skills/edgebase*/SKILL.md`, `.claude/skills/goal/SKILL.md` | none by default | MCP server, automatic Goal Capsules, PreToolUse Work Contract warnings or strict denials, SessionStart/PostToolUse/PreCompact/SessionEnd hooks, `/edgebase`, `/edgebase-goal`, `/goal`, and `/edgebase-*` command skills | +| Codex | `.codex/config.toml`, `.codex/hooks.json`, `.agents/skills/edgebase*/SKILL.md`, `.agents/skills/goal/SKILL.md` | `~/.codex/config.toml`, `~/.codex/skills/edgebase*/SKILL.md`, `~/.codex/skills/goal/SKILL.md` | MCP server entry, project hook config, project/global skills including `/edgebase-*`, AGENTS.md routing | | Cursor | `.cursor/mcp.json` | `~/.cursor/mcp.json` | MCP server entry | | Gemini CLI | `.gemini/settings.json` | `~/.gemini/settings.json` | MCP server entry | | OpenCode | `.opencode.json` | `~/.opencode.json` | Enabled local MCP server | | Windsurf | none | `~/.codeium/windsurf/mcp_config.json` | Global MCP server entry | -| Git | `.git/hooks/post-commit` | none | Refreshes the index after commits | +| Git | `.git/hooks/post-commit`, `post-checkout`, `post-merge`, `post-rewrite` | none | Refreshes the index after commits, branch switches, merges, and rebases | No Docker, cloud service, graph database, or API key is required. @@ -204,8 +220,8 @@ Generated hook and MCP config use the Python interpreter that ran setup instead Most users do not run Edgebase manually after setup. When an explicit action is useful, run slash commands inside Claude Code, Codex, or any client that exposes project skills or MCP prompts. -- Claude Code: `UserPromptSubmit` records and injects a Goal Capsule before planning. `PreToolUse` blocks Write/Edit/MultiEdit if no fresh capsule exists. `PostToolUse` refreshes the graph after edits. `PreCompact` saves a checkpoint, and `SessionEnd` saves a Patch Passport. Project skills install `/edgebase`, `/edgebase-goal`, `/goal`, and the `/edgebase-*` command set. -- Codex: setup writes MCP config, project `.codex/hooks.json`, `[features] hooks = true`, `.agents/skills/edgebase*`, `.agents/skills/goal`, and the `AGENTS.md` marker. Codex uses MCP plus project skills by default; when trusted hook support is active, the same preflight gate records capsules, blocks stale edits, refreshes after edits, checkpoints before compaction, and saves a Patch Passport on stop. +- Claude Code: `UserPromptSubmit` records `.edgebase/session/active-goal.json` and injects a Goal Capsule before planning. `PreToolUse` warns before stale or risky Write/Edit/MultiEdit calls; `edgebase setup --strict` can deny unsafe edits. `PostToolUse` refreshes the graph after edits. `PreCompact` saves a checkpoint, and `SessionEnd` saves a Patch Passport. Project skills install `/edgebase`, `/edgebase-goal`, `/goal`, and the `/edgebase-*` command set. +- Codex: setup writes MCP config, project `.codex/hooks.json`, `[features] hooks = true`, `.agents/skills/edgebase*`, `.agents/skills/goal`, global Codex skills when global scope is selected, and the `AGENTS.md` marker. Codex uses MCP plus skills by default; when trusted hook support is active, the same runtime records capsules, checks the Work Contract before edits, refreshes after edits, checkpoints before compaction, and saves a Patch Passport on stop. - Cursor, Gemini CLI, OpenCode, and Windsurf: Edgebase installs MCP config and a marker-bounded `AGENTS.md` instruction telling agents to use `edgebase_context` or `edgebase_goal` automatically before broad code exploration or edits. Those MCP calls update `.edgebase/graphs/latest.*` and return the artifact paths. - Any client: the MCP prompts named `edgebase`, `edgebase-goal`, `goal`, and the `/edgebase-*` aliases are available for clients that expose MCP prompts or slash-command-style prompt menus. @@ -216,7 +232,9 @@ Useful explicit slash commands inside supported agent REPLs/apps: /edgebase-goal "add passwordless login without breaking OAuth" /edgebase-radius "src/auth/login.py" --goal "add passwordless login without breaking OAuth" /edgebase-passport "add passwordless login without breaking OAuth" --test "python3 -m unittest -v: pass" +/edgebase-status /edgebase-preflight-status +/edgebase-finish "add passwordless login without breaking OAuth" --test "python3 -m unittest -v: pass" /edgebase-preflight-refresh "add passwordless login without breaking OAuth" /edgebase-checkpoint "handoff after auth refactor" /edgebase-resume @@ -233,9 +251,11 @@ Shell fallback and server/development commands: ```bash python3 -m edgebase install-prompt --agent codex python3 -m edgebase context "change the auth login flow" --budget 1200 -python3 -m edgebase goal "add passwordless login without breaking OAuth" --budget 1200 +python3 -m edgebase goal "add passwordless login without breaking OAuth" --budget 1200 --record python3 -m edgebase radius src/auth/login.py --goal "add passwordless login without breaking OAuth" python3 -m edgebase passport "add passwordless login without breaking OAuth" --test "python3 -m unittest -v: pass" +python3 -m edgebase status +python3 -m edgebase finish "add passwordless login without breaking OAuth" --test "python3 -m unittest -v: pass" python3 -m edgebase preflight status python3 -m edgebase checkpoint "handoff after auth refactor" python3 -m edgebase resume @@ -260,8 +280,8 @@ Dynamic-language call graphs are confidence-scored. Low-confidence call edges ar | Agent | Status | Notes | | --- | --- | --- | -| Claude Code | Supported | Project `.mcp.json`; automatic UserPromptSubmit Goal Capsule; PreToolUse stale-capsule block; async PostToolUse refresh; PreCompact checkpoint; SessionEnd Patch Passport; `/edgebase`, `/edgebase-goal`, `/edgebase-radius`, `/goal`, and `/edgebase-*` project skills | -| Codex | Supported | Project `.codex/config.toml`, `.codex/hooks.json`, `.agents/skills`; global `~/.codex/config.toml` MCP entry for CLI discovery; verify with `codex mcp list` and `python3 -m edgebase doctor` | +| Claude Code | Supported | Project `.mcp.json`; automatic UserPromptSubmit Goal Capsule; warn-by-default PreToolUse Work Contract checks with optional strict denial; async PostToolUse refresh; PreCompact checkpoint; SessionEnd Patch Passport; `/edgebase`, `/edgebase-goal`, `/edgebase-radius`, `/goal`, and `/edgebase-*` project skills | +| Codex | Supported | Project `.codex/config.toml`, `.codex/hooks.json`, `.agents/skills`; global `~/.codex/config.toml` MCP entry and global skills for CLI discovery; verify with `codex mcp list` and `python3 -m edgebase doctor` | | Cursor | Supported | Project and global `mcp.json`; Cursor says Composer Agent automatically uses relevant MCP tools | | Gemini CLI | Supported | Project and global `settings.json` with `mcpServers` | | OpenCode | Supported | Local MCP server under `mcp.edgebase`, `enabled: true` | @@ -286,11 +306,11 @@ The cache is rebuildable. Git remains the source of truth. Automation layers: - prompt hook: Claude Code and trusted Codex hooks record a Goal Capsule before the agent starts planning -- pre-edit hook: Write/Edit/MultiEdit is blocked when no fresh Goal Capsule exists +- pre-edit hook: Write/Edit/MultiEdit receives Work Contract warnings by default; strict setup can deny missing/stale contracts or protected-path edits - edit hook: edited files are reindexed and edit deltas are returned after Write/Edit/MultiEdit - compaction hook: `.edgebase/checkpoints/latest.md` preserves the active capsule before context compaction - session-end hook: `.edgebase/passports/latest.md` and `.json` preserve changed files and explicit evidence at stop/session end -- git hook: post-commit refresh keeps the cache aligned with committed changes +- git hook: post-commit, post-checkout, post-merge, and post-rewrite refresh keep the cache aligned with branch and history changes - MCP: every supported agent gets `edgebase_context`, `edgebase_goal`, checkpoint, fork-plan, and resume tools over stdio - graph artifacts: hooks and MCP calls refresh self-contained local HTML, JSON, and DOT files and surface their paths as optional visual aids - AGENTS marker: static repo instructions stay tiny and tell agents to route structural context through Edgebase diff --git a/docs/AGENT_CLIENTS.md b/docs/AGENT_CLIENTS.md index ea1ca93..4394a9f 100644 --- a/docs/AGENT_CLIENTS.md +++ b/docs/AGENT_CLIENTS.md @@ -1,6 +1,8 @@ # Agent Client Setup -Edgebase is a local stdio MCP server plus project-scoped hook/skill setup for clients that support it. It exposes six agent-facing MCP tools: +Edgebase is a local stdio MCP server plus project-scoped hook/skill setup for clients that support it. The v1 workflow is an automatic work-contract runtime: setup installs host-specific MCP, skills, hooks, and lightweight repo guidance so agents naturally follow Goal Capsule -> Work Contract -> edit delta -> Patch Passport without users remembering a context command. + +It exposes six agent-facing MCP tools: ```text edgebase_context(task, changed_files?, budget?) @@ -11,7 +13,15 @@ edgebase_fork_plan(message, from_id?, branch?, path?, allow_dirty?, budget?) edgebase_resume(snapshot_id?) ``` -The normal installation path is a prompt, not a command the user has to run manually. Paste this into the coding agent that is already working in the repository: +The normal installation path is a generated prompt, not a command the user has to run manually: + +```bash +python3 -m edgebase install-prompt --agent all +``` + +Paste the output into the coding agent that is already working in the repository. It instructs the agent to install Edgebase, run `edgebase setup --scope both`, run `edgebase doctor --scope both`, check `edgebase status --json`, and report which capabilities became automatic. + +If Edgebase is not installed yet, paste this into the agent: ```text Set up Edgebase in this repo: current working directory. Install it from https://github.com/ychampion/edgebase, run the local setup and doctor checks yourself, preserve existing agent config, do not commit, and report exactly what changed. @@ -58,6 +68,8 @@ Default state after setup: **on** for selected agents. In Claude Code, Codex, an /edgebase "implement password reset" /edgebase-goal "implement password reset without regressing login" /edgebase-radius "src/auth/login.py" --goal "implement password reset" +/edgebase-status +/edgebase-finish "implement password reset without regressing login" --test "python3 -m unittest -v: pass" /edgebase-preflight-status /edgebase-checkpoint "handoff after password reset" /edgebase-doctor --scope both @@ -85,13 +97,15 @@ Setup writes or updates only local configuration files: - `.edgebase/index.sqlite3`: rebuildable local cache, ignored by git. - `.edgebase/graphs/latest.html`, `.json`, and `.dot`: optional local graph artifacts refreshed by hooks and MCP calls, ignored by git. +- `.edgebase/session/active-goal.json`: active Work Contract and recorded test state, ignored by git. +- `.edgebase/passports/latest.md` and `.json`: latest Patch Passport from `edgebase finish`, ignored by git. - `.git/info/exclude`: local ignore entry for `.edgebase/`; committed `.gitignore` is not modified. - `AGENTS.md`: marker-bounded instructions that tell agents to use Edgebase automatically when broad code context is needed. - `.mcp.json`: Claude Code project MCP server. - `.claude/settings.json`: Claude Code SessionStart, UserPromptSubmit, PreToolUse, async PostToolUse, PreCompact, and SessionEnd hooks. - `.claude/skills/edgebase/SKILL.md`: Claude Code project skill exposed as `/edgebase `. - `.claude/skills/edgebase-goal/SKILL.md`: Claude Code project skill exposed as `/edgebase-goal `. -- `.claude/skills/edgebase-*/SKILL.md`: Claude Code project skills exposed as `/edgebase-*` operational commands such as `/edgebase-radius`, `/edgebase-checkpoint`, `/edgebase-preflight-status`, `/edgebase-index`, and `/edgebase-doctor`. +- `.claude/skills/edgebase-*/SKILL.md`: Claude Code project skills exposed as `/edgebase-*` operational commands such as `/edgebase-radius`, `/edgebase-status`, `/edgebase-finish`, `/edgebase-checkpoint`, `/edgebase-preflight-status`, `/edgebase-index`, and `/edgebase-doctor`. - `.claude/skills/goal/SKILL.md`: Claude Code compatibility skill exposed as `/goal `. - `.codex/config.toml` and/or `~/.codex/config.toml`: Codex MCP server plus project `[features] hooks = true`. - `.codex/hooks.json`: Codex project hook commands for the preflight gate. @@ -99,11 +113,12 @@ Setup writes or updates only local configuration files: - `.agents/skills/edgebase-goal/SKILL.md`: Codex project skill exposed as `/edgebase-goal ` where project skills are enabled. - `.agents/skills/edgebase-*/SKILL.md`: Codex project skills exposed as `/edgebase-*` operational commands where project skills are enabled. - `.agents/skills/goal/SKILL.md`: Codex compatibility skill exposed as `/goal `. +- `~/.codex/skills/edgebase*/SKILL.md` and `~/.codex/skills/goal/SKILL.md`: Codex global skills when `--scope global` or `--scope both` is selected. - `.cursor/mcp.json` and/or `~/.cursor/mcp.json`: Cursor MCP server. - `.gemini/settings.json` and/or `~/.gemini/settings.json`: Gemini CLI MCP server. - `.opencode.json` and/or `~/.opencode.json`: OpenCode local MCP server. - `~/.codeium/windsurf/mcp_config.json`: Windsurf Cascade MCP server. -- `.git/hooks/post-commit`: refreshes the Edgebase index after commits. +- `.git/hooks/post-commit`, `post-checkout`, `post-merge`, and `post-rewrite`: refresh the Edgebase index after commits, branch switches, merges, and rebases. All generated config points at the Python interpreter that ran setup, so GUI-launched agents do not depend on shell PATH. @@ -127,7 +142,7 @@ It also writes `.claude/settings.json` hooks: - `SessionStart`: adds a short freshness note to Claude's context. - `UserPromptSubmit`: records a Goal Capsule and injects it next to likely coding prompts before Claude starts planning. -- `PreToolUse`: blocks Write/Edit/MultiEdit when no fresh Goal Capsule exists. +- `PreToolUse`: warns before Write/Edit/MultiEdit when the active Work Contract is missing, stale, outside the blast radius, or targeting protected paths. `edgebase setup --strict` can deny missing/stale contracts and protected-path edits. - `PostToolUse`: asynchronously reindexes files, reports edit deltas, and refreshes local graph artifacts after Write/Edit/MultiEdit. - `PreCompact`: saves `.edgebase/checkpoints/latest.md` before compaction. - `SessionEnd`: saves `.edgebase/passports/latest.md` and `.json` at session end. @@ -139,6 +154,8 @@ It also writes project skills: /edgebase-goal /goal /edgebase-radius [--goal ""] +/edgebase-status [--json] +/edgebase-finish --test "command: result" /edgebase-checkpoint /edgebase-resume [snapshot id] /edgebase-fork-plan @@ -216,6 +233,8 @@ And project skills: /edgebase-goal /goal /edgebase-radius [--goal ""] +/edgebase-status [--json] +/edgebase-finish --test "command: result" /edgebase-checkpoint /edgebase-resume [snapshot id] /edgebase-fork-plan @@ -238,7 +257,7 @@ codex mcp get edgebase python3 -m edgebase doctor --agents codex --scope project ``` -The currently verified Codex CLI path reads `~/.codex/config.toml`, so the frictionless install command uses `--scope both`. Edgebase also writes `.codex/config.toml`, `.codex/hooks.json`, and `.agents/skills/*` for project-local workflows. When Codex trusts project hooks, the hook path gives the same preflight behavior as Claude Code: Goal Capsule before planning, edit block if stale, refresh after edit, checkpoint before compaction, and Patch Passport on stop. +The currently verified Codex CLI path reads `~/.codex/config.toml`, so the frictionless install command uses `--scope both`. Edgebase also writes `.codex/config.toml`, `.codex/hooks.json`, and `.agents/skills/*` for project-local workflows, plus global skills under `~/.codex/skills` when global scope is selected. When Codex trusts project hooks, the hook path gives the same runtime shape as Claude Code: Goal Capsule before planning, Work Contract check before edits, refresh after edit, checkpoint before compaction, and Patch Passport on stop. ## Cursor @@ -307,6 +326,8 @@ Inside Claude Code, Codex, or any client that exposes project skills or MCP prom /edgebase "implement password reset" /edgebase-goal "implement password reset without regressing login" /edgebase-radius "src/auth/login.py" --goal "implement password reset" +/edgebase-status +/edgebase-finish "implement password reset without regressing login" --test "python3 -m unittest -v: pass" /edgebase-passport "implement password reset without regressing login" --test "python3 -m unittest -v: pass" /edgebase-preflight-status /edgebase-preflight-refresh "implement password reset without regressing login" @@ -323,6 +344,8 @@ python3 -m edgebase context "implement password reset" --changed-file src/auth.p python3 -m edgebase goal "implement password reset without regressing login" --changed-file src/auth.py --budget 1200 python3 -m edgebase radius src/auth.py --goal "implement password reset without regressing login" python3 -m edgebase passport "implement password reset without regressing login" --test "python3 -m unittest -v: pass" +python3 -m edgebase status +python3 -m edgebase finish "implement password reset without regressing login" --test "python3 -m unittest -v: pass" python3 -m edgebase preflight status python3 -m edgebase preflight refresh "implement password reset without regressing login" python3 -m edgebase checkpoint "handoff after password reset" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9a1dff7..cac41d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,6 +17,8 @@ context ranker v edgebase_context(task, changed_files?, budget?) edgebase_goal(goal, changed_files?, budget?) +edgebase_status() +edgebase_finish(goal, tests?) edgebase_checkpoint(message, budget?) edgebase_fork_plan(message, from_id?, branch?, path?, allow_dirty?, budget?) edgebase_resume(snapshot_id?) @@ -67,6 +69,21 @@ Current extractors are deliberately conservative: Dynamic call edges are not presented as facts. They are low-confidence leads unless the extractor can prove more. +## Runtime State + +The active work contract is persisted under `.edgebase/session/active-goal.json`. It is local cache state, ignored by git, and contains the current goal, Work Contract, commit, worktree fingerprint, blast radius, required checks, stale files, recorded tests, and timestamps. + +`edgebase status` reads that active state plus git/index freshness and reports: + +- active goal +- changed files and stale files +- blast-radius drift +- elevated tests +- required checks not yet recorded +- latest checkpoint and Patch Passport paths + +`edgebase finish "" --test "command: pass"` writes `.edgebase/passports/latest.md` and `.json`. It records only explicit test evidence, then separates inferred required checks from tests actually recorded. + ## Freshness Freshness is checked with file hashes, git HEAD, the working tree fingerprint, and a short-lived preflight state file. A context or goal capsule reports stale files when the working tree no longer matches the indexed hash. A preflight capsule is considered stale when it expires, HEAD changes, the working tree changes after recording, or the index reports stale files. @@ -75,9 +92,9 @@ Freshness paths: - `edgebase index`: full rebuild. - `edgebase index --changed`: incremental refresh for git-changed files. -- Git `post-commit` hook: refresh after commits. +- Git `post-commit`, `post-checkout`, `post-merge`, and `post-rewrite` hooks: refresh after commits, branch switches, merges, and rewrites. - Claude Code and Codex `UserPromptSubmit` hooks: record and inject a Goal Capsule before the agent plans. -- Claude Code and Codex `PreToolUse` hooks: block Write/Edit/MultiEdit when no fresh Goal Capsule exists. +- Claude Code and Codex `PreToolUse` hooks: warn before Write/Edit/MultiEdit when no fresh active contract exists, the target is outside blast radius, or the target is protected. Claude strict setup can deny missing/stale contracts and protected-path edits. - Claude Code and Codex `PostToolUse` hooks: async refresh, edit-delta reporting, and graph artifact update after Write/Edit/MultiEdit. - Claude Code `PreCompact` and Codex `PreCompact` hooks: save `.edgebase/checkpoints/latest.md` before compaction. - Claude Code `SessionEnd` and Codex `Stop` hooks: save `.edgebase/passports/latest.md` and `.json`. @@ -111,7 +128,7 @@ The returned capsule includes: - stable `repo_commit` and `worktree_fingerprint`. - read-first files, blast radius, protected areas, tests, risks, uncertainties, and provenance. -`edgebase passport` runs after edits and produces a Patch Passport from the current diff plus explicit test evidence. Edgebase records only tests supplied with `--test`; it never infers that a check passed. +`edgebase passport` renders a Patch Passport from the current diff plus explicit test evidence. `edgebase finish` is the runtime finishing gate: it writes the latest Markdown/JSON passport and updates the active state with recorded tests. Edgebase records only tests supplied with `--test`; it never infers that a check passed. `edgebase checkpoint`, `edgebase fork-plan`, and `edgebase resume` preserve compact context across compaction, handoff, and git worktree branching. They reuse the context ranker rather than adding a separate memory store. @@ -139,15 +156,17 @@ Claude Code has documented prompt and tool hooks, so Edgebase provides: - `UserPromptSubmit`: Goal Capsule before Claude plans. - `SessionStart`: freshness summary when a session opens. -- `PreToolUse`: stale-capsule block before Write/Edit/MultiEdit. +- `PreToolUse`: Work Contract warnings before Write/Edit/MultiEdit, with opt-in strict denial. - `PostToolUse`: async refresh and edit delta after Write/Edit/MultiEdit. - `PreCompact`: context checkpoint before compaction. - `SessionEnd`: Patch Passport at session end. - project skill `/edgebase ` for explicit manual refresh. - project skill `/edgebase-goal ` for explicit Goal Capsules, with `/goal ` as a compatibility alias. -- project skills for operational commands such as `/edgebase-radius`, `/edgebase-checkpoint`, `/edgebase-resume`, `/edgebase-fork-plan`, `/edgebase-passport`, `/edgebase-preflight-status`, `/edgebase-preflight-refresh`, `/edgebase-index`, `/edgebase-stats`, `/edgebase-doctor`, `/edgebase-setup`, `/edgebase-disable`, and `/edgebase-version`. +- project skills for operational commands such as `/edgebase-radius`, `/edgebase-status`, `/edgebase-finish`, `/edgebase-checkpoint`, `/edgebase-resume`, `/edgebase-fork-plan`, `/edgebase-passport`, `/edgebase-preflight-status`, `/edgebase-preflight-refresh`, `/edgebase-index`, `/edgebase-stats`, `/edgebase-doctor`, `/edgebase-setup`, `/edgebase-disable`, and `/edgebase-version`. + +Codex setup is MCP plus project-scoped hooks and skills. Edgebase writes `.codex/config.toml`, `[features] hooks = true`, `.codex/hooks.json`, `.agents/skills/edgebase`, `.agents/skills/edgebase-goal`, `.agents/skills/goal`, and the same `/edgebase-*` operational command skills. Global setup also installs `~/.codex/skills/edgebase*` and `~/.codex/skills/goal`. When project hooks are trusted, Codex receives the same runtime shape: prompt-time capsule, Work Contract check, post-edit refresh, pre-compact checkpoint, and stop-time Patch Passport. -Codex setup is MCP plus project-scoped hooks and skills. Edgebase writes `.codex/config.toml`, `[features] hooks = true`, `.codex/hooks.json`, `.agents/skills/edgebase`, `.agents/skills/edgebase-goal`, `.agents/skills/goal`, and the same `/edgebase-*` operational command skills. When project hooks are trusted, Codex receives the same preflight gate: prompt-time capsule, stale edit block, post-edit refresh, pre-compact checkpoint, and stop-time Patch Passport. +Team mode is intentionally lightweight. `edgebase team init optional` writes marker-bounded repo guidance that Edgebase is recommended. `edgebase team init required` adds marker-bounded guidance plus a Claude pre-tool enforcement hook that blocks broad edits if Edgebase is not installed or enabled. `edgebase team disable` removes only Edgebase-owned team guidance and hook entries. Cursor, Gemini CLI, OpenCode, and Windsurf are MCP-first. For those clients, setup writes MCP config and an `AGENTS.md` marker that instructs the agent to route broad structural context through Edgebase automatically. Edgebase also exposes MCP prompts named `edgebase`, `edgebase-goal`, `goal`, and the `edgebase-*` aliases for clients that surface prompt menus. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index cefbb26..f26cd8e 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -51,13 +51,17 @@ For every release: ```bash python3 -m unittest -v python3 -m compileall -q src tests +python3 -m edgebase install-prompt --agent all python3 -m edgebase setup --scope project --agents claude,codex,cursor,gemini,opencode python3 -m edgebase doctor --scope project --agents claude,codex,cursor,gemini,opencode python3 -m edgebase goal "change login hashing behavior" --changed-file tests/test_edgebase.py --json +python3 -m edgebase goal "change login hashing behavior" --changed-file tests/test_edgebase.py --record --json python3 -m edgebase radius tests/test_edgebase.py --goal "change login hashing behavior" --json +python3 -m edgebase status --json python3 -m edgebase preflight refresh "change login hashing behavior" --changed-file tests/test_edgebase.py --json python3 -m edgebase preflight status --json python3 -m edgebase passport "change login hashing behavior" --test "python3 -m unittest -v: pass" +python3 -m edgebase finish "change login hashing behavior" --test "python3 -m unittest -v: pass" python3 -m edgebase checkpoint "release smoke checkpoint" python3 -m edgebase resume printf '{"prompt":"change login hashing behavior"}' | python3 -m edgebase hooks claude-user-prompt-submit --root . @@ -83,12 +87,24 @@ Where binaries are available: - Gemini CLI: `gemini mcp list` - OpenCode: `opencode mcp list` -For Claude Code, also validate that `.claude/settings.json` contains `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, and `SessionEnd`, and that `.claude/skills/edgebase/SKILL.md`, `.claude/skills/edgebase-goal/SKILL.md`, `.claude/skills/goal/SKILL.md`, and the generated `.claude/skills/edgebase-*/SKILL.md` command skills exist. +For Claude Code, also validate that `.claude/settings.json` contains `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, and `SessionEnd`, and that `.claude/skills/edgebase/SKILL.md`, `.claude/skills/edgebase-goal/SKILL.md`, `.claude/skills/goal/SKILL.md`, and the generated `.claude/skills/edgebase-*/SKILL.md` command skills exist. Simulate warn-mode and strict-mode `PreToolUse` payloads. -For Codex, validate that `.codex/config.toml` contains `[mcp_servers.edgebase]` and `hooks = true`, `.codex/hooks.json` uses the event-keyed Codex hook shape with `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, and `Stop`, and `.agents/skills/edgebase/SKILL.md`, `.agents/skills/edgebase-goal/SKILL.md`, `.agents/skills/goal/SKILL.md`, and the generated `.agents/skills/edgebase-*/SKILL.md` command skills exist. +For Codex, validate that `.codex/config.toml` contains `[mcp_servers.edgebase]` and `hooks = true`, `.codex/hooks.json` uses the event-keyed Codex hook shape with `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, and `Stop`, and `.agents/skills/edgebase/SKILL.md`, `.agents/skills/edgebase-goal/SKILL.md`, `.agents/skills/goal/SKILL.md`, and the generated `.agents/skills/edgebase-*/SKILL.md` command skills exist. With global scope, also validate the generated `~/.codex/skills` files. For clients not installed in the verification environment, validate the generated config shape and the Edgebase MCP stdio handshake. +## Install E2E + +Before v1, run temporary-home install tests for every supported host: + +- create a fresh temp git repository and temp `$HOME` +- run `edgebase setup --scope both --agents ` +- inspect generated host config, skills, agent-doc marker, and git hooks +- simulate MCP initialize, `tools/list`, and `edgebase_goal` +- simulate Claude/Codex hook payloads where those hooks are supported +- run `edgebase doctor --scope both --agents ` +- run `edgebase disable --scope both --agents ` and verify marker-bounded cleanup + ## Kill Criteria Stop or redesign if: diff --git a/src/edgebase/bootstrap.py b/src/edgebase/bootstrap.py index 893fcc3..16873e0 100644 --- a/src/edgebase/bootstrap.py +++ b/src/edgebase/bootstrap.py @@ -1,36 +1,29 @@ from __future__ import annotations -from .setup import ALL_AGENTS, normalize_agents - - -DISPLAY_NAMES = { - "claude": "Claude Code", - "codex": "Codex", - "cursor": "Cursor", - "gemini": "Gemini CLI", - "opencode": "OpenCode", - "windsurf": "Windsurf", -} +from .hosts import ALL_AGENTS, HOSTS, normalize_agents def render_install_prompt(agent: str = "all") -> str: selected = sorted(normalize_agents([agent])) agents_arg = "all" if selected == sorted(ALL_AGENTS) else ",".join(selected) - host_lines = "\n".join(f"- {DISPLAY_NAMES.get(name, name)}" for name in selected) + host_lines = "\n".join(f"- {HOSTS[name].display_name}" for name in selected) return ( - "Set up Edgebase for this repository.\n\n" + "Install Edgebase for this repository and verify the automatic work-contract runtime.\n\n" "Repository target: current working directory.\n\n" "Do the setup yourself. Do not ask me to run Edgebase commands manually.\n\n" - "Steps:\n" - "1. Confirm you are inside a git repository. If I gave you a repository URL instead of a local repo, " - "clone it first and cd into it.\n" - "2. Run:\n" - " python3 -m pip install --user --upgrade git+https://github.com/ychampion/edgebase.git\n" - "3. Run:\n" - f" python3 -m edgebase setup --scope both --agents {agents_arg}\n" - "4. Run:\n" - f" python3 -m edgebase doctor --scope both --agents {agents_arg}\n" - "5. Report the files Edgebase changed and whether doctor passed.\n\n" + "Run:\n" + "```bash\n" + "python3 -m pip install --user --upgrade git+https://github.com/ychampion/edgebase.git &&\n" + f"python3 -m edgebase setup --scope both --agents {agents_arg} &&\n" + f"python3 -m edgebase doctor --scope both --agents {agents_arg} &&\n" + "python3 -m edgebase status --json\n" + "```\n\n" + "After setup, report exactly which capabilities became automatic:\n" + "- MCP tools and prompts, including `edgebase_context`, `edgebase_goal`, and `edgebase_radius`\n" + "- prompt-time Goal Capsule creation where hooks are supported and trusted\n" + "- pre-edit Work Contract checks where hooks are supported and trusted\n" + "- post-edit refresh, checkpoints, and Patch Passport finish flow where hooks are supported\n" + "- slash commands or skills installed for the selected agent host\n\n" "Rules:\n" "- Do not add generated architecture summaries to AGENTS.md.\n" "- Do not remove existing agent config.\n" @@ -38,13 +31,6 @@ def render_install_prompt(agent: str = "all") -> str: "- Explain that Edgebase is on by default after setup, can be disabled with " "`python3 -m edgebase disable --scope both`, and can be bypassed for one emergency session with " "`EDGEBASE_PREFLIGHT=off`.\n\n" - "After setup, report exactly which capabilities became automatic:\n" - "- MCP tools: edgebase_context, edgebase_goal, edgebase_radius, edgebase_checkpoint, " - "edgebase_fork_plan, and edgebase_resume.\n" - "- Prompt-time Goal Capsule creation where hooks are supported and trusted.\n" - "- Pre-edit freshness checks where hooks are supported and trusted.\n" - "- Post-edit graph refresh, pre-compact checkpoint, and Patch Passport flows where hooks are supported.\n" - "- Slash commands or skills installed for the selected agent host.\n\n" "Selected agent hosts:\n" f"{host_lines}" ) diff --git a/src/edgebase/cli.py b/src/edgebase/cli.py index 13205ec..2cffa1a 100644 --- a/src/edgebase/cli.py +++ b/src/edgebase/cli.py @@ -25,17 +25,20 @@ handle_codex_session_start, handle_codex_stop, handle_codex_user_prompt_submit, + handle_git_refresh, handle_git_post_commit, install_claude_hooks, install_codex_hooks, - install_git_hook, + install_git_hooks, ) from .indexer import index_repo from .mcp import serve from .preflight import load_preflight_state, preflight_status, prepare_goal_capsule from .radius import build_change_radius +from .runtime import render_status, status_payload, write_finish_passport from .setup import ALL_AGENTS, disable_repo, setup_repo from .store import Store +from .team import team_disable, team_init def main(argv: list[str] | None = None) -> int: @@ -61,6 +64,10 @@ def build_parser() -> argparse.ArgumentParser: init_p.add_argument("--write-agents-md", action="store_true", help="Write a minimal AGENTS.md if missing.") init_p.set_defaults(func=cmd_init) + bootstrap_p = sub.add_parser("bootstrap", help="Print the Edgebase bootstrap prompt for agents.") + bootstrap_p.add_argument("--agent", default="all", help=f"Agent host or all. Supported: all,{','.join(ALL_AGENTS)}.") + bootstrap_p.set_defaults(func=cmd_bootstrap) + index_p = sub.add_parser("index", help="Build or refresh the local graph index.") add_subcommand_root(index_p) index_p.add_argument("--changed", action="store_true", help="Only reindex files changed in git status.") @@ -87,6 +94,11 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="Executable MCP clients should run. Defaults to the current Python module invocation.", ) + setup_p.add_argument( + "--strict", + action="store_true", + help="Install strict Claude pre-edit hooks that deny edits without a fresh active Work Contract.", + ) setup_p.set_defaults(func=cmd_setup) disable_p = sub.add_parser("disable", help="Turn off Edgebase agent integrations for this repo.") @@ -144,6 +156,7 @@ def build_parser() -> argparse.ArgumentParser: goal_p.add_argument("--changed-file", action="append", default=[], help="Changed file hint.") goal_p.add_argument("--budget", type=int, default=1200, help="Approximate token budget.") goal_p.add_argument("--json", action="store_true", help="Emit the Work Contract JSON schema.") + goal_p.add_argument("--record", action="store_true", help="Record this Goal Capsule as the active Work Contract.") goal_p.add_argument("--record-preflight", action="store_true", help="Record this Goal Capsule as fresh preflight.") goal_p.set_defaults(func=cmd_goal) @@ -160,6 +173,25 @@ def build_parser() -> argparse.ArgumentParser: passport_p.add_argument("--budget", type=int, default=1200, help="Approximate token budget.") passport_p.set_defaults(func=cmd_passport) + finish_p = sub.add_parser("finish", help="Write a final Patch Passport for the current work.") + add_subcommand_root(finish_p) + finish_p.add_argument("goal", help="Goal the patch is meant to satisfy.") + finish_p.add_argument("--changed-file", action="append", default=[], help="Changed file hint.") + finish_p.add_argument( + "--test", + action="append", + default=[], + help='Explicit test evidence, for example "python3 -m unittest -v: pass".', + ) + finish_p.add_argument("--budget", type=int, default=1200, help="Approximate token budget.") + finish_p.add_argument("--json", action="store_true", help="Emit written Patch Passport metadata.") + finish_p.set_defaults(func=cmd_finish) + + status_p = sub.add_parser("status", help="Show active Edgebase work-contract status.") + add_subcommand_root(status_p) + status_p.add_argument("--json", action="store_true", help="Emit machine-readable status.") + status_p.set_defaults(func=cmd_status) + checkpoint_p = sub.add_parser("checkpoint", help="Save an Edgebase context checkpoint for later resume.") add_subcommand_root(checkpoint_p) checkpoint_p.add_argument("message", help="Checkpoint goal or handoff message.") @@ -205,18 +237,23 @@ def build_parser() -> argparse.ArgumentParser: hooks_p = sub.add_parser("install-hooks", help="Install git, Claude Code, and Codex hooks.") add_subcommand_root(hooks_p) - hooks_p.add_argument("--git", action="store_true", help="Install git post-commit hook.") + hooks_p.add_argument("--git", action="store_true", help="Install git freshness hooks.") hooks_p.add_argument("--claude", action="store_true", help="Install Claude Code hooks.") hooks_p.add_argument("--codex", action="store_true", help="Install Codex hooks.") + hooks_p.add_argument("--strict", action="store_true", help="Install strict Claude pre-edit hooks.") hooks_p.set_defaults(func=cmd_install_hooks) hook_p = sub.add_parser("hooks", help=argparse.SUPPRESS) add_subcommand_root(hook_p) hook_sub = hook_p.add_subparsers(dest="hook_command") add_hook_command(hook_sub, "git-post-commit", handle_git_post_commit) + add_hook_command(hook_sub, "git-refresh", handle_git_refresh) add_hook_command(hook_sub, "claude-session-start", handle_claude_session_start) add_hook_command(hook_sub, "claude-user-prompt-submit", handle_claude_user_prompt_submit) - add_hook_command(hook_sub, "claude-pre-tool-use", handle_claude_pre_tool_use) + claude_pre = hook_sub.add_parser("claude-pre-tool-use") + add_subcommand_root(claude_pre) + claude_pre.add_argument("--strict", action="store_true") + claude_pre.set_defaults(func=lambda args: handle_claude_pre_tool_use(args.root, strict=args.strict)) add_hook_command(hook_sub, "claude-post-tool-use", handle_claude_post_tool_use) add_hook_command(hook_sub, "claude-pre-compact", handle_claude_pre_compact) add_hook_command(hook_sub, "claude-session-end", handle_claude_session_end) @@ -254,6 +291,17 @@ def build_parser() -> argparse.ArgumentParser: doctor_p.add_argument("--json", action="store_true", help="Emit machine-readable check results.") doctor_p.set_defaults(func=cmd_doctor) + team_p = sub.add_parser("team", help="Manage Edgebase team-mode repo guidance.") + add_subcommand_root(team_p) + team_sub = team_p.add_subparsers(dest="team_command") + team_init_p = team_sub.add_parser("init", help="Initialize optional or required Edgebase team mode.") + add_subcommand_root(team_init_p) + team_init_p.add_argument("mode", choices=("optional", "required")) + team_init_p.set_defaults(func=cmd_team_init) + team_disable_p = team_sub.add_parser("disable", help="Remove Edgebase team-mode guidance and hooks.") + add_subcommand_root(team_disable_p) + team_disable_p.set_defaults(func=cmd_team_disable) + return parser @@ -295,6 +343,11 @@ def cmd_init(args: argparse.Namespace) -> int: return 0 +def cmd_bootstrap(args: argparse.Namespace) -> int: + print(render_bootstrap(args.agent)) + return 0 + + def cmd_index(args: argparse.Namespace) -> int: root = find_repo_root(args.root) paths = list(args.file) @@ -319,6 +372,7 @@ def cmd_setup(args: argparse.Namespace) -> int: install_hooks=not args.no_hooks, write_agents_md=not args.no_agent_docs, command=args.command, + strict=args.strict, ) except (RuntimeError, ValueError) as exc: print(f"setup failed: {exc}", file=sys.stderr) @@ -398,7 +452,7 @@ def cmd_radius(args: argparse.Namespace) -> int: def cmd_goal(args: argparse.Namespace) -> int: - if args.record_preflight: + if args.record or args.record_preflight: capsule = prepare_goal_capsule(args.root, args.goal, args.changed_file, args.budget, source="cli-goal") else: capsule = build_goal_capsule(args.root, args.goal, args.changed_file, args.budget) @@ -415,6 +469,24 @@ def cmd_passport(args: argparse.Namespace) -> int: return 0 +def cmd_finish(args: argparse.Namespace) -> int: + payload = write_finish_passport(args.root, args.goal, args.test, args.changed_file, args.budget) + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(payload["passport_markdown"]) + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + payload = status_payload(args.root) + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(render_status(payload)) + return 0 + + def cmd_checkpoint(args: argparse.Namespace) -> int: try: snapshot = create_checkpoint(args.root, args.message, args.budget) @@ -503,9 +575,10 @@ def cmd_install_hooks(args: argparse.Namespace) -> int: args.codex = True root = find_repo_root(args.root) if args.git: - print(f"Installed git hook: {install_git_hook(root)}") + for path in install_git_hooks(root): + print(f"Installed git hook: {path}") if args.claude: - print(f"Installed Claude hooks: {install_claude_hooks(root)}") + print(f"Installed Claude hooks: {install_claude_hooks(root, strict=args.strict)}") if args.codex: print(f"Installed Codex hooks: {install_codex_hooks(root)}") return 0 @@ -542,3 +615,15 @@ def cmd_doctor(args: argparse.Namespace) -> int: for check in checks: print(f"{check.status.upper():5} {check.name}: {check.detail}") return 1 if any(check.status == "fail" for check in checks) else 0 + + +def cmd_team_init(args: argparse.Namespace) -> int: + for result in team_init(args.root, args.mode): + print(f"{result.action}: {result.path} ({result.detail})") + return 0 + + +def cmd_team_disable(args: argparse.Namespace) -> int: + for result in team_disable(args.root): + print(f"{result.action}: {result.path} ({result.detail})") + return 0 diff --git a/src/edgebase/doctor.py b/src/edgebase/doctor.py index 339aded..047dd03 100644 --- a/src/edgebase/doctor.py +++ b/src/edgebase/doctor.py @@ -164,6 +164,20 @@ def agent_config_checks(repo_root: Path, agents: set[str], scope: str) -> list[C checks.append( text_contains_check(Path.home() / ".codex" / "config.toml", "[mcp_servers.edgebase]", "Codex global config") ) + checks.append( + text_contains_check( + Path.home() / ".codex" / "skills" / "edgebase" / "SKILL.md", + CODEX_SKILL_START, + "Codex global /edgebase skill", + ) + ) + checks.append( + text_contains_check( + Path.home() / ".codex" / "skills" / "goal" / "SKILL.md", + CODEX_GOAL_SKILL_START, + "Codex global /goal skill", + ) + ) if "cursor" in agents and scope in {"project", "both"}: checks.append(json_mcp_check(repo_root / ".cursor" / "mcp.json", "Cursor project config")) if "cursor" in agents and scope in {"global", "both"}: diff --git a/src/edgebase/goal.py b/src/edgebase/goal.py index fd23411..42f0bd6 100644 --- a/src/edgebase/goal.py +++ b/src/edgebase/goal.py @@ -275,8 +275,10 @@ def render_patch_passport(contract: WorkContract, files_changed: list[str], test lines.append(f"- {format_test_evidence(test)}") else: lines.append("- No tests recorded by Edgebase.") + missing = unrecorded_checks(contract.test_plan, tests_run) + if missing: lines.append("Required checks not recorded:") - append_bullets(lines, contract.test_plan, empty="- No focused check inferred.") + append_bullets(lines, missing, empty="- No focused check inferred.") lines.append("") lines.append("Risk:") risks = list(contract.risk_flags) @@ -377,6 +379,18 @@ def format_test_evidence(test: str) -> str: return f"{command.strip()} [{status.strip()}]" +def unrecorded_checks(required: list[str], tests: list[str]) -> list[str]: + recorded = {test_command(test) for test in tests} + return [check for check in required if check not in recorded] + + +def test_command(test: str) -> str: + if ":" not in test: + return test.strip() + command, _status = test.rsplit(":", 1) + return command.strip() + + def is_test_path(path: str) -> bool: parts = Path(path).parts name = Path(path).name diff --git a/src/edgebase/hooks.py b/src/edgebase/hooks.py index 2f8e07a..1f03ad7 100644 --- a/src/edgebase/hooks.py +++ b/src/edgebase/hooks.py @@ -3,11 +3,12 @@ import json import os import sys +from fnmatch import fnmatch from pathlib import Path from typing import Any, Iterable from .commands import shell_join -from .git import changed_files, find_repo_root, is_git_repo +from .git import changed_files, find_repo_root, is_git_repo, run_git from .goal import render_edit_delta from .graph import graph_artifact_summary, write_graph_artifacts from .indexer import index_repo @@ -19,45 +20,79 @@ save_patch_passport, update_after_edit, ) +from .runtime import load_active_goal from .store import Store +GIT_HOOK_NAMES = ("post-commit", "post-checkout", "post-merge", "post-rewrite") + + def install_git_hook(root: str | Path) -> Path: + return install_git_hooks(root)[0] + + +def install_git_hooks(root: str | Path) -> list[Path]: repo_root = find_repo_root(root) if not is_git_repo(repo_root): raise RuntimeError(f"Not a git repository: {repo_root}") - hook_path = repo_root / ".git" / "hooks" / "post-commit" - marker = "# edgebase post-commit hook" - command = f"\n{marker}\n{hook_command(repo_root, 'git-post-commit')}\n" - existing = hook_path.read_text(encoding="utf-8") if hook_path.exists() else "#!/bin/sh\n" - if marker not in existing: - hook_path.write_text(existing.rstrip() + command, encoding="utf-8") - hook_path.chmod(0o755) - return hook_path + paths: list[Path] = [] + for hook_name in GIT_HOOK_NAMES: + hook_path = git_hook_path(repo_root, hook_name) + marker = f"# edgebase {hook_name} hook" + command = f"\n{marker}\n{hook_command(repo_root, 'git-refresh')}\n" + existing = hook_path.read_text(encoding="utf-8") if hook_path.exists() else "#!/bin/sh\n" + if marker not in existing: + hook_path.write_text(existing.rstrip() + command, encoding="utf-8") + hook_path.chmod(0o755) + paths.append(hook_path) + return paths def uninstall_git_hook(root: str | Path) -> Path: + paths = uninstall_git_hooks(root) + return paths[0] if paths else git_hook_path(find_repo_root(root), "post-commit") + + +def uninstall_git_hooks(root: str | Path) -> list[Path]: repo_root = find_repo_root(root) - hook_path = repo_root / ".git" / "hooks" / "post-commit" - if not hook_path.exists(): - return hook_path - lines = hook_path.read_text(encoding="utf-8").splitlines() - filtered: list[str] = [] - skip_next = False - for line in lines: - if skip_next: - skip_next = False - continue - if line.strip() == "# edgebase post-commit hook": - skip_next = True + paths: list[Path] = [] + for hook_name in GIT_HOOK_NAMES: + hook_path = git_hook_path(repo_root, hook_name) + paths.append(hook_path) + if not hook_path.exists(): continue - filtered.append(line) - hook_path.write_text("\n".join(filtered).rstrip() + "\n", encoding="utf-8") - return hook_path + lines = hook_path.read_text(encoding="utf-8").splitlines() + filtered: list[str] = [] + skip_next = False + for line in lines: + if skip_next: + skip_next = False + continue + if line.strip() == f"# edgebase {hook_name} hook": + skip_next = True + continue + filtered.append(line) + hook_path.write_text("\n".join(filtered).rstrip() + "\n", encoding="utf-8") + return paths + + +def git_hook_path(repo_root: Path, hook_name: str) -> Path: + proc = run_git(repo_root, ["rev-parse", "--git-path", f"hooks/{hook_name}"], timeout=3) + if proc and proc.returncode == 0 and proc.stdout.strip(): + path = Path(proc.stdout.strip()) + return path if path.is_absolute() else repo_root / path + return repo_root / ".git" / "hooks" / hook_name -def hook_entry(repo_root: Path, hook_name: str, matcher: str | None = None, timeout: int = 30, asynchronous: bool = False) -> dict[str, object]: - command: dict[str, object] = {"type": "command", "command": hook_command(repo_root, hook_name), "timeout": timeout} +def hook_entry( + repo_root: Path, + hook_name: str, + matcher: str | None = None, + timeout: int = 30, + asynchronous: bool = False, + strict: bool = False, +) -> dict[str, object]: + command: dict[str, object] = {"type": "command", "command": hook_command(repo_root, hook_name, strict=strict), "timeout": timeout} if asynchronous: command["async"] = True entry: dict[str, object] = {"hooks": [command]} @@ -66,7 +101,7 @@ def hook_entry(repo_root: Path, hook_name: str, matcher: str | None = None, time return entry -def install_claude_hooks(root: str | Path) -> Path: +def install_claude_hooks(root: str | Path, strict: bool = False) -> Path: repo_root = Path(root).resolve() settings_path = repo_root / ".claude" / "settings.json" settings_path.parent.mkdir(parents=True, exist_ok=True) @@ -82,7 +117,7 @@ def install_claude_hooks(root: str | Path) -> Path: pre_tool = hooks.setdefault("PreToolUse", []) post_tool = hooks.setdefault("PostToolUse", []) for matcher in ("Write", "Edit", "MultiEdit"): - append_unique(pre_tool, hook_entry(repo_root, "claude-pre-tool-use", matcher=matcher)) + append_unique(pre_tool, hook_entry(repo_root, "claude-pre-tool-use", matcher=matcher, strict=strict)) append_unique(post_tool, hook_entry(repo_root, "claude-post-tool-use", matcher=matcher, timeout=60, asynchronous=True)) append_unique(hooks.setdefault("PreCompact", []), hook_entry(repo_root, "claude-pre-compact")) append_unique(hooks.setdefault("SessionEnd", []), hook_entry(repo_root, "claude-session-end")) @@ -236,8 +271,11 @@ def codex_hook_runs_edgebase(entry: object) -> bool: return isinstance(entry, str) and "edgebase hooks codex-" in entry -def hook_command(repo_root: Path, hook_name: str) -> str: - return shell_join([sys.executable, "-m", "edgebase", "hooks", hook_name, "--root", str(repo_root)]) +def hook_command(repo_root: Path, hook_name: str, strict: bool = False) -> str: + parts = [sys.executable, "-m", "edgebase", "hooks", hook_name, "--root", str(repo_root)] + if strict: + parts.append("--strict") + return shell_join(parts) def uninstall_claude_hooks(root: str | Path) -> Path: @@ -322,6 +360,10 @@ def safe_graph_artifact_summary(root: str | Path, task: str | None, changed: lis def handle_git_post_commit(root: str | Path) -> int: + return handle_git_refresh(root) + + +def handle_git_refresh(root: str | Path) -> int: result = index_repo(root) print(f"edgebase indexed {result.files} files at {result.commit_sha}") return 0 @@ -358,7 +400,7 @@ def handle_claude_user_prompt_submit(root: str | Path) -> int: return 0 -def handle_claude_pre_tool_use(root: str | Path) -> int: +def handle_claude_pre_tool_use(root: str | Path, strict: bool = False) -> int: payload = read_json_stdin() if preflight_disabled() or not is_edit_tool(payload): return 0 @@ -366,7 +408,26 @@ def handle_claude_pre_tool_use(root: str | Path) -> int: touched = extract_tool_paths(payload, repo_root) fresh, status = ensure_fresh_preflight(repo_root, touched, budget=900) if not fresh: - print(deny_pre_tool_payload("PreToolUse", status.get("reason") or "Goal Capsule is stale")) + reason = status.get("reason") or "Goal Capsule is stale" + if strict: + print(deny_pre_tool_payload("PreToolUse", reason)) + else: + msg = ( + "Edgebase pre-edit warning: " + f"{reason}. Run `/edgebase-goal ` or `edgebase goal \"\" --record` " + "to refresh the active Work Contract before editing." + ) + print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": msg}})) + return 0 + state = load_active_goal(repo_root) or {} + warnings = contract_warnings(touched, state) + protected = protected_path_warnings(touched, state) + if warnings: + if strict and protected: + print(deny_pre_tool_payload("PreToolUse", "; ".join(protected))) + else: + msg = "Edgebase Work Contract warning: " + " ".join(warnings) + print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": msg}})) return 0 @@ -456,7 +517,7 @@ def should_inject_prompt_context(prompt: str) -> bool: return False if text in {"ok", "okay", "yes", "no", "thanks", "thank you", "continue", "go on", "sounds good"}: return False - hints = ("add", "build", "change", "debug", "error", "fail", "fix", "implement", "install", "migrate", "refactor", "remove", "rename", "review", "test", "update", "where", "why") + hints = ("add", "build", "change", "debug", "error", "fail", "fix", "implement", "install", "migrate", "modify", "refactor", "remove", "rename", "review", "test", "update", "where", "why") return any(hint in text for hint in hints) or len(text.split()) >= 6 @@ -471,11 +532,13 @@ def is_edit_tool(payload: dict[str, Any]) -> bool: def deny_pre_tool_payload(event_name: str, reason: object) -> str: return json.dumps( { + "permissionDecision": "deny", + "permissionDecisionReason": f"Edgebase blocked this edit. Reason: {reason}.", "hookSpecificOutput": { "hookEventName": event_name, "permissionDecision": "deny", "permissionDecisionReason": ( - "Edgebase blocked this edit because no fresh Goal Capsule exists. " + "Edgebase blocked this edit. " f"Reason: {reason}. Use `/edgebase-goal `, `/edgebase-preflight-refresh `, or `/goal `, " "or submit a coding prompt so Edgebase can inject one automatically. " "Set EDGEBASE_PREFLIGHT=off only for emergency bypass." @@ -517,3 +580,43 @@ def extract_tool_paths(payload: dict[str, Any], root: str | Path | None = None) except ValueError: rels.append(path.name) return sorted(set(rels)) + + +def contract_warnings(touched: list[str], state: dict[str, Any]) -> list[str]: + warnings: list[str] = [] + if not state.get("goal"): + warnings.append("No active Work Contract is recorded.") + if not touched: + warnings.append("Edit target was not available in the hook payload; verify the active Work Contract before editing.") + return warnings + warnings.extend(protected_path_warnings(touched, state)) + blast = [normalize_rel(path) for path in state.get("blast_radius") or [] if isinstance(path, str)] + if blast: + blast_set = set(blast) + outside = [path for path in touched if normalize_rel(path) not in blast_set] + if outside: + warnings.append("Edit target outside blast radius: " + ", ".join(outside) + ".") + return warnings + + +def protected_path_warnings(touched: list[str], state: dict[str, Any]) -> list[str]: + protected = [str(path) for path in state.get("must_not_touch") or [] if str(path).strip()] + matches: list[str] = [] + for path in touched: + normalized = normalize_rel(path) + for pattern in protected: + if protected_match(normalized, pattern): + matches.append(f"Edit target matches protected path `{pattern}`: {path}.") + break + return matches + + +def protected_match(path: str, pattern: str) -> bool: + normalized_pattern = normalize_rel(pattern) + if " " in normalized_pattern and "/" not in normalized_pattern and "*" not in normalized_pattern: + return False + return fnmatch(path, normalized_pattern) or path == normalized_pattern.rstrip("/") + + +def normalize_rel(path: str) -> str: + return path.replace("\\", "/").lstrip("./") diff --git a/src/edgebase/hosts.py b/src/edgebase/hosts.py new file mode 100644 index 0000000..1a4318d --- /dev/null +++ b/src/edgebase/hosts.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class HostCapability: + name: str + display_name: str + mcp: bool + project_skills: bool = False + global_skills: bool = False + hooks: bool = False + agent_docs: bool = True + verified_hooks: bool = False + + +HOSTS: dict[str, HostCapability] = { + "claude": HostCapability( + name="claude", + display_name="Claude Code", + mcp=True, + project_skills=True, + hooks=True, + verified_hooks=True, + ), + "codex": HostCapability( + name="codex", + display_name="Codex", + mcp=True, + project_skills=True, + global_skills=True, + hooks=True, + verified_hooks=True, + ), + "cursor": HostCapability(name="cursor", display_name="Cursor", mcp=True), + "gemini": HostCapability(name="gemini", display_name="Gemini CLI", mcp=True), + "opencode": HostCapability(name="opencode", display_name="OpenCode", mcp=True), + "windsurf": HostCapability(name="windsurf", display_name="Windsurf", mcp=True), +} + +ALL_AGENTS = tuple(HOSTS) + + +def host_capabilities() -> list[HostCapability]: + return [HOSTS[name] for name in ALL_AGENTS] + + +def normalize_agents(agents: list[str] | None) -> set[str]: + if not agents: + return set(ALL_AGENTS) + normalized: set[str] = set() + for item in agents: + for part in item.split(","): + name = part.strip().lower() + if name == "all": + normalized.update(ALL_AGENTS) + elif name: + if name not in HOSTS: + raise ValueError(f"Unknown agent `{name}`. Supported: {', '.join(ALL_AGENTS)}") + normalized.add(name) + return normalized diff --git a/src/edgebase/preflight.py b/src/edgebase/preflight.py index 27a1498..de17786 100644 --- a/src/edgebase/preflight.py +++ b/src/edgebase/preflight.py @@ -31,6 +31,10 @@ def preflight_markdown_path(root: str | Path) -> Path: return find_repo_root(root) / ".edgebase" / "preflight.md" +def active_goal_path(root: str | Path) -> Path: + return find_repo_root(root) / ".edgebase" / "session" / "active-goal.json" + + def prepare_goal_capsule( root: str | Path, goal: str, @@ -56,13 +60,21 @@ def prepare_goal_capsule( "worktree_changed_files": git_changed_files(repo_root), "selected_files": capsule.contract.selected_files, "must_read": capsule.contract.must_read, + "must_not_touch": capsule.contract.must_not_touch, "blast_radius": capsule.contract.blast_radius, "test_plan": capsule.contract.test_plan, + "acceptance_criteria": capsule.contract.acceptance_criteria, + "risk_flags": capsule.contract.risk_flags, + "uncertainties": capsule.contract.uncertainties, + "provenance": capsule.contract.provenance, "stale_files": current_stale_files(repo_root), "graph_artifacts": capsule.graph_artifacts, "capsule_path": str(markdown_path), + "capsule_markdown": capsule.markdown, + "work_contract": capsule.contract.to_dict(), } write_preflight_state(repo_root, state) + write_active_goal_state(repo_root, state) return capsule @@ -84,6 +96,17 @@ def write_preflight_state(root: str | Path, state: dict[str, Any]) -> Path: return path +def write_active_goal_state(root: str | Path, state: dict[str, Any]) -> Path: + repo_root = find_repo_root(root) + active = dict(state) + active["version"] = 1 + active["working_tree_changed_files"] = active.get("worktree_changed_files") or [] + path = active_goal_path(repo_root) + path.parent.mkdir(parents=True, exist_ok=True) + write_text_atomic(path, json.dumps(active, indent=2, sort_keys=True) + "\n") + return path + + def preflight_status(root: str | Path) -> dict[str, Any]: if preflight_disabled(): return {"fresh": True, "reason": "disabled by EDGEBASE_PREFLIGHT", "disabled": True} diff --git a/src/edgebase/runtime.py b/src/edgebase/runtime.py new file mode 100644 index 0000000..e7a9765 --- /dev/null +++ b/src/edgebase/runtime.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .context import stale_files +from .git import changed_files as git_changed_files +from .git import current_commit, find_repo_root +from .goal import build_patch_passport +from .preflight import load_preflight_state, preflight_status, write_text_atomic +from .store import Store + + +STATE_VERSION = 1 +ACTIVE_GOAL_REL = Path(".edgebase") / "session" / "active-goal.json" + + +def active_goal_path(root: str | Path) -> Path: + return find_repo_root(root) / ACTIVE_GOAL_REL + + +def load_active_goal(root: str | Path) -> dict[str, Any] | None: + path = active_goal_path(root) + if not path.exists(): + return load_preflight_state(root) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return load_preflight_state(root) + return data if isinstance(data, dict) else load_preflight_state(root) + + +def status_payload(root: str | Path) -> dict[str, Any]: + repo_root = find_repo_root(root) + state = load_active_goal(repo_root) + store = Store(repo_root) + graph = store.load_graph() if store.exists() else {"files": []} + stale = stale_files(repo_root, graph["files"]) if store.exists() else [] + changed = git_changed_files(repo_root) + required = list((state.get("test_plan") if isinstance(state, dict) else []) or []) + recorded_tests = list((state.get("recorded_tests") if isinstance(state, dict) else []) or []) + blast_radius = list((state.get("blast_radius") if isinstance(state, dict) else []) or []) + blast_set = set(str(path) for path in blast_radius) + preflight = preflight_status(repo_root) + latest_passport = latest_file(repo_root / ".edgebase" / "passports", "latest.md") + latest_checkpoint = latest_file(repo_root / ".edgebase" / "checkpoints", "latest.md") + return { + "repo": str(repo_root), + "active_goal": state.get("goal") if isinstance(state, dict) else None, + "state_fresh": bool(preflight.get("fresh")) and not stale, + "freshness_reason": preflight.get("reason"), + "changed_files": changed, + "stale_files": stale, + "blast_radius": blast_radius, + "blast_radius_drift": [path for path in changed if blast_set and path not in blast_set], + "elevated_tests": required, + "required_checks_unrecorded": unrecorded_checks(required, recorded_tests), + "latest_passport": str(latest_passport) if latest_passport else None, + "latest_checkpoint": str(latest_checkpoint) if latest_checkpoint else None, + } + + +def render_status(payload: dict[str, Any]) -> str: + lines = ["# Edgebase Status", ""] + lines.append(f"Active goal: {payload.get('active_goal') or 'none'}") + lines.append(f"State fresh: {str(payload.get('state_fresh')).lower()}") + if payload.get("freshness_reason"): + lines.append(f"Freshness reason: {payload['freshness_reason']}") + lines.append("") + for title, key in ( + ("Changed files", "changed_files"), + ("Stale files", "stale_files"), + ("Blast radius", "blast_radius"), + ("Blast radius drift", "blast_radius_drift"), + ("Elevated tests", "elevated_tests"), + ("Required checks unrecorded", "required_checks_unrecorded"), + ): + lines.append(f"{title}:") + values = list(payload.get(key) or []) + lines.extend(f"- {value}" for value in values) if values else lines.append("- none") + lines.append("") + lines.append(f"Latest Patch Passport: {payload.get('latest_passport') or 'none'}") + lines.append(f"Latest checkpoint: {payload.get('latest_checkpoint') or 'none'}") + return "\n".join(lines) + + +def write_finish_passport( + root: str | Path, + goal: str, + tests: list[str], + changed_files: list[str] | None = None, + budget: int = 1200, +) -> dict[str, Any]: + repo_root = find_repo_root(root) + passport = build_patch_passport(repo_root, goal, tests, changed_files, budget) + passports = repo_root / ".edgebase" / "passports" + passports.mkdir(parents=True, exist_ok=True) + payload = { + "version": STATE_VERSION, + "created_at_iso": iso_timestamp(time.time()), + "goal": passport.goal, + "files_changed": passport.files_changed, + "tests_run": passport.tests_run, + "required_checks": passport.required_checks, + "missing_tests": unrecorded_checks(passport.required_checks, passport.tests_run), + "passport_markdown": passport.markdown, + } + write_json(passports / "latest.json", payload) + write_text_atomic(passports / "latest.md", passport.markdown + "\n") + state = load_active_goal(repo_root) + if isinstance(state, dict): + state["recorded_tests"] = passport.tests_run + state["latest_passport"] = str(passports / "latest.md") + state["latest_passport_json"] = str(passports / "latest.json") + state["updated_at_iso"] = iso_timestamp(time.time()) + write_json(active_goal_path(repo_root), state) + return payload + + +def latest_file(directory: Path, name: str) -> Path | None: + path = directory / name + return path if path.exists() else None + + +def write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + write_text_atomic(path, json.dumps(value, indent=2, sort_keys=True) + "\n") + + +def unrecorded_checks(required: list[str], tests: list[str]) -> list[str]: + recorded = {test_command(test) for test in tests} + return [check for check in required if check not in recorded] + + +def test_command(test: str) -> str: + if ":" not in test: + return test.strip() + command, _status = test.rsplit(":", 1) + return command.strip() + + +def iso_timestamp(ts: float) -> str: + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/src/edgebase/setup.py b/src/edgebase/setup.py index e635fc7..0d20c28 100644 --- a/src/edgebase/setup.py +++ b/src/edgebase/setup.py @@ -11,15 +11,15 @@ from .hooks import ( install_claude_hooks, install_codex_hooks, - install_git_hook, + install_git_hooks, uninstall_claude_hooks, uninstall_codex_hooks, - uninstall_git_hook, + uninstall_git_hooks, ) +from .hosts import ALL_AGENTS, normalize_agents from .indexer import index_repo -ALL_AGENTS = ("claude", "codex", "cursor", "gemini", "opencode", "windsurf") AGENT_DOC_START = "" AGENT_DOC_END = "" CLAUDE_SKILL_START = "" @@ -87,6 +87,22 @@ class SlashCommandSpec: " $ARGUMENTS --budget 1200", "Summarize the current patch, changed files, explicit test evidence, risk, and review focus.", ), + SlashCommandSpec( + "edgebase-finish", + ("finish",), + "Write the final Patch Passport for the current work.", + '"" --test "command: result"', + " $ARGUMENTS --budget 1200", + "Write `.edgebase/passports/latest.md` and `.json`, separating inferred required checks from explicit test evidence.", + ), + SlashCommandSpec( + "edgebase-status", + ("status",), + "Show active Edgebase work-contract status.", + "[--json]", + " $ARGUMENTS", + "Show active goal, freshness, changed files, stale files, elevated tests, missing recorded checks, and latest artifacts.", + ), SlashCommandSpec( "edgebase-preflight-status", ("preflight", "status"), @@ -168,6 +184,7 @@ def setup_repo( install_hooks: bool = True, write_agents_md: bool = True, command: str | None = None, + strict: bool = False, ) -> list[SetupResult]: repo_root = Path(root).resolve() selected = normalize_agents(agents) @@ -182,7 +199,7 @@ def setup_repo( results.append(write_agent_docs(repo_root)) if "claude" in selected: - results.extend(setup_claude(repo_root, scope, install_hooks, command)) + results.extend(setup_claude(repo_root, scope, install_hooks, command, strict)) if "codex" in selected: results.extend(setup_codex(repo_root, scope, install_hooks, command)) if "cursor" in selected: @@ -196,11 +213,12 @@ def setup_repo( if install_hooks: try: - path = install_git_hook(repo_root) + paths = install_git_hooks(repo_root) except RuntimeError as exc: results.append(SetupResult(repo_root, "skipped", str(exc))) else: - results.append(SetupResult(path, "updated", "git post-commit refresh hook")) + for path in paths: + results.append(SetupResult(path, "updated", "git freshness refresh hook")) return results @@ -227,28 +245,13 @@ def disable_repo( if "windsurf" in selected: results.extend(disable_windsurf(scope)) if remove_hooks: - results.append(SetupResult(uninstall_git_hook(repo_root), "updated", "removed git post-commit refresh hook")) + for path in uninstall_git_hooks(repo_root): + results.append(SetupResult(path, "updated", "removed git freshness refresh hook")) if remove_agent_docs: results.append(remove_agent_docs_block(repo_root)) return results -def normalize_agents(agents: list[str] | None) -> set[str]: - if not agents: - return set(ALL_AGENTS) - normalized: set[str] = set() - for item in agents: - for part in item.split(","): - name = part.strip().lower() - if name == "all": - normalized.update(ALL_AGENTS) - elif name: - if name not in ALL_AGENTS: - raise ValueError(f"Unknown agent `{name}`. Supported: {', '.join(ALL_AGENTS)}") - normalized.add(name) - return normalized - - def stdio_config(repo_root: Path, command: str | None = None) -> dict[str, object]: executable, prefix = command_parts(command) return {"command": executable, "args": [*prefix, "mcp", "--root", str(repo_root)], "env": {}} @@ -355,7 +358,7 @@ def remove_agent_docs_block(repo_root: Path) -> SetupResult: def setup_claude( - repo_root: Path, scope: str, install_hooks: bool, command: str | None + repo_root: Path, scope: str, install_hooks: bool, command: str | None, strict: bool = False ) -> list[SetupResult]: results: list[SetupResult] = [] if scope in {"project", "both"}: @@ -400,8 +403,9 @@ def setup_claude( else: results.append(SetupResult(command_skill_path, "updated", f"Claude Code project skill /{spec.name}")) if install_hooks: - hooks_path = install_claude_hooks(repo_root) - results.append(SetupResult(hooks_path, "updated", "Claude Code automatic prompt and freshness hooks")) + hooks_path = install_claude_hooks(repo_root, strict=strict) + mode = "strict" if strict else "warn-only" + results.append(SetupResult(hooks_path, "updated", f"Claude Code automatic prompt and freshness hooks ({mode})")) if scope in {"global", "both"}: results.append( SetupResult( @@ -595,9 +599,69 @@ def setup_codex(repo_root: Path, scope: str, install_hooks: bool, command: str | path = Path.home() / ".codex" / "config.toml" upsert_codex_toml(path, repo_root, command) results.append(SetupResult(path, "updated", "Codex global MCP server")) + global_skill_root = Path.home() / ".codex" / "skills" + append_marked_skill_result( + results, + global_skill_root / "edgebase" / "SKILL.md", + CODEX_SKILL_START, + codex_skill_content(repo_root, command), + "Codex global skill /edgebase", + ) + append_marked_skill_result( + results, + global_skill_root / "goal" / "SKILL.md", + CODEX_GOAL_SKILL_START, + codex_goal_skill_content(repo_root, command, "goal", CODEX_GOAL_SKILL_START, CODEX_GOAL_SKILL_END), + "Codex global skill /goal", + ) + append_marked_skill_result( + results, + global_skill_root / "edgebase-goal" / "SKILL.md", + CODEX_EDGEBASE_GOAL_SKILL_START, + codex_goal_skill_content( + repo_root, + command, + "edgebase-goal", + CODEX_EDGEBASE_GOAL_SKILL_START, + CODEX_EDGEBASE_GOAL_SKILL_END, + ), + "Codex global skill /edgebase-goal", + ) + for spec in EDGEBASE_SLASH_COMMANDS: + marker_start, _ = slash_command_markers("codex", spec.name) + append_marked_skill_result( + results, + global_skill_root / spec.name / "SKILL.md", + marker_start, + codex_command_skill_content(repo_root, command, spec), + f"Codex global skill /{spec.name}", + ) return results +def append_marked_skill_result( + results: list[SetupResult], + path: Path, + marker: str, + content: str, + detail: str, +) -> None: + try: + written = write_marked_skill(path, marker, content) + except RuntimeError as exc: + results.append(SetupResult(path, "skipped", str(exc))) + else: + results.append(SetupResult(written, "updated", detail)) + + +def write_marked_skill(path: Path, marker: str, content: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists() and marker not in path.read_text(encoding="utf-8"): + raise RuntimeError(f"Refusing to overwrite existing skill without Edgebase marker: {path}") + path.write_text(content, encoding="utf-8") + return path + + def install_codex_skill(repo_root: Path, command: str | None) -> Path: skill_path = repo_root / ".agents" / "skills" / "edgebase" / "SKILL.md" skill_path.parent.mkdir(parents=True, exist_ok=True) @@ -990,6 +1054,40 @@ def disable_codex(repo_root: Path, scope: str, remove_hooks: bool) -> list[Setup path = Path.home() / ".codex" / "config.toml" action = "updated" if remove_codex_toml(path) else "skipped" results.append(SetupResult(path, action, "Codex global MCP server removed")) + global_skill_root = Path.home() / ".codex" / "skills" + results.append( + SetupResult( + uninstall_marked_skill(global_skill_root / "edgebase" / "SKILL.md", CODEX_SKILL_START), + "updated", + "removed Codex global skill /edgebase", + ) + ) + results.append( + SetupResult( + uninstall_marked_skill(global_skill_root / "goal" / "SKILL.md", CODEX_GOAL_SKILL_START), + "updated", + "removed Codex global skill /goal", + ) + ) + results.append( + SetupResult( + uninstall_marked_skill( + global_skill_root / "edgebase-goal" / "SKILL.md", + CODEX_EDGEBASE_GOAL_SKILL_START, + ), + "updated", + "removed Codex global skill /edgebase-goal", + ) + ) + for spec in EDGEBASE_SLASH_COMMANDS: + marker_start, _ = slash_command_markers("codex", spec.name) + results.append( + SetupResult( + uninstall_marked_skill(global_skill_root / spec.name / "SKILL.md", marker_start), + "updated", + f"removed Codex global skill /{spec.name}", + ) + ) return results @@ -1006,6 +1104,18 @@ def uninstall_codex_skill(repo_root: Path) -> Path: return skill_path +def uninstall_marked_skill(path: Path, marker: str) -> Path: + if not path.exists() or marker not in path.read_text(encoding="utf-8"): + return path + path.unlink() + for parent in (path.parent, path.parent.parent): + try: + parent.rmdir() + except OSError: + break + return path + + def uninstall_codex_goal_skill(repo_root: Path) -> Path: return uninstall_codex_goal_skill_named(repo_root, "goal", CODEX_GOAL_SKILL_START) diff --git a/src/edgebase/team.py b/src/edgebase/team.py new file mode 100644 index 0000000..4008ff8 --- /dev/null +++ b/src/edgebase/team.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import json +import shlex +import sys +from pathlib import Path + +from .git import find_repo_root +from .setup import SetupResult + + +TEAM_DOC_START = "" +TEAM_DOC_END = "" + + +def team_init(root: str | Path, mode: str) -> list[SetupResult]: + if mode not in {"optional", "required"}: + raise ValueError("team mode must be optional or required") + repo_root = find_repo_root(root) + results = [write_team_docs(repo_root, mode)] + if mode == "required": + hook_path = write_required_hook(repo_root) + results.append(SetupResult(hook_path, "updated", "Edgebase required-mode Claude hook script")) + settings_path = install_required_hook(repo_root, hook_path) + results.append(SetupResult(settings_path, "updated", "Edgebase required-mode Claude PreToolUse hook")) + return results + + +def team_disable(root: str | Path) -> list[SetupResult]: + repo_root = find_repo_root(root) + return [ + remove_team_docs(repo_root), + SetupResult(uninstall_required_hook(repo_root), "updated", "removed Edgebase required-mode Claude hook"), + ] + + +def write_team_docs(repo_root: Path, mode: str) -> SetupResult: + path = repo_root / "AGENTS.md" + block = team_doc_block(mode) + if not path.exists(): + path.write_text("# Agent Instructions\n\n" + block, encoding="utf-8") + return SetupResult(path, "created", f"Edgebase team mode ({mode})") + existing = path.read_text(encoding="utf-8") + if TEAM_DOC_START in existing and TEAM_DOC_END in existing: + before, rest = existing.split(TEAM_DOC_START, 1) + _, after = rest.split(TEAM_DOC_END, 1) + updated = before.rstrip() + "\n\n" + block + after + else: + updated = existing.rstrip() + "\n\n" + block + path.write_text(updated.rstrip() + "\n", encoding="utf-8") + return SetupResult(path, "updated", f"Edgebase team mode ({mode})") + + +def team_doc_block(mode: str) -> str: + if mode == "required": + body = ( + "## Edgebase Required\n\n" + "This repo requires Edgebase for AI-assisted implementation work. Before broad code exploration or edits, " + "verify `edgebase doctor --scope both` and use the active Goal Capsule / Work Contract flow. If Edgebase " + "is missing, install it with `edgebase install-prompt --agent all` and restart the agent.\n" + ) + else: + body = ( + "## Edgebase Recommended\n\n" + "This repo recommends Edgebase for AI-assisted work. Install once, then use Goal Capsules, active Work " + "Contracts, status, checkpoints, and Patch Passports instead of manually rebuilding context each session.\n" + ) + return f"{TEAM_DOC_START}\n{body}{TEAM_DOC_END}\n" + + +def remove_team_docs(repo_root: Path) -> SetupResult: + path = repo_root / "AGENTS.md" + if not path.exists(): + return SetupResult(path, "skipped", "AGENTS.md not found") + existing = path.read_text(encoding="utf-8") + if TEAM_DOC_START not in existing or TEAM_DOC_END not in existing: + return SetupResult(path, "skipped", "Edgebase team marker not found") + before, rest = existing.split(TEAM_DOC_START, 1) + _, after = rest.split(TEAM_DOC_END, 1) + path.write_text((before.rstrip() + after).strip() + "\n", encoding="utf-8") + return SetupResult(path, "updated", "removed Edgebase team marker") + + +def write_required_hook(repo_root: Path) -> Path: + path = repo_root / ".claude" / "hooks" / "edgebase-required.sh" + path.parent.mkdir(parents=True, exist_ok=True) + command = shlex.join( + [ + sys.executable, + "-m", + "edgebase", + "doctor", + "--root", + str(repo_root), + "--scope", + "project", + "--agents", + "claude", + "--json", + ] + ) + path.write_text( + "#!/bin/sh\n" + "# Edgebase required-mode hook. Generated by `edgebase team init required`.\n" + f"if ! {command} >/dev/null 2>&1; then\n" + " echo '{\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Edgebase is required for AI-assisted edits in this repo. Run edgebase install-prompt --agent all.\"}'\n" + " exit 0\n" + "fi\n" + "echo '{}'\n", + encoding="utf-8", + ) + path.chmod(0o755) + return path + + +def install_required_hook(repo_root: Path, hook_path: Path) -> Path: + settings_path = repo_root / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + settings: dict[str, object] = {} + if settings_path.exists(): + loaded = json.loads(settings_path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + settings = loaded + hooks = settings.setdefault("hooks", {}) + if not isinstance(hooks, dict): + raise RuntimeError(f"Expected hooks object in {settings_path}") + entries = hooks.setdefault("PreToolUse", []) + if not isinstance(entries, list): + raise RuntimeError(f"Expected PreToolUse list in {settings_path}") + entry = { + "matcher": "Write|Edit|MultiEdit", + "hooks": [{"type": "command", "command": shlex.quote(str(hook_path)), "timeout": 10}], + } + if entry not in entries: + entries.append(entry) + settings_path.write_text(json.dumps(settings, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return settings_path + + +def uninstall_required_hook(repo_root: Path) -> Path: + settings_path = repo_root / ".claude" / "settings.json" + if settings_path.exists(): + loaded = json.loads(settings_path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + hooks = loaded.get("hooks") + if isinstance(hooks, dict): + entries = hooks.get("PreToolUse") + if isinstance(entries, list): + hooks["PreToolUse"] = [ + entry for entry in entries if "edgebase-required.sh" not in json.dumps(entry) + ] + if hooks["PreToolUse"] == []: + del hooks["PreToolUse"] + if hooks == {}: + loaded.pop("hooks", None) + settings_path.write_text(json.dumps(loaded, indent=2, sort_keys=True) + "\n", encoding="utf-8") + hook_path = repo_root / ".claude" / "hooks" / "edgebase-required.sh" + if hook_path.exists(): + hook_path.unlink() + return settings_path diff --git a/tests/test_edgebase.py b/tests/test_edgebase.py index 290e64e..5e9dac6 100644 --- a/tests/test_edgebase.py +++ b/tests/test_edgebase.py @@ -20,7 +20,9 @@ from edgebase.context import build_context from edgebase.doctor import run_doctor from edgebase.goal import build_goal_capsule, build_patch_passport -from edgebase.preflight import load_preflight_state, preflight_status +from edgebase.hosts import HOSTS +from edgebase.preflight import load_preflight_state, preflight_status, prepare_goal_capsule +from edgebase.runtime import active_goal_path, status_payload, write_finish_passport from edgebase.radius import build_change_radius from edgebase.graph import build_graph_export, render_dot, render_html, render_json, write_graph_artifacts from edgebase.hooks import ( @@ -34,6 +36,7 @@ from edgebase.mcp import McpServer from edgebase.setup import AGENT_DOC_START, EDGEBASE_SLASH_COMMANDS, disable_repo, setup_repo from edgebase.store import Store +from edgebase.team import TEAM_DOC_START, team_disable, team_init class EdgebaseTests(unittest.TestCase): @@ -298,6 +301,53 @@ def test_cli_version_flag_reports_package_version(self) -> None: self.assertEqual(proc.returncode, 0, proc.stderr) self.assertEqual(proc.stdout.strip(), f"edgebase {__version__}") + def test_install_prompt_and_host_capabilities(self) -> None: + prompt = render_install_prompt("all") + self.assertIn("edgebase setup --scope both --agents all", prompt) + self.assertIn("edgebase doctor --scope both --agents all", prompt) + self.assertIn("edgebase status --json", prompt) + for host in HOSTS.values(): + self.assertIn(host.display_name, prompt) + self.assertTrue(HOSTS["claude"].verified_hooks) + self.assertTrue(HOSTS["codex"].project_skills) + self.assertFalse(HOSTS["cursor"].hooks) + + def test_status_and_finish_write_runtime_passport_state(self) -> None: + with sample_repo() as repo: + prepare_goal_capsule(repo, "modify login", ["app/auth.py"], source="test") + status = status_payload(repo) + self.assertEqual(status["active_goal"], "modify login") + self.assertIn("pytest tests/test_auth.py", status["required_checks_unrecorded"]) + + payload = write_finish_passport( + repo, + "modify login", + ["pytest tests/test_auth.py: pass"], + ["app/auth.py"], + ) + self.assertEqual(payload["missing_tests"], []) + self.assertTrue((repo / ".edgebase" / "passports" / "latest.md").exists()) + updated = status_payload(repo) + self.assertEqual(updated["required_checks_unrecorded"], []) + + def test_team_optional_required_and_disable_cleanup(self) -> None: + with sample_repo() as repo: + team_init(repo, "optional") + agents_md = (repo / "AGENTS.md").read_text(encoding="utf-8") + self.assertIn(TEAM_DOC_START, agents_md) + self.assertIn("Edgebase Recommended", agents_md) + + team_init(repo, "required") + required_hook = repo / ".claude" / "hooks" / "edgebase-required.sh" + self.assertTrue(required_hook.exists()) + settings = json.loads((repo / ".claude" / "settings.json").read_text(encoding="utf-8")) + self.assertIn("edgebase-required.sh", json.dumps(settings)) + + team_disable(repo) + agents_md = (repo / "AGENTS.md").read_text(encoding="utf-8") + self.assertNotIn(TEAM_DOC_START, agents_md) + self.assertFalse(required_hook.exists()) + def test_graph_export_model_and_static_artifacts(self) -> None: with sample_repo() as repo: index_repo(repo) @@ -612,20 +662,37 @@ def test_codex_setup_skips_unmarked_existing_skills(self) -> None: existing_skill = repo / ".agents" / "skills" / "edgebase-goal" / "SKILL.md" existing_skill.parent.mkdir(parents=True, exist_ok=True) existing_skill.write_text("# Existing custom skill\n", encoding="utf-8") + old_home = os.environ.get("HOME") + home_tmp = tempfile.TemporaryDirectory() + self.addCleanup(home_tmp.cleanup) + os.environ["HOME"] = home_tmp.name + existing_global = Path.home() / ".codex" / "skills" / "edgebase-goal" / "SKILL.md" + existing_global.parent.mkdir(parents=True, exist_ok=True) + existing_global.write_text("# Existing global custom skill\n", encoding="utf-8") - results = setup_repo( - repo, - agents=["codex"], - scope="project", - install_hooks=True, - write_agents_md=True, - ) + try: + results = setup_repo( + repo, + agents=["codex"], + scope="both", + install_hooks=True, + write_agents_md=True, + ) + finally: + if old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = old_home skipped = [result for result in results if result.path == existing_skill] self.assertEqual(len(skipped), 1) self.assertEqual(skipped[0].action, "skipped") self.assertIn("Refusing to overwrite existing Codex skill", skipped[0].detail) self.assertEqual(existing_skill.read_text(encoding="utf-8"), "# Existing custom skill\n") + skipped_global = [result for result in results if result.path == existing_global] + self.assertEqual(len(skipped_global), 1) + self.assertEqual(skipped_global[0].action, "skipped") + self.assertEqual(existing_global.read_text(encoding="utf-8"), "# Existing global custom skill\n") self.assertTrue((repo / ".agents" / "skills" / "edgebase" / "SKILL.md").exists()) self.assertTrue((repo / ".agents" / "skills" / "goal" / "SKILL.md").exists()) @@ -779,6 +846,7 @@ def test_claude_prompt_hook_injects_context_for_coding_prompts(self) -> None: self.assertTrue((repo / ".edgebase" / "graphs" / "latest.html").exists()) self.assertTrue(preflight_status(repo)["fresh"]) self.assertEqual(load_preflight_state(repo)["goal"], "change login hashing behavior") + self.assertTrue(active_goal_path(repo).exists()) def test_claude_pre_tool_hook_injects_work_contract(self) -> None: with sample_repo() as repo: @@ -802,10 +870,72 @@ def test_claude_pre_tool_hook_injects_work_contract(self) -> None: data = json.loads(stdout.getvalue()) output = data["hookSpecificOutput"] self.assertEqual(output["hookEventName"], "PreToolUse") + self.assertIn("Edgebase pre-edit warning", output["additionalContext"]) + self.assertIn("no fresh Edgebase Goal Capsule", output["additionalContext"]) + + old_stdin = sys.stdin + stdout = io.StringIO() + try: + sys.stdin = io.StringIO( + json.dumps( + { + "goal": "modify login", + "tool_input": {"file_path": str(repo / "app" / "auth.py")}, + } + ) + ) + with contextlib.redirect_stdout(stdout): + code = handle_claude_pre_tool_use(repo, strict=True) + finally: + sys.stdin = old_stdin + self.assertEqual(code, 0) + data = json.loads(stdout.getvalue()) + output = data["hookSpecificOutput"] self.assertEqual(output["permissionDecision"], "deny") self.assertIn("no fresh Edgebase Goal Capsule", output["permissionDecisionReason"]) self.assertIn("/edgebase-preflight-refresh ", output["permissionDecisionReason"]) + prepare_goal_capsule(repo, "modify login", ["app/auth.py"], source="test") + old_stdin = sys.stdin + stdout = io.StringIO() + try: + sys.stdin = io.StringIO( + json.dumps( + { + "goal": "modify login", + "tool_input": {"file_path": str(repo / "README.md")}, + } + ) + ) + with contextlib.redirect_stdout(stdout): + code = handle_claude_pre_tool_use(repo) + finally: + sys.stdin = old_stdin + self.assertEqual(code, 0) + data = json.loads(stdout.getvalue()) + self.assertIn("outside blast radius", data["hookSpecificOutput"]["additionalContext"]) + + old_stdin = sys.stdin + stdout = io.StringIO() + try: + sys.stdin = io.StringIO( + json.dumps( + { + "goal": "modify login", + "tool_input": {"file_path": str(repo / "migrations" / "001.sql")}, + } + ) + ) + with contextlib.redirect_stdout(stdout): + code = handle_claude_pre_tool_use(repo, strict=True) + finally: + sys.stdin = old_stdin + self.assertEqual(code, 0) + data = json.loads(stdout.getvalue()) + output = data["hookSpecificOutput"] + self.assertEqual(output["permissionDecision"], "deny") + self.assertIn("protected path", output["permissionDecisionReason"]) + def test_pre_tool_hook_does_not_block_named_read_tools(self) -> None: with sample_repo() as repo: index_repo(repo) @@ -908,7 +1038,7 @@ def test_hook_commands_shell_quote_repo_paths(self) -> None: "-m", "edgebase", "hooks", - "git-post-commit", + "git-refresh", "--root", str(repo.resolve()), ],