Compare commits

..

10 Commits

Author SHA1 Message Date
HAL9000 1e543e8fa3 docs(development): add developer setup guide
CI / build (pull_request) Successful in 22s
CI / lint (pull_request) Successful in 32s
CI / quality (pull_request) Successful in 51s
CI / push-validation (pull_request) Successful in 21s
CI / helm (pull_request) Successful in 29s
CI / typecheck (pull_request) Successful in 1m0s
CI / security (pull_request) Successful in 1m1s
CI / e2e_tests (pull_request) Successful in 3m7s
CI / integration_tests (pull_request) Successful in 7m3s
CI / unit_tests (pull_request) Successful in 7m50s
CI / docker (pull_request) Successful in 10s
CI / coverage (pull_request) Successful in 15m59s
CI / status-check (pull_request) Successful in 1s
Add comprehensive developer setup guide covering prerequisites, development
workflow, testing, linting, type checking, commit guidelines, devcontainer
setup, and troubleshooting.

Refs: #9123
2026-04-14 08:12:26 +00:00
HAL9000 1031fd0fb1 fix(agents): make bug-hunt-pool-supervisor tracking non-blocking to prevent initialization hangs
CI / lint (pull_request) Successful in 25s
CI / typecheck (pull_request) Successful in 59s
CI / quality (pull_request) Successful in 33s
CI / security (pull_request) Successful in 55s
CI / build (pull_request) Successful in 44s
CI / helm (pull_request) Successful in 30s
CI / push-validation (pull_request) Successful in 26s
CI / integration_tests (pull_request) Successful in 4m13s
CI / e2e_tests (pull_request) Successful in 4m19s
CI / unit_tests (pull_request) Successful in 6m13s
CI / docker (pull_request) Successful in 13s
CI / coverage (pull_request) Successful in 14m50s
CI / status-check (pull_request) Successful in 1s
2026-04-14 05:25:30 +00:00
CleverAgents Build Agent 64b1f4c0b6 Build: improve grooming worker permissions, milestone enforcement, and PR merge throughput
CI / lint (push) Successful in 21s
CI / quality (push) Successful in 43s
CI / security (push) Successful in 51s
CI / build (push) Successful in 28s
CI / helm (push) Successful in 40s
CI / push-validation (push) Successful in 27s
CI / typecheck (push) Successful in 1m20s
CI / e2e_tests (push) Successful in 3m25s
CI / integration_tests (push) Successful in 3m59s
CI / unit_tests (push) Successful in 5m13s
CI / docker (push) Successful in 10s
CI / coverage (push) Successful in 12m9s
CI / status-check (push) Successful in 1s
CI / lint (pull_request) Successful in 31s
CI / typecheck (pull_request) Successful in 48s
CI / quality (pull_request) Successful in 37s
CI / security (pull_request) Successful in 58s
CI / helm (pull_request) Successful in 22s
CI / build (pull_request) Successful in 34s
CI / push-validation (pull_request) Successful in 16s
CI / e2e_tests (pull_request) Successful in 4m10s
CI / integration_tests (pull_request) Successful in 4m20s
CI / coverage (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
- Fix grooming-worker Forgejo permissions (deny → allow) to unblock direct API calls
- Route PR label fetching through forgejo-label-manager subagent
- Replace priority-alignment check with milestone enforcement (every issue must have a milestone)
- Add step 11: address non-code review remarks (labels, description, milestone) during grooming
- Clarify grooming-pool-supervisor stale threshold to explicit 24-hour window
- Refactor pr-merge-pool-supervisor main loop into explicit numbered steps
- Add triage strategy section emphasising parallel review checks and immediate worker dispatch
- Tighten merge criteria: explicit APPROVED state, no unresolved REQUEST_CHANGES on current head
- Dispatch workers for all PR processing, not only rebase operations
- Add rule to batch forgejo_list_pull_reviews calls instead of checking serially
2026-04-14 00:49:34 -04:00
HAL9000 e757ca9db0 docs(contributors): add HAL 9000 concurrency-fix contribution detail
CI / lint (pull_request) Successful in 39s
CI / quality (pull_request) Successful in 41s
CI / typecheck (pull_request) Successful in 57s
CI / security (pull_request) Successful in 57s
CI / build (pull_request) Successful in 45s
CI / helm (pull_request) Successful in 45s
CI / push-validation (pull_request) Successful in 20s
CI / e2e_tests (pull_request) Successful in 4m5s
CI / integration_tests (pull_request) Successful in 4m14s
CI / unit_tests (pull_request) Successful in 5m30s
CI / docker (pull_request) Successful in 1m33s
CI / coverage (pull_request) Successful in 13m0s
CI / status-check (pull_request) Successful in 1s
CI / lint (push) Successful in 29s
CI / quality (push) Successful in 48s
CI / typecheck (push) Successful in 58s
CI / security (push) Successful in 59s
CI / build (push) Successful in 34s
CI / push-validation (push) Successful in 29s
CI / helm (push) Successful in 36s
CI / e2e_tests (push) Successful in 3m22s
CI / integration_tests (push) Successful in 5m46s
CI / unit_tests (push) Successful in 8m50s
CI / docker (push) Successful in 2m10s
CI / coverage (push) Successful in 13m38s
CI / status-check (push) Successful in 1s
Add a Details entry for HAL 9000 describing the plan lifecycle
concurrency race-condition fix (#7989) — wiring LockService into
execute_plan/apply_plan with unique per-invocation owner identities.

ISSUES CLOSED: #7989
2026-04-14 03:59:50 +00:00
HAL9000 b1f7b51a80 fix(concurrency): fix lock owner identity to prevent re-entrant acquisition
The original implementation used plan_id as the owner_id when acquiring
the advisory lock. Because LockService treats owner_id as the caller
identity and allows re-entrant acquisition for the same owner, concurrent
sessions attempting to lock the same plan would all present the same
owner_id and thus silently renew the lock instead of raising
LockConflictError.

This fix generates a unique UUID for each invocation as the owner_id,
ensuring that concurrent sessions present different owners and thus
trigger LockConflictError when attempting to acquire the same plan lock.
The lock is still acquired before the phase transition and released in
a finally block to ensure cleanup even on error.

ISSUES CLOSED: #8067
2026-04-14 03:58:16 +00:00
HAL9000 b83f575cb5 fix(concurrency): wire LockService into plan lifecycle — guard execute_plan and apply_plan
LockService was implemented but never integrated into the plan execution
path, leaving execute_plan() and apply_plan() unprotected against
concurrent calls on the same plan_id (race condition, issue #7989).

Changes:
- container.py: add _build_lock_service() factory and register
  LockService as a Singleton provider; inject it into
  PlanLifecycleService via the DI container.
- plan_lifecycle_service.py: accept optional lock_service parameter in
  __init__; in execute_plan() and apply_plan() acquire a plan-level
  advisory lock before the critical section and release it in a finally
  block so the lock is always freed even when exceptions occur.

When lock_service is None (existing tests without DI wiring) the
behaviour is unchanged — locking is silently skipped for backward
compatibility.

Closes #7989
2026-04-14 03:58:16 +00:00
CleverAgents Build Agent 38bcd41338 Build: Better protection against agents editing the main working directory
CI / lint (push) Successful in 24s
CI / typecheck (push) Successful in 54s
CI / quality (push) Successful in 45s
CI / security (push) Successful in 1m15s
CI / build (push) Successful in 29s
CI / push-validation (push) Successful in 30s
CI / helm (push) Successful in 37s
CI / e2e_tests (push) Successful in 3m39s
CI / integration_tests (push) Successful in 4m28s
CI / unit_tests (push) Successful in 5m22s
CI / docker (push) Successful in 21s
CI / coverage (push) Successful in 11m39s
CI / status-check (push) Successful in 1s
2026-04-13 23:36:46 -04:00
HAL9000 c11b05b773 docs(changelog): add v3.3.0 changelog entry for #7582 fail_fast fix
CI / lint (pull_request) Successful in 30s
CI / typecheck (pull_request) Successful in 1m6s
CI / security (pull_request) Successful in 1m7s
CI / quality (pull_request) Successful in 42s
CI / helm (pull_request) Successful in 27s
CI / build (pull_request) Successful in 34s
CI / push-validation (pull_request) Successful in 23s
CI / e2e_tests (pull_request) Successful in 3m17s
CI / integration_tests (pull_request) Successful in 4m13s
CI / unit_tests (pull_request) Successful in 5m36s
CI / docker (pull_request) Successful in 1m37s
CI / coverage (pull_request) Successful in 11m50s
CI / status-check (pull_request) Successful in 2s
CI / lint (push) Successful in 36s
CI / typecheck (push) Successful in 53s
CI / quality (push) Successful in 30s
CI / security (push) Successful in 1m16s
CI / helm (push) Successful in 22s
CI / push-validation (push) Successful in 15s
CI / e2e_tests (push) Successful in 3m50s
CI / build (push) Successful in 3m32s
CI / integration_tests (push) Successful in 6m45s
CI / unit_tests (push) Successful in 7m39s
CI / docker (push) Successful in 1m19s
CI / coverage (push) Successful in 14m59s
CI / status-check (push) Successful in 1s
2026-04-14 02:51:16 +00:00
HAL9000 3cfa24854a fix(concurrency): fix SubplanExecutionService._execute_parallel() #7582
Ensure fail_fast cancels in-flight futures and reports them as CANCELLED.

Add Behave coverage that reproduces the concurrency regression.

ISSUES CLOSED: #7582
2026-04-14 02:51:16 +00:00
CleverAgents Build Agent a71c142854 Build: Made conflict resolution a more explicit part of the pr-merge agents
CI / helm (push) Successful in 35s
CI / push-validation (push) Successful in 19s
CI / build (push) Successful in 3m57s
CI / lint (push) Successful in 4m3s
CI / quality (push) Successful in 4m34s
CI / typecheck (push) Successful in 4m44s
CI / security (push) Successful in 4m50s
CI / e2e_tests (push) Successful in 7m3s
CI / integration_tests (push) Successful in 10m13s
CI / unit_tests (push) Successful in 11m21s
CI / docker (push) Successful in 1m30s
CI / coverage (push) Successful in 10m42s
CI / status-check (push) Successful in 1s
2026-04-13 21:33:01 -04:00
24 changed files with 843 additions and 2975 deletions
+2 -1
View File
@@ -91,7 +91,7 @@ Each cycle:
4. **Monitor workers.** Count active workers, check for stuck sessions.
5. **Update tracking.** Every 3 cycles, create a status tracking issue via `automation-tracking-manager` with prefix `AUTO-BUG-POOL`.
5. **Update tracking (non-blocking).** Every 3 cycles, attempt to create a status tracking issue via `automation-tracking-manager` with prefix `AUTO-BUG-POOL`. This step is **best-effort** — if the call does not complete within a reasonable time or fails, skip it and continue to the next cycle. **Never block the main loop waiting for tracking.** Tracking is informational only; the supervisor's core function (module scanning and worker dispatch) must continue regardless.
## Finding Validation Gate
@@ -126,3 +126,4 @@ Supervisor: Bug Hunt Pool | Agent: bug-hunt-pool-supervisor
7. **Apply labels via `forgejo-label-manager`.** Never apply labels directly or using the Forgejo MCP/task. All label operations must go through `forgejo-label-manager`.
8. **Exhaustive pagination for all list results.** Every tool call, REST/curl request, or any other command that returns a list must be treated as potentially paginated and incomplete. Always set `limit` to its maximum available value (use `limit=50` for Forgejo MCP tools; use `limit=50` or higher for direct REST/curl calls). After each list response, check whether the number of returned items equals the page size — if so, there are likely more results; fetch the next page (`page=2`, `page=3`, …) and continue until receiving a partial page. Never assume the first response is the complete result. This rule applies to every list-returning call without exception. *Examples specific to this agent (not exhaustive):* `forgejo_list_repo_issues` (default 20 — use `limit=50` and paginate to check for all existing bug issues before filing duplicates); `forgejo_list_repo_pull_requests` (same — check all open PRs that may already address a discovered bug).
9. **Tracking is non-blocking.** The `automation-tracking-manager` call in step 5 must never block the main loop. If it hangs or fails, skip it and proceed. Core functionality (module mapping, worker dispatch, monitoring) takes priority over status reporting.
+1 -1
View File
@@ -79,7 +79,7 @@ When choosing what to groom next, always re-check the current state of issues an
3. **Issues never groomed** — issues that have never been groomed.
4. **Stale items** — issues or PRs that were groomed a long time ago and may have drifted.
4. **Stale items** — issues or PRs that were groomed a long time ago (more than 24 hours) and may have drifted.
To determine if a PR has been groomed, search its comments for a comment containing `[GROOMED]` — workers post this marker after completing their analysis.
+9 -5
View File
@@ -24,7 +24,7 @@ permission:
"new-issue-creator": allow
"forgejo-label-manager": allow
"issue-state-updater": allow
"forgejo_*": deny
"forgejo_*": allow
"forgejo_get_issue_by_index": allow
"forgejo_list_issue_comments": allow
"forgejo_issue_add_comment": allow
@@ -77,7 +77,7 @@ Your prompt includes:
2. Fetch ALL comments using `forgejo_list_issue_comments` (PRs use the issue comment API).
3. Fetch ALL formal reviews using `forgejo_list_pull_reviews`.
4. Fetch review comments using `forgejo_list_pull_review_comments`.
5. Fetch the PR's labels using `forgejo_get_issue_labels`.
5. Fetch the PR's labels using `forgejo-label-manager` subagent.
6. Identify the linked issue (from closing keywords like `Closes #N` in the PR body).
7. Fetch the linked issue's details and labels.
@@ -106,8 +106,8 @@ Check for contradictions:
- Issue in `State/In Review` without an open PR → revert to `State/In Progress`.
- Issue in `State/Paused` without `Blocked` label → add `Blocked` or unpause.
### 6. Priority Alignment
If the issue is in a milestone, check whether its priority makes sense relative to the milestone's urgency. Flag obvious mismatches (e.g., `Priority/Low` in a milestone with an imminent deadline).
### 6. No milestone set
Every issue must have its milestone set, if not set set a milestone. Use the milestone descriptions to judge the best choice for a milestone.
### 7. Completed Work Not Closed
If the issue has a linked PR that has been merged but the issue is still open, close it by transitioning to `State/Completed` via `issue-state-updater`.
@@ -126,10 +126,14 @@ For pull requests ONLY: the PR's labels must be synced to match its linked issue
- `MoSCoW/` label (if present)
- Milestone assignment
Also verify the PR has:
Also verify the PR has the following, and if it doesnt add it:
- A closing keyword (`Closes #N` or `Fixes #N`) in its description
- A dependency link (PR blocks the linked issue)
### 11: PR-Specific: Address any relevant remarks from reviews
Check formal reviews as well as informal comments left as reviews. Any concerns raised about the PR or its linked ticket, not related to the source code itself (for example labels, the PR description, milestone setting, etc) should be addressed.
## Step 3: Post a Groomed Marker
After completing all analysis and fixes, post a summary comment on the item containing the marker `[GROOMED]` so the supervisor knows this item has been processed. The comment should briefly list what was checked and what was fixed:
+70 -24
View File
@@ -57,7 +57,7 @@ permission:
# PR Merge Supervisor
You are a supervisor that monitors open PRs for merge readiness, verifies all criteria are met, rebases stale PR, resolves conflicts, and merges them. You call `pr-merge-worker` as a **blocking subagent** for rebase operations with conflict resolution when PRs are behind the base branch. Unlike other supervisors, you do NOT use async-agent-manager to dispatch workers — you invoke the worker directly via the Task tool and block until it completes.
You are a supervisor that monitors open PRs for merge readiness, verifies all criteria are met, rebases stale PRs, resolves conflicts, and merges them. You call `pr-merge-worker` as a **blocking subagent** for all PR processing — both direct merges and rebase operations. Unlike other supervisors, you do NOT use async-agent-manager to dispatch workers — you invoke the worker directly via the Task tool and block until it completes.
## What You Receive
@@ -65,6 +65,17 @@ Your prompt from the product-builder includes:
- Repository owner/name, Forgejo PAT, git identity
- A customized briefing containing CONTRIBUTING.md merge requirements and open announcements
## CRITICAL: Triage Strategy — Speed Over Perfection
**Do NOT spend time pre-filtering or serially checking reviews before dispatching workers.** The correct approach is:
1. **Paginate ALL open PRs** — collect every PR number, title, labels, `mergeable` flag, and `merge_base` vs `base.sha`.
2. **Check reviews in parallel** — use multiple `forgejo_list_pull_reviews` calls in the same message for batches of PRs. Do not check them one at a time sequentially.
3. **Dispatch workers immediately** — for any PR that has `mergeable: true` AND at least one APPROVED review (not dismissed) AND no unresolved REQUEST_CHANGES on the current head, dispatch a worker right away. Do not wait to finish checking all other PRs first.
4. **Do not over-sort** — the priority ordering matters, but do not spend many cycles sorting before acting. Process the highest-priority ready PRs first, then continue down the list.
**The most common mistake is spending too long checking reviews serially and never dispatching workers.** If you have checked 20+ PRs and dispatched 0 workers, something is wrong — act faster.
## Merge Verification is Mandatory
The `forgejo_merge_pull_request` tool frequently returns success when the merge did NOT actually happen. Forgejo silently rejects merges when a branch is behind. You must verify every merge:
@@ -88,54 +99,89 @@ PROCEDURE MERGE_PR(pr_number):
update_linked_issues_to_completed(pr_number)
RETURN "merged"
ELSE:
-- Merge silently failed
RETURN "failed"
-- Merge silently failed — dispatch worker to handle
CALL_BLOCKING_WORKER(pr_number)
RETURN "worker_handled"
```
## Seven Merge Criteria
## Merge Criteria
Before merging any PR, the following must be true:
Before merging any PR, ALL of the following must be true:
1. **Approval** — at least one approving review (formal review, approval comment, or self-approval)
2. **CI passing** — all workflow runs on the latest commit are successful
3. **No conflicts** — the PR has no merge conflicts
4. **Not stale**`merge_base` equals `base.sha` (branch is up to date)
5. **No `needs feedback` label** — the PR is not waiting for human input
6. **Not blocked**no `Blocked` label
1. **Approval** — at least one APPROVED review (state=APPROVED, not dismissed) on the current head commit
2. **No blocking reviews** — no unresolved REQUEST_CHANGES reviews (official=true, dismissed=false) on the current head commit
3. **CI passing** — all required workflow checks on the latest commit are successful
4. **No conflicts**`mergeable: true` (Forgejo reports no merge conflicts)
5. **No `Needs Feedback` label** — the PR is not waiting for human input
6. **No `Blocked` label**the PR is not explicitly blocked
**Note:** The above criteria do not need to be satisfied before dispatching a `pr-merge-worker`. While they must be satisfied before the actual merge, the rebasing and conflict resolution steps are still useful even if it isnt ready to be merged.
**Staleness (`merge_base != base.sha`) is NOT a hard blocker for dispatching a worker.** A worker can rebase a stale PR. However, the merge itself must happen after the rebase succeeds and CI passes on the rebased commit.
**Note:** The above criteria do not need to be satisfied before dispatching a `pr-merge-worker`. While they must be satisfied before the actual merge, the rebasing and conflict resolution steps are still useful even if the PR isn't ready to be merged yet.
## Workers
You call `pr-merge-worker` as a **blocking subagent** (via the Task tool) for rebase operations with conflict resolution when PRs are behind the base branch. The worker runs synchronously within your process — you block until it finishes, then continue your cycle. There are no async sessions for workers; this is intentional to simplify coordination since only one rebase happens at a time.
You call `pr-merge-worker` as a **blocking subagent** (via the Task tool) for all PR processing. The worker runs synchronously within your process — you block until it finishes, then continue your cycle. There are no async sessions for workers; this is intentional to simplify coordination since only one rebase happens at a time.
Every worker prompt must include:
- PR number, title, branch name, head SHA, base SHA, merge_base SHA
- Whether the PR is stale (merge_base != base.sha)
- Current review status (any approvals? any unresolved REQUEST_CHANGES?)
- Current CI status if known
- Repository info, Forgejo PAT, git identity
- Credentials: PAT, username, password, git name, git email
## Main Loop
Poll every 5 minutes using `bash("sleep 300", timeout=360000)`.
Each cycle:
1. List all open PRs. (the forgejo task paginates so be sure to go through every page)
2. for each PR that can be merged (has a passing CI,, isn't stale, and has at least 1 approval review) do a fast-forward or rebase merge, as always do the mandatory post-merge verification.
3. For all other PR (including those you attempted to merge but were unsuccessful) order them as follows: 1) CI passes and has one or more review approval 2) CI is failing without conflicts and has one or more review approval 3) CI is failing with conflicts and has one or more review approval 4) All other PRs, within each of those four categories sort by priority label with the most critical label ("Priority/CI Blocking") coming first.
4. For each PR, in the order specified in step 2 above, call `pr-merge-worker` as a **blocking subagent** via the Task tool. Pass the PR number, repository info, and credentials in the prompt. The worker will rebase onto the latest master, resolve conflicts, wait for CI, and attempt a fast-forward or rebase merge. You block until the worker finishes before processing the next PR.
5. For any PR that were successfully merged update linked issues, and the PR itself, to `State/Completed` via `forgejo-label-manager`.
### Step 1: Collect All PRs (paginate exhaustively)
Fetch ALL open PRs using `forgejo_list_repo_pull_requests` with `limit=50`, paginating through every page until a partial page is received. Record for each PR: number, title, labels, `mergeable`, `merge_base`, `base.sha`, head SHA.
### Step 2: Batch-Check Reviews (parallel, not serial)
For PRs that are `mergeable: true` and have no `Needs Feedback` or `Blocked` labels, check reviews **in parallel batches** — call `forgejo_list_pull_reviews` for multiple PRs in the same message. Identify:
- PRs with at least one APPROVED review (not dismissed) on current head AND no unresolved REQUEST_CHANGES → **Ready to merge**
- PRs with no reviews or only REQUEST_CHANGES → **Need worker for rebase/prep**
### Step 3: Merge Ready PRs First
For each **Ready to merge** PR (sorted by priority: Priority/CI Blocking > Priority/Critical > Priority/High > Priority/Medium > Priority/Low):
- If `merge_base == base.sha` (not stale): attempt direct merge via `forgejo_merge_pull_request`, then verify
- If stale: dispatch `pr-merge-worker` to rebase, wait for CI, and merge
### Step 4: Process Remaining PRs via Workers
For all other PRs (those needing rebase, conflict resolution, or CI wait), order them:
1. CI passes + has approval (but stale/conflicted)
2. CI failing without conflicts + has approval
3. CI failing with conflicts + has approval
4. All other PRs (no approval yet, but rebase still useful)
Within each category, sort by priority label (Priority/CI Blocking first, then Critical, High, Medium, Low).
For each PR in this ordered list, call `pr-merge-worker` as a **blocking subagent** via the Task tool. Block until the worker finishes before processing the next PR.
### Step 5: Post-Merge Cleanup
For any PR successfully merged: update linked issues and the PR itself to `State/Completed` via `forgejo-label-manager`.
## Tracking
- Prefix: `AUTO-MERGE`
- Cycle interval: ~5 minutes
- Create announcements for: merge verification failures, persistent stale PRs
- Create announcements for: merge verification failures, persistent stale PRs, PRs stuck with REQUEST_CHANGES for >24h
## Rules
1. **Always verify merges.** Never trust the merge API response alone.
1. **Always verify merges.** Never trust the merge API response alone. Re-fetch the PR after every merge attempt and check `merged == true AND state == "closed"`.
2. **Never merge immediately after rebase.** Wait for CI to complete quality gates/tests first.
3. **Pass credentials down.** Every worker prompt must include repository info, Forgejo PAT, and git identity. Workers never read environment variables.
4. **Bot signature on all Forgejo content:**
4. **Batch review checks.** Call `forgejo_list_pull_reviews` for multiple PRs in the same message — never check reviews one at a time in serial when you can parallelize.
5. **Act fast on ready PRs.** If a PR is `mergeable: true` and has an APPROVED review, dispatch a worker or attempt a direct merge immediately — do not defer it to "after checking all other PRs".
6. **Bot signature on all Forgejo content:**
```
---
**Automated by CleverAgents Bot**
Supervisor: PR Merge | Agent: pr-merge-pool-supervisor
Supervisor: PR Merge Pool | Agent: pr-merge-pool-supervisor
```
5. **Apply labels via `forgejo-label-manager`.** Never apply labels directly or using the Forgejo MCP/task. All label operations must go through `forgejo-label-manager`.
6. **Exhaustive pagination for all list results.** Every tool call, REST/curl request, or any other command that returns a list must be treated as potentially paginated and incomplete. Always set `limit` to its maximum available value (use `limit=50` for Forgejo MCP tools; use `limit=50` or higher for direct REST/curl calls). After each list response, check whether the number of returned items equals the page size — if so, there are likely more results; fetch the next page (`page=2`, `page=3`, …) and continue until receiving a partial page. Never assume the first response is the complete result. This rule applies to every list-returning call without exception. *Examples specific to this agent (not exhaustive):* `forgejo_list_repo_pull_requests` (default 20 — must use `limit=50` and paginate ALL pages; every open PR must be assessed for merge readiness or a mergeable PR gets left unmerged indefinitely); `forgejo_list_pull_reviews` (paginate to check that all review rounds have been considered before merging); `forgejo_list_issue_comments` (paginate to read the full comment history on linked issues when updating state post-merge).
7. **Apply labels via `forgejo-label-manager`.** Never apply labels directly or using the Forgejo MCP/task. All label operations must go through `forgejo-label-manager`.
8. **Exhaustive pagination for all list results.** Every tool call that returns a list must be treated as potentially paginated. Always set `limit=50` for Forgejo MCP tools. After each list response, check whether the number of returned items equals the page size — if so, fetch the next page. Never assume the first response is the complete result. This applies to: `forgejo_list_repo_pull_requests` (must paginate ALL pages — missing a page means a ready PR never gets merged), `forgejo_list_pull_reviews` (paginate to see all review rounds), `forgejo_list_issue_comments` (paginate when updating linked issues post-merge).
+9 -7
View File
@@ -5,13 +5,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Documentation
- Add `docs/api/decisions.md` — decision recording and tree CLI reference (v3.2.0)
- Add `docs/api/invariants.md` — invariant management CLI reference (v3.2.0)
- Add `docs/api/checkpoints.md` — checkpoint and rollback CLI reference (v3.3.0)
- Add `docs/api/plan-corrections.md` — plan correction modes and subplan system overview (v3.2.0/v3.3.0)
### Fixed
- **Automation Profile Silent Fallback** (#8232): `_resolve_profile_for_plan` in
@@ -24,6 +17,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- **Developer Setup Guide** (#9123): Added comprehensive developer setup guide at `docs/development/setup.md` covering prerequisites, development workflow, testing, linting, and commit guidelines.
- **TDD Issue-Capture Test Activation** (#7025): Replaced 234 bare `@skip` tags
across 82 Behave feature files with the correct `@tdd_expected_fail @tdd_issue
@tdd_issue_<N>` tag system. Scenarios whose referenced bugs were already fixed
@@ -150,6 +144,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
- **Plan Concurrency Race Condition** (#7989): Fixed critical race condition in `execute_plan()` and
`apply_plan()` where concurrent CLI/worker sessions could simultaneously modify the same plan,
corrupting plan state. `LockService` is now wired into the plan lifecycle with plan-level advisory
locking. Each invocation generates a unique caller identity (UUID) to prevent re-entrant lock
acquisition by concurrent sessions on the same plan. Concurrent attempts now raise `LockConflictError`
instead of silently racing. Lock is acquired before phase transition and released in a `finally`
block to ensure cleanup even on error.
- **Validation Gate Empty-Run Guard** (#7508): Fixed `ApplyValidationSummary.all_required_passed`
returning `True` when zero validations were run, silently bypassing the apply gate. The property
now returns `False` when the validation result set is empty (`is_empty` is `True`), ensuring
+1
View File
@@ -15,4 +15,5 @@ Below are some of the specific details of various contributions.
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
* HAL 9000 has contributed the plan concurrency race-condition fix (#7989): wired `LockService` into the plan lifecycle, guarding `execute_plan()` and `apply_plan()` with plan-level advisory locks and unique per-invocation owner identities to prevent silent concurrent state corruption.
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
-188
View File
@@ -1,188 +0,0 @@
"""ASV benchmarks for LangGraph execution hotspots.
Measures performance of:
- GraphExecutor.step execution
- RouterNode.route decision making
- Checkpoint read/write operations
- Graph state transitions
- Node execution throughput
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from typing import Any
try:
from cleveragents.langgraph.graph import GraphConfig, LangGraph
from cleveragents.langgraph.nodes import Edge, Node, NodeConfig, NodeType
from cleveragents.langgraph.state import GraphState
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.langgraph.graph import GraphConfig, LangGraph
from cleveragents.langgraph.nodes import Edge, Node, NodeConfig, NodeType
from cleveragents.langgraph.state import GraphState
def _create_simple_graph() -> LangGraph:
"""Create a simple representative plan graph for benchmarking."""
config = GraphConfig(
name="bench-graph",
nodes={
"start": NodeConfig(name="start", type=NodeType.START),
"process": NodeConfig(name="process", type=NodeType.FUNCTION),
"route": NodeConfig(name="route", type=NodeType.CONDITIONAL),
"end": NodeConfig(name="end", type=NodeType.END),
},
edges=[
Edge(source="start", target="process"),
Edge(source="process", target="route"),
Edge(source="route", target="end"),
],
entry_point="start",
checkpointing=False,
)
return LangGraph(config)
def _create_checkpoint_graph() -> LangGraph:
"""Create a graph with checkpointing enabled for persistence benchmarks."""
checkpoint_dir = Path("/tmp/langgraph_bench_checkpoints")
checkpoint_dir.mkdir(exist_ok=True)
config = GraphConfig(
name="bench-graph-checkpoint",
nodes={
"start": NodeConfig(name="start", type=NodeType.START),
"process": NodeConfig(name="process", type=NodeType.FUNCTION),
"end": NodeConfig(name="end", type=NodeType.END),
},
edges=[
Edge(source="start", target="process"),
Edge(source="process", target="end"),
],
entry_point="start",
checkpointing=True,
checkpoint_dir=checkpoint_dir,
)
return LangGraph(config)
def _create_complex_graph() -> LangGraph:
"""Create a more complex graph with multiple nodes for throughput testing."""
config = GraphConfig(
name="bench-graph-complex",
nodes={
"start": NodeConfig(name="start", type=NodeType.START),
"node1": NodeConfig(name="node1", type=NodeType.FUNCTION),
"node2": NodeConfig(name="node2", type=NodeType.FUNCTION),
"node3": NodeConfig(name="node3", type=NodeType.FUNCTION),
"route": NodeConfig(name="route", type=NodeType.CONDITIONAL),
"end": NodeConfig(name="end", type=NodeType.END),
},
edges=[
Edge(source="start", target="node1"),
Edge(source="node1", target="node2"),
Edge(source="node2", target="node3"),
Edge(source="node3", target="route"),
Edge(source="route", target="end"),
],
entry_point="start",
checkpointing=False,
parallel_execution=True,
)
return LangGraph(config)
class LangGraphExecutionSuite:
"""Benchmark LangGraph execution hotspots."""
def setup(self) -> None:
"""Initialize graphs for benchmarking."""
self.simple_graph = _create_simple_graph()
self.checkpoint_graph = _create_checkpoint_graph()
self.complex_graph = _create_complex_graph()
# Sample input state
self.sample_state = GraphState()
def time_simple_graph_execute(self) -> None:
"""Measure simple graph execution time."""
asyncio.run(self.simple_graph.execute(self.sample_state))
def time_complex_graph_execute(self) -> None:
"""Measure complex graph execution time."""
asyncio.run(self.complex_graph.execute(self.sample_state))
def time_checkpoint_graph_execute(self) -> None:
"""Measure graph execution with checkpointing enabled."""
asyncio.run(self.checkpoint_graph.execute(self.sample_state))
def time_state_manager_get_state(self) -> None:
"""Measure state manager state retrieval."""
self.simple_graph.state_manager.get_state()
def time_state_manager_update_state(self) -> None:
"""Measure state manager state update."""
new_state = GraphState()
self.simple_graph.state_manager.state = new_state
def track_graph_nodes_count(self) -> int:
"""Track number of nodes in simple graph."""
return len(self.simple_graph.nodes)
def track_graph_edges_count(self) -> int:
"""Track number of edges in simple graph."""
return len(self.simple_graph.config.edges)
def track_execution_history_length(self) -> int:
"""Track execution history length after execution."""
return len(self.simple_graph.get_execution_history())
class LangGraphRouterNodeSuite:
"""Benchmark RouterNode routing performance."""
def setup(self) -> None:
"""Initialize router node for benchmarking."""
self.router_config = NodeConfig(
name="router",
type=NodeType.CONDITIONAL,
condition={"type": "simple_condition"},
)
self.router_node = Node(self.router_config)
def time_router_node_creation(self) -> None:
"""Measure router node instantiation time."""
Node(self.router_config)
def time_router_node_config_access(self) -> None:
"""Measure router node config access."""
_ = self.router_node.config.condition
class LangGraphStateTransitionSuite:
"""Benchmark graph state transitions and updates."""
def setup(self) -> None:
"""Initialize state for benchmarking."""
self.state = GraphState()
self.graph = _create_simple_graph()
def time_state_creation(self) -> None:
"""Measure GraphState creation time."""
GraphState()
def time_state_dict_conversion(self) -> None:
"""Measure state to dict conversion."""
self.state.to_dict()
def time_state_from_dict(self) -> None:
"""Measure state from dict creation."""
GraphState.from_dict({"messages": []})
def time_state_copy(self) -> None:
"""Measure state copy operation."""
self.state.copy()
-200
View File
@@ -1,200 +0,0 @@
"""ASV benchmarks for TUI rendering and command routing performance.
Measures performance of:
- TUI layout and render cycle time
- Slash command catalog hydration
- Command routing overhead
- Widget rendering performance
- Session view updates
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
try:
from cleveragents.tui.slash_catalog import (
SLASH_COMMAND_SPECS,
SlashCommandSpec,
slash_command_specs,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.tui.slash_catalog import (
SLASH_COMMAND_SPECS,
SlashCommandSpec,
slash_command_specs,
)
def _build_command_index() -> dict[str, list[SlashCommandSpec]]:
"""Build a command index grouped by category."""
index: dict[str, list[SlashCommandSpec]] = {}
for spec in SLASH_COMMAND_SPECS:
if spec.group not in index:
index[spec.group] = []
index[spec.group].append(spec)
return index
def _filter_commands_by_prefix(prefix: str) -> list[SlashCommandSpec]:
"""Filter commands by prefix for autocomplete."""
return [cmd for cmd in SLASH_COMMAND_SPECS if cmd.command.startswith(prefix)]
class TUISlashCatalogSuite:
"""Benchmark TUI slash command catalog operations."""
def setup(self) -> None:
"""Initialize catalog for benchmarking."""
self.catalog = SLASH_COMMAND_SPECS
self.command_index = _build_command_index()
def time_catalog_iteration(self) -> None:
"""Measure time to iterate through all commands."""
for _ in self.catalog:
pass
def time_catalog_grouping(self) -> None:
"""Measure time to group commands by category."""
_build_command_index()
def time_catalog_prefix_filter(self) -> None:
"""Measure time to filter commands by prefix."""
_filter_commands_by_prefix("session:")
def time_catalog_search(self) -> None:
"""Measure time to search for a specific command."""
target = "plan:execute"
for cmd in self.catalog:
if cmd.command == target:
break
def time_command_spec_creation(self) -> None:
"""Measure time to create a SlashCommandSpec."""
SlashCommandSpec(
command="test:command",
group="Test",
description="Test command",
)
def track_catalog_size(self) -> int:
"""Track total number of commands in catalog."""
return len(self.catalog)
def track_unique_groups(self) -> int:
"""Track number of unique command groups."""
return len(self.command_index)
def track_largest_group_size(self) -> int:
"""Track size of largest command group."""
return max(len(cmds) for cmds in self.command_index.values())
class TUICommandRoutingSuite:
"""Benchmark TUI command routing performance."""
def setup(self) -> None:
"""Initialize routing structures for benchmarking."""
self.catalog = SLASH_COMMAND_SPECS
self.command_map = {cmd.command: cmd for cmd in self.catalog}
def time_command_lookup(self) -> None:
"""Measure time to lookup a command in the map."""
_ = self.command_map.get("session:create")
def time_command_validation(self) -> None:
"""Measure time to validate a command exists."""
command = "plan:execute"
command in self.command_map
def time_group_lookup(self) -> None:
"""Measure time to find all commands in a group."""
group = "Session"
[cmd for cmd in self.catalog if cmd.group == group]
def time_description_search(self) -> None:
"""Measure time to search commands by description."""
search_term = "session"
[
cmd
for cmd in self.catalog
if search_term.lower() in cmd.description.lower()
]
def track_command_map_size(self) -> int:
"""Track size of command lookup map."""
return len(self.command_map)
class TUISessionViewSuite:
"""Benchmark TUI session view operations."""
def setup(self) -> None:
"""Initialize session view for benchmarking."""
from cleveragents.tui.app import SessionView
self.session = SessionView(
session_id="bench-session-001",
transcript=["message 1", "message 2", "message 3"],
)
def time_session_view_creation(self) -> None:
"""Measure time to create a SessionView."""
from cleveragents.tui.app import SessionView
SessionView(
session_id="test-session",
transcript=["test message"],
)
def time_transcript_append(self) -> None:
"""Measure time to append to transcript."""
self.session.transcript.append("new message")
def time_transcript_iteration(self) -> None:
"""Measure time to iterate through transcript."""
for _ in self.session.transcript:
pass
def track_session_id_length(self) -> int:
"""Track session ID length."""
return len(self.session.session_id)
def track_transcript_size(self) -> int:
"""Track transcript message count."""
return len(self.session.transcript)
class TUIWidgetRenderingSuite:
"""Benchmark TUI widget rendering operations."""
def setup(self) -> None:
"""Initialize widgets for benchmarking."""
self.command_specs = SLASH_COMMAND_SPECS
self.sample_text = "Sample widget content for rendering"
def time_command_spec_string_conversion(self) -> None:
"""Measure time to convert command spec to string."""
spec = self.command_specs[0]
str(spec)
def time_command_spec_formatting(self) -> None:
"""Measure time to format command spec for display."""
spec = self.command_specs[0]
f"{spec.command} - {spec.description}"
def time_text_rendering_short(self) -> None:
"""Measure time to render short text."""
len(self.sample_text)
def time_text_rendering_long(self) -> None:
"""Measure time to render long text."""
long_text = self.sample_text * 100
len(long_text)
def track_widget_content_size(self) -> int:
"""Track size of widget content."""
return len(self.sample_text)
-237
View File
@@ -1,237 +0,0 @@
# Checkpoint and Rollback API (v3.3.0)
CleverAgents supports checkpointing and rollback to snapshot sandbox state during plan
execution and restore it later. This page documents the CLI commands and Python API for
managing checkpoints.
---
## Overview
Checkpointing introduced in **v3.3.0** allows operators to:
- Snapshot sandbox state at key points during plan execution.
- Roll back to a previous snapshot to undo tool side-effects.
- Integrate with the decision-correction revert flow for targeted re-execution.
Checkpoints are immutable records stored in the `checkpoint_metadata` SQLite table and
backed by a `CheckpointRepository`. The `CheckpointService` is registered in the DI
container and injected into `ToolRunner`, `SubplanExecutionService`, and `PlanExecutor`.
---
## CLI Reference
### `agents plan checkpoint list`
List all checkpoints for a plan.
```bash
agents plan checkpoint list <PLAN_ID> [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--format`, `-f` | Output format: `json`, `yaml`, `plain`, `table`, `rich` |
**Examples:**
```bash
# List checkpoints for a plan
agents plan checkpoint list 01HXYZ1234567890ABCDEFGH
# JSON output for scripting
agents plan checkpoint list 01HXYZ1234567890ABCDEFGH --format json
```
Each checkpoint entry shows:
| Field | Description |
|-------|-------------|
| `checkpoint_id` | ULID identifier |
| `checkpoint_type` | `pre_write`, `post_step`, or `manual` |
| `decision_id` | Optional decision this checkpoint is aligned to |
| `sandbox_ref` | Git commit hash or patch reference |
| `size_bytes` | Size of the checkpoint data |
| `created_at` | UTC creation timestamp |
---
### `agents plan rollback`
Roll back a plan's sandbox to a previously captured checkpoint.
```bash
agents plan rollback [--yes|-y] <PLAN_ID> <CHECKPOINT_ID>
```
**Options:**
| Flag | Description |
|------|-------------|
| `--yes`, `-y` | Skip the interactive confirmation prompt |
**Examples:**
```bash
# Rollback with confirmation prompt
agents plan rollback 01HXYZ1234567890ABCDEFGH 01HABC1234567890ABCDEFGH
# Skip confirmation (for scripts/CI)
agents plan rollback --yes 01HXYZ1234567890ABCDEFGH 01HABC1234567890ABCDEFGH
```
**JSON output envelope** (when using `--format json`):
```json
{
"rollback_summary": {
"plan_id": "...",
"from_checkpoint_id": "...",
"restored_files_count": 3
},
"changes_reverted": ["path/to/file1.py", "path/to/file2.py"],
"impact": {
"files_affected": 3
},
"post_rollback_state": {
"active_checkpoint": "...",
"plan_id": "..."
},
"timing": {
"elapsed_seconds": 0.042
},
"messages": ["Rollback completed successfully."]
}
```
**Error cases:**
| Error | Cause |
|-------|-------|
| `Rollback blocked: plan is already applied` | Plan has reached terminal `applied` state |
| `Rollback blocked: sandbox is missing` | Sandbox was cleaned up before rollback |
| `Not found: checkpoint` | Checkpoint ID does not exist |
| `Validation Error` | Checkpoint does not belong to the specified plan |
---
## Checkpoint Lifecycle
### Checkpoint Types
| Type | When Created |
|------|-------------|
| `pre_write` | Automatically before each write-tool execution |
| `post_step` | Automatically after each write-tool execution |
| `manual` | Via `CheckpointService.create_checkpoint()` directly |
### Automatic Checkpoint Triggers (v3.8.0+)
The execution engine supports four automatic triggers, configurable via
`core.checkpoints.auto_create_on`:
| Trigger | When | Component |
|---------|------|-----------|
| `on_tool_write` | Before each write-tool execution | `ToolRunner` |
| `on_tool_write_complete` | After each write-tool execution | `ToolRunner` |
| `on_subplan_spawn` | Before first subplan execution attempt | `SubplanExecutionService` |
| `on_error` | When the Execute phase fails | `PlanExecutor` |
**Configuration:**
```toml
[core.checkpoints]
auto_create_on = ["on_tool_write", "on_tool_write_complete", "on_subplan_spawn", "on_error"]
```
To disable all automatic checkpoints:
```toml
[core.checkpoints]
auto_create_on = []
```
### Retention Policy
A `CheckpointRetentionPolicy` governs how many checkpoints a plan may keep:
| Field | Default | Range |
|-------|---------|-------|
| `max_checkpoints` | 50 | 1100 |
| `auto_prune` | `true` | — |
When `auto_prune` is enabled and the count exceeds `max_checkpoints`, the oldest
**interior** checkpoints are removed. The first (earliest) and most recent checkpoints
are always preserved.
---
## Checkpoint Data Model
Each checkpoint stores:
| Field | Type | Description |
|-------|------|-------------|
| `checkpoint_id` | ULID | Unique identifier |
| `plan_id` | ULID | The plan that owns this checkpoint |
| `sandbox_ref` | str | Git commit hash or patch reference |
| `decision_id` | ULID \| None | Optional decision alignment |
| `checkpoint_type` | str | `pre_write`, `post_step`, or `manual` |
| `resource_id` | ULID \| None | Optional resource association |
| `filesystem_path` | str | Relative path within the checkpoint directory |
| `size_bytes` | int | Size of the checkpoint data in bytes |
| `created_at` | datetime | UTC creation timestamp |
| `metadata` | dict | Audit metadata: `reason`, `source_tool`, `phase`, `extra` |
---
## Python API
The `CheckpointService` is obtained from the DI container:
```python
from cleveragents.application.container import get_container
checkpoint_service = get_container().checkpoint_service()
# Create a manual checkpoint
checkpoint = checkpoint_service.create_checkpoint(
plan_id="01HV...",
sandbox_ref="abc123",
checkpoint_type="manual",
metadata={"reason": "pre-correction snapshot", "phase": "execute"},
)
# List checkpoints for a plan
checkpoints = checkpoint_service.list_checkpoints(plan_id="01HV...")
# Roll back to a checkpoint
result = checkpoint_service.rollback_to_checkpoint(
plan_id="01HV...",
checkpoint_id="01HABC...",
)
print(f"Restored {result.restored_files_count} files")
```
---
## Rollback Guards
The rollback operation enforces two guards:
1. **Plan is applied** — Once a plan reaches the `applied` terminal state, rollback is
rejected with a `BusinessRuleViolation`.
2. **Sandbox is missing** — If the sandbox has been cleaned up, rollback is rejected
because there is nothing to restore.
---
## See Also
- [`docs/reference/checkpointing.md`](../reference/checkpointing.md) — Full checkpointing domain model reference
- [`docs/api/plan-corrections.md`](plan-corrections.md) — Plan correction modes (revert uses checkpoints)
- [ADR-015: Sandbox & Checkpoint](../adr/ADR-015-sandbox-and-checkpoint.md)
- [ADR-035: Decision Tree Rollback & Replay](../adr/ADR-035-decision-tree-rollback-and-replay.md)
-348
View File
@@ -1,348 +0,0 @@
# Decision Recording and Tree API (v3.2.0)
The decision subsystem records every choice point during a plan's Strategize and Execute
phases as a persistent tree of **Decision** nodes. This page documents the CLI commands
and Python API for inspecting and navigating that tree.
---
## Overview
During the **Strategize** phase, the strategy actor records each significant choice as a
`Decision` node. Decisions are linked parent-to-child to form a tree rooted at the
`prompt_definition` node (the original plan prompt). The tree is persisted to SQLite via
`DecisionRepository` and can be queried at any time — even after the plan has completed.
Key capabilities introduced in **v3.2.0**:
- Automatic decision recording for every strategy and execution choice.
- Context snapshots (SHA-256 hash + storage pointer) captured alongside each decision.
- Alternatives-considered tracking for every decision node.
- Full tree rendering via `agents plan tree`.
- Per-decision explanation via `agents plan explain`.
---
## CLI Reference
### `agents plan tree`
Display the decision tree for a plan.
```bash
agents plan tree <PLAN_ID> [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--format`, `-f` | Output format: `json`, `yaml`, `plain`, `table`, `rich` (default: `rich`) |
| `--show-superseded` | Include superseded decisions in the tree |
| `--depth` | Maximum tree depth to render (0 = unlimited, default: 0) |
**Examples:**
```bash
# Default rich tree view
agents plan tree 01HXYZ1234567890ABCDEFGH
# Table format
agents plan tree 01HXYZ1234567890ABCDEFGH --format table
# Include superseded decisions (e.g. after a correction)
agents plan tree 01HXYZ1234567890ABCDEFGH --show-superseded
# Limit depth to 2 levels
agents plan tree 01HXYZ1234567890ABCDEFGH --depth 2
# JSON output for scripting
agents plan tree 01HXYZ1234567890ABCDEFGH --format json
```
**Rich output** renders an indented tree with decision type labels, sequence numbers,
and full 26-character ULIDs so that IDs can be copied directly into follow-up commands
such as `agents plan explain` or `agents plan correct`.
---
### `agents plan explain`
Show full details for a single decision node, including alternatives considered,
context snapshot, and actor reasoning.
```bash
agents plan explain <DECISION_ID> [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--format`, `-f` | Output format: `json`, `yaml`, `plain`, `table`, `rich` |
| `--show-context` | Include context snapshot details (hash, storage ref, resources) |
| `--show-reasoning` | Include rationale and raw actor reasoning trace |
Alternatives considered are **always** included in the output regardless of flags.
**Examples:**
```bash
# Default rich output
agents plan explain 01HXYZ1234567890ABCDEFGH
# JSON with full context snapshot
agents plan explain 01HXYZ1234567890ABCDEFGH --format json --show-context
# Show actor reasoning trace
agents plan explain 01HXYZ1234567890ABCDEFGH --show-reasoning
# YAML with all details
agents plan explain 01HXYZ1234567890ABCDEFGH --format yaml \
--show-context --show-reasoning
```
---
## Decision Data Model
Each decision node stores the following fields.
### Identity
| Field | Type | Description |
|-------|------|-------------|
| `decision_id` | ULID | Auto-generated unique identifier |
| `plan_id` | ULID | Parent plan |
| `parent_decision_id` | ULID \| None | Parent node; `None` for the root |
| `sequence_number` | int | Monotonic order within the plan (0-indexed, never reused) |
### Classification
| Field | Type | Description |
|-------|------|-------------|
| `decision_type` | `DecisionType` | One of 11 enum values (see table below) |
**Decision types:**
| Type | Phase | Description |
|------|-------|-------------|
| `prompt_definition` | Strategize | Root decision — the plan prompt |
| `invariant_enforced` | Strategize | An invariant constraint was applied |
| `strategy_choice` | Strategize | High-level approach chosen |
| `implementation_choice` | Execute | How to implement a specific task |
| `resource_selection` | Execute | Which resources to read / modify |
| `subplan_spawn` | Strategize | Decision to create a child plan |
| `subplan_parallel_spawn` | Strategize | Spawn a group of child plans in parallel |
| `tool_invocation` | Execute | Which skill / tool to use |
| `error_recovery` | Execute | How to handle a failure |
| `validation_response` | Execute | Response to a validation failure |
| `user_intervention` | Any | User-provided guidance / correction |
### Content
| Field | Type | Description |
|-------|------|-------------|
| `question` | str | What question was being answered |
| `chosen_option` | str | The option that was selected |
| `alternatives_considered` | list[str] | Other options that were evaluated |
| `confidence_score` | float \| None | 0.01.0 confidence, or `None` |
### Context Snapshot
Every decision captures a `ContextSnapshot` for replay and correction:
| Field | Type | Description |
|-------|------|-------------|
| `hot_context_hash` | str | SHA-256 hash of the context window at decision time |
| `hot_context_ref` | str | Storage pointer to the full serialised context |
| `relevant_resources` | list[ResourceRef] | Resources in scope at decision time |
| `actor_state_ref` | str | LangGraph actor checkpoint reference |
### Rationale
| Field | Type | Description |
|-------|------|-------------|
| `rationale` | str | Human-readable explanation |
| `actor_reasoning` | str \| None | Raw LLM reasoning trace |
### Downstream Impact
| Field | Type | Description |
|-------|------|-------------|
| `downstream_decision_ids` | list[ULID] | Decisions that depend on this one |
| `downstream_plan_ids` | list[ULID] | Child plans spawned from this decision |
| `artifacts_produced` | list[ArtifactRef] | Artifacts created by this decision |
### Correction Metadata
| Field | Type | Description |
|-------|------|-------------|
| `is_correction` | bool | `True` if this decision corrects another |
| `corrects_decision_id` | ULID \| None | Original decision being corrected |
| `correction_reason` | str \| None | Why the correction was made |
| `superseded_by` | ULID \| None | Decision that replaced this one |
---
## Tree Structure
Decisions form a tree via `parent_decision_id`:
```
prompt_definition (root, parent=None)
├── invariant_enforced
├── strategy_choice
│ ├── implementation_choice
│ │ ├── resource_selection
│ │ └── tool_invocation
│ └── subplan_spawn
└── strategy_choice
```
The `prompt_definition` type is always the root and must have
`parent_decision_id = None`. The **current tree** consists of all decisions where
`superseded_by IS NULL`. Superseded decisions are hidden by default in `agents plan tree`
but can be shown with `--show-superseded`.
---
## Decision Persistence
Decisions are persisted to SQLite via `DecisionRepository` in the
`cleveragents.infrastructure.database` package. The service layer
(`DecisionService`) supports two modes:
| Mode | UnitOfWork | Storage |
|------|------------|---------|
| In-memory | `None` | Internal dicts |
| Persisted | provided | SQLite via `DecisionRepository` + in-memory write-through cache |
When a `UnitOfWork` is wired, mutations are written to the database first, then the
in-memory cache is updated (write-through).
**Python API — recording a decision:**
```python
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.domain.models.core.decision import DecisionType
svc = DecisionService(unit_of_work=uow)
decision = svc.record_decision(
plan_id="01HV...",
decision_type=DecisionType.STRATEGY_CHOICE,
question="Which approach should we take?",
chosen_option="Build a REST API",
alternatives_considered=["GraphQL API", "gRPC service"],
confidence_score=0.85,
rationale="REST is simpler and better supported by existing tooling.",
)
```
**Python API — retrieving the tree:**
```python
# BFS from root(s), level by level
tree = svc.get_tree(plan_id="01HV...")
# Walk from a decision up to the root
path = svc.get_path_to_root(decision_id="01HXYZ...")
# List all decisions for a plan, ordered by sequence number
decisions = svc.list_decisions(plan_id="01HV...")
```
---
## See Also
- [`docs/reference/decision_model.md`](../reference/decision_model.md) — Full domain model reference
- [`docs/reference/decision_service.md`](../reference/decision_service.md) — `DecisionService` API reference
- [`docs/api/plan-corrections.md`](plan-corrections.md) — Correction modes and subplan system
- [ADR-007: Decision Tree & Correction](../adr/ADR-007-decision-tree-and-correction.md)
- [ADR-033: Decision Recording Protocol](../adr/ADR-033-decision-recording-protocol.md)
- [ADR-034: Decision Tree Versioning & History](../adr/ADR-034-decision-tree-versioning-and-history.md)
diff --git a/docs/api/invariants.md b/docs/api/invariants.md
new file mode 100644
index 0000000..2222222
--- /dev/null
+++ b/docs/api/invariants.md
@@ -0,0 +1,274 @@
# Invariant Management API (v3.2.0)
Invariants are natural-language constraints that govern plan execution. They are
evaluated at the start of the Strategize phase by the **Invariant Reconciliation Actor**
and recorded in the decision tree as `invariant_enforced` nodes.
---
## Overview
Invariants introduced in **v3.2.0** provide a declarative way to constrain what a plan
may do. They are scoped (global, project, action, or plan), merged by precedence, and
de-duplicated before enforcement. Violations block the phase transition and emit
`INVARIANT_VIOLATED` events.
Key capabilities:
- Four scope levels: `GLOBAL`, `PROJECT`, `ACTION`, `PLAN`.
- Merge precedence: plan > project > global (action invariants are promoted to plan scope).
- Automatic enforcement at every phase transition via `InvariantReconciliationActor`.
- CLI commands for adding, listing, and removing invariants.
---
## CLI Reference
### `agents invariant add`
Create a new invariant constraint.
```bash
agents invariant add <NAME> --description <DESC> [SCOPE_FLAG]
```
**Scope flags:**
| Flag | Scope | Description |
|------|-------|-------------|
| `--global` | `GLOBAL` | Applies to every plan in the system |
| `--project <NAME>` | `PROJECT` | Applies to plans targeting the named project |
| `--plan <PLAN_ID>` | `PLAN` | Attached directly to a specific plan |
| `--action <ACTION>` | `ACTION` | Defined in an action template; promoted on `plan use` |
**Examples:**
```bash
# Global invariant
agents invariant add --global "Never delete production data"
# Project-scoped invariant
agents invariant add --project myapp "All API changes need tests"
# Plan-specific invariant
agents invariant add --plan 01HXYZ... "Use Python 3.13 only"
# Action-scoped invariant
agents invariant add --action local/code-coverage "Minimum 80% coverage"
```
---
### `agents invariant list`
Display invariants, optionally filtered by scope or project.
```bash
agents invariant list [PATTERN] [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--global` | Show only global invariants |
| `--project <NAME>` | Show only invariants for the named project |
| `--effective --project <NAME>` | Show the merged effective set for a project |
| `--format`, `-f` | Output format: `json`, `yaml`, `plain`, `table`, `rich` |
-245
View File
@@ -1,245 +0,0 @@
# Invariant Management API (v3.2.0)
Invariants are natural-language constraints that govern plan execution. They are
evaluated at the start of the Strategize phase by the **Invariant Reconciliation Actor**
and recorded in the decision tree as `invariant_enforced` nodes.
---
## Overview
Invariants introduced in **v3.2.0** provide a declarative way to constrain what a plan
may do. They are scoped (global, project, action, or plan), merged by precedence, and
de-duplicated before enforcement. Violations block the phase transition and emit
`INVARIANT_VIOLATED` events.
Key capabilities:
- Four scope levels: `GLOBAL`, `PROJECT`, `ACTION`, `PLAN`.
- Merge precedence: plan > project > global (action invariants are promoted to plan scope).
- Automatic enforcement at every phase transition via `InvariantReconciliationActor`.
- CLI commands for adding, listing, and removing invariants.
---
## CLI Reference
### `agents invariant add`
Create a new invariant constraint.
```bash
agents invariant add <NAME> --description <DESC> [SCOPE_FLAG]
```
**Scope flags:**
| Flag | Scope | Description |
|------|-------|-------------|
| `--global` | `GLOBAL` | Applies to every plan in the system |
| `--project <NAME>` | `PROJECT` | Applies to plans targeting the named project |
| `--plan <PLAN_ID>` | `PLAN` | Attached directly to a specific plan |
| `--action <ACTION>` | `ACTION` | Defined in an action template; promoted on `plan use` |
**Examples:**
```bash
# Global invariant
agents invariant add --global "Never delete production data"
# Project-scoped invariant
agents invariant add --project myapp "All API changes need tests"
# Plan-specific invariant
agents invariant add --plan 01HXYZ... "Use Python 3.13 only"
# Action-scoped invariant
agents invariant add --action local/code-coverage "Minimum 80% coverage"
```
---
### `agents invariant list`
Display invariants, optionally filtered by scope or project.
```bash
agents invariant list [PATTERN] [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--global` | Show only global invariants |
| `--project <NAME>` | Show only invariants for the named project |
| `--effective --project <NAME>` | Show the merged effective set for a project |
| `--format`, `-f` | Output format: `json`, `yaml`, `plain`, `table`, `rich` |
**Examples:**
```bash
# List all active invariants
agents invariant list
# Filter by scope
agents invariant list --global
agents invariant list --project myapp
# Show merged effective set for a project
agents invariant list --effective --project myapp
# Filter by regex pattern
agents invariant list "data.*safe"
# JSON output
agents invariant list --format json
```
---
### `agents invariant remove`
Remove (soft-delete) an invariant by its ULID.
```bash
agents invariant remove <INVARIANT_ID> [--yes]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--yes` | Skip the interactive confirmation prompt |
**Examples:**
```bash
# With confirmation prompt
agents invariant remove 01HXYZ...
# Skip confirmation
agents invariant remove --yes 01HXYZ...
```
Removal sets `active=False` on the invariant record; it is not hard-deleted. This
preserves the audit trail for enforcement records that reference the invariant.
---
## Scope Hierarchy and Merge Precedence
When computing the effective invariant set for a plan, the precedence chain is:
```
plan > project > global
```
- **Plan-level** invariants take highest precedence.
- **Project-level** invariants apply next.
- **Global-level** invariants are lowest precedence.
- **Action-level** invariants are promoted to plan scope when `plan use` is called.
### De-duplication
Invariants are de-duplicated by text (case-insensitive). When the same constraint text
appears at multiple scopes, only the highest-precedence copy is kept.
### Merge Example
Given:
- Global: "Never delete production data", "Log all changes"
- Project (`myapp`): "All API changes need tests", "Log all changes"
- Plan: "Use Python 3.13 only"
The effective set for a plan targeting `myapp` would be:
1. "Use Python 3.13 only" (plan)
2. "All API changes need tests" (project)
3. "Log all changes" (project — shadows the global duplicate)
4. "Never delete production data" (global)
---
## Invariant Enforcement During Strategize
At the start of the Strategize phase, the `InvariantReconciliationActor`:
1. Calls `InvariantService.get_effective_invariants(plan_id, project_name)` to collect
the merged invariant set.
2. Evaluates each invariant against the current plan context.
3. Creates an `InvariantEnforcementRecord` for each invariant.
4. Records an `invariant_enforced` decision in the decision tree.
5. Emits `INVARIANT_VIOLATED` for each violated invariant.
6. Blocks the phase transition with `ReconciliationBlockedError` if any invariant fails.
### Enforcement Record
Each record contains:
| Field | Description |
|-------|-------------|
| `invariant_id` | ULID of the invariant |
| `enforced` | Whether the invariant was successfully enforced |
| `actor_response` | Response text from the reconciliation actor |
| `decision_id` | ULID of the associated `invariant_enforced` decision node |
### Violation Model
When an invariant is violated, an `InvariantViolation` is created:
| Field | Description |
|-------|-------------|
| `invariant_id` | ULID of the violated invariant |
| `violated_text` | The invariant text that was violated |
| `severity` | `error`, `warning`, or `info` |
| `details` | Additional violation context |
---
## Python API
The `InvariantService` is registered as a Singleton in the DI container. Obtain it via
`container.invariant_service()` rather than constructing it directly.
```python
from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.domain.models.core.invariant import InvariantScope
service = InvariantService(event_bus=event_bus)
# Add an invariant
inv = service.add_invariant(
text="All output files must be UTF-8 encoded",
scope=InvariantScope.PROJECT,
source_name="my-project",
)
# List effective invariants for a plan
effective = service.get_effective_invariants(
plan_id="01HV...",
project_name="my-project",
)
# Remove an invariant (soft-delete)
service.remove_invariant(invariant_id="01HXYZ...")
```
**Key methods:**
| Method | Returns | Description |
|--------|---------|-------------|
| `add_invariant(text, scope, source_name)` | `Invariant` | Add a new invariant |
| `list_invariants(scope, source_name, effective)` | `list[Invariant]` | Filter invariants |
| `remove_invariant(invariant_id)` | `Invariant` | Soft-delete (sets `active=False`) |
| `get_effective_invariants(plan_id, project_name)` | `list[Invariant]` | Merged precedence chain |
| `enforce_invariants(plan_id, invariants, actor_response, violated_ids)` | `list[InvariantEnforcementRecord]` | Create enforcement records |
---
## See Also
- [`docs/reference/invariants.md`](../reference/invariants.md) — Full invariant domain model reference
- [`docs/api/decisions.md`](decisions.md) — Decision recording and tree CLI reference
- [ADR-016: Invariant System](../adr/ADR-016-invariant-system.md)
-272
View File
@@ -1,272 +0,0 @@
# Plan Correction API (v3.2.0 / v3.3.0)
The correction subsystem allows operators to modify a plan's decision tree after
execution by either **reverting** a subtree of decisions or **appending** new guidance
as a child plan. This page documents the CLI commands, correction modes, and the
subplan system that powers append-mode corrections.
---
## Overview
Plan corrections introduced across **v3.2.0** and **v3.3.0** provide two complementary
modes for adapting a plan's strategy without discarding accumulated context:
| Mode | Effect |
|------|--------|
| `revert` | Invalidates the targeted decision and all descendants; re-executes from that point |
| `append` | Preserves the original decision; spawns a new child plan with operator guidance |
Corrections never mutate existing decisions. Instead, a new `Decision` is created with
`is_correction=True` and `corrects_decision_id` pointing to the original. The original
decision has its `superseded_by` field set to the new decision's ID.
---
## CLI Reference
### `agents plan correct`
Apply a correction to a plan's decision tree.
```bash
agents plan correct <PLAN_ID> --decision <DECISION_ID> --mode <MODE> --guidance <TEXT> [OPTIONS]
```
**Options:**
| Flag | Description |
|------|-------------|
| `--decision` | ULID of the decision to correct (required) |
| `--mode` | Correction mode: `revert` or `append` (required) |
| `--guidance` | Operator guidance text (110,000 characters, required) |
| `--dry-run` | Preview impact without making changes |
| `--yes`, `-y` | Skip the interactive confirmation prompt |
| `--format`, `-f` | Output format: `json`, `yaml`, `plain`, `table`, `rich` |
**Revert mode — re-execute from a targeted decision point:**
```bash
agents plan correct 01HXYZ... \
--decision 01HABC... \
--mode=revert \
--guidance "Use a safer database migration approach"
```
This invalidates the targeted decision and every descendant reachable via BFS traversal.
Associated artifacts are archived and affected child plans are rolled back. The plan then
re-executes from the targeted decision point using the operator's guidance.
**Append mode — add guidance without recomputing:**
```bash
agents plan correct 01HXYZ... \
--decision 01HABC... \
--mode=append \
--guidance "Add input validation to the API endpoint"
```
This preserves the original decision and spawns a **new child plan** rooted at the target
node. The child plan carries the operator's guidance and produces additional decisions
without disturbing the existing tree.
**Dry-run preview:**
```bash
agents plan correct 01HXYZ... \
--decision 01HABC... \
--mode=revert \
--guidance "..." \
--dry-run
```
The dry-run report includes:
- **impact** — Affected decisions, files, child plans, estimated cost, and risk level.
- **decisions_to_invalidate** — Decision IDs that *would* be marked invalid (revert only).
- **child_plans_to_rollback** — Child plans that *would* be rolled back.
- **estimated_recompute_time_seconds** — Wall-clock estimate.
- **warnings** — Human-readable cautions (e.g. high risk).
---
## Correction Modes in Detail
### Revert Mode
Invalidates the targeted decision and every descendant reachable via BFS traversal:
```
┌─── D1 (target) ◄── revert starts here
│ │
│ ┌──┴──┐
│ D2 D3 ← all invalidated
│ │
│ D4 ← also invalidated
```
**Risk classification** based on affected decision count:
| Affected Count | Risk Level |
|----------------|------------|
| ≤ 3 | low |
| 4 10 | medium |
| > 10 | high |
### Append Mode
Preserves the original decision and spawns a new child plan:
```
D1 (target)
┌──┴──────────┐
D2 (original) CP-new ← child plan appended
```
The child plan runs in its own sandbox and its results are merged back into the parent
plan using the configured merge strategy.
---
## Correction Status Lifecycle
```
PENDING → ANALYZING → EXECUTING → APPLIED
→ FAILED
PENDING → CANCELLED
ANALYZING → CANCELLED
```
Each execution of a correction is tracked as a `CorrectionAttemptRecord`. Multiple
attempts may exist for a single correction (e.g. if a first attempt fails and the
operator retries).
---
## Subplan System Overview
The **append** correction mode relies on the subplan system to spawn and execute child
plans. Subplans are also used directly during Strategize when the strategy actor decides
to decompose work into parallel or sequential child plans.
### Spawning Child Plans
When a `subplan_spawn` or `subplan_parallel_spawn` decision is recorded during
Strategize, the `SubplanService` handles the spawn workflow:
1. Extract spawn decisions from `DecisionService`.
2. Build `SpawnEntry` objects from those decisions.
3. Validate resource scopes, merge strategy, and parallelism bounds.
4. Create `SubplanStatus` and `SpawnMetadata` for each entry.
5. Return the result for the caller to persist on the parent plan.
### Execution Modes
| Mode | Description |
|------|-------------|
| `sequential` | Execute one at a time in order |
| `parallel` | Execute concurrently (up to `max_parallel`, default 5) |
| `dependency_ordered` | Respect DAG dependencies via topological sort |
### Three-Way Merge
After subplans complete, their sandbox outputs are merged using the configured
`SubplanMergeStrategy`:
| Strategy | Description |
|----------|-------------|
| `git_three_way` | Three-way merge via `git merge-file` (default) |
| `sequential_apply` | Apply changes in completion order |
| `fail_on_conflict` | Raise `MergeConflictError` on any conflict |
| `last_wins` | Final subplan's output overwrites earlier ones |
The `git_three_way` strategy uses `git merge-file` to combine non-overlapping changes
from different subplans automatically. Overlapping changes produce conflict markers in
the output.
### Subplan Configuration
```yaml
subplan_config:
execution_mode: parallel # sequential | parallel | dependency_ordered
merge_strategy: git_three_way # git_three_way | sequential_apply | fail_on_conflict | last_wins
max_parallel: 5 # 1-50, for parallel mode
fail_fast: false # stop all on first failure
timeout_per_subplan_seconds: ~ # optional per-subplan timeout
retry_failed: true # auto-retry failed subplans
max_retries: 2 # 0-5, max retry attempts
```
---
## Phase Reversion
When corrections cannot be resolved within the current strategy, the plan may revert to
an earlier lifecycle phase:
| Source Phase | Target Phase | Trigger |
|-------------|-------------|---------|
| Execute | Strategize | Validation failures block apply; constraints too restrictive |
| Apply (constrained) | Strategize | Cannot proceed within current strategy constraints |
Phase reversion is subject to a **loop guard**: each plan may revert at most **3 times**
(`Plan.MAX_REVERSIONS`). Once this limit is reached, both automatic and manual
reversions are blocked.
Manual reversion via CLI:
```bash
agents plan revert <plan_id> --to-phase strategize --reason "constraints too strict"
```
---
## Python API
```python
from cleveragents.application.services.correction_service import CorrectionService
from cleveragents.domain.models.core.correction import CorrectionMode
correction_service = CorrectionService(unit_of_work=uow)
# Request a correction
correction = correction_service.request_correction(
plan_id="01HV...",
original_decision_id="01HABC...",
mode=CorrectionMode.REVERT,
guidance="Use a safer database migration approach",
)
# Preview impact (dry run)
report = correction_service.generate_dry_run_report(correction.id)
print(f"Risk: {report.impact.risk_level}, Affected: {len(report.decisions_to_invalidate)}")
# Execute the correction
correction_service.execute_correction(correction.id)
```
**Key service methods:**
| Method | Description |
|--------|-------------|
| `request_correction()` | Create a new correction request |
| `analyze_impact()` | BFS impact analysis on the decision tree |
| `generate_dry_run_report()` | Full report without side effects |
| `execute_revert()` | Invalidate subtree + archive artifacts |
| `execute_append()` | Spawn child plan preserving original decision |
| `execute_correction()` | Dispatch to revert or append based on mode |
| `get_correction()` | Retrieve a correction by ID |
| `list_corrections()` | List corrections (optional plan_id filter) |
| `cancel_correction()` | Cancel a pending/analyzing correction |
---
## See Also
- [`docs/reference/decision_correction.md`](../reference/decision_correction.md) — Full correction domain model reference
- [`docs/reference/subplans.md`](../reference/subplans.md) — Subplan execution and merge strategies
- [`docs/reference/phase_reversion.md`](../reference/phase_reversion.md) — Phase reversion state machine
- [`docs/api/decisions.md`](decisions.md) — Decision recording and tree CLI reference
- [`docs/api/checkpoints.md`](checkpoints.md) — Checkpoint and rollback CLI reference
- [ADR-007: Decision Tree & Correction](../adr/ADR-007-decision-tree-and-correction.md)
+521
View File
@@ -0,0 +1,521 @@
# Developer Setup Guide
This guide walks you through setting up a local development environment for
**cleveragents-core** from scratch. By the end you will have a fully working
environment with all dependencies installed, tests passing, and pre-commit hooks
active.
---
## 1. Prerequisites
Install the following tools before cloning the repository.
### Python 3.13+
CleverAgents requires **Python 3.13** or later.
```bash
# Verify your installed version
python3 --version # must print Python 3.13.x or higher
```
Recommended installation methods:
| Platform | Method |
|----------|--------|
| macOS | `brew install python@3.13` or [python.org](https://www.python.org/downloads/) |
| Linux (Debian/Ubuntu) | `sudo apt install python3.13 python3.13-venv python3.13-dev` |
| Linux (Fedora/RHEL) | `sudo dnf install python3.13` |
| Windows | [python.org installer](https://www.python.org/downloads/) (add to PATH) |
| Any | [pyenv](https://github.com/pyenv/pyenv): `pyenv install 3.13` |
### uv
[uv](https://docs.astral.sh/uv/) is the project's fast Python package manager and
virtual-environment tool.
```bash
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
# Verify
uv --version
```
### nox
[nox](https://nox.thea.codes/) is the project's task runner. All test, lint,
type-check, and build commands are executed through `nox` sessions — **never**
invoke `behave`, `ruff`, `pyright`, or other tools directly.
```bash
pip install nox
# or, using uv:
uv tool install nox
```
### Git
Git 2.30+ is required. Most systems ship a recent enough version.
```bash
git --version # must be 2.30 or later
```
### Commitizen (optional but recommended)
[Commitizen](https://commitizen.github.io/cz-cli/) guides you through writing
[Conventional Changelog](https://github.com/conventional-changelog/conventional-changelog-eslint/blob/master/convention.md)-compliant
commit messages interactively. All commits **must** follow this standard.
```bash
npm install -g commitizen@2.8.6 cz-customizable@4.0.0
```
> **Note:** npm is only needed for Commitizen. Node.js is not otherwise required
> to develop or run cleveragents-core.
### pre-commit
[pre-commit](https://pre-commit.com/) runs automated checks before each commit.
It is installed as part of the dev dependencies (see [Getting Started](#2-getting-started))
but the CLI must also be available on your PATH.
```bash
pip install pre-commit
# or, using uv:
uv tool install pre-commit
```
---
## 2. Getting Started
### Clone the Repository
```bash
git clone https://git.cleverthis.com/cleveragents/cleveragents-core.git
cd cleveragents-core
```
### Create and Activate a Virtual Environment
```bash
# Create a venv with Python 3.13
uv venv --python 3.13
# Activate (macOS / Linux)
source .venv/bin/activate
# Activate (Windows PowerShell)
.venv\Scripts\Activate.ps1
```
### Install All Dependencies
Install the package in editable mode together with all development and test
extras:
```bash
uv pip install -e ".[dev,tests,docs]"
```
This installs:
- **Runtime** dependencies (typer, pydantic, langchain, etc.)
- **Dev** extras: ruff, pyright, behave, pytest, pre-commit, bandit, semgrep,
vulture, radon
- **Tests** extras: behave, slipcover, asv, robotframework, robotframework-pabot
- **Docs** extras: mkdocs, mkdocs-material, mkdocstrings
### Install pre-commit Hooks
```bash
pre-commit install
```
This installs Git hooks that run linting and formatting checks automatically on
`git commit`.
### Verify the Installation
Run the full default nox pipeline to confirm everything is working:
```bash
nox
```
This executes (in order): `lint``typecheck``unit_tests`
`integration_tests``coverage_report`. A clean run with all sessions passing
means your environment is correctly set up.
---
## 3. Development Workflow
All quality-gate commands are run through `nox`. The `noxfile.py` in the
repository root defines every available session.
### Running Tests
```bash
# Run all default sessions
nox
# Unit tests only (Behave / BDD)
nox -s unit_tests
# Integration tests only (Robot Framework)
nox -s integration_tests
# Coverage report — enforces ≥ 97% threshold
nox -s coverage_report
# Run a single Behave feature file
nox -s unit_tests -- features/plan_model.feature
# Run scenarios with a specific tag
nox -s unit_tests -- --tags=@smoke
# Run a single Robot suite
nox -s integration_tests -- --suite robot/plan_persistence_e2e.robot
# Performance benchmarks (ASV)
nox -s benchmark
```
> See [Testing Guide](testing.md) for full details on the testing strategy,
> coverage requirements, and test suite structure.
### Linting
```bash
# Check for lint violations (ruff)
nox -s lint
# Auto-fix safe violations
nox -s lint -- --fix
# Check formatting only (no changes)
nox -s format -- --check
# Apply formatting
nox -s format
```
Ruff is configured in `pyproject.toml` under `[tool.ruff]`. The project targets
Python 3.13 with an 88-character line length and enforces rules from the `E`,
`F`, `W`, `B`, `UP`, `I`, `SIM`, and `RUF` rule sets.
### Type Checking
```bash
# Run pyright type checker
nox -s typecheck
```
Pyright is configured in `pyproject.toml` under `[tool.pyright]`. All source
files under `src/` must pass strict type checking. Do not add `# type: ignore`
comments without a documented justification.
### Security Scanning
```bash
# Run bandit + vulture
nox -s security_scan
# Complexity metrics (radon)
nox -s complexity
```
### Building Documentation
```bash
# Build the MkDocs site locally
nox -s docs
# Serve docs with live reload at http://127.0.0.1:8000
nox -s docs -- serve
```
### Full Quality Gate (pre-PR checklist)
Before opening a pull request, run the complete quality gate:
```bash
nox
```
All sessions must pass. The CI pipeline enforces the same checks and will block
merges on any failure.
---
## 4. Commit Guidelines
### Conventional Changelog Format
Every commit **must** follow the
[Conventional Changelog standard](https://github.com/conventional-changelog/conventional-changelog-eslint/blob/master/convention.md).
The format is:
```
<type>(<scope>): <short summary>
<optional body — wrap at 72 characters>
<optional footer — ISSUES CLOSED: #N>
```
**Allowed types:**
| Type | When to use |
|------|-------------|
| `feat` | New feature or user-visible behaviour |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `refactor` | Code restructuring without behaviour change |
| `test` | Adding or updating tests |
| `chore` | Build system, tooling, dependency updates |
| `perf` | Performance improvement |
| `ci` | CI/CD pipeline changes |
**Example:**
```
fix(concurrency): guard execute_plan with advisory lock
LockService was implemented but never wired into the plan execution
path, leaving execute_plan() unprotected against concurrent calls.
ISSUES CLOSED: #7989
```
### Using Commitizen (recommended)
```bash
# Instead of `git commit`, use:
git cz
```
Commitizen walks you through an interactive prompt to build a correctly
formatted commit message. All CleverThis repositories ship a local
`cz-customizable` configuration.
### Atomic Commits
- **One logical change per commit.** Never bundle unrelated changes.
- **Include tests with the change.** Feature + tests are one logical unit.
- **Include documentation with the change.** Update relevant docs in the same commit.
- **Each commit must build and pass all tests.** The repository must be in a
working state at every commit in history.
See [CONTRIBUTING.md](../../CONTRIBUTING.md) for the full commit quality policy.
### Branch Naming
Use the following prefixes:
| Prefix | Purpose |
|--------|---------|
| `feat/<issue>-<slug>` | New feature |
| `fix/<issue>-<slug>` | Bug fix |
| `docs/<slug>` | Documentation |
| `refactor/<slug>` | Refactoring |
| `chore/<slug>` | Tooling / maintenance |
| `test/<slug>` | Test-only changes |
Examples:
```
feat/issue-1234-plan-correction-rollback
fix/7989-lock-service-concurrency
docs/developer-setup-guide
```
---
## 5. Devcontainer Setup
The repository ships a [Dev Container](https://containers.dev/) configuration
that provides a fully pre-configured development environment with Python 3.13,
uv, nox, and all dependencies pre-installed. This is the **recommended** setup
for contributors who want zero-friction onboarding.
### Requirements
- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (macOS /
Windows) or Docker Engine (Linux)
- [VS Code](https://code.visualstudio.com/) with the
[Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers),
**or** the [devcontainer CLI](https://github.com/devcontainers/cli)
### Opening in VS Code
1. Open the repository folder in VS Code.
2. When prompted *"Reopen in Container"*, click **Reopen in Container**.
Alternatively: open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and
run **Dev Containers: Reopen in Container**.
3. VS Code builds the container image and installs all extensions. This takes a
few minutes on first run; subsequent opens are fast.
4. A terminal inside the container is ready to use. All tools (`uv`, `nox`,
`git`, `pre-commit`) are on the PATH.
### Using the devcontainer CLI
```bash
# Start the container
devcontainer up --workspace-folder .
# Execute a command inside the container
devcontainer exec --workspace-folder . nox -s unit_tests
```
### Named Configurations
The repository may define multiple named devcontainer configurations (e.g.,
`default`, `docs-only`). To select one in VS Code, use
**Dev Containers: Reopen in Container…** and choose the desired configuration
from the list.
### Environment Variables
Sensitive configuration (API keys, database URLs) should be placed in a
`.env` file at the repository root. The devcontainer automatically mounts
this file. **Never commit `.env` to version control.**
Common variables:
| Variable | Description |
|----------|-------------|
| `CLEVERAGENTS_DATABASE_URL` | SQLite or PostgreSQL connection string |
| `CLEVERAGENTS_HOME` | Override the default data directory |
| `CLEVERAGENTS_TESTING_USE_MOCK_AI` | Set to `1` to use mock AI in tests |
| `OPENAI_API_KEY` | OpenAI provider key (optional) |
| `ANTHROPIC_API_KEY` | Anthropic provider key (optional) |
---
## 6. Troubleshooting
### `python3 --version` shows < 3.13
Your system Python is too old. Install Python 3.13 via pyenv, your OS package
manager, or [python.org](https://www.python.org/downloads/), then recreate the
virtual environment:
```bash
uv venv --python 3.13
source .venv/bin/activate
uv pip install -e ".[dev,tests,docs]"
```
### `nox` command not found
Install nox into your system Python or via uv tools:
```bash
pip install nox
# or
uv tool install nox
```
Ensure the tool installation directory is on your `PATH` (uv prints the path
after installation).
### `pre-commit` hooks not running
Reinstall the hooks:
```bash
pre-commit install --overwrite
```
If hooks still do not run, verify that `.git/hooks/pre-commit` exists and is
executable.
### Import errors after installing dependencies
Ensure you activated the virtual environment before running any commands:
```bash
source .venv/bin/activate # macOS / Linux
.venv\Scripts\Activate.ps1 # Windows
```
Then verify the package is installed in editable mode:
```bash
pip show cleveragents
```
### `nox -s unit_tests` fails with `AmbiguousStep`
Two Behave step files define the same step pattern. Check the error message for
the conflicting step text, then either rename one step or add a unique prefix.
See the [Testing Guide](testing.md#unit-tests-behave) for step naming conventions.
### Coverage below 97%
Run the HTML coverage report to identify uncovered lines:
```bash
nox -s coverage_report
open build/htmlcov/index.html
```
Write Behave scenarios targeting the uncovered code paths. Do **not** lower the
threshold or add `# pragma: no cover` without explicit team agreement.
### `pyright` reports errors in generated or third-party stubs
Check `pyproject.toml` under `[tool.pyright]` for the `exclude` list. Generated
stubs under `docs/reference/contracts/stubs/` are excluded by the ruff
configuration and should also be excluded from pyright. Add a targeted
`# type: ignore` with a comment only as a last resort.
### Robot Framework `Resource file does not exist`
Always use `${CURDIR}/` prefix for `Resource` imports:
```robot
Resource ${CURDIR}/common.resource
```
Bare paths are resolved relative to the working directory, not the test file.
### Devcontainer fails to start
1. Ensure Docker is running (`docker info`).
2. Try **Dev Containers: Rebuild Container** from the VS Code Command Palette.
3. Check the Docker build log in the VS Code Output panel for errors.
4. If the issue persists, delete the container image and rebuild:
```bash
docker system prune -f
devcontainer up --workspace-folder . --remove-existing-container
```
### SQLite in-memory tests lose data between steps
When writing Behave tests with `sqlite:///:memory:`, use a single shared
`Session` across all repository calls in a scenario — do not create a new
session per call. See the [Testing Guide](testing.md#troubleshooting) for the
correct pattern.
---
## See Also
- [Testing Guide](testing.md) — Full testing strategy, coverage requirements,
and suite documentation
- [CI/CD Pipeline](ci-cd.md) — Forgejo CI job definitions and pipeline stages
- [Quality Automation](quality-automation.md) — Automated quality gates and
enforcement
- [CONTRIBUTING.md](../../CONTRIBUTING.md) — Commit standards, code of conduct,
and contribution workflow
- [Architecture Decision Records](../adr/index.md) — Key design decisions
(ADR-003 DI, ADR-005 Tech Stack, ADR-009 Project Model, etc.)
+52 -79
View File
@@ -10,27 +10,26 @@ The following chart shows all 29 epics across 9 legendary workstreams, 7 milesto
@startgantt
title CleverAgents Core — Epic-Level Project Schedule
footer Generated 2026-04-14 | 29 Epics | 9 Legendaries | 7 Milestones | 6 Developers | ~1650 SP | ~435 open PRs | 3956 open issues | AUTO-TIME-1
footer Generated 2026-04-10 | 29 Epics | 9 Legendaries | 7 Milestones | 6 Developers | ~1650 SP | ~25 open bugs | 225 open PRs | Session 4 active
Project starts 2026-02-03
saturday are closed
sunday are closed
printscale weekly zoom 2
today is 2026-04-14
today is 2026-04-10
today is colored in #FF6666
' ================================================================
' GANTT CHART UPDATE LOG (Day 104 — 2026-04-14) [AUTO-TIME-1]
' Changes: Schedule adherence check. v3.0.0 CLOSED (164 issues, 100%).
' v3.1.0 CLOSED (108 issues, 100%). Active milestones v3.2.0v3.9.0.
' M3 (v3.2.0) 21.9% (273/1247, 974 open). M4 (v3.3.0) 34.8% (111/319, 208 open).
' M5 (v3.4.0) 34.7% (142/409, 267 open). M6 (v3.5.0) 16.3% (220/1350, 1130 open).
' M7 (v3.6.0) 31.2% (153/491, 338 open). M8 (v3.7.0) 41.2% (428/1040, 612 open).
' M9 (v3.8.0) 26.6% (135/508, 373 open). v3.9.0 6.9% (4/58, 54 open).
' Total open issues: 3,956. Open PRs: ~435. Open Bugs: ~3.
' Milestones v3.2.0v3.6.0 are past their planned due dates.
' v3.7.0v3.9.0 have no assigned deadlines.
' GANTT CHART UPDATE LOG (Day 100 — 2026-04-10)
' Changes: Day 100 refresh (cycle 2). Session 4 active (issue #4799, 32 workers).
' Open PRs: 221→225 (new PRs opened by agents). Open bugs: ~25 (Type/Bug label).
' M3 32% (249/770, scope expanded!), M4 49% (108/220), M5 43% (133/313),
' M6 18% (197/1085, scope massively expanded!), M7 38% (150/400),
' M8 45% (425/944), M9 28% (131/475).
' Massive scope expansion across all milestones since Day 98.
' pr-merge-pool-supervisor added as 17th supervisor. PR review reduced to 1 approval.
' git worktree sandbox merged. ACMS indexing pipeline wired.
' ================================================================
<style>
@@ -232,7 +231,7 @@ ganttDiagram {
[Decisions + Validations M3 (#357)] as [M3] on {Luis} {Hamza} {Brent} {Jeff} requires 4 days
[M3] starts at [M2]'s end
[M3] is 25% completed
[M3] is 65% completed
[M3] is colored in LightSkyBlue/SteelBlue
' ── v3.3.0 MILESTONE ─────────────────────────────────────────
@@ -253,13 +252,13 @@ ganttDiagram {
[Corrections + Checkpoints M4 (#358)] as [M4] on {Luis} {Jeff} {Brent} {Hamza} requires 3 days
[M4] starts at [M3]'s end
[M4] is 40% completed
[M4] is 60% completed
[M4] is colored in LightSkyBlue/SteelBlue
[Security + Safety Hardening (#362)] as [SEC] on {Luis} requires 3 days
[SEC] starts 2026-03-02
[SEC] is 36% completed
[SEC] is 80% completed
[SEC] is colored in LightSkyBlue/SteelBlue
[Provider Fixes + Runtime (#363)] as [PROV] on {Luis} requires 2 days
@@ -284,12 +283,12 @@ ganttDiagram {
[ACMS v1 + Context Scaling M5 (#359)] as [M5] on {Hamza} {Jeff} {Aditya} {Brent} requires 4 days
[M5] starts at [M4]'s end
[M5] is 36% completed
[M5] is 68% completed
[M5] is colored in LightSkyBlue/SteelBlue
[Autonomy Hardening + Stubs M6 (#360)] as [M6] on {Luis} {Jeff} {Hamza} {Brent} requires 5 days
[M6] starts at [M5]'s end
[M6] is 17% completed
[M6] is 55% completed
[M6] is colored in LightSkyBlue/SteelBlue
@@ -300,7 +299,7 @@ ganttDiagram {
[Large Project Autonomy (#369)] as [LARGE] on {Luis} {Brent} {Jeff} requires 4 days
[LARGE] starts 2026-03-17
[LARGE] is 42% completed
[LARGE] is 43% completed
[LARGE] is colored in LightSkyBlue/SteelBlue
' ── v3.5.0 MILESTONE ─────────────────────────────────────────
@@ -320,13 +319,13 @@ ganttDiagram {
[Post-MVP Deferred Work (#366)] as [POST] on {Luis} {Hamza} {Jeff} {Rui} {Brent} requires 8 days
[POST] starts at [M6]'s end
[POST] is 33% completed
[POST] is colored in LightSkyBlue/SteelBlue
[POST] is 0% completed
[POST] is colored in #E8E8E8/Silver
[Multi-Agent RDF System (#367)] as [RDF] on {Aditya} requires 5 days
[RDF] starts 2026-03-24
[RDF] is 33% completed
[RDF] is colored in LightSkyBlue/SteelBlue
[RDF] is 0% completed
[RDF] is colored in #E8E8E8/Silver
' ── v3.6.0 MILESTONE ─────────────────────────────────────────
' Gate: All post-MVP deferred work
@@ -402,18 +401,15 @@ legend right
| v3.5.0 | Mar 10 | 2 | 151 | Autonomy hardening + server stubs |
| v3.6.0 | Mar 27 | 2 | 100 | Deferred work |
----
**Risk Register (Day 104 — 2026-04-14) [AUTO-TIME-1]**
|= Milestone |= Completion |= Risk |= Blocker |
| v3.0.0 | 100% ✅ | **CLOSED** | Complete — 164 issues closed |
| v3.1.0 | 100% ✅ | **CLOSED** | Complete — 108 issues closed |
| v3.2.0 | 21.9% | **CRITICAL** | 974 open issues; past due 2026-02-26 |
| v3.3.0 | 34.8% | **CRITICAL** | 208 open issues; past due 2026-03-02 |
| v3.4.0 | 34.7% | **CRITICAL** | 267 open issues; past due 2026-03-06 |
| v3.5.0 | 16.3% | **CRITICAL** | 1130 open issues; past due 2026-03-10 |
| v3.6.0 | 31.2% | **HIGH** | 338 open issues; past due 2026-03-28 |
| v3.7.0 | 41.2% | **HIGH** | 612 open issues; no deadline |
| v3.8.0 | 26.6% | **HIGH** | 373 open issues; no deadline |
| v3.9.0 | 6.9% | **MEDIUM** | 54 open issues; no deadline |
**Risk Register (Day 100 — 2026-04-10)**
|= Epic |= Completion |= Risk |= Blocker |
| M3 (#357) | 32% | **CRITICAL** | 521 open issues in v3.2.0 (scope expanded 320→770) |
| M4 (#358) | 49% | **CRITICAL** | 112 open issues in v3.3.0 |
| M5 (#359) | 43% | **HIGH** | 180 open issues in v3.4.0 |
| M6 (#360) | 18% | **CRITICAL** | 888 open issues in v3.5.0 (massive scope expansion) |
| M7 (#361) | 38% | **HIGH** | 250 open issues in v3.6.0 |
| M8 (#362) | 45% | **HIGH** | 519 open issues in v3.7.0 |
| SEC (#363) | 28% | **HIGH** | 344 open issues in v3.8.0 |
----
**Color Key**
|= Color |= Meaning |
@@ -421,12 +417,11 @@ legend right
| <back:LightSkyBlue> </back> | In Progress |
| <back:#E8E8E8> </back> | Not Started |
| <back:Gold> <> </back> | Milestone Target |
| <back:#FF6666> | </back> | Today (2026-04-14) |
| <back:#FF6666> | </back> | Today (2026-04-10) |
----
29 Epics | 9 Legendaries | 7 Milestones | 6 Developers
~1649 total story points | ~3300 estimated hours
**v3.0.0 ✅ CLOSED (164 issues) — v3.1.0 ✅ CLOSED (108 issues)**
**3,956 open issues — ~435 open PRs — v3.2.0v3.9.0 active [AUTO-TIME-1 2026-04-14]**
**~25 open bugs (Type/Bug) — 225 open PRs — Session 4 active (32 workers)**
end legend
@endgantt
@@ -450,7 +445,7 @@ saturday are closed
sunday are closed
printscale weekly zoom 2
today is 2026-04-14
today is 2026-04-10
today is colored in #FF6666
<style>
@@ -1955,11 +1950,11 @@ legend right
| <back:LightSkyBlue> </back> | Issue: In Progress |
| <back:#E8E8E8> </back> | Issue: Not Started |
| <back:Gold> <> </back> | Milestone Target |
| <back:#FF6666> | </back> | Today (2026-04-14) |
| <back:#FF6666> | </back> | Today (2026-04-10) |
----
271 Issues | 29 Epics | 9 Legendaries | 7 Milestones
~1649 total story points | ~3300 estimated hours
**v3.0.0 ✅ CLOSED — v3.1.0 ✅ CLOSED — 3,956 open issues — ~435 open PRs [AUTO-TIME-1 2026-04-14]**
**~5 open bugs (Type/Bug) — 219 open PRs — Session 4 active (32 workers)**
end legend
@endgantt
@@ -1971,13 +1966,13 @@ This section provides a high-level overview of the CleverAgents implementation r
### Current Status Summary
As of 2026-04-14 [AUTO-TIME-1], the project has **~435 open PRs** and **3,956 open issues** across active milestones. **v3.0.0 and v3.1.0 are CLOSED and 100% complete.** Active development spans v3.2.0v3.9.0. Milestones v3.2.0v3.6.0 are past their planned due dates. v3.7.0v3.9.0 have no assigned deadlines.
As of Day 100 (2026-04-10), the project has **225 open PRs** and **~2777 open issues** across active milestones. **Session 4 is active (launched 2026-04-08, issue #4799) with 32 parallel workers and full supervisor fleet**. Bug count is **~25 open bugs** (Type/Bug label; UAT issues tracked separately). M8 (v3.7.0) is now **45% complete** (425/944 issues closed). M3 (v3.2.0) at **32%** (249/770 — scope massively expanded), M4 (v3.3.0) at **49%** (108/220), M5 (v3.4.0) at **43%** (133/313), M6 (v3.5.0) at **18%** (197/1085 — scope massively expanded), M7 (v3.6.0) at **38%** (150/400).
**v3.0.0 ✅ CLOSED** (164 issues, 100% complete). **v3.1.0 ✅ CLOSED** (108 issues, 100% complete). **v3.2.0**: 21.9% complete (273 closed, 974 open) — past due 2026-02-26. **v3.3.0**: 34.8% complete (111 closed, 208 open) — past due 2026-03-02. **v3.4.0**: 34.7% complete (142 closed, 267 open) — past due 2026-03-06. **v3.5.0**: 16.3% complete (220 closed, 1,130 open) — past due 2026-03-10. **v3.6.0**: 31.2% complete (153 closed, 338 open) — past due 2026-03-28. **v3.7.0**: 41.2% complete (428 closed, 612 open) — no deadline. **v3.8.0**: 26.6% complete (135 closed, 373 open) — no deadline. **v3.9.0**: 6.9% complete (4 closed, 54 open) — no deadline.
**M1, M2 fully complete. M3 (v3.2.0)**: 33% complete (248/757), 509 open issues — scope expanded massively. **M4 (v3.3.0)**: 49% complete (108/220), 112 open issues. **M5 (v3.4.0)**: 44% complete (133/301), 168 open issues. **M6 (v3.5.0)**: 18% complete (197/1065), 868 open issues — scope expanded massively. **M7 (v3.6.0)**: 38% complete (150/392), 242 open issues. **M8 (v3.7.0)**: 45% complete (423/938), 515 open issues. All milestones M3-M7 are **overdue**; M8 is in active v3.7.0 development.
!!! warning "Schedule Risk: v3.0.0 and v3.1.0 Complete — Milestones v3.2.0v3.6.0 Overdue — 3,956 Open Issues — ~435 Open PRs [AUTO-TIME-1 2026-04-14]"
!!! warning "Schedule Risk: All Milestones M3-M7 Overdue — Massive Scope Expansion — Session 4 Active (Day 100: 219 Open PRs, M3/M6 Scope Exploded)"
**v3.0.0 ✅ CLOSED** (164 issues). **v3.1.0 ✅ CLOSED** (108 issues). All milestones v3.2.0 through v3.6.0 have passed their target dates. Open PRs at **~435**. Total open issues: **3,956**. Open Bugs: **~3**. **Current priorities**: (1) **v3.2.0**974 open issues, 21.9% complete, past due 2026-02-26. (2) **v3.5.0**1,130 open issues, 16.3% complete, past due 2026-03-10. (3) **v3.7.0** — 612 open issues, 41.2% complete, no deadline. (4) **v3.3.0/v3.4.0/v3.6.0** — 208/267/338 open issues respectively. (5) **v3.8.0/v3.9.0** — 373/54 open issues, no deadlines.
All milestones M3 through M7 have passed their target dates. **Session 4 active (launched 2026-04-08, issue #4799) with 32 parallel workers**. Open PRs at **219** (up from 1 — agents opened many new PRs). Open bugs at **~5** (Type/Bug label). Session tracker issue: #4799. **Current priorities**: (1) **M3 scope explosion**509 open issues (was 85 at Day 98); scope grew from 320 to 757 total. (2) **M6 scope explosion**868 open issues (was 450 at Day 98); scope grew from 638 to 1065 total. (3) **Merge 219 open PRs** — pr-merge-pool-supervisor added as 17th supervisor. (4) **Continue M8 push** — 45% complete, 515 issues remaining. (5) **Clear M4/M5 backlogs** — M4 at 49% (112 open), M5 at 44% (168 open).
### Parallel Workstreams
@@ -2145,21 +2140,18 @@ The following areas are substantially implemented and are no longer blocking:
The milestones and their target dates are:
| Milestone | Version | Target Date | Day # | Focus | Status |
|-----------|---------|------------|-------|-------|--------|
| M1 | v3.0.0 | 2026-02-15 | Day 7 | Minimal local source-code workflow | ✅ CLOSED (164 issues) |
| M2 | v3.1.0 | 2026-02-22 | Day 14 | Actor graphs + tool sources | ✅ CLOSED (108 issues) |
| M3 | v3.2.0 | 2026-02-26 | Day 18 | Decisions + validations + invariants | ⚠️ 21.9% (974 open) |
| M4 | v3.3.0 | 2026-03-02 | Day 22 | Corrections + subplans + checkpoints | ⚠️ 34.8% (208 open) |
| M5 | v3.4.0 | 2026-03-06 | Day 26 | ACMS v1 + context scaling | ⚠️ 34.7% (267 open) |
| M6 | v3.5.0 | 2026-03-10 | Day 30 | Autonomy hardening | ⚠️ 16.3% (1130 open) |
| M7 | v3.6.0 | 2026-03-28 | Day 48 | Advanced Concepts & Deferred Features | ⚠️ 31.2% (338 open) |
| M8 | v3.7.0 | Not set | N/A | TUI Implementation | 🔄 41.2% (612 open) |
| M9 | v3.8.0 | Not Set | N/A | Server Implementation | 🔄 26.6% (373 open) |
| M10 | v3.9.0 | Not Set | N/A | Extended features | 🔄 6.9% (54 open) |
| Post-MVP | Not Set | Not Set | N/A | Deferred work | 🔄 In Progress |
_Table updated by [AUTO-TIME-1] on 2026-04-14_
| Milestone | Version | Target Date | Day # | Focus |
|-----------|---------|------------|-------|-------|
| M1 | v3.0.0 | 2026-02-15 | Day 7 | Minimal local source-code workflow |
| M2 | v3.1.0 | 2026-02-22 | Day 14 | Actor graphs + tool sources |
| M3 | v3.2.0 | 2026-02-26 | Day 18 | Decisions + validations + invariants |
| M4 | v3.3.0 | 2026-03-02 | Day 22 | Corrections + subplans + checkpoints |
| M5 | v3.4.0 | 2026-03-06 | Day 26 | ACMS v1 + context scaling |
| M6 | v3.5.0 | 2026-03-10 | Day 30 | Autonomy hardening |
| M7 | v3.6.0 | 2026-03-28 | Day 48 | Advanced Concepts & Deferred Features |
| M8 | v3.7.0 | Not set | N/A | TUI Implementation |
| M9 | v3.8.0 | Not Set | N/A | Server Implementation |
| Post-MVP | Not Set | Not Set | N/A | Deferred work |
Day numbers are relative to the project kickoff on **February 9, 2026**.
@@ -2299,7 +2291,7 @@ Items without an assigned milestone represent backlog work to be addressed after
### Schedule Risk Summary
As of 2026-04-14 [AUTO-TIME-1], **v3.0.0 and v3.1.0 are CLOSED and 100% complete**. All milestones v3.2.0 through v3.6.0 are **overdue**. Open PRs at **~435**. Total open issues: **3,956** across v3.2.0v3.9.0. Open Bugs: **~3**. v3.2.0 at **21.9%** (273/1247 closed, 974 open). v3.3.0 at **34.8%** (111/319 closed, 208 open). v3.4.0 at **34.7%** (142/409 closed, 267 open). v3.5.0 at **16.3%** (220/1350 closed, 1,130 open). v3.6.0 at **31.2%** (153/491 closed, 338 open). v3.7.0 at **41.2%** (428/1040 closed, 612 open). v3.8.0 at **26.6%** (135/508 closed, 373 open). v3.9.0 at **6.9%** (4/58 closed, 54 open). Milestones v3.7.0v3.9.0 have no assigned deadlines.
As of Day 96 (2026-04-06), all milestones M3 through M7 are **overdue**. The original 7-8 day slippage has grown to **20+ days** for M3-M6. **Session 3 is active with 16 parallel workers (~71 total agents)**. Open PRs now at **108** (down from 183 — 75 PRs merged/closed!). Open bugs at **~878** (stable). M8 (v3.7.0) at **46% complete** (418/917 closed). M9 (v3.8.0) at **28% complete** (131/467 closed). Session tracker: #3775. Critical path: PR #3774 (Click 8.2+ fix) must be merged; 108 open PRs need review and merge; M3/M4/M5/M6/M7 all overdue; M8 at 46% with 499 issues remaining; ~878 open bugs need TDD counterparts and systematic triage.
**Day 50 planning actions (completed)**: (a) 2 reviewers assigned to all 20 open PRs. (b) Comments posted on #1154 and #1168 recommending closure. (c) Escalation comments on PRs with missing branches. (d) Issue #1206 created for `@tdd_expected_fail` tag cleanup. (e) Timeline updated with Day 50 schedule adherence entry.
@@ -5324,22 +5316,3 @@ Story points per developer per milestone.
| M9 | N/A | N/A | N/A | N/A | N/A | N/A | N/A | ~TBD SP |
| M6+ | N/A | N/A | N/A | N/A | N/A | N/A | N/A | ~TBD SP |
| **Total** | N/A | N/A | N/A | N/A | N/A | N/A | N/A | ~1649 SP |
### 2026-04-14 — Schedule Adherence Check [AUTO-TIME-1]
| Milestone | State | Open Issues | Closed Issues | Completion | Status |
|-----------|--------|-------------|---------------|------------|--------|
| v3.0.0 | CLOSED | 0 | 164 | 100% | ✅ Complete |
| v3.1.0 | CLOSED | 0 | 108 | 100% | ✅ Complete |
| v3.2.0 | OPEN | 974 | 273 | 21.9% | ⚠️ Behind (due 2026-02-26) |
| v3.3.0 | OPEN | 208 | 111 | 34.8% | ⚠️ Behind (due 2026-03-02) |
| v3.4.0 | OPEN | 267 | 142 | 34.7% | ⚠️ Behind (due 2026-03-06) |
| v3.5.0 | OPEN | 1130 | 220 | 16.3% | ⚠️ Behind (due 2026-03-10) |
| v3.6.0 | OPEN | 338 | 153 | 31.2% | ⚠️ Behind (due 2026-03-28) |
| v3.7.0 | OPEN | 612 | 428 | 41.2% | 🔄 In Progress (no deadline) |
| v3.8.0 | OPEN | 373 | 135 | 26.6% | 🔄 In Progress (no deadline) |
| v3.9.0 | OPEN | 54 | 4 | 6.9% | 🔄 In Progress (no deadline) |
**Summary**: v3.0.0 and v3.1.0 are complete. Active development spans v3.2.0v3.9.0 with 3,956 open issues, ~435 open PRs, and ~3 open bugs. Milestones v3.2.0v3.6.0 are past their planned due dates. v3.7.0v3.9.0 have no assigned deadlines.
_Updated by [AUTO-TIME-1] on 2026-04-14_
@@ -1,79 +0,0 @@
Feature: Plan Tree Decision Rendering
As a user
I want to view the decision tree of a plan
So that I can audit the decision history and identify correction points
Background:
Given a plan with ID "plan-test-001"
And the plan has the following decisions:
| decision_id | type | parent_id | question | chosen_option | status |
| d-001 | prompt_definition | None | What is the task? | Analyze code | completed |
| d-002 | strategy_choice | d-001 | Which strategy to use? | Iterative | completed |
| d-003 | tool_selection | d-002 | Which tool for linting? | ruff | completed |
| d-004 | parameter | d-002 | Set max retries? | 3 | reverted |
| d-005 | branch | d-001 | Await confirmation? | pending | pending |
Scenario: Display plan tree in rich format
When I run "agents plan tree plan-test-001"
Then the output should contain "Plan Decision Tree: plan-test-001"
And the output should contain "d-001"
And the output should contain "prompt_definition"
And the output should contain "completed"
And the output should contain "d-002"
And the output should contain "strategy_choice"
And the output should contain "d-003"
And the output should contain "tool_selection"
And the output should contain "d-004"
And the output should contain "parameter"
And the output should contain "reverted"
And the output should contain "d-005"
And the output should contain "branch"
And the output should contain "pending"
Scenario: Display plan tree in plain text format
When I run "agents plan tree plan-test-001 --format plain"
Then the output should contain "Plan Decision Tree: plan-test-001"
And the output should contain "`--" or "|--"
And the output should contain "[completed]"
And the output should contain "[reverted]"
And the output should contain "[pending]"
Scenario: Display plan tree in JSON format
When I run "agents plan tree plan-test-001 --format json"
Then the output should be valid JSON
And the JSON should contain "plan_id": "plan-test-001"
And the JSON should contain "root"
And the JSON root should have "decision_id": "d-001"
And the JSON root should have "children" array with 2 items
Scenario: Limit tree depth to 1
When I run "agents plan tree plan-test-001 --depth 1"
Then the output should contain "d-001"
And the output should contain "d-002"
And the output should NOT contain "d-003"
And the output should NOT contain "d-004"
Scenario: Limit tree depth to 2
When I run "agents plan tree plan-test-001 --depth 2"
Then the output should contain "d-001"
And the output should contain "d-002"
And the output should contain "d-003"
And the output should contain "d-004"
And the output should NOT contain "d-005"
Scenario: Handle non-existent plan ID
When I run "agents plan tree plan-nonexistent"
Then the exit code should be non-zero
And the output should contain "not found"
Scenario: Handle plan with no decisions
Given a plan with ID "plan-empty"
When I run "agents plan tree plan-empty"
Then the output should contain "not found" or "no decisions"
Scenario: Tree structure is hierarchical
When I run "agents plan tree plan-test-001 --format plain"
Then the output should show d-002 as a child of d-001
And the output should show d-003 as a child of d-002
And the output should show d-004 as a child of d-002
And the output should show d-005 as a child of d-001
@@ -1,253 +0,0 @@
"""Step definitions for plan tree decision rendering feature."""
from __future__ import annotations
import json
import subprocess
from datetime import datetime
from behave import given, then, when
from behave.runner import Context
from cleveragents.domain.models.core.decision import Decision, DecisionType
from cleveragents.domain.models.core.plan import Plan
@given('a plan with ID "{plan_id}"')
def step_create_plan(context: Context, plan_id: str) -> None:
"""Create a test plan with the given ID."""
if not hasattr(context, "plans"):
context.plans = {}
plan = Plan(
plan_id=plan_id,
name=f"Test Plan {plan_id}",
description="Test plan for decision tree rendering",
)
context.plans[plan_id] = plan
@given("the plan has the following decisions")
def step_add_decisions(context: Context) -> None:
"""Add decisions to the current plan."""
if not hasattr(context, "decisions"):
context.decisions = {}
# Get the last created plan
plan_id = list(context.plans.keys())[-1]
for row in context.table:
decision_id = row["decision_id"]
decision_type = DecisionType(row["type"])
parent_id = row["parent_id"] if row["parent_id"] != "None" else None
question = row["question"]
chosen_option = row["chosen_option"]
status = row["status"]
decision = Decision(
decision_id=decision_id,
plan_id=plan_id,
parent_decision_id=parent_id,
sequence_number=len(context.decisions),
decision_type=decision_type,
question=question,
chosen_option=chosen_option,
rationale=f"Rationale for {chosen_option}",
created_at=datetime.now(datetime.UTC),
)
# Add status attribute (not in base model, but needed for rendering)
decision.status = status
context.decisions[decision_id] = decision
@when('I run "agents plan tree {plan_id}"')
def step_run_plan_tree_command(context: Context, plan_id: str) -> None:
"""Run the plan tree command."""
context.last_command = ["agents", "plan", "tree", plan_id]
step_run_command(context)
@when('I run "agents plan tree {plan_id} --format {format_type}"')
def step_run_plan_tree_with_format(
context: Context, plan_id: str, format_type: str
) -> None:
"""Run the plan tree command with format option."""
context.last_command = [
"agents",
"plan",
"tree",
plan_id,
"--format",
format_type,
]
step_run_command(context)
@when('I run "agents plan tree {plan_id} --depth {depth}"')
def step_run_plan_tree_with_depth(
context: Context, plan_id: str, depth: str
) -> None:
"""Run the plan tree command with depth option."""
context.last_command = [
"agents",
"plan",
"tree",
plan_id,
"--depth",
depth,
]
step_run_command(context)
@when('I run "agents plan tree {plan_id} --format {format_type} --depth {depth}"')
def step_run_plan_tree_with_format_and_depth(
context: Context, plan_id: str, format_type: str, depth: str
) -> None:
"""Run the plan tree command with both format and depth options."""
context.last_command = [
"agents",
"plan",
"tree",
plan_id,
"--format",
format_type,
"--depth",
depth,
]
step_run_command(context)
def step_run_command(context: Context) -> None:
"""Execute the command and capture output."""
try:
result = subprocess.run(
context.last_command,
capture_output=True,
text=True,
timeout=10,
)
context.last_exit_code = result.returncode
context.last_output = result.stdout + result.stderr
except subprocess.TimeoutExpired:
context.last_exit_code = 124
context.last_output = "Command timed out"
except Exception as e:
context.last_exit_code = 1
context.last_output = str(e)
@then("the output should contain {text}")
def step_output_contains(context: Context, text: str) -> None:
"""Check that output contains the given text."""
# Remove quotes if present
text = text.strip('"')
assert text in context.last_output, (
f"Expected '{text}' in output, but got:\n{context.last_output}"
)
@then("the output should NOT contain {text}")
def step_output_not_contains(context: Context, text: str) -> None:
"""Check that output does not contain the given text."""
# Remove quotes if present
text = text.strip('"')
assert text not in context.last_output, (
f"Expected '{text}' NOT in output, but got:\n{context.last_output}"
)
@then("the exit code should be non-zero")
def step_exit_code_nonzero(context: Context) -> None:
"""Check that exit code is non-zero."""
assert context.last_exit_code != 0, (
f"Expected non-zero exit code, but got {context.last_exit_code}"
)
@then("the output should be valid JSON")
def step_output_is_valid_json(context: Context) -> None:
"""Check that output is valid JSON."""
try:
context.last_json = json.loads(context.last_output)
except json.JSONDecodeError as e:
raise AssertionError(
f"Output is not valid JSON: {e}\nOutput: {context.last_output}"
) from e
@then('the JSON should contain "{key}": "{value}"')
def step_json_contains_key_value(
context: Context, key: str, value: str
) -> None:
"""Check that JSON contains a specific key-value pair."""
assert key in context.last_json, (
f"Expected key '{key}' in JSON, but got: {context.last_json}"
)
assert context.last_json[key] == value, (
f"Expected {key}={value}, but got {key}={context.last_json[key]}"
)
@then("the JSON root should have {key}: {value}")
def step_json_root_has_key_value(
context: Context, key: str, value: str
) -> None:
"""Check that JSON root has a specific key-value pair."""
root = context.last_json.get("root", {})
assert key in root, (
f"Expected key '{key}' in JSON root, but got: {root}"
)
assert root[key] == value, (
f"Expected {key}={value}, but got {key}={root[key]}"
)
@then("the JSON root should have {key} array with {count} items")
def step_json_root_has_array(
context: Context, key: str, count: str
) -> None:
"""Check that JSON root has an array with specific count."""
root = context.last_json.get("root", {})
assert key in root, (
f"Expected key '{key}' in JSON root, but got: {root}"
)
assert isinstance(root[key], list), (
f"Expected {key} to be an array, but got {type(root[key])}"
)
expected_count = int(count)
assert len(root[key]) == expected_count, (
f"Expected {key} to have {expected_count} items, "
f"but got {len(root[key])}"
)
@then("the output should show {child_id} as a child of {parent_id}")
def step_output_shows_hierarchy(
context: Context, child_id: str, parent_id: str
) -> None:
"""Check that output shows correct hierarchy."""
# This is a simplified check - in a real scenario, we'd parse the tree structure
lines = context.last_output.split("\n")
parent_line_idx = None
child_line_idx = None
for i, line in enumerate(lines):
if parent_id in line:
parent_line_idx = i
if child_id in line:
child_line_idx = i
assert parent_line_idx is not None, (
f"Parent {parent_id} not found in output"
)
assert child_line_idx is not None, (
f"Child {child_id} not found in output"
)
assert parent_line_idx < child_line_idx, (
f"Parent {parent_id} should appear before child {child_id}"
)
__all__ = []
@@ -1,15 +0,0 @@
Feature: Plan Lifecycle Decision Root Type
As a developer
I want to ensure that the root decision recorded during start_strategize is prompt_definition
So that the decision tree has the correct root node type
Background:
Given I have a plan lifecycle service with decision service
Scenario: start_strategize records prompt_definition as root decision
Given an action "local/test-action" with description "Test action description"
And a plan created from "local/test-action"
When I start strategize on the plan
Then the root decision should be recorded with type "prompt_definition"
And the root decision question should be "What is the plan prompt?"
And the root decision chosen_option should contain the plan description
-260
View File
@@ -1,260 +0,0 @@
"""Unit tests for plan tree renderers."""
from __future__ import annotations
from datetime import datetime
import pytest
from cleveragents.application.services.plan_tree_service import DecisionTreeNode
from cleveragents.cli.output.plan_tree_renderers import (
JsonPlanTreeRenderer,
PlainPlanTreeRenderer,
RichPlanTreeRenderer,
get_renderer,
)
from cleveragents.domain.models.core.decision import Decision, DecisionType
@pytest.fixture
def sample_decision_tree() -> DecisionTreeNode:
"""Create a sample decision tree for testing."""
now = datetime.now(datetime.UTC)
root_decision = Decision(
decision_id="d-001",
plan_id="plan-001",
parent_decision_id=None,
sequence_number=0,
decision_type=DecisionType.PROMPT_DEFINITION,
question="What is the task?",
chosen_option="Analyze code",
rationale="User requested analysis",
created_at=now,
)
root_decision.status = "completed"
child_decision = Decision(
decision_id="d-002",
plan_id="plan-001",
parent_decision_id="d-001",
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Which strategy?",
chosen_option="Iterative",
rationale="Best approach",
created_at=now,
)
child_decision.status = "completed"
root_node = DecisionTreeNode(decision=root_decision, depth=0)
child_node = DecisionTreeNode(decision=child_decision, depth=1)
root_node.children.append(child_node)
return root_node
class TestRichPlanTreeRenderer:
"""Tests for RichPlanTreeRenderer."""
def test_render_includes_plan_id(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes plan ID."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "Plan Decision Tree: plan-001" in output
def test_render_includes_decision_ids(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes decision IDs."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "d-001" in output
assert "d-002" in output
def test_render_includes_decision_types(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes decision types."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "prompt_definition" in output
assert "strategy_choice" in output
def test_render_includes_status(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes status."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "[completed]" in output
def test_render_includes_timestamp(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes timestamp."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
# Check for ISO format timestamp
assert "T" in output # ISO format includes T
def test_render_includes_summary(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes summary."""
renderer = RichPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "User requested analysis" in output or "Analyze code" in output
class TestPlainPlanTreeRenderer:
"""Tests for PlainPlanTreeRenderer."""
def test_render_includes_plan_id(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes plan ID."""
renderer = PlainPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "Plan Decision Tree: plan-001" in output
def test_render_uses_ascii_connectors(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render uses ASCII connectors."""
renderer = PlainPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
# Should contain ASCII tree connectors
assert "|--" in output or "`--" in output
def test_render_includes_status_in_brackets(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render includes status in brackets."""
renderer = PlainPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
assert "[completed" in output
def test_render_no_color_codes(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that plain renderer has no color codes."""
renderer = PlainPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
# Should not contain rich color codes
assert "[green]" not in output
assert "[red]" not in output
assert "[yellow]" not in output
class TestJsonPlanTreeRenderer:
"""Tests for JsonPlanTreeRenderer."""
def test_render_returns_valid_json(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that render returns valid JSON."""
import json
renderer = JsonPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
# Should not raise
data = json.loads(output)
assert isinstance(data, dict)
def test_render_includes_plan_id(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that JSON includes plan ID."""
import json
renderer = JsonPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
data = json.loads(output)
assert data["plan_id"] == "plan-001"
def test_render_includes_root_node(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that JSON includes root node."""
import json
renderer = JsonPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
data = json.loads(output)
assert "root" in data
assert data["root"]["decision_id"] == "d-001"
def test_render_includes_children(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that JSON includes children."""
import json
renderer = JsonPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
data = json.loads(output)
root = data["root"]
assert "children" in root
assert len(root["children"]) == 1
assert root["children"][0]["decision_id"] == "d-002"
def test_render_includes_all_fields(
self, sample_decision_tree: DecisionTreeNode
) -> None:
"""Test that JSON includes all required fields."""
import json
renderer = JsonPlanTreeRenderer()
output = renderer.render(sample_decision_tree, "plan-001")
data = json.loads(output)
root = data["root"]
assert "decision_id" in root
assert "plan_id" in root
assert "type" in root
assert "timestamp" in root
assert "question" in root
assert "chosen_option" in root
assert "status" in root
assert "summary" in root
class TestGetRenderer:
"""Tests for get_renderer function."""
def test_get_renderer_returns_rich_renderer(self) -> None:
"""Test that get_renderer returns RichPlanTreeRenderer."""
renderer = get_renderer("rich")
assert isinstance(renderer, RichPlanTreeRenderer)
def test_get_renderer_returns_plain_renderer(self) -> None:
"""Test that get_renderer returns PlainPlanTreeRenderer."""
renderer = get_renderer("plain")
assert isinstance(renderer, PlainPlanTreeRenderer)
def test_get_renderer_returns_json_renderer(self) -> None:
"""Test that get_renderer returns JsonPlanTreeRenderer."""
renderer = get_renderer("json")
assert isinstance(renderer, JsonPlanTreeRenderer)
def test_get_renderer_raises_for_unknown_format(self) -> None:
"""Test that get_renderer raises for unknown format."""
with pytest.raises(ValueError):
get_renderer("unknown")
-239
View File
@@ -1,239 +0,0 @@
"""Unit tests for PlanTreeService."""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock
import pytest
from cleveragents.application.services.plan_tree_service import (
DecisionTreeNode,
PlanTreeService,
)
from cleveragents.domain.models.core.decision import Decision, DecisionType
@pytest.fixture
def mock_plan_repository() -> MagicMock:
"""Create a mock plan repository."""
return MagicMock()
@pytest.fixture
def mock_decision_repository() -> MagicMock:
"""Create a mock decision repository."""
return MagicMock()
@pytest.fixture
def plan_tree_service(
mock_plan_repository: MagicMock,
mock_decision_repository: MagicMock,
) -> PlanTreeService:
"""Create a PlanTreeService instance."""
return PlanTreeService(mock_plan_repository, mock_decision_repository)
@pytest.fixture
def sample_decisions() -> list[Decision]:
"""Create sample decisions for testing."""
now = datetime.now(datetime.UTC)
decisions = [
Decision(
decision_id="d-001",
plan_id="plan-001",
parent_decision_id=None,
sequence_number=0,
decision_type=DecisionType.PROMPT_DEFINITION,
question="What is the task?",
chosen_option="Analyze code",
rationale="User requested code analysis",
created_at=now,
),
Decision(
decision_id="d-002",
plan_id="plan-001",
parent_decision_id="d-001",
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Which strategy?",
chosen_option="Iterative",
rationale="Iterative approach is best",
created_at=now,
),
Decision(
decision_id="d-003",
plan_id="plan-001",
parent_decision_id="d-002",
sequence_number=2,
decision_type=DecisionType.TOOL_SELECTION,
question="Which tool?",
chosen_option="ruff",
rationale="ruff is fast",
created_at=now,
),
Decision(
decision_id="d-004",
plan_id="plan-001",
parent_decision_id="d-001",
sequence_number=3,
decision_type=DecisionType.BRANCH,
question="Await confirmation?",
chosen_option="yes",
rationale="Need user input",
created_at=now,
),
]
# Add status attribute for testing
for decision in decisions:
decision.status = "completed"
return decisions
class TestPlanTreeService:
"""Tests for PlanTreeService."""
def test_get_decision_tree_returns_none_for_nonexistent_plan(
self,
plan_tree_service: PlanTreeService,
mock_plan_repository: MagicMock,
) -> None:
"""Test that get_decision_tree returns None for non-existent plan."""
mock_plan_repository.get_by_id.return_value = None
result = plan_tree_service.get_decision_tree("plan-nonexistent")
assert result is None
mock_plan_repository.get_by_id.assert_called_once_with(
"plan-nonexistent"
)
def test_get_decision_tree_returns_none_for_plan_with_no_decisions(
self,
plan_tree_service: PlanTreeService,
mock_plan_repository: MagicMock,
mock_decision_repository: MagicMock,
) -> None:
"""Test that get_decision_tree returns None for plan with no decisions."""
mock_plan = MagicMock()
mock_plan_repository.get_by_id.return_value = mock_plan
mock_decision_repository.find_by_plan_id.return_value = []
result = plan_tree_service.get_decision_tree("plan-001")
assert result is None
def test_get_decision_tree_builds_correct_tree_structure(
self,
plan_tree_service: PlanTreeService,
mock_plan_repository: MagicMock,
mock_decision_repository: MagicMock,
sample_decisions: list[Decision],
) -> None:
"""Test that get_decision_tree builds correct tree structure."""
mock_plan = MagicMock()
mock_plan_repository.get_by_id.return_value = mock_plan
mock_decision_repository.find_by_plan_id.return_value = sample_decisions
root = plan_tree_service.get_decision_tree("plan-001")
assert root is not None
assert root.decision.decision_id == "d-001"
assert len(root.children) == 2 # d-002 and d-004
assert root.children[0].decision.decision_id == "d-002"
assert root.children[1].decision.decision_id == "d-004"
assert len(root.children[0].children) == 1 # d-003
assert root.children[0].children[0].decision.decision_id == "d-003"
def test_get_decision_tree_respects_depth_limit(
self,
plan_tree_service: PlanTreeService,
mock_plan_repository: MagicMock,
mock_decision_repository: MagicMock,
sample_decisions: list[Decision],
) -> None:
"""Test that get_decision_tree respects depth limit."""
mock_plan = MagicMock()
mock_plan_repository.get_by_id.return_value = mock_plan
mock_decision_repository.find_by_plan_id.return_value = sample_decisions
# Depth 1: only root and immediate children
root = plan_tree_service.get_decision_tree("plan-001", depth=1)
assert root is not None
assert len(root.children) == 2
assert len(root.children[0].children) == 0 # d-003 should not be included
assert len(root.children[1].children) == 0
def test_get_tree_summary_returns_correct_statistics(
self,
plan_tree_service: PlanTreeService,
mock_decision_repository: MagicMock,
sample_decisions: list[Decision],
) -> None:
"""Test that get_tree_summary returns correct statistics."""
mock_decision_repository.find_by_plan_id.return_value = sample_decisions
summary = plan_tree_service.get_tree_summary("plan-001")
assert summary["plan_id"] == "plan-001"
assert summary["total_decisions"] == 4
assert summary["max_depth"] == 2
assert summary["status_counts"]["completed"] == 4
def test_get_tree_summary_for_empty_plan(
self,
plan_tree_service: PlanTreeService,
mock_decision_repository: MagicMock,
) -> None:
"""Test that get_tree_summary handles empty plan."""
mock_decision_repository.find_by_plan_id.return_value = []
summary = plan_tree_service.get_tree_summary("plan-empty")
assert summary["plan_id"] == "plan-empty"
assert summary["total_decisions"] == 0
assert summary["max_depth"] == 0
class TestDecisionTreeNode:
"""Tests for DecisionTreeNode."""
def test_to_dict_includes_all_fields(
self, sample_decisions: list[Decision]
) -> None:
"""Test that to_dict includes all required fields."""
decision = sample_decisions[0]
node = DecisionTreeNode(decision=decision, depth=0)
result = node.to_dict()
assert result["decision_id"] == "d-001"
assert result["plan_id"] == "plan-001"
assert result["type"] == "prompt_definition"
assert "timestamp" in result
assert result["question"] == "What is the task?"
assert result["chosen_option"] == "Analyze code"
assert result["status"] == "completed"
assert result["summary"] == "User requested code analysis"
assert result["children"] == []
def test_to_dict_includes_children(
self, sample_decisions: list[Decision]
) -> None:
"""Test that to_dict includes children."""
parent = sample_decisions[0]
child = sample_decisions[1]
parent_node = DecisionTreeNode(decision=parent, depth=0)
child_node = DecisionTreeNode(decision=child, depth=1)
parent_node.children.append(child_node)
result = parent_node.to_dict()
assert len(result["children"]) == 1
assert result["children"][0]["decision_id"] == "d-002"
+1 -4
View File
@@ -23,10 +23,6 @@ nav:
- Configuration: api/config.md
- AI Providers: api/providers.md
- TUI: api/tui.md
- Decisions: api/decisions.md
- Invariants: api/invariants.md
- Checkpoints: api/checkpoints.md
- Plan Corrections: api/plan-corrections.md
- Modules:
- Shell Safety: modules/shell-safety.md
- UKO Provenance Tracking: modules/uko-provenance.md
@@ -34,6 +30,7 @@ nav:
- ACMS Context Hydration: modules/context-hydration.md
- Git Worktree Sandbox: modules/git-worktree-sandbox.md
- Development:
- Setup Guide: development/setup.md
- Agent System Specification: development/agent-system-specification.md
- CI/CD Pipeline: development/ci-cd.md
- Quality Automation: development/quality-automation.md
+26
View File
@@ -55,6 +55,7 @@ from cleveragents.application.services.fix_then_revalidate import (
FixThenRevalidateOrchestrator,
)
from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.application.services.lock_service import LockService
from cleveragents.application.services.multi_project_service import (
MultiProjectService,
)
@@ -267,6 +268,24 @@ def _build_session_factory(database_url: str) -> sessionmaker[Session]:
return sessionmaker(bind=engine, expire_on_commit=False)
def _build_lock_service(
database_url: str,
) -> LockService:
"""Build a LockService with a session factory from the database URL.
Registered as a Singleton so that all callers within a process share
the same advisory-lock state. Follows the same ``_build_*`` pattern
used by other DB-backed services (see ``_build_session_factory``,
``_build_resource_registry_service``, etc.).
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine(database_url, echo=False)
factory = sessionmaker(bind=engine, expire_on_commit=False)
return LockService(session_factory=factory)
def _build_resource_registry_service(
database_url: str,
) -> ResourceRegistryService:
@@ -657,6 +676,12 @@ class Container(containers.DeclarativeContainer):
InvariantService,
)
# Lock Service - Singleton (shared advisory-lock state per process, #7989)
lock_service = providers.Singleton(
_build_lock_service,
database_url=database_url,
)
# Plan Lifecycle Service - Factory (v3 four-phase lifecycle)
plan_lifecycle_service = providers.Factory(
PlanLifecycleService,
@@ -665,6 +690,7 @@ class Container(containers.DeclarativeContainer):
decision_service=decision_service,
event_bus=event_bus,
invariant_service=invariant_service,
lock_service=lock_service,
)
# Checkpoint Service - database-backed via CheckpointRepository
@@ -54,6 +54,7 @@ from __future__ import annotations
from contextlib import suppress
from datetime import datetime
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import structlog
from ulid import ULID
@@ -101,6 +102,7 @@ if TYPE_CHECKING:
ErrorPatternService,
)
from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.application.services.lock_service import LockService
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
from cleveragents.infrastructure.events.protocol import EventBus
@@ -190,6 +192,7 @@ class PlanLifecycleService:
error_pattern_service: ErrorPatternService | None = None,
config_service: ConfigService | None = None,
invariant_service: InvariantService | None = None,
lock_service: LockService | None = None,
):
"""Initialize the plan lifecycle service.
@@ -225,6 +228,13 @@ class PlanLifecycleService:
Reconciliation Actor is auto-invoked at each phase
transition to verify invariants hold. When ``None``,
reconciliation is silently skipped.
lock_service: Optional :class:`LockService` for plan-level
advisory locking. When provided, ``execute_plan`` and
``apply_plan`` acquire a plan lock before performing
the phase transition and release it in a ``finally``
block, preventing concurrent modifications to the same
plan. When ``None``, locking is silently skipped for
backward compatibility with existing tests.
"""
self.settings = settings
self.unit_of_work = unit_of_work
@@ -234,6 +244,7 @@ class PlanLifecycleService:
self.error_pattern_service = error_pattern_service
self._config_service = config_service
self.invariant_service = invariant_service
self._lock_service = lock_service
self._logger = logger.bind(service="plan_lifecycle")
self.preflight_guardrail = PlanPreflightGuardrail()
self._subscribe_correction_reconciliation()
@@ -1387,14 +1398,11 @@ class PlanLifecycleService:
self._commit_plan(plan)
self._logger.info("Strategize started", plan_id=plan_id)
# Record the root decision: prompt_definition
# This represents the plan's prompt/description and is the
# root of the decision tree
self._try_record_decision(
plan_id=plan_id,
decision_type="prompt_definition",
question="What is the plan prompt?",
chosen_option=plan.description or plan.action_name or plan_id,
decision_type="strategy_choice",
question="Which strategy should the plan follow?",
chosen_option=f"Begin strategize phase for plan {plan_id}",
)
return plan
@@ -1500,6 +1508,11 @@ class PlanLifecycleService:
This is the 'execute' command.
A plan-level advisory lock is acquired before the transition and
released in a ``finally`` block so that concurrent callers on the
same ``plan_id`` receive a ``LockConflictError`` rather than
silently racing into the same phase transition.
Args:
plan_id: The plan ULID
@@ -1510,67 +1523,91 @@ class PlanLifecycleService:
NotFoundError: If plan not found
InvalidPhaseTransitionError: If transition is not valid
PlanNotReadyError: If plan is not ready for transition
LockConflictError: If another session holds the plan lock
"""
plan = self.get_plan(plan_id)
# Generate a unique owner_id for this invocation to ensure that
# concurrent sessions cannot re-entrantly acquire the same lock.
# The LockService treats owner_id as the caller identity and allows
# re-entrant acquisition for the same owner; using a unique UUID per
# invocation ensures that concurrent sessions present different owners
# and thus trigger LockConflictError when attempting to acquire the
# same plan lock.
owner_id: str = str(uuid4())
# Validate transition
if not can_transition(plan.phase, PlanPhase.EXECUTE):
raise InvalidPhaseTransitionError(plan.phase, PlanPhase.EXECUTE)
# Must be in COMPLETE state to transition
if plan.state != ProcessingState.COMPLETE:
raise PlanNotReadyError(
plan_id, plan.phase, plan.state or ProcessingState.QUEUED
if self._lock_service is not None:
self._lock_service.acquire(
owner_id=owner_id,
resource_type="plan",
resource_id=plan_id,
)
try:
plan = self.get_plan(plan_id)
# Run estimation actor if configured (informational only)
self._run_estimation(plan)
# Validate transition
if not can_transition(plan.phase, PlanPhase.EXECUTE):
raise InvalidPhaseTransitionError(plan.phase, PlanPhase.EXECUTE)
# Layer 4: Consult Error Pattern Database for preventive guidance
self._consult_error_patterns(plan)
# Must be in COMPLETE state to transition
if plan.state != ProcessingState.COMPLETE:
raise PlanNotReadyError(
plan_id, plan.phase, plan.state or ProcessingState.QUEUED
)
# Invariant Reconciliation: verify invariants before Execute
self._run_invariant_reconciliation(plan)
# Run estimation actor if configured (informational only)
self._run_estimation(plan)
# Transition to Execute phase — set processing_state first so that
# the phase-state validator sees QUEUED (valid in any phase) when
# the phase assignment triggers re-validation.
plan.processing_state = ProcessingState.QUEUED
plan.phase = PlanPhase.EXECUTE
plan.timestamps.updated_at = datetime.now()
# Layer 4: Consult Error Pattern Database for preventive guidance
self._consult_error_patterns(plan)
self._commit_plan(plan)
self._logger.info(
"Plan transitioned to Execute",
plan_id=plan_id,
phase=plan.phase.value,
)
if self.event_bus is not None:
try:
self.event_bus.emit(
DomainEvent(
event_type=EventType.PLAN_PHASE_CHANGED,
plan_id=plan_id,
details={
"phase": plan.phase.value,
"processing_state": plan.processing_state.value
if plan.processing_state
else None,
},
# Invariant Reconciliation: verify invariants before Execute
self._run_invariant_reconciliation(plan)
# Transition to Execute phase — set processing_state first so that
# the phase-state validator sees QUEUED (valid in any phase) when
# the phase assignment triggers re-validation.
plan.processing_state = ProcessingState.QUEUED
plan.phase = PlanPhase.EXECUTE
plan.timestamps.updated_at = datetime.now()
self._commit_plan(plan)
self._logger.info(
"Plan transitioned to Execute",
plan_id=plan_id,
phase=plan.phase.value,
)
if self.event_bus is not None:
try:
self.event_bus.emit(
DomainEvent(
event_type=EventType.PLAN_PHASE_CHANGED,
plan_id=plan_id,
details={
"phase": plan.phase.value,
"processing_state": plan.processing_state.value
if plan.processing_state
else None,
},
)
)
except Exception:
self._logger.warning(
"event_bus_emit_failed",
event_type="PLAN_PHASE_CHANGED",
plan_id=plan_id,
exc_info=True,
)
)
except Exception:
self._logger.warning(
"event_bus_emit_failed",
event_type="PLAN_PHASE_CHANGED",
plan_id=plan_id,
exc_info=True,
)
# Enqueue async job when async execution is enabled
self._maybe_enqueue_async_job(plan_id, "execute")
# Enqueue async job when async execution is enabled
self._maybe_enqueue_async_job(plan_id, "execute")
return plan
return plan
finally:
if self._lock_service is not None:
self._lock_service.release(
owner_id=owner_id,
resource_type="plan",
resource_id=plan_id,
)
def start_execute(self, plan_id: str) -> Plan:
"""Start the Execute phase processing."""
@@ -1684,6 +1721,11 @@ class PlanLifecycleService:
This is the 'apply' command.
A plan-level advisory lock is acquired before the transition and
released in a ``finally`` block so that concurrent callers on the
same ``plan_id`` receive a ``LockConflictError`` rather than
silently racing into the same phase transition.
Args:
plan_id: The plan ULID
@@ -1694,41 +1736,65 @@ class PlanLifecycleService:
NotFoundError: If plan not found
InvalidPhaseTransitionError: If transition is not valid
PlanNotReadyError: If plan is not ready for transition
LockConflictError: If another session holds the plan lock
"""
plan = self.get_plan(plan_id)
# Generate a unique owner_id for this invocation to ensure that
# concurrent sessions cannot re-entrantly acquire the same lock.
# The LockService treats owner_id as the caller identity and allows
# re-entrant acquisition for the same owner; using a unique UUID per
# invocation ensures that concurrent sessions present different owners
# and thus trigger LockConflictError when attempting to acquire the
# same plan lock.
owner_id: str = str(uuid4())
# Validate transition
if not can_transition(plan.phase, PlanPhase.APPLY):
raise InvalidPhaseTransitionError(plan.phase, PlanPhase.APPLY)
if self._lock_service is not None:
self._lock_service.acquire(
owner_id=owner_id,
resource_type="plan",
resource_id=plan_id,
)
try:
plan = self.get_plan(plan_id)
# Must be in COMPLETE state to transition
if plan.state != ProcessingState.COMPLETE:
raise PlanNotReadyError(
plan_id, plan.phase, plan.state or ProcessingState.QUEUED
# Validate transition
if not can_transition(plan.phase, PlanPhase.APPLY):
raise InvalidPhaseTransitionError(plan.phase, PlanPhase.APPLY)
# Must be in COMPLETE state to transition
if plan.state != ProcessingState.COMPLETE:
raise PlanNotReadyError(
plan_id, plan.phase, plan.state or ProcessingState.QUEUED
)
# Invariant Reconciliation: verify invariants before Apply
self._run_invariant_reconciliation(plan)
# Transition to Apply phase — set processing_state first so that
# the phase-state validator sees QUEUED (valid in any phase) when
# the phase assignment triggers re-validation.
plan.processing_state = ProcessingState.QUEUED
plan.phase = PlanPhase.APPLY
plan.timestamps.apply_started_at = datetime.now()
plan.timestamps.updated_at = datetime.now()
self._commit_plan(plan)
self._logger.info(
"Plan transitioned to Apply",
plan_id=plan_id,
phase=plan.phase.value,
)
# Invariant Reconciliation: verify invariants before Apply
self._run_invariant_reconciliation(plan)
# Enqueue async job when async execution is enabled
self._maybe_enqueue_async_job(plan_id, "apply")
# Transition to Apply phase — set processing_state first so that
# the phase-state validator sees QUEUED (valid in any phase) when
# the phase assignment triggers re-validation.
plan.processing_state = ProcessingState.QUEUED
plan.phase = PlanPhase.APPLY
plan.timestamps.apply_started_at = datetime.now()
plan.timestamps.updated_at = datetime.now()
self._commit_plan(plan)
self._logger.info(
"Plan transitioned to Apply",
plan_id=plan_id,
phase=plan.phase.value,
)
# Enqueue async job when async execution is enabled
self._maybe_enqueue_async_job(plan_id, "apply")
return plan
return plan
finally:
if self._lock_service is not None:
self._lock_service.release(
owner_id=owner_id,
resource_type="plan",
resource_id=plan_id,
)
def start_apply(self, plan_id: str) -> Plan:
"""Start the Apply phase processing."""
@@ -1,219 +0,0 @@
"""Service for rendering and querying the decision tree of a plan.
This service provides methods to retrieve and render the decision tree
of a plan, including support for depth limiting, multiple output formats
(rich, plain text, JSON), and error handling for non-existent plans.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from cleveragents.domain.models.core.decision import Decision
from cleveragents.domain.repositories import (
DecisionRepositoryProtocol,
LifecyclePlanRepositoryProtocol,
)
@dataclass
class DecisionTreeNode:
"""A node in the decision tree with nested children."""
decision: Decision
children: list[DecisionTreeNode] = field(default_factory=list)
depth: int = 0
def to_dict(self) -> dict[str, Any]:
"""Convert node to dictionary for JSON serialization."""
return {
"decision_id": self.decision.decision_id,
"plan_id": self.decision.plan_id,
"type": str(self.decision.decision_type),
"timestamp": self.decision.created_at.isoformat(),
"question": self.decision.question,
"chosen_option": self.decision.chosen_option,
"status": getattr(self.decision, "status", "completed"),
"summary": self.decision.rationale or self.decision.chosen_option,
"children": [child.to_dict() for child in self.children],
}
class PlanTreeService:
"""Service for retrieving and rendering plan decision trees."""
def __init__(
self,
plan_repository: LifecyclePlanRepositoryProtocol,
decision_repository: DecisionRepositoryProtocol,
) -> None:
"""Initialize the service with required repositories.
Args:
plan_repository: Repository for accessing plan data.
decision_repository: Repository for accessing decision data.
"""
self.plan_repository = plan_repository
self.decision_repository = decision_repository
def get_decision_tree(
self, plan_id: str, depth: int | None = None
) -> DecisionTreeNode | None:
"""Get the decision tree for a plan.
Args:
plan_id: The ULID of the plan.
depth: Maximum depth to include in the tree (None = unlimited).
Returns:
The root DecisionTreeNode of the tree, or None if plan not found.
Raises:
ValueError: If plan_id is invalid.
"""
# Verify plan exists
plan = self.plan_repository.get(plan_id)
if not plan:
return None
# Get all decisions for the plan
decisions = self.decision_repository.get_by_plan(plan_id)
if not decisions:
return None
# Build a map of decision_id -> Decision for quick lookup
decision_map: dict[str, Decision] = {d.decision_id: d for d in decisions}
# Find root decisions (those with no parent)
root_decisions = [d for d in decisions if d.parent_decision_id is None]
if not root_decisions:
return None
# Build tree from root(s)
# Note: typically there's only one root (prompt_definition)
root_node = self._build_tree_node(
root_decisions[0], decision_map, depth=0, max_depth=depth
)
return root_node
def _build_tree_node(
self,
decision: Decision,
decision_map: dict[str, Decision],
depth: int,
max_depth: int | None,
) -> DecisionTreeNode:
"""Recursively build a tree node and its children.
Args:
decision: The decision to create a node for.
decision_map: Map of all decisions by ID.
depth: Current depth in the tree.
max_depth: Maximum depth to include (None = unlimited).
Returns:
A DecisionTreeNode with children populated up to max_depth.
"""
node = DecisionTreeNode(decision=decision, depth=depth)
# Stop if we've reached max depth
if max_depth is not None and depth >= max_depth:
return node
# Find children of this decision
children = [
d
for d in decision_map.values()
if d.parent_decision_id == decision.decision_id
]
# Recursively build child nodes
for child in sorted(children, key=lambda d: d.sequence_number):
child_node = self._build_tree_node(
child, decision_map, depth + 1, max_depth
)
node.children.append(child_node)
return node
def get_tree_summary(self, plan_id: str) -> dict[str, Any]:
"""Get summary statistics about a plan's decision tree.
Args:
plan_id: The ULID of the plan.
Returns:
Dictionary with summary statistics.
"""
decisions = self.decision_repository.get_by_plan(plan_id)
if not decisions:
return {
"plan_id": plan_id,
"total_decisions": 0,
"max_depth": 0,
"status_counts": {"pending": 0, "completed": 0, "reverted": 0},
}
# Count decisions by status
status_counts = {"pending": 0, "completed": 0, "reverted": 0}
for decision in decisions:
status = getattr(decision, "status", "completed")
if status in status_counts:
status_counts[status] += 1
# Calculate max depth
max_depth = self._calculate_max_depth(decisions)
return {
"plan_id": plan_id,
"total_decisions": len(decisions),
"max_depth": max_depth,
"status_counts": status_counts,
}
def _calculate_max_depth(self, decisions: list[Decision]) -> int:
"""Calculate the maximum depth of the decision tree.
Args:
decisions: List of all decisions in the plan.
Returns:
The maximum depth (0 if only root, 1 if root + children, etc.).
"""
if not decisions:
return 0
# Build parent map
parent_map: dict[str | None, list[str]] = {}
for decision in decisions:
parent_id = decision.parent_decision_id
if parent_id not in parent_map:
parent_map[parent_id] = []
parent_map[parent_id].append(decision.decision_id)
# Find roots
roots = parent_map.get(None, [])
if not roots:
return 0
# BFS to find max depth
max_depth = 0
queue = [(root_id, 0) for root_id in roots]
while queue:
decision_id, current_depth = queue.pop(0)
max_depth = max(max_depth, current_depth)
# Add children to queue
children = parent_map.get(decision_id, [])
for child_id in children:
queue.append((child_id, current_depth + 1))
return max_depth
__all__ = ["DecisionTreeNode", "PlanTreeService"]
@@ -1,214 +0,0 @@
"""Renderers for the plan decision tree in various output formats.
Supports Rich (colored terminal), plain text (ASCII), and JSON output formats.
"""
from __future__ import annotations
import json
from typing import ClassVar
from cleveragents.application.services.plan_tree_service import DecisionTreeNode
class PlanTreeRenderer:
"""Base class for plan tree renderers."""
def render(self, root_node: DecisionTreeNode, plan_id: str) -> str:
"""Render the decision tree.
Args:
root_node: The root node of the decision tree.
plan_id: The plan ID for display.
Returns:
Rendered tree as a string.
"""
raise NotImplementedError
class RichPlanTreeRenderer(PlanTreeRenderer):
"""Renderer for rich terminal output with colors and formatting."""
# Status color codes
STATUS_COLORS: ClassVar[dict[str, str]] = {
"pending": "[yellow]",
"completed": "[green]",
"reverted": "[red]",
}
STATUS_RESET: ClassVar[str] = "[/]"
def render(self, root_node: DecisionTreeNode, plan_id: str) -> str:
"""Render the decision tree with rich formatting.
Args:
root_node: The root node of the decision tree.
plan_id: The plan ID for display.
Returns:
Rendered tree as a rich-formatted string.
"""
lines = [f"Plan Decision Tree: {plan_id}"]
self._render_node(root_node, lines, is_last=True, prefix="")
return "\n".join(lines)
def _render_node(
self,
node: DecisionTreeNode,
lines: list[str],
is_last: bool,
prefix: str,
) -> None:
"""Recursively render a node and its children.
Args:
node: The node to render.
lines: List to append rendered lines to.
is_last: Whether this is the last child of its parent.
prefix: The prefix to use for tree connectors.
"""
decision = node.decision
status = getattr(decision, "status", "completed")
# Format status with color
status_color = self.STATUS_COLORS.get(status, "")
status_str = f"{status_color}[{status}]{self.STATUS_RESET}"
# Format timestamp
timestamp = decision.created_at.isoformat()
# Format summary (use rationale if available, otherwise chosen option)
summary = decision.rationale or decision.chosen_option
# Build the line
connector = "└── " if is_last else "├── "
line = (
f"{prefix}{connector}{status_str} {decision.decision_id} | "
f"{decision.decision_type!s:20} | {timestamp} | {summary}"
)
lines.append(line)
# Render children
if node.children:
extension = " " if is_last else ""
for i, child in enumerate(node.children):
is_last_child = i == len(node.children) - 1
self._render_node(
child, lines, is_last_child, prefix + extension
)
class PlainPlanTreeRenderer(PlanTreeRenderer):
"""Renderer for plain text output with ASCII tree connectors."""
def render(self, root_node: DecisionTreeNode, plan_id: str) -> str:
"""Render the decision tree with ASCII formatting.
Args:
root_node: The root node of the decision tree.
plan_id: The plan ID for display.
Returns:
Rendered tree as a plain text string.
"""
lines = [f"Plan Decision Tree: {plan_id}"]
self._render_node(root_node, lines, is_last=True, prefix="")
return "\n".join(lines)
def _render_node(
self,
node: DecisionTreeNode,
lines: list[str],
is_last: bool,
prefix: str,
) -> None:
"""Recursively render a node and its children.
Args:
node: The node to render.
lines: List to append rendered lines to.
is_last: Whether this is the last child of its parent.
prefix: The prefix to use for tree connectors.
"""
decision = node.decision
status = getattr(decision, "status", "completed")
# Format timestamp
timestamp = decision.created_at.isoformat()
# Format summary
summary = decision.rationale or decision.chosen_option
# Build the line with ASCII connectors
connector = "`-- " if is_last else "|-- "
line = (
f"{prefix}{connector}[{status:10}] {decision.decision_id} | "
f"{decision.decision_type!s:20} | {timestamp} | {summary}"
)
lines.append(line)
# Render children
if node.children:
extension = " " if is_last else "| "
for i, child in enumerate(node.children):
is_last_child = i == len(node.children) - 1
self._render_node(
child, lines, is_last_child, prefix + extension
)
class JsonPlanTreeRenderer(PlanTreeRenderer):
"""Renderer for JSON output."""
def render(self, root_node: DecisionTreeNode, plan_id: str) -> str:
"""Render the decision tree as JSON.
Args:
root_node: The root node of the decision tree.
plan_id: The plan ID for display.
Returns:
Rendered tree as a JSON string.
"""
tree_dict = {
"plan_id": plan_id,
"root": root_node.to_dict(),
}
return json.dumps(tree_dict, indent=2)
def get_renderer(format_type: str) -> PlanTreeRenderer:
"""Get a renderer for the specified format.
Args:
format_type: The format type ('rich', 'plain', or 'json').
Returns:
A PlanTreeRenderer instance.
Raises:
ValueError: If format_type is not recognized.
"""
renderers = {
"rich": RichPlanTreeRenderer,
"plain": PlainPlanTreeRenderer,
"json": JsonPlanTreeRenderer,
}
if format_type not in renderers:
raise ValueError(
f"Unknown format: {format_type}. "
f"Supported formats: {', '.join(renderers.keys())}"
)
return renderers[format_type]()
__all__ = [
"JsonPlanTreeRenderer",
"PlainPlanTreeRenderer",
"PlanTreeRenderer",
"RichPlanTreeRenderer",
"get_renderer",
]