diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d07a2f..b6d265db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Updated agent output path references from `.claude/runs/` to `.closedloop-ai/runs/` in `agent-prompt-generator` - Updated bootstrap configuration documentation in `agent-bootstrap.md` to reference `.closedloop-ai/` state directory +### code v1.9.0 + +#### Added +- Multi-repo planning and exploration support via new `--add-dir` flag in `run-loop.sh`, exposing `CLOSEDLOOP_ADD_DIRS` and `CLOSEDLOOP_REPO_MAP` env vars to downstream agents +- `pre-explorer` agent produces per-repo code maps (`code-map-{name}.json`) when secondary repos are supplied +- `plan-draft-writer` agent emits multi-repo plans with a `## Repositories` table and `@{repo}:path` task prefixes +- `repositories` map field added to the plan root schema in `plan-schema.json` for multi-repo plan traceability, keyed by repo short-name with `path` and `isPrimary` metadata +- Tier 0 explicit-directory discovery and dedup helpers in `discover-repos.sh`, with structured JSON output and a `local: true` marker on `--add-dir` peers +- Tests for `discover-repos.sh` and `setup-closedloop.sh` (`test_discover_repos.py`, `test_setup_closedloop.py`) plus new multi-repo cases in `test_validate_plan.py` + +#### Fixed +- `run-loop.sh` now scans the full per-iteration stream for the `` completion marker instead of only inspecting the final `type==result` record, preventing missed completion signals when the orchestrator emits the promise in an intermediate message followed by additional tool_use or wrap-up output +- `discover-repos.sh` now filters add-dirs that are ancestors of the workdir and deduplicates repo entries to prevent duplicate discovery results + +#### Changed +- Consolidated Tier 0 `discover-repos.sh` tests into a single scenario-driven harness, replacing the prior fragmented per-case test files +- Migrated workdir internal state directory from `.closedloop/` to `.closedloop-ai/` across hooks, setup scripts, and loop state management +- Established `CLOSEDLOOP_STATE_DIR` constant as single source of truth for state directory name across shell scripts +- Added `Skill` to `plan-evaluator` agent's allowed tools to enable `code:plan-validate` skill execution + ### code v1.6.0 #### Changed @@ -45,6 +65,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Domain critic pass injection in fast-path reviewer via `{DOMAIN_CRITIC_PASS}` placeholder, enabling domain expert review within single-agent fast-path runs - Replaced shared prompt reasoning checklist with structured `PREMISE / EVIDENCE / GUARD CHECK / SEVERITY CHECK` analysis framework +### judges v1.5.1 + +#### Changed +- Migrated perf-substep state paths from `.closedloop/` to `.closedloop-ai/` in `run-judges` skill telemetry instrumentation + ### judges v1.5.0 #### Changed @@ -55,6 +80,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). #### Changed - Version bump to align with cross-plugin `.closedloop-ai/` directory migration +### self-learning v1.1.1 + +#### Changed +- Established `CLOSEDLOOP_STATE_DIR` constant as single source of truth for state directory name in `bootstrap-learnings.sh`, `compute_success_rates.py`, and `write_merged_patterns.py` + ### self-learning v1.1.0 #### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 84aadced..a330d733 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ Always use `plugin-name:skill-name` format (e.g., `self-learning:learning-qualit ### Closed Loop (run-loop.sh) -The core orchestration loop in `plugins/code/scripts/run-loop.sh`. Drives fresh-context Claude iterations — each `claude -p` invocation gets a clean context window. The orchestrator prompt at `plugins/code/prompts/prompt.md` coordinates 8 workflow phases via subagent delegation. Post-iteration, `run-loop.sh` runs an 11-step pipeline calling Python scripts from `self-learning/tools/python/`. +The core orchestration loop in `plugins/code/scripts/run-loop.sh`. Drives fresh-context Claude iterations — each `claude -p` invocation gets a clean context window. The orchestrator prompt at `plugins/code/prompts/prompt.md` coordinates 8 workflow phases via subagent delegation. Post-iteration, `run-loop.sh` runs an 11-step pipeline calling Python scripts from `self-learning/tools/python/`. Multi-repo behavior lives in the agents themselves (`pre-explorer`, `plan-draft-writer`, `cross-repo-coordinator`, `cross-repo-prd-writer`), which read `CLOSEDLOOP_REPO_MAP`, `CLOSEDLOOP_ADD_DIRS`, and the `local` flag on peers — no orchestrator-level branching is required. ### Hooks diff --git a/plugins/code/.claude-plugin/plugin.json b/plugins/code/.claude-plugin/plugin.json index a202807f..a59a3b6f 100644 --- a/plugins/code/.claude-plugin/plugin.json +++ b/plugins/code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "code", "description": "Code and planning framework plugin", - "version": "1.8.1", + "version": "1.9.0", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" diff --git a/plugins/code/README.md b/plugins/code/README.md index cbdadcac..1077f487 100644 --- a/plugins/code/README.md +++ b/plugins/code/README.md @@ -63,7 +63,7 @@ State is maintained in `$CLOSEDLOOP_WORKDIR/state.json` at each phase transition ``` - `working-directory`: Path to the work directory containing the PRD (defaults to current directory) -- `--prompt `: Select an alternate orchestrator prompt from `prompts/` (defaults to `prompt`) +- `--prompt `: Select an alternate orchestrator prompt (`prompts/.md`). Defaults to `prompt`. - `--prd `: Explicitly specify the requirements file (auto-detected if omitted) **What it does:** @@ -276,7 +276,7 @@ Cleans up session-level artifacts: removes the session workdir mapping file, cle Runs when any subagent starts. Performs three tasks: -1. **Loop agent state creation**: If the agent type appears in `loop-agents.json`, creates the initial state file in `{WORKDIR}/.closedloop/` if it does not already exist. +1. **Loop agent state creation**: If the agent type appears in `loop-agents.json`, creates the initial state file in `{WORKDIR}/.closedloop-ai/` if it does not already exist. 2. **Agent type tracking**: Writes the agent type, short name, and start timestamp to `.agent-types/{agent_id}` so the stop hook can track timing and type. 3. **Learning injection**: Reads `~/.closedloop-ai/learnings/org-patterns.toon`, filters patterns matching the agent's name, sorts by category priority (mistake > convention > pattern > insight) and confidence, and injects up to 15 patterns into the agent's context via `additionalContext`. Also injects environment variables (`CLOSEDLOOP_WORKDIR`, `CLAUDE_PLUGIN_ROOT`, etc.) into every agent's context. @@ -293,7 +293,7 @@ Runs when any subagent exits. Performs: Implements the validation loop for agents registered in `loop-agents.json`. When an agent exits: -1. Reads the loop state file (`{WORKDIR}/.closedloop/{state_file_suffix}`) +1. Reads the loop state file (`{WORKDIR}/.closedloop-ai/{state_file_suffix}`) 2. Checks whether the agent output contains the expected completion promise (e.g., `PLAN_VALIDATED`) 3. If the promise is present, optionally runs a validation script (e.g., `validate-plan.sh`) 4. If validation passes, allows the agent to exit (returns nothing) @@ -346,7 +346,7 @@ Defines the structure of `code-map.json` produced by the `pre-explorer` agent. R ### `setup-closedloop.sh` -Initializes a ClosedLoop session. Parses arguments (`--prd`, `--max-iterations`, `--prompt`, positional workdir), auto-detects the PRD file by checking common patterns (`prd.md`, `prd.pdf`, `requirements.md`, etc.), establishes the session-to-workdir mapping, validates the prompt name, and writes `{WORKDIR}/.closedloop/config.env` with all environment variables. +Initializes a ClosedLoop session. Parses arguments (`--prd`, `--max-iterations`, `--prompt`, positional workdir), auto-detects the PRD file by checking common patterns (`prd.md`, `prd.pdf`, `requirements.md`, etc.), establishes the session-to-workdir mapping, validates the prompt name, and writes `{WORKDIR}/.closedloop-ai/config.env` with all environment variables. ### `run-loop.sh` @@ -485,7 +485,7 @@ After a full run, the work directory will contain: log.md # Change log appended each phase perf.jsonl # Agent timing events reviews/ # Critic review files (*.review.json) - .closedloop/config.env # Session environment variables + .closedloop-ai/config.env # Session environment variables .learnings/ # Self-learning artifacts pending/ # Unprocessed learning JSON files outcomes.log # Pattern application outcomes diff --git a/plugins/code/agents/cross-repo-coordinator.md b/plugins/code/agents/cross-repo-coordinator.md index cd0e149e..09ec58e1 100644 --- a/plugins/code/agents/cross-repo-coordinator.md +++ b/plugins/code/agents/cross-repo-coordinator.md @@ -89,7 +89,7 @@ Extract task IDs (T-X.Y format) that depend on each capability. ### Step 4: Write Capability Needs -Write `$CLOSEDLOOP_WORKDIR/.cross-repo-needs.json`: +Write `$CLOSEDLOOP_WORKDIR/.cross-repo-needs.json`. The `local` field on each need entry comes directly from the peer's `local` field in `discover-repos.sh` output (defaulting to `false` when absent — only `--add-dir` peers carry `local: true`): ```json { @@ -104,6 +104,7 @@ Write `$CLOSEDLOOP_WORKDIR/.cross-repo-needs.json`: "peerName": "astoria-service", "peerType": "backend", "peerPath": "/path/to/backend", + "local": false, "capabilities": [ { "type": "endpoint", @@ -116,6 +117,19 @@ Write `$CLOSEDLOOP_WORKDIR/.cross-repo-needs.json`: "neededBy": ["T-3.1"] } ] + }, + { + "peerName": "astoria-shared", + "peerType": "library", + "peerPath": "/path/to/shared", + "local": true, + "capabilities": [ + { + "type": "module", + "description": "Shared auth utilities", + "neededBy": ["T-1.2"] + } + ] } ] } diff --git a/plugins/code/agents/cross-repo-prd-writer.md b/plugins/code/agents/cross-repo-prd-writer.md index 77e404b1..440f5f7e 100644 --- a/plugins/code/agents/cross-repo-prd-writer.md +++ b/plugins/code/agents/cross-repo-prd-writer.md @@ -21,15 +21,25 @@ Read `$CLOSEDLOOP_WORKDIR/.cross-repo-needs.json` for the list of needed capabil Read `$CLOSEDLOOP_WORKDIR/.discovery-cache/{peer_name}.json` for each peer to see verification results. -### Step 2: Identify Missing Capabilities +### Step 2: Filter Out Local Repos -For each capability in the needs file, check the discovery cache: +Before evaluating capabilities, inspect each peer entry in `.cross-repo-needs.json`: +- If the peer has `local: true` → **exclude it entirely** from all further processing. These are add-dir repos whose tasks are already incorporated into the plan; there is nothing to build in a remote repo. +- Only process peers where `local` is absent or `local: false`. + +Track the count of excluded peers for the output summary. + +### Step 3: Identify Missing Capabilities + +For each **non-local** peer's capabilities, check the discovery cache: - If `exists: true` → skip (already exists in peer) - If `exists: false` → include in PRD -### Step 3: Generate PRDs +### Step 4: Generate PRDs + +For each non-local peer with missing capabilities, create `$CLOSEDLOOP_WORKDIR/cross-repo-prd-{peer_name}.md`. -For each peer with missing capabilities, create `$CLOSEDLOOP_WORKDIR/cross-repo-prd-{peer_name}.md`: +**Guard:** If a capability's parent peer has `local: true`, skip PRD generation for that capability entirely — do not create or modify any PRD file for it. **IMPORTANT:** Before writing any file, you MUST first attempt to Read it. This is required by Claude Code's safety system: 1. Try to Read the target file path @@ -67,7 +77,7 @@ This document describes capabilities needed from **{peer_name}** to support impl ... ``` -### Step 4: Update plan.json +### Step 5: Update plan.json **Note:** Read `$CLOSEDLOOP_WORKDIR/plan.json` first before editing it. @@ -91,6 +101,7 @@ PRDS_GENERATED: - PRDs written: [list of files] - Missing capabilities: [count] - Existing capabilities: [count] +- Local repos skipped: [count] - plan.json updated: yes/no ``` @@ -98,4 +109,5 @@ If no missing capabilities: ``` NO_PRDS_NEEDED: - All capabilities exist in peer repos +- Local repos skipped: [count] ``` diff --git a/plugins/code/agents/plan-draft-writer.md b/plugins/code/agents/plan-draft-writer.md index 553a1f76..4d025075 100644 --- a/plugins/code/agents/plan-draft-writer.md +++ b/plugins/code/agents/plan-draft-writer.md @@ -92,6 +92,14 @@ The `content` field contains the full markdown plan following this structure: ## Summary [2-3 sentences describing what will be implemented] +## Repositories +(Only present when CLOSEDLOOP_ADD_DIRS is non-empty) + +| Repo | Path | Primary | +|------|------|---------| +| primary | /path/to/primary | Yes | +| secondary-name | /path/to/secondary | No | + ## Acceptance Criteria | ID | Criterion | Source | @@ -140,6 +148,7 @@ The `content` field contains the full markdown plan following this structure: 7. **Visual References** (if attachments exist) - Embed images using `![description](attachments/filename.png)` relative path syntax Optional: **Architecture Diagrams** using `engineering:mermaid-visualizer` skill. +Optional: **Repositories** (only when `CLOSEDLOOP_ADD_DIRS` is non-empty) - Table of all repos, placed after Summary. See `## Multi-Repository Plans`. ## JSON Field Sync @@ -179,6 +188,87 @@ Optional: **Architecture Diagrams** using `engineering:mermaid-visualizer` skill Manual tasks do NOT block the automated loop from completing. They are reported at the end for the human to perform. +## Multi-Repository Plans + +**Skip this entire section if `CLOSEDLOOP_ADD_DIRS` is empty or unset.** + +When `CLOSEDLOOP_ADD_DIRS` is non-empty, the plan spans multiple repositories. Follow these steps: + +### Step M1: Parse Repository Map + +Read the `CLOSEDLOOP_REPO_MAP` environment variable (pipe-separated `name=path` entries) to get the list of secondary repos. The primary repo is the main project codebase. Example: + +``` +CLOSEDLOOP_REPO_MAP="frontend=/workspace/ui|backend=/workspace/api" +``` + +Parse each entry as `{name}={path}`. + +### Step M2: Read Per-Repo Code Maps + +For each `name=path` entry in `CLOSEDLOOP_REPO_MAP`, read the pre-computed code map if it exists: + +```bash +cat $CLOSEDLOOP_WORKDIR/code-map-{name}.json 2>/dev/null +``` + +These files are produced by the pre-explorer agent. Each contains the relevant files and patterns for that repository. Use this information to understand what files in each secondary repo are affected by the plan. + +### Step M3: Use `@{repo-name}:path` Prefix for File References + +When writing task descriptions that reference files in secondary repos, prefix them with `@{repo-name}:`: + +- **Primary repo** (no prefix): `src/components/LoginForm.tsx` +- **Secondary repo** (with prefix): `@frontend:src/components/LoginForm.tsx` +- **Another secondary repo**: `@backend:api/routes/auth.py` + +This convention makes cross-repo task scope unambiguous. Apply it consistently in all task descriptions, acceptance criteria references, and the Repositories section. + +### Step M4: Add `## Repositories` Section to Plan Markdown + +Include a `## Repositories` section in the markdown `content` field, placed immediately after `## Summary`: + +```markdown +## Repositories + +| Repo | Path | Primary | +|------|------|---------| +| primary | /absolute/path/to/primary/repo | Yes | +| frontend | /workspace/ui | No | +| backend | /workspace/api | No | +``` + +- The primary repo name is derived from the base directory name of the project (or `primary` if ambiguous). +- Each secondary repo appears as a row with `No` in the Primary column. +- Use the absolute path as it appears in `CLOSEDLOOP_REPO_MAP`. + +### Step M5: Populate `repositories` Field in plan.json + +Add an optional `repositories` field to plan.json as an object map keyed by repo short name: + +```json +{ + "repositories": { + "primary": { + "path": "/absolute/path/to/primary/repo", + "isPrimary": true + }, + "frontend": { + "path": "/workspace/ui", + "isPrimary": false + }, + "backend": { + "path": "/workspace/api", + "isPrimary": false + } + } +} +``` + +Fields per entry: +- `path`: Absolute filesystem path to the repository root +- `isPrimary`: `true` only for the primary repo + ## Process Before writing, analyze in `` tags: @@ -305,6 +395,7 @@ PRD mentions: "Real-time updates from Linear" | No Code | Zero code snippets, function signatures, or pseudo-code in task descriptions | | Valid JSON | Output is valid JSON with all required fields | | JSON Sync | Structured fields match markdown content exactly | +| Multi-Repo (if applicable) | When `CLOSEDLOOP_ADD_DIRS` is set: `## Repositories` table present in markdown, `repositories` field in plan.json, `@{repo-name}:path` prefix used for secondary repo file references | ## Completion diff --git a/plugins/code/agents/plan-evaluator.md b/plugins/code/agents/plan-evaluator.md index 76a79f2b..7c5c50af 100644 --- a/plugins/code/agents/plan-evaluator.md +++ b/plugins/code/agents/plan-evaluator.md @@ -31,7 +31,7 @@ You evaluate whether an implementation plan qualifies for **simple mode** (skipp ### Step 2: Evaluate Simple Mode -Apply ALL six thresholds. ALL must pass for `simple_mode = true`. Default to `false` when uncertain. +Apply ALL seven thresholds. ALL must pass for `simple_mode = true`. Default to `false` when uncertain. | # | Signal | Threshold | Source | |---|--------|-----------|--------| @@ -41,12 +41,14 @@ Apply ALL six thresholds. ALL must pass for `simple_mode = true`. Default to `fa | 4 | Open questions count | <= 3 | `plan.json` → `openQuestions.length` | | 5 | Forbidden terms | 0 found | `plan.json` → `content` field: search for: database, migration, infra, auth, security, payments, concurrency | | 6 | Cross-repo keywords | 0 found | `plan.json` → `content` field: search for: backend, frontend, mobile, api contract, shared library | +| 7 | Add-dir repos | 0 | Read CLOSEDLOOP_ADD_DIRS from environment; non-empty value forces count >= 1, failing this signal | **Evaluation rules:** - Count PRD words using whitespace splitting (approximate is fine) - Forbidden term matching is case-insensitive - Cross-repo keyword matching is case-insensitive - If any threshold fails, `simple_mode = false` +- Signal 7 evaluates `CLOSEDLOOP_ADD_DIRS` from the injected environment -- if non-empty (count of pipe-delimited paths >= 1) the signal fails, forcing `simple_mode = false`; if empty or unset the signal passes. ### Step 3: Select Critics (only if simple_mode = false) @@ -74,7 +76,8 @@ Write `$CLOSEDLOOP_WORKDIR/plan-evaluation.json`: "task_count": { "value": N, "threshold": 6, "pass": true | false }, "open_questions_count": { "value": N, "threshold": 3, "pass": true | false }, "forbidden_terms": { "value": ["term1", ...], "threshold": 0, "pass": true | false }, - "cross_repo_keywords": { "value": ["keyword1", ...], "threshold": 0, "pass": true | false } + "cross_repo_keywords": { "value": ["keyword1", ...], "threshold": 0, "pass": true | false }, + "add_dir_repos": { "value": N, "threshold": 0, "pass": true | false } }, "selected_critics": ["critic-name-1", "critic-name-2"], "evaluation_summary": "Simple mode: true/false. Reason: ..." diff --git a/plugins/code/agents/pre-explorer.md b/plugins/code/agents/pre-explorer.md index b63eaf67..140d6ed7 100644 --- a/plugins/code/agents/pre-explorer.md +++ b/plugins/code/agents/pre-explorer.md @@ -29,13 +29,15 @@ Write these files to `$CLOSEDLOOP_WORKDIR/`: Check which output files already exist: ```bash ls $CLOSEDLOOP_WORKDIR/requirements-extract.json $CLOSEDLOOP_WORKDIR/code-map.json $CLOSEDLOOP_WORKDIR/investigation-log.md 2>/dev/null +ls $CLOSEDLOOP_WORKDIR/code-map-*.json 2>/dev/null ``` -- If **ALL three** files exist: output `PRE_EXPLORATION_CACHED` and stop immediately. +- If **ALL three** primary files exist AND all expected `code-map-{name}.json` files exist for every repo in `CLOSEDLOOP_REPO_MAP`: output `PRE_EXPLORATION_CACHED` and stop immediately. - If **some** files exist, skip the steps that produced them: - `requirements-extract.json` exists → skip Steps 1-3, read it for search terms - `code-map.json` exists → skip Steps 4-5, read it for file list - `investigation-log.md` exists → skip Steps 6-7, use as-is + - `code-map-{name}.json` exists for a given repo → skip re-exploration for that repo in the Multi-Repo Exploration section - If **no** files exist: proceed with all steps. ## Step 1: Read and Parse the PRD @@ -91,6 +93,98 @@ If attachments exist: } ``` +## Multi-Repo Exploration + +**Skip this entire section if `CLOSEDLOOP_ADD_DIRS` is empty or unset.** + +If `CLOSEDLOOP_ADD_DIRS` is set (non-empty), iterate over each `name=path` entry in `CLOSEDLOOP_REPO_MAP`. The variable uses pipe (`|`) as the separator between entries. For example: + +``` +CLOSEDLOOP_REPO_MAP="frontend=/workspace/ui|backend=/workspace/api" +``` + +Parse each entry as `{name}={path}`. For each repo: + +### Per-Repo Steps + +**A. Skip check**: If `$CLOSEDLOOP_WORKDIR/code-map-{name}.json` already exists, skip this repo (it was explored in a prior run). + +**B. Read repo identity files** (if they exist): +- Read `{path}/CLAUDE.md` — captures project conventions, architecture notes, and key directories +- Read `{path}/.closedloop-ai/.repo-identity.json` — captures repo metadata (tech stack, entry points, owners) +- Note what was found; these files inform which patterns to search for in the next step. + +**C. Run Glob/Grep searches rooted at `{path}`**, mirroring Steps 4-5 of the primary exploration: +1. **Entity-based searches**: For each entity from `requirements-extract.json`, run `Glob` patterns `{path}/**/*{Entity}*` +2. **Pattern searches**: Look for structural patterns inside `{path}`: + - `{path}/**/routes*`, `{path}/**/api*` — API routes + - `{path}/**/components*`, `{path}/**/screens*` — UI components + - `{path}/**/services*`, `{path}/**/hooks*` — Business logic + - `{path}/**/test*`, `{path}/**/*.test.*`, `{path}/**/*.spec.*` — Tests + - `{path}/**/config*`, `{path}/**/*.config.*` — Configuration +3. **Project structure**: Run `Glob` for `{path}/**/package.json`, `{path}/**/pyproject.toml`, `{path}/**/Cargo.toml` to understand the repo type +4. **Technology searches**: Use `Grep` for imports/usages of technologies mentioned in `requirements-extract.json`, scoped to `{path}` + +**D. Classify discovered files** using the same table from Step 5: + +| Convention | Role | +|-----------|------| +| `routes/`, `api/`, `endpoints/` | `route` | +| `screens/`, `pages/`, `views/` | `screen` | +| `components/` | `component` | +| `hooks/`, `use*.ts` | `hook` | +| `services/`, `clients/` | `service` | +| `utils/`, `helpers/`, `lib/` | `util` | +| `test`, `.test.`, `.spec.` | `test` | +| `config`, `.config.`, `settings` | `config` | + +Assign confidence scores using the same 0-1 scale as Step 5. + +**E. Write `$CLOSEDLOOP_WORKDIR/code-map-{name}.json`** using the same schema as `code-map.json`: + +```json +{ + "feature": "Feature name from PRD", + "scope": "Cross-repo context for {name} ({path})", + "platforms": [], + "modules": ["relative/module/path"], + "files": [ + { + "path": "{path}/src/components/Example.tsx", + "role": "component", + "confidence": 0.8, + "neighbors": [] + } + ], + "parity_risk": false, + "parity_reasons": [] +} +``` + +Note: file paths in `code-map-{name}.json` use the absolute path rooted at `{path}` so the plan-draft-writer can locate them unambiguously. + +**F. Append a `## Cross-Repo Context` subsection to `$CLOSEDLOOP_WORKDIR/investigation-log.md`**: + +```markdown +## Cross-Repo Context: {name} ({path}) + +### Repo Identity +[Summary of CLAUDE.md and .repo-identity.json findings, or "No identity files found"] + +### Files Discovered +[Relevant files found in this repo with roles and confidence scores] + +### Key Patterns +[Architecture patterns, conventions, integration points observed in this repo] + +### Relevance to PRD +[How this repo relates to the current task/feature being planned] +``` + +If `investigation-log.md` does not yet exist, create it with the standard structure from Step 7, then append the `## Cross-Repo Context` subsection. If the file already exists, append the subsection at the end. + +Process all repos in `CLOSEDLOOP_REPO_MAP` before moving on to Step 4. + ## Step 4: Run Targeted Codebase Searches Using the search terms from Step 1 (or from existing `requirements-extract.json`): diff --git a/plugins/code/hooks/loop-stop-hook.sh b/plugins/code/hooks/loop-stop-hook.sh index 996591eb..723fd723 100755 --- a/plugins/code/hooks/loop-stop-hook.sh +++ b/plugins/code/hooks/loop-stop-hook.sh @@ -6,6 +6,9 @@ set -euo pipefail +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + # Debug logging (redirected to WORKDIR once discovered) DEBUG_LOG="/dev/null" @@ -39,7 +42,7 @@ SESSION_ID=$(echo "$HOOK_INPUT" | jq -r '.session_id // empty') # Discover WORKDIR via session_id mapping (created by setup-closedloop.sh) CLOSEDLOOP_WORKDIR="" if [[ -n "$SESSION_ID" ]]; then - WORKDIR_FILE="$CWD/.closedloop-ai/session-$SESSION_ID.workdir" + WORKDIR_FILE="$CWD/$CLOSEDLOOP_STATE_DIR/session-$SESSION_ID.workdir" if [[ -f "$WORKDIR_FILE" ]]; then CLOSEDLOOP_WORKDIR=$(cat "$WORKDIR_FILE") fi @@ -47,7 +50,7 @@ fi # Source closedloop config from WORKDIR if found if [[ -n "$CLOSEDLOOP_WORKDIR" ]]; then - CLOSEDLOOP_CONFIG="$CLOSEDLOOP_WORKDIR/.closedloop-ai/config.env" + CLOSEDLOOP_CONFIG="$CLOSEDLOOP_WORKDIR/$CLOSEDLOOP_STATE_DIR/config.env" if [[ -f "$CLOSEDLOOP_CONFIG" ]]; then source "$CLOSEDLOOP_CONFIG" fi @@ -90,14 +93,14 @@ STATE_FILE_SUFFIX=$(echo "$AGENT_CONFIG" | jq -r '.state_file_suffix // "loop.lo echo "$(date): Agent config - validation=$VALIDATION_SCRIPT, max_iter=$MAX_ITERATIONS_DEFAULT, promise=$PROMISE, state_suffix=$STATE_FILE_SUFFIX" >> "$DEBUG_LOG" -# Build state file path (in CLOSEDLOOP_WORKDIR/.closedloop-ai/) +# Build state file path (in CLOSEDLOOP_WORKDIR/$CLOSEDLOOP_STATE_DIR/) # Exit early if CLOSEDLOOP_WORKDIR is not set - no loop context if [[ -z "$CLOSEDLOOP_WORKDIR" ]]; then echo "$(date): No CLOSEDLOOP_WORKDIR, exiting loop-stop-hook" >> "$DEBUG_LOG" exit 0 fi -STATE_FILE="$CLOSEDLOOP_WORKDIR/.closedloop-ai/$STATE_FILE_SUFFIX" +STATE_FILE="$CLOSEDLOOP_WORKDIR/$CLOSEDLOOP_STATE_DIR/$STATE_FILE_SUFFIX" if [[ ! -f "$STATE_FILE" ]]; then echo "$(date): No active loop - state file not found: $STATE_FILE" >> "$DEBUG_LOG" diff --git a/plugins/code/hooks/plan-review.sh b/plugins/code/hooks/plan-review.sh index c98c1118..97e2e7b3 100755 --- a/plugins/code/hooks/plan-review.sh +++ b/plugins/code/hooks/plan-review.sh @@ -3,13 +3,16 @@ # Triggers when Claude exits plan mode to get a second opinion on the plan # The plan content is available directly from tool_response.plan +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + # Read hook input from stdin (must happen before CWD extraction) INPUT=$(cat) -# Debug logging — keeps at most 15 log files in .closedloop-ai/plan-review-logs/ +# Debug logging — keeps at most 15 log files in $CLOSEDLOOP_STATE_DIR/plan-review-logs/ CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null) CWD="${CWD:-$PWD}" -LOG_DIR="$CWD/.closedloop-ai/plan-review-logs" +LOG_DIR="$CWD/$CLOSEDLOOP_STATE_DIR/plan-review-logs" mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/$(date +%Y%m%d-%H%M%S).log" diff --git a/plugins/code/hooks/pretooluse-hook.sh b/plugins/code/hooks/pretooluse-hook.sh index 75f66311..3bde2658 100755 --- a/plugins/code/hooks/pretooluse-hook.sh +++ b/plugins/code/hooks/pretooluse-hook.sh @@ -8,6 +8,9 @@ set -e +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + # Debug logging (redirected to WORKDIR once discovered) DEBUG_LOG="/dev/null" @@ -122,7 +125,7 @@ esac # Discover WORKDIR via session_id mapping (same pattern as subagent-start-hook.sh) CLOSEDLOOP_WORKDIR="" if [[ -n "$SESSION_ID" ]]; then - WORKDIR_FILE="$CWD/.closedloop-ai/session-$SESSION_ID.workdir" + WORKDIR_FILE="$CWD/$CLOSEDLOOP_STATE_DIR/session-$SESSION_ID.workdir" if [[ -f "$WORKDIR_FILE" ]]; then CLOSEDLOOP_WORKDIR=$(cat "$WORKDIR_FILE") fi @@ -139,7 +142,7 @@ DEBUG_LOG="$CLOSEDLOOP_WORKDIR/.learnings/pretooluse-hook-debug.log" echo "$(date): PreToolUse hook started, tool=$TOOL_NAME" >> "$DEBUG_LOG" # Source closedloop config and skip learning injection if disabled -CLOSEDLOOP_CONFIG="$CLOSEDLOOP_WORKDIR/.closedloop-ai/config.env" +CLOSEDLOOP_CONFIG="$CLOSEDLOOP_WORKDIR/$CLOSEDLOOP_STATE_DIR/config.env" if [[ -f "$CLOSEDLOOP_CONFIG" ]]; then source "$CLOSEDLOOP_CONFIG" fi @@ -148,7 +151,7 @@ if [[ "${CLOSEDLOOP_SELF_LEARNING:-false}" != "true" ]]; then fi # Path to org-patterns.toon -PATTERNS_FILE="$HOME/.closedloop-ai/learnings/org-patterns.toon" +PATTERNS_FILE="$HOME/$CLOSEDLOOP_STATE_DIR/learnings/org-patterns.toon" if [[ ! -f "$PATTERNS_FILE" ]]; then echo "$(date): No patterns file found, exiting" >> "$DEBUG_LOG" diff --git a/plugins/code/hooks/session-end-hook.sh b/plugins/code/hooks/session-end-hook.sh index 81e9a1f3..b4cad5f8 100755 --- a/plugins/code/hooks/session-end-hook.sh +++ b/plugins/code/hooks/session-end-hook.sh @@ -4,6 +4,9 @@ set -e +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + # Debug logging (redirected once CWD is known) DEBUG_LOG="/dev/null" @@ -17,8 +20,8 @@ REASON=$(echo "$INPUT" | jq -r '.reason // empty') # Redirect debug logs into project dir (not shared /tmp) if [[ -n "$CWD" ]]; then - mkdir -p "$CWD/.closedloop-ai" - DEBUG_LOG="$CWD/.closedloop-ai/session-end-hook-debug.log" + mkdir -p "$CWD/$CLOSEDLOOP_STATE_DIR" + DEBUG_LOG="$CWD/$CLOSEDLOOP_STATE_DIR/session-end-hook-debug.log" fi echo "$(date): Session end hook started, session=$SESSION_ID, reason=$REASON" >> "$DEBUG_LOG" @@ -27,7 +30,7 @@ echo "$(date): Session end hook started, session=$SESSION_ID, reason=$REASON" >> # Remove session-specific files from .closedloop-ai/ # ============================================================================ -CLOSEDLOOP_DIR="$CWD/.closedloop-ai" +CLOSEDLOOP_DIR="$CWD/$CLOSEDLOOP_STATE_DIR" # Discover CLOSEDLOOP_WORKDIR before cleaning up mappings CLOSEDLOOP_WORKDIR="" diff --git a/plugins/code/hooks/session-start-hook.sh b/plugins/code/hooks/session-start-hook.sh index 235487aa..8ff50900 100755 --- a/plugins/code/hooks/session-start-hook.sh +++ b/plugins/code/hooks/session-start-hook.sh @@ -5,6 +5,9 @@ set -e +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + # Debug logging (redirected once CWD is known) DEBUG_LOG="/dev/null" @@ -19,16 +22,16 @@ if [[ -z "$SESSION_ID" ]] || [[ -z "$CWD" ]]; then exit 0 fi -# Create .closedloop-ai directory at project root -mkdir -p "$CWD/.closedloop-ai" +# Create state directory at project root +mkdir -p "$CWD/$CLOSEDLOOP_STATE_DIR" # Redirect debug logs into project dir (not shared /tmp) -DEBUG_LOG="$CWD/.closedloop-ai/session-start-hook-debug.log" +DEBUG_LOG="$CWD/$CLOSEDLOOP_STATE_DIR/session-start-hook-debug.log" echo "$(date): SessionStart hook started, PPID=$PPID" >> "$DEBUG_LOG" # Write PID -> session_id mapping using Claude Code's PID (our PPID) # ! commands will walk up their process tree to find this -echo "$SESSION_ID" > "$CWD/.closedloop-ai/pid-$PPID.session" +echo "$SESSION_ID" > "$CWD/$CLOSEDLOOP_STATE_DIR/pid-$PPID.session" echo "$(date): Wrote session mapping: pid-$PPID.session -> $SESSION_ID" >> "$DEBUG_LOG" exit 0 diff --git a/plugins/code/hooks/subagent-start-hook.sh b/plugins/code/hooks/subagent-start-hook.sh index 23c90423..611167c7 100755 --- a/plugins/code/hooks/subagent-start-hook.sh +++ b/plugins/code/hooks/subagent-start-hook.sh @@ -4,6 +4,9 @@ set -e +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + # Debug logging (redirected to WORKDIR once discovered) DEBUG_LOG="/dev/null" @@ -17,14 +20,14 @@ CWD=$(echo "$INPUT" | jq -r '.cwd // empty') SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty') # Early debug log (before WORKDIR discovery) to catch all SubagentStart events -EARLY_DEBUG_LOG="${CWD:-.}/.closedloop-ai/subagent-start-hook-debug.log" +EARLY_DEBUG_LOG="${CWD:-.}/$CLOSEDLOOP_STATE_DIR/subagent-start-hook-debug.log" mkdir -p "$(dirname "$EARLY_DEBUG_LOG")" 2>/dev/null echo "$(date): SubagentStart hook fired — agent_type=$AGENT_TYPE agent_id=$AGENT_ID session_id=$SESSION_ID" >> "$EARLY_DEBUG_LOG" # Discover WORKDIR via session_id mapping (created by setup-closedloop.sh) CLOSEDLOOP_WORKDIR="" if [[ -n "$SESSION_ID" ]]; then - WORKDIR_FILE="$CWD/.closedloop-ai/session-$SESSION_ID.workdir" + WORKDIR_FILE="$CWD/$CLOSEDLOOP_STATE_DIR/session-$SESSION_ID.workdir" if [[ -f "$WORKDIR_FILE" ]]; then CLOSEDLOOP_WORKDIR=$(cat "$WORKDIR_FILE") echo "$(date): Found WORKDIR=$CLOSEDLOOP_WORKDIR from session mapping" >> "$DEBUG_LOG" @@ -35,7 +38,7 @@ fi # Source closedloop config from WORKDIR if found if [[ -n "$CLOSEDLOOP_WORKDIR" ]]; then - CLOSEDLOOP_CONFIG="$CLOSEDLOOP_WORKDIR/.closedloop-ai/config.env" + CLOSEDLOOP_CONFIG="$CLOSEDLOOP_WORKDIR/$CLOSEDLOOP_STATE_DIR/config.env" if [[ -f "$CLOSEDLOOP_CONFIG" ]]; then source "$CLOSEDLOOP_CONFIG" fi @@ -72,13 +75,13 @@ if [[ -f "$LOOP_CONFIG" ]] && [[ -n "$AGENT_TYPE" ]]; then MAX_ITERATIONS="${CLOSEDLOOP_MAX_ITERATIONS:-$CONFIG_MAX_ITERATIONS}" PRD_FILE="${CLOSEDLOOP_PRD_FILE:-}" WORKDIR="${CLOSEDLOOP_WORKDIR:-$CWD}" - STATE_FILE="$WORKDIR/.closedloop-ai/$STATE_FILE_SUFFIX" + STATE_FILE="$WORKDIR/$CLOSEDLOOP_STATE_DIR/$STATE_FILE_SUFFIX" echo "$(date): Loop agent detected: $AGENT_TYPE, state_file=$STATE_FILE" >> "$DEBUG_LOG" # Only create if state file doesn't exist (idempotent) if [[ ! -f "$STATE_FILE" ]] && [[ -n "$WORKDIR" ]]; then - mkdir -p "$WORKDIR/.closedloop-ai" + mkdir -p "$WORKDIR/$CLOSEDLOOP_STATE_DIR" PROMPT="Create a comprehensive implementation plan for the requirements in @${PRD_FILE}. @@ -124,8 +127,8 @@ if [[ -z "$CLOSEDLOOP_WORKDIR" ]]; then fi # Write base environment (same for all agents, only write once) -mkdir -p "$CWD/.closedloop-ai" -BASE_ENV_FILE="$CWD/.closedloop-ai/env" +mkdir -p "$CWD/$CLOSEDLOOP_STATE_DIR" +BASE_ENV_FILE="$CWD/$CLOSEDLOOP_STATE_DIR/env" if [[ ! -f "$BASE_ENV_FILE" ]]; then cat > "$BASE_ENV_FILE" << EOF CLOSEDLOOP_WORKDIR=$CLOSEDLOOP_WORKDIR @@ -150,12 +153,18 @@ CLOSEDLOOP_PRD_FILE=${CLOSEDLOOP_PRD_FILE:-} CLOSEDLOOP_PLAN_FILE=${CLOSEDLOOP_PLAN_FILE:-} CLOSEDLOOP_MAX_ITERATIONS=${CLOSEDLOOP_MAX_ITERATIONS:-10} CLAUDE_PLUGIN_ROOT=$PLUGIN_ROOT +CLOSEDLOOP_ADD_DIRS=${CLOSEDLOOP_ADD_DIRS:-} +CLOSEDLOOP_ADD_DIR_NAMES=${CLOSEDLOOP_ADD_DIR_NAMES:-} +CLOSEDLOOP_REPO_MAP=${CLOSEDLOOP_REPO_MAP:-} IMPORTANT: When your instructions reference \${VARIABLE_NAME} (e.g., \${CLAUDE_PLUGIN_ROOT}), substitute it with the corresponding value from this block. For example, \${CLAUDE_PLUGIN_ROOT}/schemas/plan-schema.json means: $PLUGIN_ROOT/schemas/plan-schema.json When running bash commands that need these variables, first export them: export CLOSEDLOOP_WORKDIR=\"$CLOSEDLOOP_WORKDIR\" export CLAUDE_PLUGIN_ROOT=\"$PLUGIN_ROOT\" +export CLOSEDLOOP_ADD_DIRS=\"${CLOSEDLOOP_ADD_DIRS:-}\" +export CLOSEDLOOP_ADD_DIR_NAMES=\"${CLOSEDLOOP_ADD_DIR_NAMES:-}\" +export CLOSEDLOOP_REPO_MAP=\"${CLOSEDLOOP_REPO_MAP:-}\" " SUFFIX_PARTS="$ENV_INFO" @@ -173,7 +182,7 @@ fi AGENT_NAME="$AGENT_NAME_ONLY" # Path to org-patterns.toon -PATTERNS_FILE="$HOME/.closedloop-ai/learnings/org-patterns.toon" +PATTERNS_FILE="$HOME/$CLOSEDLOOP_STATE_DIR/learnings/org-patterns.toon" # Only process learnings if we have agent name and patterns file if [[ -z "$AGENT_NAME" ]] || [[ ! -f "$PATTERNS_FILE" ]]; then @@ -421,7 +430,7 @@ if [[ -n "$LEARNINGS" ]]; then $LEARNINGS" # Write learnings to agent-specific file - LEARNINGS_FILE="$CWD/.closedloop-ai/learnings-$AGENT_NAME_LOWER" + LEARNINGS_FILE="$CWD/$CLOSEDLOOP_STATE_DIR/learnings-$AGENT_NAME_LOWER" echo "$LEARNINGS" > "$LEARNINGS_FILE" echo "$(date): Wrote learnings to $LEARNINGS_FILE" >> "$DEBUG_LOG" fi diff --git a/plugins/code/hooks/subagent-stop-hook.sh b/plugins/code/hooks/subagent-stop-hook.sh index 073735d5..ac2458b4 100755 --- a/plugins/code/hooks/subagent-stop-hook.sh +++ b/plugins/code/hooks/subagent-stop-hook.sh @@ -5,6 +5,9 @@ set -e +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + # Debug logging (redirected to WORKDIR once discovered) DEBUG_LOG="/dev/null" @@ -25,7 +28,7 @@ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty') # Discover WORKDIR via session_id mapping (created by setup-closedloop.sh) CLOSEDLOOP_WORKDIR="" if [[ -n "$SESSION_ID" ]]; then - WORKDIR_FILE="$CWD/.closedloop-ai/session-$SESSION_ID.workdir" + WORKDIR_FILE="$CWD/$CLOSEDLOOP_STATE_DIR/session-$SESSION_ID.workdir" if [[ -f "$WORKDIR_FILE" ]]; then CLOSEDLOOP_WORKDIR=$(cat "$WORKDIR_FILE") echo "$(date): Found WORKDIR=$CLOSEDLOOP_WORKDIR from session mapping" >> "$DEBUG_LOG" @@ -36,7 +39,7 @@ fi # Source closedloop config from WORKDIR if found if [[ -n "$CLOSEDLOOP_WORKDIR" ]]; then - CLOSEDLOOP_CONFIG="$CLOSEDLOOP_WORKDIR/.closedloop-ai/config.env" + CLOSEDLOOP_CONFIG="$CLOSEDLOOP_WORKDIR/$CLOSEDLOOP_STATE_DIR/config.env" if [[ -f "$CLOSEDLOOP_CONFIG" ]]; then source "$CLOSEDLOOP_CONFIG" fi @@ -179,7 +182,7 @@ if [[ "${CLOSEDLOOP_SELF_LEARNING:-false}" == "true" ]]; then # This ensures compute_success_rates.py always has data to work with. # ============================================================================ AGENT_NAME_LOWER=$(echo "$AGENT_NAME" | tr '[:upper:]' '[:lower:]') - LEARNINGS_FILE="$CWD/.closedloop-ai/learnings-$AGENT_NAME_LOWER" + LEARNINGS_FILE="$CWD/$CLOSEDLOOP_STATE_DIR/learnings-$AGENT_NAME_LOWER" if [[ -f "$LEARNINGS_FILE" ]]; then echo "$(date): Reading injected patterns from $LEARNINGS_FILE" >> "$DEBUG_LOG" diff --git a/plugins/code/schemas/plan-schema.json b/plugins/code/schemas/plan-schema.json index 00a4c369..fc80932d 100644 --- a/plugins/code/schemas/plan-schema.json +++ b/plugins/code/schemas/plan-schema.json @@ -132,6 +132,24 @@ } } }, + "repositories": { + "type": "object", + "description": "Map of repositories involved in this plan (only present for multi-repo plans). Keys are repo short names; values describe each repo.", + "additionalProperties": { + "type": "object", + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": "Absolute filesystem path to the repository root" + }, + "isPrimary": { + "type": "boolean", + "description": "True only for the primary repository" + } + } + } + }, "amendments": { "type": "array", "description": "History of plan amendments applied via amend-plan command", diff --git a/plugins/code/scripts/discover-repos.sh b/plugins/code/scripts/discover-repos.sh index 90b928c5..b0a5e108 100755 --- a/plugins/code/scripts/discover-repos.sh +++ b/plugins/code/scripts/discover-repos.sh @@ -4,11 +4,15 @@ # Output: JSON to stdout set -e + +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + PROJECT_ROOT="${1:-$PWD}" PROJECT_ROOT=$(cd "$PROJECT_ROOT" && pwd) # Read current repo's identity -CURRENT_IDENTITY="$PROJECT_ROOT/.closedloop-ai/.repo-identity.json" +CURRENT_IDENTITY="$PROJECT_ROOT/$CLOSEDLOOP_STATE_DIR/.repo-identity.json" if [[ -f "$CURRENT_IDENTITY" ]]; then CURRENT_NAME=$(jq -r '.name // "unknown"' "$CURRENT_IDENTITY") CURRENT_TYPE=$(jq -r '.type // "unknown"' "$CURRENT_IDENTITY") @@ -17,20 +21,62 @@ else CURRENT_TYPE="unknown" fi -# Start JSON output -echo "{" -echo " \"currentRepo\": {" -echo " \"name\": \"$CURRENT_NAME\"," -echo " \"type\": \"$CURRENT_TYPE\"," -echo " \"path\": \"$PROJECT_ROOT\"" -echo " }," +# Accumulator for all peer JSON objects (dedup by path) +# SEEN_PATHS is a newline-delimited list of resolved paths already added +SEEN_PATHS="" +PEER_JSONS=() + +# Helper: check if a path is already seen +_path_seen() { + local p="$1" + echo "$SEEN_PATHS" | grep -qxF "$p" +} + +# Helper: mark a path as seen +_mark_seen() { + SEEN_PATHS="${SEEN_PATHS} +$1" +} + +# Tier 0: Explicitly added directories via CLOSEDLOOP_ADD_DIRS (pipe-separated) +if [[ -n "${CLOSEDLOOP_ADD_DIRS:-}" ]]; then + IFS='|' read -ra ADD_PATHS <<< "$CLOSEDLOOP_ADD_DIRS" + for path in "${ADD_PATHS[@]}"; do + # Expand ~ and resolve path + path="${path/#\~/$HOME}" + [[ "$path" != /* ]] && path="$PROJECT_ROOT/$path" + path=$(cd "$path" 2>/dev/null && pwd) || continue -# Tier 1: Environment variable -if [[ -n "$CLAUDE_WORKSPACE_REPOS" ]]; then - echo " \"discoveryMethod\": \"env_var\"," - echo " \"peers\": [" + # Skip current repo + [[ "$path" == "$PROJECT_ROOT" ]] && continue + + # Skip already seen paths + _path_seen "$path" && continue + _mark_seen "$path" + + # Read identity if exists, fall back to basename + identity_file="$path/$CLOSEDLOOP_STATE_DIR/.repo-identity.json" + repo_name="" + repo_type="unknown" + if [[ -f "$identity_file" ]]; then + repo_name=$(jq -r '.name // empty' "$identity_file") + repo_type=$(jq -r '.type // "unknown"' "$identity_file") + fi + repo_name="${repo_name:-$(basename "$path")}" + + PEER_JSONS+=("{\"name\": \"$repo_name\", \"type\": \"$repo_type\", \"path\": \"$path\", \"local\": true}") + done +fi - first=true +# Determine root-level discovery method. Tier 1 (env_var) wins if present; +# otherwise Tier 0 (add_dir) wins if it contributed peers; sibling_scan is +# the default fallback when nothing else produced results. +DISCOVERY_METHOD="sibling_scan" +if [[ ${#PEER_JSONS[@]} -gt 0 ]]; then + DISCOVERY_METHOD="add_dir" +fi +if [[ -n "$CLAUDE_WORKSPACE_REPOS" ]]; then + DISCOVERY_METHOD="env_var" IFS=',' read -ra REPOS <<< "$CLAUDE_WORKSPACE_REPOS" for repo in "${REPOS[@]}"; do name="${repo%%:*}" @@ -44,51 +90,65 @@ if [[ -n "$CLAUDE_WORKSPACE_REPOS" ]]; then # Skip current repo [[ "$path" == "$PROJECT_ROOT" ]] && continue + # Skip already seen paths (dedup with Tier 0) + _path_seen "$path" && continue + _mark_seen "$path" + # Read identity if exists - identity_file="$path/.closedloop-ai/.repo-identity.json" + identity_file="$path/$CLOSEDLOOP_STATE_DIR/.repo-identity.json" + type="unknown" + repo_name="" if [[ -f "$identity_file" ]]; then type=$(jq -r '.type // "unknown"' "$identity_file") - repo_name=$(jq -r '.name // "'"$name"'"' "$identity_file") - else - type="unknown" - repo_name="$name" + repo_name=$(jq -r '.name // empty' "$identity_file") fi + repo_name="${repo_name:-$name}" - $first || echo "," - first=false - echo " {\"name\": \"$repo_name\", \"type\": \"$type\", \"path\": \"$path\"}" + PEER_JSONS+=("{\"name\": \"$repo_name\", \"type\": \"$type\", \"path\": \"$path\"}") done - - echo " ]," - echo " \"monorepo\": false" - echo "}" - exit 0 fi -# Tier 2: Sibling directory scan -echo " \"discoveryMethod\": \"sibling_scan\"," -echo " \"peers\": [" +# Tier 2: Sibling directory scan (only if no Tier 1 env var) +if [[ -z "$CLAUDE_WORKSPACE_REPOS" ]]; then + PARENT_DIR=$(dirname "$PROJECT_ROOT") -PARENT_DIR=$(dirname "$PROJECT_ROOT") -first=true + for sibling in "$PARENT_DIR"/*/; do + sibling="${sibling%/}" + [[ "$sibling" == "$PROJECT_ROOT" ]] && continue + [[ ! -d "$sibling" ]] && continue + + identity_file="$sibling/$CLOSEDLOOP_STATE_DIR/.repo-identity.json" + if [[ -f "$identity_file" ]]; then + name=$(jq -r '.name // "unknown"' "$identity_file") + type=$(jq -r '.type // "unknown"' "$identity_file") + discoverable=$(jq -r '.discoverable // true' "$identity_file") + + [[ "$discoverable" == "false" ]] && continue -for sibling in "$PARENT_DIR"/*/; do - sibling="${sibling%/}" - [[ "$sibling" == "$PROJECT_ROOT" ]] && continue - [[ ! -d "$sibling" ]] && continue + # Skip already seen paths (dedup with Tier 0) + _path_seen "$sibling" && continue + _mark_seen "$sibling" - identity_file="$sibling/.closedloop-ai/.repo-identity.json" - if [[ -f "$identity_file" ]]; then - name=$(jq -r '.name // "unknown"' "$identity_file") - type=$(jq -r '.type // "unknown"' "$identity_file") - discoverable=$(jq -r '.discoverable // true' "$identity_file") + PEER_JSONS+=("{\"name\": \"$name\", \"type\": \"$type\", \"path\": \"$sibling\"}") + fi + done +fi - [[ "$discoverable" == "false" ]] && continue +# Emit merged JSON output +echo "{" +echo " \"currentRepo\": {" +echo " \"name\": \"$CURRENT_NAME\"," +echo " \"type\": \"$CURRENT_TYPE\"," +echo " \"path\": \"$PROJECT_ROOT\"" +echo " }," +echo " \"discoveryMethod\": \"$DISCOVERY_METHOD\"," +echo " \"peers\": [" - $first || echo "," - first=false - echo " {\"name\": \"$name\", \"type\": \"$type\", \"path\": \"$sibling\"}" - fi +first=true +for peer in "${PEER_JSONS[@]}"; do + $first || echo "," + first=false + echo " $peer" done echo " ]," diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index a30b2dd6..ed2ccd95 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -7,6 +7,9 @@ set -euo pipefail +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' @@ -15,8 +18,8 @@ BLUE='\033[0;34m' NC='\033[0m' # No Color # State file location -STATE_FILE=".closedloop-ai/closedloop-loop.local.md" -PROGRESS_LOG=".closedloop-ai/closedloop-progress.log" +STATE_FILE="$CLOSEDLOOP_STATE_DIR/closedloop-loop.local.md" +PROGRESS_LOG="$CLOSEDLOOP_STATE_DIR/closedloop-progress.log" # Learning system paths LOCK_FILE=".learnings/.lock" @@ -122,9 +125,9 @@ bootstrap_learnings() { local org_learnings_dir="" local workdir_state_dir="$(dirname "$workdir")" # Check project root first, then the workdir-adjacent .closedloop-ai state directory. - if [[ -d ".closedloop-ai/learnings" ]]; then - org_learnings_dir=".closedloop-ai/learnings" - elif [[ "$(basename "$workdir_state_dir")" == ".closedloop-ai" ]] && [[ -d "$workdir_state_dir/learnings" ]]; then + if [[ -d "$CLOSEDLOOP_STATE_DIR/learnings" ]]; then + org_learnings_dir="$CLOSEDLOOP_STATE_DIR/learnings" + elif [[ "$(basename "$workdir_state_dir")" == "$CLOSEDLOOP_STATE_DIR" ]] && [[ -d "$workdir_state_dir/learnings" ]]; then org_learnings_dir="$workdir_state_dir/learnings" fi @@ -468,6 +471,7 @@ run_post_loop_review() { local formatter="$SCRIPTS_DIR/../tools/python/stream_formatter.py" local max_cycles="${POST_LOOP_REVIEW_CYCLES:-2}" local cycle=1 + local consecutive_failures=0 while [[ "$cycle" -le "$max_cycles" ]]; do echo -e "\n${BLUE}Review cycle $cycle of $max_cycles${NC}" @@ -592,6 +596,18 @@ run_post_loop_review() { rm -f "$fix_output" "$fix_stderr" + if [[ "$fix_exit" -ne 0 ]]; then + echo -e "${YELLOW}Warning: Fix subprocess failed (exit $fix_exit). Retrying on next cycle.${NC}" + consecutive_failures=$((consecutive_failures + 1)) + if [[ "$consecutive_failures" -ge 2 ]]; then + echo -e "${RED}Fix failed $consecutive_failures consecutive times. Skipping remaining cycles.${NC}" + log_progress "Post-loop fix failed $consecutive_failures consecutive times. Aborting." + return 0 + fi + else + consecutive_failures=0 + fi + log_progress "Post-loop fix cycle $cycle completed (exit: $fix_exit)" cycle=$((cycle + 1)) done @@ -616,6 +632,7 @@ OPTIONS: --prompt Orchestrator prompt name from prompts/ folder (default: prompt) --max-iterations Maximum iterations (default: 50) --completion-promise '' Promise phrase to signal completion (default: COMPLETE) + --add-dir Add a secondary repository for multi-repo planning (repeatable) --self-learning Enable self-learning (disabled by default) -h, --help Show this help message @@ -671,6 +688,7 @@ PRD_FILE="" PROMPT_NAME="" MAX_ITERATIONS=50 COMPLETION_PROMISE="COMPLETE" +ADD_DIRS=() while [[ $# -gt 0 ]]; do case $1 in @@ -678,6 +696,18 @@ while [[ $# -gt 0 ]]; do show_help exit 0 ;; + --add-dir) + if [[ -z "${2:-}" ]]; then + echo -e "${RED}Error: --add-dir requires a directory path${NC}" >&2 + exit 1 + fi + if [[ ! -d "$2" ]]; then + echo -e "${RED}Error: --add-dir path does not exist or is not a directory: $2${NC}" >&2 + exit 1 + fi + ADD_DIRS+=("$2") + shift 2 + ;; --prd) if [[ -z "${2:-}" ]]; then echo -e "${RED}Error: --prd requires a file path${NC}" >&2 @@ -696,7 +726,7 @@ while [[ $# -gt 0 ]]; do exit 1 fi if [[ ! -f "$SCRIPTS_DIR/../prompts/$2.md" ]]; then - echo -e "${RED}Error: prompt file not found: prompts/$2.md${NC}" >&2 + echo -e "${RED}Error: prompt not found: prompts/$2.md${NC}" >&2 exit 1 fi PROMPT_NAME="$2" @@ -837,7 +867,7 @@ update_iteration() { # Create state file create_state_file() { - mkdir -p .closedloop-ai + mkdir -p "$CLOSEDLOOP_STATE_DIR" # WORKDIR is the closedloop work directory passed by the caller # (e.g., /path/to/worktree/.closedloop-ai/work) @@ -851,6 +881,9 @@ create_state_file() { if [[ -n "$PRD_FILE" ]]; then prompt="$prompt --prd $PRD_FILE" fi + for add_dir in "${ADD_DIRS[@]+"${ADD_DIRS[@]}"}"; do + prompt="$prompt --add-dir \"$add_dir\"" + done cat > "$STATE_FILE" < "$TMP_FILE" @@ -1074,7 +1107,7 @@ main() { set +e ( claude \ - --allowed-tools=Bash,Grep,Glob,Read,Edit,Write,Task,TodoWrite,WebSearch,WebFetch,mcp__playwright__browser_navigate,mcp__playwright__browser_snapshot,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_click,mcp__playwright__browser_type,mcp__playwright__browser_evaluate \ + --allowed-tools=Bash,Grep,Glob,Read,Edit,Write,Task,TodoWrite,Skill,WebSearch,WebFetch,mcp__playwright__browser_navigate,mcp__playwright__browser_snapshot,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_click,mcp__playwright__browser_type,mcp__playwright__browser_evaluate \ --output-format stream-json \ --verbose \ -p "$full_prompt" 2>"$stderr_file" \ @@ -1125,13 +1158,25 @@ main() { consecutive_empty=0 fi + # Scan the full per-iteration stream for the completion promise, not + # just the final `type==result` record. It was observed that the orchestrator may + # emit the promise in an intermediate assistant/subagent message and then continue + # producing output (tool_use, wrap-up text), which overwrite + # $result and cause the completion signal to be missed. $output_file is + # a per-iteration mktemp, so this cannot match a stale promise from a + # prior iteration. + local promise_found=0 + if grep -qF "$completion_promise" "$output_file"; then + promise_found=1 + fi + rm -f "$output_file" # Post-iteration processing: learning capture, aggregation, citation verification post_iteration_processing "$effective_workdir" "$iteration" # Check for completion - if [[ "$result" == *"$completion_promise"* ]]; then + if [[ $promise_found -eq 1 ]]; then # Emit iteration perf event for the completing iteration local iter_end_epoch iter_end_epoch=$(date +%s) @@ -1254,8 +1299,10 @@ cleanup_on_interrupt() { local pids pids=$(jobs -p 2>/dev/null) if [[ -n "$pids" ]]; then + # shellcheck disable=SC2086 -- intentional word splitting: $pids contains newline-separated PIDs from jobs -p kill $pids 2>/dev/null || true sleep 0.5 + # shellcheck disable=SC2086 kill -9 $pids 2>/dev/null || true fi diff --git a/plugins/code/scripts/setup-closedloop.sh b/plugins/code/scripts/setup-closedloop.sh index 8bec602a..95bdab33 100755 --- a/plugins/code/scripts/setup-closedloop.sh +++ b/plugins/code/scripts/setup-closedloop.sh @@ -4,6 +4,9 @@ set -e +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + DEBUG_LOG="/tmp/setup-closedloop-debug.log" echo "$(date): Setup started, PID=$$, PPID=$PPID, args: $*" >> "$DEBUG_LOG" @@ -17,6 +20,75 @@ PLAN_FILE="" MAX_ITERATIONS=10 PROMPT_NAME="" POSITIONAL_ARGS=() +ADD_DIRS=() + +array_contains() { + local needle="$1" + shift + + local value + for value in "$@"; do + if [[ "$value" == "$needle" ]]; then + return 0 + fi + done + + return 1 +} + +make_unique_repo_name() { + local base_name="$1" + local repo_path="$2" + shift 2 + local used_names=("$@") + + local path_without_root="${repo_path#/}" + local path_parts=() + local old_ifs="$IFS" + IFS='/' + read -r -a path_parts <<< "$path_without_root" + IFS="$old_ifs" + + local last_index=$((${#path_parts[@]} - 1)) + local suffix_end_index="$last_index" + local start_index="$last_index" + if [[ ${#path_parts[@]} -gt 0 ]] && [[ "${path_parts[$last_index]}" == "$base_name" ]]; then + start_index=$((last_index - 1)) + suffix_end_index=$((last_index - 1)) + fi + + local candidate="" + local suffix="" + local counter=2 + while [[ $start_index -ge 0 ]]; do + suffix="$(IFS='-'; echo "${path_parts[*]:$start_index:$((suffix_end_index - start_index + 1))}")" + candidate="$base_name-$suffix" + if ! array_contains "$candidate" "${used_names[@]}"; then + echo "$candidate" + return 0 + fi + start_index=$((start_index - 1)) + done + + candidate="$base_name-$counter" + while array_contains "$candidate" "${used_names[@]}"; do + counter=$((counter + 1)) + candidate="$base_name-$counter" + done + + echo "$candidate" +} + +resolve_directory_path() { + local raw_dir="$1" + local resolved_path="" + + if ! resolved_path="$(cd "$raw_dir" 2>/dev/null && pwd -P)"; then + return 1 + fi + + echo "$resolved_path" +} while [[ $# -gt 0 ]]; do case $1 in @@ -40,6 +112,14 @@ while [[ $# -gt 0 ]]; do PROMPT_NAME="$2" shift 2 ;; + --add-dir) + if [[ -z "${2:-}" ]]; then + echo "Error: --add-dir requires a directory path" >&2 + exit 1 + fi + ADD_DIRS+=("$2") + shift 2 + ;; -*) echo "Unknown option: $1" >&2 shift @@ -57,12 +137,44 @@ if [[ ${#POSITIONAL_ARGS[@]} -gt 0 ]]; then WORKDIR="${POSITIONAL_ARGS[0]}" fi -PROMPT_NAME="${PROMPT_NAME:-prompt}" WORKDIR="${WORKDIR:-.}" -# Convert to absolute path for consistent hook injection -if [[ ! "$WORKDIR" = /* ]]; then - WORKDIR="$PWD/$WORKDIR" +# Resolve to a canonical absolute path so primary-vs-secondary comparisons are reliable. +if ! WORKDIR="$(resolve_directory_path "$WORKDIR")"; then + echo "Error: workdir path does not exist or is not a directory: ${POSITIONAL_ARGS[0]:-.}" >&2 + exit 1 fi + +PRIMARY_REPO_NAME="$(basename "$WORKDIR")" +PRIMARY_REPO_NAME="${PRIMARY_REPO_NAME:-primary}" + +# Post-loop: resolve and validate each ADD_DIRS entry +RESOLVED_ADD_DIRS=() +ADD_DIR_NAMES=() +USED_REPO_NAMES=("$PRIMARY_REPO_NAME") +for raw_dir in "${ADD_DIRS[@]}"; do + if ! abs_path="$(resolve_directory_path "$raw_dir")"; then + echo "Error: --add-dir path does not exist or is not a directory: $raw_dir" >&2 + exit 1 + fi + if [[ "$abs_path" == "$WORKDIR" || "$WORKDIR" == "$abs_path"/* || "$abs_path" == "$WORKDIR"/* ]]; then + continue + fi + if array_contains "$abs_path" "${RESOLVED_ADD_DIRS[@]}"; then + continue + fi + identity_file="$abs_path/$CLOSEDLOOP_STATE_DIR/.repo-identity.json" + repo_name="$(jq -r '.name // empty' "$identity_file" 2>/dev/null || true)" + if [[ -z "$repo_name" ]]; then + repo_name="$(basename "$abs_path")" + fi + if array_contains "$repo_name" "${USED_REPO_NAMES[@]}"; then + repo_name="$(make_unique_repo_name "$repo_name" "$abs_path" "${USED_REPO_NAMES[@]}")" + fi + RESOLVED_ADD_DIRS+=("$abs_path") + ADD_DIR_NAMES+=("$repo_name") + USED_REPO_NAMES+=("$repo_name") +done + if [[ -z "$PLAN_FILE" ]] && [[ -z "$PRD_FILE" ]]; then # Try common patterns in order of preference for pattern in "prd.md" "prd.pdf" "requirements.md" "requirements.txt" "ticket.md"; do @@ -99,12 +211,12 @@ if [[ -n "$PLAN_FILE" ]]; then fi # Step 1: Find session_id by walking up process tree -# SessionStart hook wrote to .closedloop-ai/pid-.session +# SessionStart hook wrote to $CLOSEDLOOP_STATE_DIR/pid-.session # Claude Code's PID is an ancestor of this process SESSION_ID="" CURRENT_PID=$$ while [[ $CURRENT_PID -gt 1 ]]; do - SESSION_FILE=".closedloop-ai/pid-$CURRENT_PID.session" + SESSION_FILE="$CLOSEDLOOP_STATE_DIR/pid-$CURRENT_PID.session" echo "$(date): Checking $SESSION_FILE" >> "$DEBUG_LOG" if [[ -f "$SESSION_FILE" ]]; then SESSION_ID=$(cat "$SESSION_FILE") @@ -112,7 +224,7 @@ while [[ $CURRENT_PID -gt 1 ]]; do break fi # Get parent PID - CURRENT_PID=$(ps -o ppid= -p $CURRENT_PID 2>/dev/null | tr -d ' ') + CURRENT_PID=$(ps -o ppid= -p "$CURRENT_PID" 2>/dev/null | tr -d ' ') if [[ -z "$CURRENT_PID" ]]; then break fi @@ -120,24 +232,27 @@ done if [[ -n "$SESSION_ID" ]]; then # Step 2: Write workdir mapping so hooks can find it via session_id - echo "$WORKDIR" > ".closedloop-ai/session-$SESSION_ID.workdir" - echo "$(date): Wrote workdir mapping: .closedloop-ai/session-$SESSION_ID.workdir -> $WORKDIR" >> "$DEBUG_LOG" + echo "$WORKDIR" > "$CLOSEDLOOP_STATE_DIR/session-$SESSION_ID.workdir" + echo "$(date): Wrote workdir mapping: $CLOSEDLOOP_STATE_DIR/session-$SESSION_ID.workdir -> $WORKDIR" >> "$DEBUG_LOG" else echo "$(date): WARNING: Could not find session_id in process tree" >> "$DEBUG_LOG" fi -# Step 3: Validate prompt before creating any directories +# Step 3: Default prompt name +PROMPT_NAME="${PROMPT_NAME:-prompt}" + # Validate prompt name contains no path separators if [[ "$PROMPT_NAME" == */* || "$PROMPT_NAME" == *..* || "$PROMPT_NAME" =~ [[:space:]] ]]; then echo "ERROR: prompt name must not contain path separators or spaces" >&2 exit 1 fi -CLOSEDLOOP_PROMPT_FILE="$PLUGIN_ROOT/prompts/$PROMPT_NAME.md" +DIRECT_PROMPT="$PLUGIN_ROOT/prompts/$PROMPT_NAME.md" -# Validate the prompt file exists -if [[ ! -f "$CLOSEDLOOP_PROMPT_FILE" ]]; then - echo "ERROR: Prompt file not found: $CLOSEDLOOP_PROMPT_FILE" >&2 +if [[ -f "$DIRECT_PROMPT" ]]; then + CLOSEDLOOP_PROMPT_FILE="$DIRECT_PROMPT" +else + echo "ERROR: Prompt '$PROMPT_NAME' not found (no $DIRECT_PROMPT)" >&2 echo "Available prompts:" >&2 shopt -s nullglob for f in "$PLUGIN_ROOT/prompts/"*.md; do @@ -147,10 +262,12 @@ if [[ ! -f "$CLOSEDLOOP_PROMPT_FILE" ]]; then exit 1 fi +mkdir -p "$WORKDIR/$CLOSEDLOOP_STATE_DIR" + # Write full config to WORKDIR -mkdir -p "$WORKDIR/.closedloop-ai" +mkdir -p "$WORKDIR/$CLOSEDLOOP_STATE_DIR" -cat > "$WORKDIR/.closedloop-ai/config.env" << EOF +cat > "$WORKDIR/$CLOSEDLOOP_STATE_DIR/config.env" << EOF CLOSEDLOOP_WORKDIR="$WORKDIR" CLOSEDLOOP_PRD_FILE="$PRD_FILE" CLOSEDLOOP_PLAN_FILE="$PLAN_FILE" @@ -159,5 +276,24 @@ CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" CLOSEDLOOP_PROMPT_FILE="$CLOSEDLOOP_PROMPT_FILE" EOF -echo "ClosedLoop config written to $WORKDIR/.closedloop-ai/config.env" -cat "$WORKDIR/.closedloop-ai/config.env" +# Build pipe-joined multi-repo variables (empty strings when no extra repos) +add_dirs_joined="" +add_dir_names_joined="" +repo_map_joined="" +if [[ ${#RESOLVED_ADD_DIRS[@]} -gt 0 ]]; then + add_dirs_joined="$(IFS='|'; echo "${RESOLVED_ADD_DIRS[*]}")" + add_dir_names_joined="$(IFS='|'; echo "${ADD_DIR_NAMES[*]}")" + repo_map_parts=() + for i in "${!RESOLVED_ADD_DIRS[@]}"; do + repo_map_parts+=("${ADD_DIR_NAMES[$i]}=${RESOLVED_ADD_DIRS[$i]}") + done + repo_map_joined="$(IFS='|'; echo "${repo_map_parts[*]}")" +fi +cat >> "$WORKDIR/$CLOSEDLOOP_STATE_DIR/config.env" << EOF +CLOSEDLOOP_ADD_DIRS="$add_dirs_joined" +CLOSEDLOOP_ADD_DIR_NAMES="$add_dir_names_joined" +CLOSEDLOOP_REPO_MAP="$repo_map_joined" +EOF + +echo "ClosedLoop config written to $WORKDIR/$CLOSEDLOOP_STATE_DIR/config.env" +cat "$WORKDIR/$CLOSEDLOOP_STATE_DIR/config.env" diff --git a/plugins/code/scripts/setup-loop.sh b/plugins/code/scripts/setup-loop.sh index a3c1301b..6ffdb472 100755 --- a/plugins/code/scripts/setup-loop.sh +++ b/plugins/code/scripts/setup-loop.sh @@ -6,6 +6,9 @@ set -euo pipefail +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOOP_CONFIG="$SCRIPT_DIR/loop-agents.json" @@ -118,7 +121,7 @@ CONFIG_MAX_ITERATIONS=$(echo "$AGENT_CONFIG" | jq -r '.max_iterations // 10') # Use command line override or config default MAX_ITERATIONS="${MAX_ITERATIONS:-$CONFIG_MAX_ITERATIONS}" -STATE_FILE="$WORKDIR/.closedloop-ai/$STATE_FILE_SUFFIX" +STATE_FILE="$WORKDIR/$CLOSEDLOOP_STATE_DIR/$STATE_FILE_SUFFIX" # Idempotency checks if [[ -f "$WORKDIR/plan.json" ]]; then @@ -132,7 +135,7 @@ if [[ -f "$STATE_FILE" ]]; then fi # Create state file -mkdir -p "$WORKDIR/.closedloop-ai" +mkdir -p "$WORKDIR/$CLOSEDLOOP_STATE_DIR" PROMPT="Create a comprehensive implementation plan for the requirements in @${PRD_FILE}. diff --git a/plugins/code/skills/closedloop-env/scripts/get-env.sh b/plugins/code/skills/closedloop-env/scripts/get-env.sh index bf3d61f1..1550dd0e 100755 --- a/plugins/code/skills/closedloop-env/scripts/get-env.sh +++ b/plugins/code/skills/closedloop-env/scripts/get-env.sh @@ -2,8 +2,11 @@ # Reads ClosedLoop config and outputs environment variables # Usage: get-env.sh +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + WORKDIR="${1:-.}" -CONFIG_FILE="$WORKDIR/.closedloop-ai/config.env" +CONFIG_FILE="$WORKDIR/$CLOSEDLOOP_STATE_DIR/config.env" if [[ ! -f "$CONFIG_FILE" ]]; then echo "Error: Config file not found at $CONFIG_FILE" >&2 diff --git a/plugins/code/skills/codex-review/scripts/run_codex_review.sh b/plugins/code/skills/codex-review/scripts/run_codex_review.sh index 4e9cc4d2..d23b7a9f 100755 --- a/plugins/code/skills/codex-review/scripts/run_codex_review.sh +++ b/plugins/code/skills/codex-review/scripts/run_codex_review.sh @@ -20,6 +20,9 @@ set -euo pipefail +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + # ── Argument parsing ────────────────────────────────────────────────────────── PLAN_FILE="" @@ -68,7 +71,7 @@ if [[ -z "$LOG_ID" ]]; then LOG_ID=$(python3 -c "import uuid; print(uuid.uuid4())") fi -LOG_DIR="$HOME/.closedloop-ai/plan-with-codex" +LOG_DIR="$HOME/$CLOSEDLOOP_STATE_DIR/plan-with-codex" mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/$LOG_ID.jsonl" diff --git a/plugins/code/skills/critic-cache/scripts/check_critic_cache.sh b/plugins/code/skills/critic-cache/scripts/check_critic_cache.sh index 3d90e0c9..1915bbec 100755 --- a/plugins/code/skills/critic-cache/scripts/check_critic_cache.sh +++ b/plugins/code/skills/critic-cache/scripts/check_critic_cache.sh @@ -12,6 +12,9 @@ set -euo pipefail +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + WORKDIR="${1:?Usage: check_critic_cache.sh }" PLAN_JSON="$WORKDIR/plan.json" @@ -48,7 +51,7 @@ fi # --- compute current combined hash --- # Hash both plan.json and critic-gates.json (if it exists) to detect config changes current_hash="" -CRITIC_GATES_PATH=".closedloop-ai/settings/critic-gates.json" +CRITIC_GATES_PATH="$CLOSEDLOOP_STATE_DIR/settings/critic-gates.json" WORKDIR_STATE_DIR=$(dirname "$WORKDIR") if [ -f "$CRITIC_GATES_PATH" ]; then current_hash=$(cat "$PLAN_JSON" "$CRITIC_GATES_PATH" | shasum -a 256 | cut -d' ' -f1) diff --git a/plugins/code/skills/plan-validate/scripts/test_validate_plan.py b/plugins/code/skills/plan-validate/scripts/test_validate_plan.py new file mode 100644 index 00000000..ca523279 --- /dev/null +++ b/plugins/code/skills/plan-validate/scripts/test_validate_plan.py @@ -0,0 +1,33 @@ +"""Tests for validate_plan.py.""" +from validate_plan import validate_schema_fields + + +def _minimal_plan() -> dict: + """Return a plan dict with all required fields populated with valid values.""" + return { + "content": "some content", + "acceptanceCriteria": [], + "pendingTasks": [], + "completedTasks": [], + "openQuestions": [], + "answeredQuestions": [], + "gaps": [], + } + + +def test_validate_schema_accepts_canonical_repositories() -> None: + """validate_schema_fields must accept the canonical multi-repo shape. + + Each 'repositories' entry carries only `path` and `isPrimary` — the two + fields the schema defines after the `type` field was removed. A + primary+secondary shape exercises both isPrimary branches. + """ + plan = _minimal_plan() + plan["repositories"] = { + "primary": {"path": "/abs/primary", "isPrimary": True}, + "frontend": {"path": "/abs/frontend", "isPrimary": False}, + } + + issues = validate_schema_fields(plan) + + assert issues == [], f"expected no issues but got: {issues}" diff --git a/plugins/code/tools/python/conftest.py b/plugins/code/tools/python/conftest.py new file mode 100644 index 00000000..f9e29d13 --- /dev/null +++ b/plugins/code/tools/python/conftest.py @@ -0,0 +1 @@ +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" diff --git a/plugins/code/tools/python/test_critic_cache.py b/plugins/code/tools/python/test_critic_cache.py index eccd7a75..6616c0ff 100644 --- a/plugins/code/tools/python/test_critic_cache.py +++ b/plugins/code/tools/python/test_critic_cache.py @@ -6,6 +6,8 @@ import subprocess from pathlib import Path +from conftest import CLOSEDLOOP_STATE_DIR + SCRIPT = ( Path(__file__).resolve().parent.parent.parent / "skills" @@ -35,9 +37,9 @@ def _hash(plan: str, gates: str | None = None) -> str: def test_uses_closedloop_ai_critic_gates_in_hash(tmp_path: Path) -> None: project_root = tmp_path / "project" - workdir = project_root / ".closedloop-ai" / "work" + workdir = project_root / CLOSEDLOOP_STATE_DIR / "work" reviews_dir = workdir / "reviews" - settings_dir = project_root / ".closedloop-ai" / "settings" + settings_dir = project_root / CLOSEDLOOP_STATE_DIR / "settings" reviews_dir.mkdir(parents=True) settings_dir.mkdir(parents=True) @@ -55,9 +57,9 @@ def test_uses_closedloop_ai_critic_gates_in_hash(tmp_path: Path) -> None: def test_misses_when_closedloop_ai_critic_gates_change(tmp_path: Path) -> None: project_root = tmp_path / "project" - workdir = project_root / ".closedloop-ai" / "work" + workdir = project_root / CLOSEDLOOP_STATE_DIR / "work" reviews_dir = workdir / "reviews" - settings_dir = project_root / ".closedloop-ai" / "settings" + settings_dir = project_root / CLOSEDLOOP_STATE_DIR / "settings" reviews_dir.mkdir(parents=True) settings_dir.mkdir(parents=True) diff --git a/plugins/code/tools/python/test_discover_repos.py b/plugins/code/tools/python/test_discover_repos.py index 7db05651..a51b5708 100644 --- a/plugins/code/tools/python/test_discover_repos.py +++ b/plugins/code/tools/python/test_discover_repos.py @@ -1,14 +1,21 @@ - """Tests for discover-repos.sh path handling.""" import json +import os import subprocess from pathlib import Path -SCRIPT_PATH = Path(__file__).resolve().parent.parent.parent / "scripts" / "discover-repos.sh" +import pytest +from conftest import CLOSEDLOOP_STATE_DIR + +SCRIPT_PATH = ( + Path(__file__).resolve().parent.parent.parent / "scripts" / "discover-repos.sh" +) -def run_discover(project_root: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess: +def run_discover( + project_root: Path, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess: """Invoke discover-repos.sh for the given project root.""" return subprocess.run( ["bash", str(SCRIPT_PATH), str(project_root)], @@ -19,20 +26,37 @@ def run_discover(project_root: Path, env: dict[str, str] | None = None) -> subpr ) +def _run_discover_with_env( + project_root: Path, extra_env: dict[str, str] +) -> subprocess.CompletedProcess: + """Invoke discover-repos.sh with extra environment variables merged in.""" + env = {**os.environ, **extra_env} + # Remove Tier 1 env var unless explicitly set by caller, to avoid test interference + if "CLAUDE_WORKSPACE_REPOS" not in extra_env: + env.pop("CLAUDE_WORKSPACE_REPOS", None) + return subprocess.run( + ["bash", str(SCRIPT_PATH), str(project_root)], + capture_output=True, + text=True, + timeout=10, + env=env, + ) + + def test_sibling_scan_uses_closedloop_repo_identity(tmp_path: Path) -> None: """Should discover siblings from `.closedloop-ai/.repo-identity.json` only.""" parent = tmp_path / "workspace" current = parent / "current-repo" current.mkdir(parents=True) - (current / ".closedloop-ai").mkdir() - (current / ".closedloop-ai" / ".repo-identity.json").write_text( + (current / CLOSEDLOOP_STATE_DIR).mkdir() + (current / CLOSEDLOOP_STATE_DIR / ".repo-identity.json").write_text( '{"name":"current","type":"service"}' ) sibling = parent / "peer-repo" sibling.mkdir() - (sibling / ".closedloop-ai").mkdir() - (sibling / ".closedloop-ai" / ".repo-identity.json").write_text( + (sibling / CLOSEDLOOP_STATE_DIR).mkdir() + (sibling / CLOSEDLOOP_STATE_DIR / ".repo-identity.json").write_text( '{"name":"peer","type":"library","discoverable":true}' ) @@ -51,3 +75,116 @@ def test_sibling_scan_uses_closedloop_repo_identity(tmp_path: Path) -> None: assert payload["peers"] == [ {"name": "peer", "type": "library", "path": str(sibling)} ] + + +# --------------------------------------------------------------------------- +# Tier 0: CLOSEDLOOP_ADD_DIRS tests +# --------------------------------------------------------------------------- + + +def _make_repo(parent: Path, name: str, identity: dict | None = None) -> Path: + """Create a minimal repo directory, optionally with a .repo-identity.json.""" + repo = parent / name + repo.mkdir(parents=True, exist_ok=True) + if identity is not None: + (repo / CLOSEDLOOP_STATE_DIR).mkdir(exist_ok=True) + (repo / CLOSEDLOOP_STATE_DIR / ".repo-identity.json").write_text( + json.dumps(identity) + ) + return repo + + +# --------------------------------------------------------------------------- +# Tier 0 harness: each scenario is a plain dict. One parametrized test runs them. +# +# repos: {dirname: identity_or_None} — first entry is the current repo +# add_dirs: list of dirnames to join into CLOSEDLOOP_ADD_DIRS (current repo allowed) +# expect: {dirname: {field: expected_value, ...}} — peer must exist exactly once +# forbidden: list of dirnames that must NOT appear as peers +# workspace: True to nest repos under tmp_path/workspace/ (enables Tier 2 sibling scan) +# --------------------------------------------------------------------------- + + +TIER0_SCENARIOS: list[dict] = [ + { + "id": "add_dir_appears_in_peers", + "repos": {"current": None, "extra": {"name": "extra-svc", "type": "service"}}, + "add_dirs": ["extra"], + "expect": { + "extra": { + "local": True, + "name": "extra-svc", + "type": "service", + } + }, + }, + { + "id": "basename_fallback_without_identity", + "repos": {"current": None, "my-anon-repo": None}, + "add_dirs": ["my-anon-repo"], + "expect": {"my-anon-repo": {"name": "my-anon-repo"}}, + }, + { + "id": "multiple_add_dirs_pipe_separated", + "repos": {"current": None, "repo-a": None, "repo-b": None}, + "add_dirs": ["repo-a", "repo-b"], + "expect": {"repo-a": {}, "repo-b": {}}, + }, + { + "id": "skips_current_repo", + "repos": {"current": None}, + "add_dirs": ["current"], + "forbidden": ["current"], + }, + # Sibling also listed in CLOSEDLOOP_ADD_DIRS must appear exactly once + # and be marked `local: true` (Tier 0 wins over Tier 2 sibling scan). + { + "id": "add_dir_wins_over_sibling_scan", + "workspace": True, + "repos": { + "current": {"name": "current", "type": "service"}, + "sibling-svc": { + "name": "sibling-svc", + "type": "library", + "discoverable": True, + }, + }, + "add_dirs": ["sibling-svc"], + "expect": {"sibling-svc": {"local": True}}, + }, +] + + +@pytest.mark.parametrize("scenario", TIER0_SCENARIOS, ids=lambda s: s["id"]) +def test_tier0_add_dirs(tmp_path: Path, scenario: dict) -> None: + """Build repos, run discover-repos.sh, assert peer list matches expectations.""" + root = tmp_path / "workspace" if scenario.get("workspace") else tmp_path + paths = { + name: _make_repo(root, name, ident) for name, ident in scenario["repos"].items() + } + current = next( + iter(paths.values()) + ) # first entry is the current repo by convention + + add_dirs = "|".join(str(paths[d]) for d in scenario["add_dirs"]) + result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": add_dirs}) + assert result.returncode == 0, result.stderr + peers = json.loads(result.stdout)["peers"] + by_path = {p["path"]: p for p in peers} + + for dirname in scenario.get("forbidden", []): + assert str(paths[dirname]) not in by_path, ( + f"{dirname!r} must not appear in peers: {peers}" + ) + + peer_paths = [p["path"] for p in peers] + for dirname, expected_fields in scenario.get("expect", {}).items(): + target = str(paths[dirname]) + assert peer_paths.count(target) == 1, ( + f"expected exactly one peer for {dirname!r}; peers={peers}" + ) + peer = by_path[target] + for field_name, value in expected_fields.items(): + assert peer.get(field_name) == value, ( + f"peer {dirname!r} {field_name}: expected {value!r}, got {peer.get(field_name)!r}" + ) diff --git a/plugins/code/tools/python/test_pretooluse_hook.py b/plugins/code/tools/python/test_pretooluse_hook.py index 68a32053..8c787ad4 100644 --- a/plugins/code/tools/python/test_pretooluse_hook.py +++ b/plugins/code/tools/python/test_pretooluse_hook.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from conftest import CLOSEDLOOP_STATE_DIR HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "pretooluse-hook.sh" @@ -332,13 +333,13 @@ def session_env(tmp_path: Path) -> tuple[Path, Path, str]: cwd = tmp_path / "cwd" workdir = tmp_path / "workdir" - # Create session mapping: CWD/.closedloop-ai/session-$SESSION_ID.workdir -> workdir - session_dir = cwd / ".closedloop-ai" + # Create session mapping: CWD/$CLOSEDLOOP_STATE_DIR/session-$SESSION_ID.workdir -> workdir + session_dir = cwd / CLOSEDLOOP_STATE_DIR session_dir.mkdir(parents=True) (session_dir / f"session-{session_id}.workdir").write_text(str(workdir)) # Create workdir with config.env (self-learning disabled) - closedloop_dir = workdir / ".closedloop-ai" + closedloop_dir = workdir / CLOSEDLOOP_STATE_DIR closedloop_dir.mkdir(parents=True) (closedloop_dir / "config.env").write_text("CLOSEDLOOP_SELF_LEARNING=false\n") @@ -404,7 +405,7 @@ def test_security_blocklist_still_fires_when_disabled( def test_ignores_legacy_home_patterns(session_env: tuple[Path, Path, str]) -> None: """Should not inject patterns from legacy `~/.claude/.learnings`.""" cwd, workdir, session_id = session_env - (workdir / ".closedloop-ai" / "config.env").write_text( + (workdir / CLOSEDLOOP_STATE_DIR / "config.env").write_text( "CLOSEDLOOP_SELF_LEARNING=true\n" ) @@ -431,12 +432,12 @@ def test_ignores_legacy_home_patterns(session_env: tuple[Path, Path, str]) -> No def test_injects_when_only_plain_awk_is_available(session_env: tuple[Path, Path, str]) -> None: """Should continue injecting tool learnings when only plain awk is available.""" cwd, workdir, session_id = session_env - (workdir / ".closedloop-ai" / "config.env").write_text( + (workdir / CLOSEDLOOP_STATE_DIR / "config.env").write_text( "CLOSEDLOOP_SELF_LEARNING=true\n" ) home_dir = workdir / "plain-awk-home" - patterns_dir = home_dir / ".closedloop-ai" / "learnings" + patterns_dir = home_dir / CLOSEDLOOP_STATE_DIR / "learnings" patterns_dir.mkdir(parents=True) (patterns_dir / "org-patterns.toon").write_text( "# TOON\npatterns[\n" diff --git a/plugins/code/tools/python/test_self_learning_flag.py b/plugins/code/tools/python/test_self_learning_flag.py index 75347ddb..7e8fd455 100644 --- a/plugins/code/tools/python/test_self_learning_flag.py +++ b/plugins/code/tools/python/test_self_learning_flag.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from conftest import CLOSEDLOOP_STATE_DIR SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent / "scripts" RUN_LOOP_SH = SCRIPTS_DIR / "run-loop.sh" @@ -13,7 +14,7 @@ @pytest.fixture() def workdir(tmp_path: Path) -> Path: """Create a minimal workdir with .closedloop-ai/ directory.""" - (tmp_path / ".closedloop-ai").mkdir() + (tmp_path / CLOSEDLOOP_STATE_DIR).mkdir() (tmp_path / ".learnings").mkdir() return tmp_path @@ -56,13 +57,14 @@ def _base_env( return textwrap.dedent(f"""\ #!/bin/bash set -euo pipefail + CLOSEDLOOP_STATE_DIR="{CLOSEDLOOP_STATE_DIR}" SELF_LEARNING={self_learning} SCRIPTS_DIR="{SCRIPTS_DIR}" BLUE='\\033[0;34m' GREEN='\\033[0;32m' NC='\\033[0m' PROGRESS_LOG="/dev/null" - STATE_FILE="{workdir}/.closedloop-ai/state.local.md" + STATE_FILE="{workdir}/{CLOSEDLOOP_STATE_DIR}/state.local.md" WORKDIR="{workdir}" MAX_ITERATIONS=5 COMPLETION_PROMISE="COMPLETE" @@ -100,7 +102,7 @@ def test_state_file_contains_self_learning_true(self, workdir: Path) -> None: result = _run_script(self._build_script(workdir, "true"), cwd=str(workdir)) assert result.returncode == 0, f"Script failed: {result.stderr}" - content = (workdir / ".closedloop-ai" / "state.local.md").read_text() + content = (workdir / CLOSEDLOOP_STATE_DIR /"state.local.md").read_text() assert 'self_learning: "true"' in content def test_state_file_contains_self_learning_false(self, workdir: Path) -> None: @@ -108,12 +110,12 @@ def test_state_file_contains_self_learning_false(self, workdir: Path) -> None: result = _run_script(self._build_script(workdir, "false"), cwd=str(workdir)) assert result.returncode == 0, f"Script failed: {result.stderr}" - content = (workdir / ".closedloop-ai" / "state.local.md").read_text() + content = (workdir / CLOSEDLOOP_STATE_DIR /"state.local.md").read_text() assert 'self_learning: "false"' in content def test_config_env_written_with_self_learning(self, workdir: Path) -> None: """create_state_file writes CLOSEDLOOP_SELF_LEARNING to config.env.""" - config_env = workdir / ".closedloop-ai" / "config.env" + config_env = workdir / CLOSEDLOOP_STATE_DIR / "config.env" config_env.write_text("CLOSEDLOOP_WORKDIR=/tmp/test\n") result = _run_script(self._build_script(workdir, "true"), cwd=str(workdir)) @@ -151,10 +153,10 @@ class TestBootstrapLearningsCopy: def test_bootstrap_copies_adjacent_closedloop_ai_learnings(self, tmp_path: Path) -> None: """bootstrap_learnings copies org patterns from project-root .closedloop-ai/learnings.""" project_root = tmp_path / "project" - workdir = project_root / ".closedloop-ai" / "work" + workdir = project_root / CLOSEDLOOP_STATE_DIR / "work" workdir.mkdir(parents=True) - org_dir = project_root / ".closedloop-ai" / "learnings" + org_dir = project_root / CLOSEDLOOP_STATE_DIR / "learnings" org_dir.mkdir(parents=True) (org_dir / "org-patterns.toon").write_text("pattern: use tests\n") (org_dir / "goal.yaml").write_text("active_goal: improve-reliability\n") @@ -179,7 +181,7 @@ class TestSelfLearningResume: def test_get_field_reads_self_learning(self, workdir: Path) -> None: """get_field can extract self_learning from state frontmatter.""" - state_file = workdir / ".closedloop-ai" / "state.local.md" + state_file = workdir / CLOSEDLOOP_STATE_DIR / "state.local.md" # Write frontmatter without indentation -- must start at column 0 state_file.write_text( "---\n" diff --git a/plugins/code/tools/python/test_session_end_hook.py b/plugins/code/tools/python/test_session_end_hook.py index 32d27a7e..23e1e629 100644 --- a/plugins/code/tools/python/test_session_end_hook.py +++ b/plugins/code/tools/python/test_session_end_hook.py @@ -5,6 +5,8 @@ import subprocess from pathlib import Path +from conftest import CLOSEDLOOP_STATE_DIR + HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "session-end-hook.sh" @@ -25,7 +27,7 @@ def test_cleans_closedloop_session_mapping(tmp_path: Path) -> None: session_id = "cleanup-session" cwd = tmp_path / "cwd" workdir = tmp_path / "workdir" - closedloop_dir = cwd / ".closedloop-ai" + closedloop_dir = cwd / CLOSEDLOOP_STATE_DIR closedloop_dir.mkdir(parents=True) workdir.mkdir(parents=True) (closedloop_dir / f"session-{session_id}.workdir").write_text(str(workdir)) diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index d43b8c0c..60b5092f 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -5,26 +5,23 @@ from pathlib import Path import pytest +from conftest import CLOSEDLOOP_STATE_DIR SETUP_SCRIPT = Path(__file__).parent.parent.parent / "scripts" / "setup-closedloop.sh" @pytest.fixture def tmp_workdir(tmp_path: Path) -> Path: - """Write a plan.md file to tmp_path and return it.""" - (tmp_path / "plan.md").write_text("# Plan\n\nTask T-1.1: Do something\n") - return tmp_path + """Create a workdir subdirectory inside tmp_path and return it. - -def run_setup(*extra_args: str, cwd: str | None = None) -> subprocess.CompletedProcess: - """Run setup-closedloop.sh with the given extra arguments.""" - workdir = cwd or str(extra_args[0]) if extra_args else "." - return subprocess.run( - ["bash", str(SETUP_SCRIPT), workdir, *extra_args], - capture_output=True, - text=True, - cwd=cwd or workdir, - ) + Using a subdirectory (not tmp_path itself) keeps sibling directories like + extra-repo outside the workdir tree, which matters for the subdirectory + containment check in setup-closedloop.sh. + """ + workdir = tmp_path / "workdir" + workdir.mkdir() + (workdir / "plan.md").write_text("# Plan\n\nTask T-1.1: Do something\n") + return workdir def _run_setup_in_workdir( @@ -45,8 +42,10 @@ def test_plan_arg_valid_file(tmp_workdir: Path) -> None: result = _run_setup_in_workdir(tmp_workdir, "--plan", str(plan_file)) assert result.returncode == 0 - assert f"CLOSEDLOOP_PLAN_FILE={str(plan_file)!r}" in result.stdout or \ - f'CLOSEDLOOP_PLAN_FILE="{plan_file}"' in result.stdout + assert ( + f"CLOSEDLOOP_PLAN_FILE={str(plan_file)!r}" in result.stdout + or f'CLOSEDLOOP_PLAN_FILE="{plan_file}"' in result.stdout + ) def test_plan_arg_nonexistent_file(tmp_workdir: Path) -> None: @@ -71,28 +70,21 @@ def test_plan_and_prd_mutually_exclusive(tmp_workdir: Path) -> None: def test_plan_relative_path_resolves_to_absolute(tmp_workdir: Path) -> None: """Should resolve a relative --plan path to an absolute path in config output.""" - result = _run_setup_in_workdir(tmp_workdir, "--plan", "plan.md", cwd=str(tmp_workdir)) + result = _run_setup_in_workdir( + tmp_workdir, "--plan", "plan.md", cwd=str(tmp_workdir) + ) assert result.returncode == 0 # Extract the CLOSEDLOOP_PLAN_FILE value from stdout for line in result.stdout.splitlines(): if line.startswith("CLOSEDLOOP_PLAN_FILE="): value = line.split("=", 1)[1].strip('"') - assert value.startswith("/"), ( - f"Expected absolute path, got: {value!r}" - ) + assert value.startswith("/"), f"Expected absolute path, got: {value!r}" break else: pytest.fail("CLOSEDLOOP_PLAN_FILE not found in stdout") -def test_plan_missing_value_exits_error(tmp_workdir: Path) -> None: - """Should fail when --plan flag is given with no following value.""" - result = _run_setup_in_workdir(tmp_workdir, "--plan") - - assert result.returncode != 0 - - def test_plan_skips_prd_autodiscovery(tmp_workdir: Path) -> None: """Should not auto-discover prd.md when --plan is specified.""" # Write a prd.md that would normally be auto-discovered @@ -105,7 +97,11 @@ def test_plan_skips_prd_autodiscovery(tmp_workdir: Path) -> None: # PLAN_FILE should be non-empty assert "CLOSEDLOOP_PLAN_FILE=" in result.stdout plan_line = next( - (line for line in result.stdout.splitlines() if line.startswith("CLOSEDLOOP_PLAN_FILE=")), + ( + line + for line in result.stdout.splitlines() + if line.startswith("CLOSEDLOOP_PLAN_FILE=") + ), None, ) assert plan_line is not None @@ -116,10 +112,9 @@ def test_plan_skips_prd_autodiscovery(tmp_workdir: Path) -> None: assert 'CLOSEDLOOP_PRD_FILE=""' in result.stdout - def test_writes_session_mapping_from_closedloop_pid_file(tmp_workdir: Path) -> None: """Should create a workdir mapping when a `.closedloop-ai` PID mapping exists.""" - session_dir = tmp_workdir / ".closedloop-ai" + session_dir = tmp_workdir / CLOSEDLOOP_STATE_DIR session_dir.mkdir(parents=True) session_id = "session-from-closedloop" (session_dir / f"pid-{os.getpid()}.session").write_text(session_id) @@ -127,7 +122,9 @@ def test_writes_session_mapping_from_closedloop_pid_file(tmp_workdir: Path) -> N result = _run_setup_in_workdir(tmp_workdir) assert result.returncode == 0 - assert (session_dir / f"session-{session_id}.workdir").read_text().strip() == str(tmp_workdir) + assert (session_dir / f"session-{session_id}.workdir").read_text().strip() == str( + tmp_workdir + ) def test_ignores_legacy_pid_mapping(tmp_workdir: Path) -> None: @@ -140,4 +137,271 @@ def test_ignores_legacy_pid_mapping(tmp_workdir: Path) -> None: result = _run_setup_in_workdir(tmp_workdir) assert result.returncode == 0 - assert not (tmp_workdir / ".closedloop-ai" / f"session-{session_id}.workdir").exists() + assert not ( + tmp_workdir / CLOSEDLOOP_STATE_DIR / f"session-{session_id}.workdir" + ).exists() + + +# --------------------------------------------------------------------------- +# --add-dir tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def extra_repo(tmp_path: Path) -> Path: + """Create a minimal extra repo directory for --add-dir tests.""" + repo = tmp_path / "extra-repo" + repo.mkdir() + return repo + + +def _config_env(workdir: Path) -> str: + """Return the contents of .closedloop-ai/config.env written by the script.""" + return (workdir / CLOSEDLOOP_STATE_DIR / "config.env").read_text() + + +def _config_value(config: str, key: str) -> str: + """Extract a quoted config.env value by key.""" + prefix = f"{key}=" + for line in config.splitlines(): + if line.startswith(prefix): + return line.split("=", 1)[1].strip('"') + pytest.fail(f"{key} not found in config") + + +def test_add_dir_nonexistent_path_fails(tmp_workdir: Path) -> None: + """Should exit non-zero when --add-dir path does not exist.""" + result = _run_setup_in_workdir( + tmp_workdir, "--add-dir", "/nonexistent/path/does/not/exist" + ) + + assert result.returncode != 0 + assert "does not exist" in result.stderr or "not a directory" in result.stderr + + +def test_add_dir_writes_closedloop_add_dirs_to_config( + tmp_workdir: Path, extra_repo: Path +) -> None: + """config.env must contain CLOSEDLOOP_ADD_DIRS with the resolved absolute path.""" + result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(extra_repo)) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + assert str(extra_repo) in config + assert "CLOSEDLOOP_ADD_DIRS=" in config + + +def test_add_dir_writes_closedloop_repo_map_to_config( + tmp_workdir: Path, extra_repo: Path +) -> None: + """config.env must contain CLOSEDLOOP_REPO_MAP in name=path format.""" + result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(extra_repo)) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + assert "CLOSEDLOOP_REPO_MAP=" in config + assert f"extra-repo={extra_repo}" in config + + +def test_add_dir_uses_identity_file_name(tmp_workdir: Path, tmp_path: Path) -> None: + """Should use .closedloop-ai/.repo-identity.json name field when present.""" + named_repo = tmp_path / "some-dir" + named_repo.mkdir() + (named_repo / CLOSEDLOOP_STATE_DIR).mkdir() + (named_repo / CLOSEDLOOP_STATE_DIR / ".repo-identity.json").write_text( + '{"name": "my-custom-name", "type": "service"}' + ) + + result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(named_repo)) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + assert "my-custom-name" in config + + +def test_multiple_add_dirs_produces_pipe_joined_values( + tmp_workdir: Path, tmp_path: Path +) -> None: + """Multiple --add-dir flags should produce pipe-separated values in config.env.""" + repo_a = tmp_path / "repo-a" + repo_b = tmp_path / "repo-b" + repo_a.mkdir() + repo_b.mkdir() + + result = _run_setup_in_workdir( + tmp_workdir, "--add-dir", str(repo_a), "--add-dir", str(repo_b) + ) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + # Both paths should appear; they should be pipe-separated + assert str(repo_a) in config + assert str(repo_b) in config + # Find the CLOSEDLOOP_ADD_DIRS line and verify pipe separator + add_dirs_line = next( + line for line in config.splitlines() if line.startswith("CLOSEDLOOP_ADD_DIRS=") + ) + assert "|" in add_dirs_line, f"Expected pipe separator in: {add_dirs_line!r}" + + +def test_add_dir_ignores_duplicate_resolved_repo_path( + tmp_workdir: Path, extra_repo: Path +) -> None: + """The same resolved repo path should only appear once in config.env.""" + result = _run_setup_in_workdir( + tmp_workdir, "--add-dir", str(extra_repo), "--add-dir", str(extra_repo) + ) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + assert _config_value(config, "CLOSEDLOOP_ADD_DIRS") == str(extra_repo) + assert _config_value(config, "CLOSEDLOOP_ADD_DIR_NAMES") == "extra-repo" + assert _config_value(config, "CLOSEDLOOP_REPO_MAP") == f"extra-repo={extra_repo}" + + +def test_add_dir_ignores_primary_workdir_path(tmp_workdir: Path) -> None: + """The primary workdir must never be re-published as a secondary repo.""" + result = _run_setup_in_workdir(tmp_workdir, "--add-dir", ".", cwd=str(tmp_workdir)) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + assert _config_value(config, "CLOSEDLOOP_ADD_DIRS") == "" + assert _config_value(config, "CLOSEDLOOP_ADD_DIR_NAMES") == "" + assert _config_value(config, "CLOSEDLOOP_REPO_MAP") == "" + + +def test_add_dir_makes_identity_name_collisions_unique( + tmp_workdir: Path, tmp_path: Path +) -> None: + """Distinct repos with the same identity name should get different repo keys.""" + repo_a = tmp_path / "repo-a" + repo_b = tmp_path / "repo-b" + repo_a.mkdir() + repo_b.mkdir() + (repo_a / CLOSEDLOOP_STATE_DIR).mkdir() + (repo_b / CLOSEDLOOP_STATE_DIR).mkdir() + (repo_a / CLOSEDLOOP_STATE_DIR / ".repo-identity.json").write_text( + '{"name": "service"}' + ) + (repo_b / CLOSEDLOOP_STATE_DIR / ".repo-identity.json").write_text( + '{"name": "service"}' + ) + + result = _run_setup_in_workdir( + tmp_workdir, "--add-dir", str(repo_a), "--add-dir", str(repo_b) + ) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + add_dir_names = _config_value(config, "CLOSEDLOOP_ADD_DIR_NAMES").split("|") + assert add_dir_names == ["service", "service-repo-b"] + assert _config_value(config, "CLOSEDLOOP_REPO_MAP") == ( + f"service={repo_a}|service-repo-b={repo_b}" + ) + + +def test_add_dir_makes_basename_collisions_unique( + tmp_workdir: Path, tmp_path: Path +) -> None: + """Distinct repos with the same basename should get different repo keys.""" + repo_a = tmp_path / "group-a" / "service" + repo_b = tmp_path / "group-b" / "service" + repo_a.mkdir(parents=True) + repo_b.mkdir(parents=True) + + result = _run_setup_in_workdir( + tmp_workdir, "--add-dir", str(repo_a), "--add-dir", str(repo_b) + ) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + add_dir_names = _config_value(config, "CLOSEDLOOP_ADD_DIR_NAMES").split("|") + assert add_dir_names == ["service", "service-group-b"] + assert _config_value(config, "CLOSEDLOOP_REPO_MAP") == ( + f"service={repo_a}|service-group-b={repo_b}" + ) + + +def test_add_dir_makes_name_collision_with_primary_repo_unique( + tmp_workdir: Path, tmp_path: Path +) -> None: + """A secondary repo key must not collide with the primary repo identifier.""" + primary_name = tmp_workdir.name + repo_with_same_name = tmp_path / "secondary-parent" / primary_name + repo_with_same_name.mkdir(parents=True) + + result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(repo_with_same_name)) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + add_dir_names = _config_value(config, "CLOSEDLOOP_ADD_DIR_NAMES").split("|") + assert add_dir_names == [f"{primary_name}-secondary-parent"] + assert _config_value(config, "CLOSEDLOOP_REPO_MAP") == ( + f"{primary_name}-secondary-parent={repo_with_same_name}" + ) + + +def test_add_dir_uses_base_prompt(tmp_workdir: Path, extra_repo: Path) -> None: + """--add-dir no longer selects a special prompt — the base prompt.md is used + and the agents consume CLOSEDLOOP_REPO_MAP directly.""" + result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(extra_repo)) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + prompt_line = next( + line + for line in config.splitlines() + if line.startswith("CLOSEDLOOP_PROMPT_FILE=") + ) + assert prompt_line.endswith('prompts/prompt.md"'), ( + f"Expected direct base prompt.md but got: {prompt_line!r}" + ) + + +def test_add_dir_ignores_ancestor_of_workdir(tmp_path: Path) -> None: + """An --add-dir that is a parent of workdir must be filtered out.""" + parent_repo = tmp_path / "parent-repo" + parent_repo.mkdir() + workdir = parent_repo / CLOSEDLOOP_STATE_DIR / "work" + workdir.mkdir(parents=True) + (workdir / "prd.md").write_text("# PRD\n") + + result = _run_setup_in_workdir(workdir, "--add-dir", str(parent_repo)) + + assert result.returncode == 0, result.stderr + config = _config_env(workdir) + assert _config_value(config, "CLOSEDLOOP_ADD_DIRS") == "" + assert _config_value(config, "CLOSEDLOOP_ADD_DIR_NAMES") == "" + assert _config_value(config, "CLOSEDLOOP_REPO_MAP") == "" + + +def test_add_dir_dot_from_repo_root_with_nested_workdir_empty_canonical( + tmp_path: Path, +) -> None: + """run-loop style: workdir at repo/.closedloop-ai/work, `--add-dir .` from repo root. + + Canonical secondary repos must be empty: repo root is filtered as ancestor of workdir. + """ + project = tmp_path / "repo" + workdir = project / CLOSEDLOOP_STATE_DIR / "work" + workdir.mkdir(parents=True) + (workdir / "prd.md").write_text("# PRD\n") + + result = _run_setup_in_workdir(workdir, "--add-dir", ".", cwd=str(project)) + + assert result.returncode == 0, result.stderr + config = _config_env(workdir) + assert _config_value(config, "CLOSEDLOOP_ADD_DIRS") == "" + assert _config_value(config, "CLOSEDLOOP_ADD_DIR_NAMES") == "" + assert _config_value(config, "CLOSEDLOOP_REPO_MAP") == "" + + +def test_no_add_dir_config_env_has_empty_add_dirs(tmp_workdir: Path) -> None: + """When no --add-dir is given, config.env must contain empty CLOSEDLOOP_ADD_DIRS.""" + result = _run_setup_in_workdir(tmp_workdir) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + assert 'CLOSEDLOOP_ADD_DIRS=""' in config + assert 'CLOSEDLOOP_ADD_DIR_NAMES=""' in config + assert 'CLOSEDLOOP_REPO_MAP=""' in config diff --git a/plugins/code/tools/python/test_subagent_start_hook.py b/plugins/code/tools/python/test_subagent_start_hook.py index 2d56a9e7..1762d0e6 100644 --- a/plugins/code/tools/python/test_subagent_start_hook.py +++ b/plugins/code/tools/python/test_subagent_start_hook.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from conftest import CLOSEDLOOP_STATE_DIR HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "subagent-start-hook.sh" @@ -20,12 +21,12 @@ def session_env(tmp_path: Path) -> tuple[Path, Path, str]: workdir = tmp_path / "workdir" # Create session mapping - session_dir = cwd / ".closedloop-ai" + session_dir = cwd / CLOSEDLOOP_STATE_DIR session_dir.mkdir(parents=True) (session_dir / f"session-{session_id}.workdir").write_text(str(workdir)) # Create workdir structure - closedloop_dir = workdir / ".closedloop-ai" + closedloop_dir = workdir / CLOSEDLOOP_STATE_DIR closedloop_dir.mkdir(parents=True) learnings_dir = workdir / ".learnings" @@ -41,14 +42,18 @@ def run_start_hook( agent_id: str = "agent-456", self_learning: bool = False, env_overrides: dict[str, str] | None = None, + config_values: dict[str, str] | None = None, ) -> subprocess.CompletedProcess: """Invoke subagent-start-hook.sh with crafted JSON input.""" # Write config.env - workdir_file = Path(cwd) / ".closedloop-ai" / f"session-{session_id}.workdir" + workdir_file = Path(cwd) / CLOSEDLOOP_STATE_DIR / f"session-{session_id}.workdir" workdir = workdir_file.read_text().strip() - config_path = Path(workdir) / ".closedloop-ai" / "config.env" + config_path = Path(workdir) / CLOSEDLOOP_STATE_DIR / "config.env" sl_value = "true" if self_learning else "false" - config_path.write_text(f"CLOSEDLOOP_SELF_LEARNING={sl_value}\n") + config_lines = [f"CLOSEDLOOP_SELF_LEARNING={sl_value}"] + if config_values: + config_lines.extend(f"{key}={value}" for key, value in config_values.items()) + config_path.write_text("\n".join(config_lines) + "\n") payload = json.dumps( { @@ -103,7 +108,7 @@ def test_no_toon_patterns_when_disabled( # Create a patterns file in HOME to verify it's NOT read home_dir = workdir / "fake_home" - patterns_dir = home_dir / ".closedloop-ai" / "learnings" + patterns_dir = home_dir / CLOSEDLOOP_STATE_DIR / "learnings" patterns_dir.mkdir(parents=True) (patterns_dir / "org-patterns.toon").write_text( '# TOON\npatterns[\n p1,testing,"Test pattern",high,5,0.8,"",*,"test context"\n]\n' @@ -140,6 +145,29 @@ def test_agent_type_file_still_written( content = agent_type_file.read_text() assert "code:implementation-subagent" in content + def test_includes_multi_repo_exports_in_additional_context( + self, session_env: tuple[Path, Path, str] + ) -> None: + """Hook includes export commands for multi-repo env vars in additionalContext.""" + cwd, _workdir, session_id = session_env + result = run_start_hook( + str(cwd), + session_id, + self_learning=False, + config_values={ + "CLOSEDLOOP_ADD_DIRS": '"/tmp/repo-a|/tmp/repo-b"', + "CLOSEDLOOP_ADD_DIR_NAMES": '"repo-a|repo-b"', + "CLOSEDLOOP_REPO_MAP": '"repo-a=/tmp/repo-a|repo-b=/tmp/repo-b"', + }, + ) + assert result.returncode == 0, f"Hook failed: {result.stderr}" + + output = json.loads(result.stdout.strip()) + ctx = output["hookSpecificOutput"]["additionalContext"] + assert 'export CLOSEDLOOP_ADD_DIRS="/tmp/repo-a|/tmp/repo-b"' in ctx + assert 'export CLOSEDLOOP_ADD_DIR_NAMES="repo-a|repo-b"' in ctx + assert 'export CLOSEDLOOP_REPO_MAP="repo-a=/tmp/repo-a|repo-b=/tmp/repo-b"' in ctx + class TestSelfLearningOn: """Tests that subagent-start-hook.sh proceeds to patterns injection when enabled.""" @@ -156,7 +184,7 @@ def test_proceeds_to_patterns_path( # Create patterns file under fake HOME home_dir = workdir / "fake_home" - patterns_dir = home_dir / ".closedloop-ai" / "learnings" + patterns_dir = home_dir / CLOSEDLOOP_STATE_DIR / "learnings" patterns_dir.mkdir(parents=True) (patterns_dir / "org-patterns.toon").write_text( '# TOON org-patterns\npatterns[\n' @@ -278,7 +306,7 @@ def test_injects_when_only_plain_awk_is_available(session_env: tuple[Path, Path, cwd, workdir, session_id = session_env home_dir = workdir / "plain-awk-home" - patterns_dir = home_dir / ".closedloop-ai" / "learnings" + patterns_dir = home_dir / CLOSEDLOOP_STATE_DIR / "learnings" patterns_dir.mkdir(parents=True) (patterns_dir / "org-patterns.toon").write_text( "# TOON org-patterns\npatterns[\n" diff --git a/plugins/code/tools/python/test_subagent_stop_hook.py b/plugins/code/tools/python/test_subagent_stop_hook.py index 44094cf8..3eb353c7 100644 --- a/plugins/code/tools/python/test_subagent_stop_hook.py +++ b/plugins/code/tools/python/test_subagent_stop_hook.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +from conftest import CLOSEDLOOP_STATE_DIR HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "subagent-stop-hook.sh" @@ -20,12 +21,12 @@ def session_env(tmp_path: Path) -> tuple[Path, Path, str]: workdir = tmp_path / "workdir" # Create session mapping - session_dir = cwd / ".closedloop-ai" + session_dir = cwd / CLOSEDLOOP_STATE_DIR session_dir.mkdir(parents=True) (session_dir / f"session-{session_id}.workdir").write_text(str(workdir)) # Create workdir structure - closedloop_dir = workdir / ".closedloop-ai" + closedloop_dir = workdir / CLOSEDLOOP_STATE_DIR closedloop_dir.mkdir(parents=True) learnings_dir = workdir / ".learnings" @@ -51,9 +52,9 @@ def run_stop_hook( ) -> subprocess.CompletedProcess: """Invoke subagent-stop-hook.sh with crafted JSON input.""" # Write config.env - workdir_file = Path(cwd) / ".closedloop-ai" / f"session-{session_id}.workdir" + workdir_file = Path(cwd) / CLOSEDLOOP_STATE_DIR / f"session-{session_id}.workdir" workdir = workdir_file.read_text().strip() - config_path = Path(workdir) / ".closedloop-ai" / "config.env" + config_path = Path(workdir) / CLOSEDLOOP_STATE_DIR / "config.env" sl_value = "true" if self_learning else "false" config_path.write_text( f"CLOSEDLOOP_SELF_LEARNING={sl_value}\n{config_env_extra}" diff --git a/plugins/self-learning/.claude-plugin/plugin.json b/plugins/self-learning/.claude-plugin/plugin.json index 26dea947..6bae698f 100644 --- a/plugins/self-learning/.claude-plugin/plugin.json +++ b/plugins/self-learning/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "self-learning", "description": "Self-learning plugin", - "version": "1.1.0", + "version": "1.1.1", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" diff --git a/plugins/self-learning/scripts/bootstrap-learnings.sh b/plugins/self-learning/scripts/bootstrap-learnings.sh index ad2c4d04..72c9fb0c 100755 --- a/plugins/self-learning/scripts/bootstrap-learnings.sh +++ b/plugins/self-learning/scripts/bootstrap-learnings.sh @@ -10,12 +10,16 @@ set -e +# Single source of truth for the state directory name +CLOSEDLOOP_STATE_DIR=".closedloop-ai" + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LEARNINGS_DIR="${1:-.closedloop-ai/learnings}" +LEARNINGS_DIR="${1:-$CLOSEDLOOP_STATE_DIR/learnings}" +PROJECT_LEARNINGS_SUFFIX="/$CLOSEDLOOP_STATE_DIR/learnings" # Derive PROJECT_DIR for gitignore updates (only if using default path) -if [[ "$LEARNINGS_DIR" == ".closedloop-ai/learnings" ]] || [[ "$LEARNINGS_DIR" == *"/.closedloop-ai/learnings" ]]; then - PROJECT_DIR="${LEARNINGS_DIR%/.closedloop-ai/learnings}" +if [[ "$LEARNINGS_DIR" == "$CLOSEDLOOP_STATE_DIR/learnings" ]] || [[ "$LEARNINGS_DIR" == *"$PROJECT_LEARNINGS_SUFFIX" ]]; then + PROJECT_DIR="${LEARNINGS_DIR%"$PROJECT_LEARNINGS_SUFFIX"}" PROJECT_DIR="${PROJECT_DIR:-.}" UPDATE_PROJECT_GITIGNORE=true else @@ -263,7 +267,7 @@ main() { init_retention_yaml init_gitignore - # Only update project .gitignore if creating org learnings in .closedloop-ai/learnings + # Only update project .gitignore if creating org learnings in $CLOSEDLOOP_STATE_DIR/learnings if [[ "$UPDATE_PROJECT_GITIGNORE" == "true" ]]; then update_project_gitignore fi diff --git a/plugins/self-learning/tools/python/compute_success_rates.py b/plugins/self-learning/tools/python/compute_success_rates.py index e9310f21..bdadf0c7 100644 --- a/plugins/self-learning/tools/python/compute_success_rates.py +++ b/plugins/self-learning/tools/python/compute_success_rates.py @@ -14,6 +14,8 @@ import sys from pathlib import Path +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" + # --- TOON parsing --- @@ -363,7 +365,7 @@ def main() -> int: workdir = Path(args.workdir).resolve() outcomes_path = workdir / ".learnings" / "outcomes.log" - toon_path = Path(args.toon_file) if args.toon_file else Path.home() / ".closedloop-ai" / "learnings" / "org-patterns.toon" + toon_path = Path(args.toon_file) if args.toon_file else Path.home() / CLOSEDLOOP_STATE_DIR / "learnings" / "org-patterns.toon" # Exit cleanly if files don't exist if not toon_path.exists(): diff --git a/plugins/self-learning/tools/python/test_compute_success_rates.py b/plugins/self-learning/tools/python/test_compute_success_rates.py index e652e39e..b280bbde 100644 --- a/plugins/self-learning/tools/python/test_compute_success_rates.py +++ b/plugins/self-learning/tools/python/test_compute_success_rates.py @@ -7,8 +7,8 @@ from pathlib import Path import pytest - from compute_success_rates import ( + CLOSEDLOOP_STATE_DIR, compute_rates, jaccard_similarity, match_outcome_to_pattern, @@ -36,6 +36,7 @@ def _write_outcomes(path: Path, content: str) -> None: # --- parse_toon_patterns --- + class TestParseToonPatterns: def test_empty_file(self, tmp_path: Path) -> None: toon = tmp_path / "org-patterns.toon" @@ -51,11 +52,14 @@ def test_missing_file(self, tmp_path: Path) -> None: def test_parses_single_pattern(self, tmp_path: Path) -> None: toon = tmp_path / "org-patterns.toon" - _write_toon(toon, """\ + _write_toon( + toon, + """\ # Comment line patterns[1]{id,category,summary,confidence,seen_count,success_rate,flags,applies_to,context,repo}: P-001,pattern,"Always run tests before merging",high,5,0.85,,*,test|CI,* - """) + """, + ) headers, patterns = parse_toon_patterns(toon) assert len(headers) == 2 # comment + schema assert len(patterns) == 1 @@ -69,10 +73,13 @@ def test_parses_single_pattern(self, tmp_path: Path) -> None: def test_parses_multiple_patterns(self, tmp_path: Path) -> None: toon = tmp_path / "org-patterns.toon" - _write_toon(toon, """\ + _write_toon( + toon, + """\ P-001,pattern,"Summary one",high,5,0.85,,*,tag1 P-002,mistake,"Summary two",medium,3,0.60,[REVIEW],agent1|agent2,tag2|tag3 - """) + """, + ) _, patterns = parse_toon_patterns(toon) assert len(patterns) == 2 assert patterns[1]["flags"] == "[REVIEW]" @@ -80,9 +87,12 @@ def test_parses_multiple_patterns(self, tmp_path: Path) -> None: def test_parses_summary_with_commas(self, tmp_path: Path) -> None: toon = tmp_path / "org-patterns.toon" - _write_toon(toon, """\ + _write_toon( + toon, + """\ P-001,pattern,"Validation: test=pnpm test, typecheck=pnpm typecheck",medium,6,0.00,[UNTESTED],build-validator|phase-5-validation,next.js|monorepo - """) + """, + ) _, patterns = parse_toon_patterns(toon) assert len(patterns) == 1 assert "test=pnpm test, typecheck=pnpm typecheck" in patterns[0]["summary"] @@ -90,10 +100,13 @@ def test_parses_summary_with_commas(self, tmp_path: Path) -> None: def test_parses_legacy_9_field_rows(self, tmp_path: Path) -> None: """Legacy 9-field TOON rows should parse with repo defaulting to '*'.""" toon = tmp_path / "org-patterns.toon" - _write_toon(toon, """\ + _write_toon( + toon, + """\ P-001,pattern,"Summary one",high,5,0.85,,*,tag1 P-002,mistake,"Summary two",medium,3,0.60,[REVIEW],agent1|agent2,tag2|tag3 - """) + """, + ) _, patterns = parse_toon_patterns(toon) assert len(patterns) == 2 assert patterns[0]["repo"] == "*" @@ -102,10 +115,13 @@ def test_parses_legacy_9_field_rows(self, tmp_path: Path) -> None: def test_parses_10_field_rows_with_repo(self, tmp_path: Path) -> None: """10-field TOON rows should parse with explicit repo value.""" toon = tmp_path / "org-patterns.toon" - _write_toon(toon, """\ + _write_toon( + toon, + """\ P-001,pattern,"Summary one",high,5,0.85,,*,tag1,my-repo P-002,mistake,"Summary two",medium,3,0.60,[REVIEW],agent1,tag2,* - """) + """, + ) _, patterns = parse_toon_patterns(toon) assert len(patterns) == 2 assert patterns[0]["repo"] == "my-repo" @@ -115,7 +131,7 @@ def test_mixed_9_and_10_field_rows(self, tmp_path: Path) -> None: """Mixed 9-field and 10-field rows should both parse correctly.""" toon = tmp_path / "org-patterns.toon" toon.write_text( - 'patterns[2]{id,category,summary,confidence,seen_count,success_rate,flags,applies_to,context,repo}:\n' + "patterns[2]{id,category,summary,confidence,seen_count,success_rate,flags,applies_to,context,repo}:\n" ' P-001,pattern,"Legacy pattern",high,5,0.85,,*,tag1\n' ' P-002,pattern,"New pattern",high,3,0.90,,*,tag2,my-repo\n' ) @@ -128,10 +144,10 @@ def test_parses_real_toon_format(self, tmp_path: Path) -> None: """Test against the actual format from ~/.closedloop-ai/learnings/org-patterns.toon.""" toon = tmp_path / "org-patterns.toon" toon.write_text( - '# Organization Patterns (TOON format)\n' - '# Last updated: 2026-01-29T23:11:00Z\n' - '\n' - 'patterns[2]{id,category,summary,confidence,seen_count,success_rate,flags,applies_to,context}:\n' + "# Organization Patterns (TOON format)\n" + "# Last updated: 2026-01-29T23:11:00Z\n" + "\n" + "patterns[2]{id,category,summary,confidence,seen_count,success_rate,flags,applies_to,context}:\n" ' P-001,pattern,"Project validation commands: test=pnpm test | typecheck=pnpm typecheck",medium,6,0.00,[UNTESTED],build-validator|phase-5-validation,next.js|monorepo|turborepo\n' ' P-005,mistake,"When task specifies exact line numbers for removal read those lines specifically",high,1,0.00,[UNTESTED],implementation-subagent,editing|precision|line-numbers\n' ) @@ -147,6 +163,7 @@ def test_parses_real_toon_format(self, tmp_path: Path) -> None: # --- parse_outcomes_log --- + class TestParseOutcomesLog: def test_empty_file(self, tmp_workdir: Path) -> None: log = tmp_workdir / ".learnings" / "outcomes.log" @@ -161,9 +178,12 @@ def test_missing_file(self, tmp_workdir: Path) -> None: def test_parses_basic_outcome(self, tmp_workdir: Path) -> None: log = tmp_workdir / ".learnings" / "outcomes.log" - _write_outcomes(log, """\ + _write_outcomes( + log, + """\ 2024-01-15T10:00:00Z|run-001|1|agent-a|test trigger|applied|src/foo.ts:10 - """) + """, + ) outcomes = parse_outcomes_log(log) assert len(outcomes) == 1 assert outcomes[0]["pattern_trigger"] == "test trigger" @@ -172,9 +192,12 @@ def test_parses_basic_outcome(self, tmp_workdir: Path) -> None: def test_parses_unverified_outcome(self, tmp_workdir: Path) -> None: log = tmp_workdir / ".learnings" / "outcomes.log" - _write_outcomes(log, """\ + _write_outcomes( + log, + """\ 2024-01-15T10:00:00Z|run-001|1|agent-a|test trigger|applied|src/foo.ts:10|unverified - """) + """, + ) outcomes = parse_outcomes_log(log) assert len(outcomes) == 1 assert outcomes[0]["unverified"] == "1" @@ -182,9 +205,12 @@ def test_parses_unverified_outcome(self, tmp_workdir: Path) -> None: def test_parses_injected_status_as_unverified(self, tmp_workdir: Path) -> None: """Outcomes with status=injected should be marked as unverified.""" log = tmp_workdir / ".learnings" / "outcomes.log" - _write_outcomes(log, """\ + _write_outcomes( + log, + """\ 2024-01-15T10:00:00Z|run-001|1|agent-a|test trigger|injected| - """) + """, + ) outcomes = parse_outcomes_log(log) assert len(outcomes) == 1 assert outcomes[0]["status"] == "injected" @@ -192,9 +218,12 @@ def test_parses_injected_status_as_unverified(self, tmp_workdir: Path) -> None: def test_parses_goal_fields(self, tmp_workdir: Path) -> None: log = tmp_workdir / ".learnings" / "outcomes.log" - _write_outcomes(log, """\ + _write_outcomes( + log, + """\ 2024-01-15T10:00:00Z|run-001|1|agent-a|test trigger|applied|src/foo.ts:10|0.8|context_tags|reduce-failures|1|0.75 - """) + """, + ) outcomes = parse_outcomes_log(log) assert len(outcomes) == 1 assert outcomes[0]["goal_name"] == "reduce-failures" @@ -205,6 +234,7 @@ def test_parses_goal_fields(self, tmp_workdir: Path) -> None: # --- matching --- + class TestMatching: def test_exact_match(self) -> None: assert match_outcome_to_pattern("test trigger", "test trigger") @@ -220,7 +250,9 @@ def test_reverse_substring_match(self) -> None: def test_jaccard_match(self) -> None: # Jaccard({run,all,unit,tests,first,before,push} & {run,unit,tests,first,before,merge}) = 5/8 = 0.625 - assert match_outcome_to_pattern("run all unit tests first before push", "run unit tests first before merge") + assert match_outcome_to_pattern( + "run all unit tests first before push", "run unit tests first before merge" + ) def test_no_match(self) -> None: assert not match_outcome_to_pattern("completely different", "test trigger") @@ -234,25 +266,46 @@ def test_jaccard_similarity_empty(self) -> None: # --- compute_rates --- + class TestComputeRates: def test_no_outcomes_marks_untested(self) -> None: patterns = [ - {"id": "P-001", "summary": "test trigger summary", "confidence": "medium", - "success_rate": "", "flags": "[UNTESTED]", - "category": "pattern", "seen_count": "1", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "test trigger summary", + "confidence": "medium", + "success_rate": "", + "flags": "[UNTESTED]", + "category": "pattern", + "seen_count": "1", + "applies_to": "*", + "context": "test", + }, ] result = compute_rates(patterns, [], max_iteration=0) assert result[0]["flags"] == "[UNTESTED]" def test_single_applied_pattern(self) -> None: patterns = [ - {"id": "P-001", "summary": "test trigger summary", "confidence": "medium", - "success_rate": "", "flags": "[UNTESTED]", - "category": "pattern", "seen_count": "1", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "test trigger summary", + "confidence": "medium", + "success_rate": "", + "flags": "[UNTESTED]", + "category": "pattern", + "seen_count": "1", + "applies_to": "*", + "context": "test", + }, ] outcomes = [ - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "", - "iteration": "1"}, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "", + "iteration": "1", + }, ] result = compute_rates(patterns, outcomes, max_iteration=1) assert result[0]["success_rate"] == "1.00" @@ -261,14 +314,37 @@ def test_single_applied_pattern(self) -> None: def test_mixed_applied_and_unverified(self) -> None: patterns = [ - {"id": "P-001", "summary": "test trigger summary", "confidence": "medium", - "success_rate": "", "flags": "", - "category": "pattern", "seen_count": "3", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "test trigger summary", + "confidence": "medium", + "success_rate": "", + "flags": "", + "category": "pattern", + "seen_count": "3", + "applies_to": "*", + "context": "test", + }, ] outcomes = [ - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "", "iteration": "1"}, - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "1", "iteration": "2"}, - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "", "iteration": "3"}, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "", + "iteration": "1", + }, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "1", + "iteration": "2", + }, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "", + "iteration": "3", + }, ] result = compute_rates(patterns, outcomes, max_iteration=3) # 2 verified out of 3 total = 0.67 @@ -277,16 +353,38 @@ def test_mixed_applied_and_unverified(self) -> None: def test_goal_weighting(self) -> None: patterns = [ - {"id": "P-001", "summary": "test trigger summary", "confidence": "medium", - "success_rate": "", "flags": "", - "category": "pattern", "seen_count": "2", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "test trigger summary", + "confidence": "medium", + "success_rate": "", + "flags": "", + "category": "pattern", + "seen_count": "2", + "applies_to": "*", + "context": "test", + }, ] outcomes = [ - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "", - "iteration": "1", "goal_success": "1", "goal_name": "reduce-failures", "goal_score": "0.8"}, - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "", - "iteration": "2", "goal_success": "0", "goal_name": "reduce-failures", "goal_score": "0.2", - "relevance_score": "0.6"}, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "", + "iteration": "1", + "goal_success": "1", + "goal_name": "reduce-failures", + "goal_score": "0.8", + }, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "", + "iteration": "2", + "goal_success": "0", + "goal_name": "reduce-failures", + "goal_score": "0.2", + "relevance_score": "0.6", + }, ] result = compute_rates(patterns, outcomes, max_iteration=2) # goal_success=1 -> 1.0, goal_success=0 -> 0.6 * 0.5 = 0.3 @@ -296,14 +394,37 @@ def test_goal_weighting(self) -> None: def test_low_success_marks_review(self) -> None: patterns = [ - {"id": "P-001", "summary": "test trigger summary", "confidence": "high", - "success_rate": "0.80", "flags": "", - "category": "pattern", "seen_count": "5", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "test trigger summary", + "confidence": "high", + "success_rate": "0.80", + "flags": "", + "category": "pattern", + "seen_count": "5", + "applies_to": "*", + "context": "test", + }, ] outcomes = [ - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "1", "iteration": "1"}, - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "1", "iteration": "2"}, - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "", "iteration": "3"}, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "1", + "iteration": "1", + }, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "1", + "iteration": "2", + }, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "", + "iteration": "3", + }, ] result = compute_rates(patterns, outcomes, max_iteration=3) # 1 verified out of 3 = 0.33 @@ -313,12 +434,25 @@ def test_low_success_marks_review(self) -> None: def test_stale_pattern(self) -> None: patterns = [ - {"id": "P-001", "summary": "test trigger summary", "confidence": "high", - "success_rate": "0.80", "flags": "", - "category": "pattern", "seen_count": "5", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "test trigger summary", + "confidence": "high", + "success_rate": "0.80", + "flags": "", + "category": "pattern", + "seen_count": "5", + "applies_to": "*", + "context": "test", + }, ] outcomes = [ - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "", "iteration": "1"}, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "", + "iteration": "1", + }, ] # max_iteration=11, last application at iteration 1 -> stale (11-1 >= 10) result = compute_rates(patterns, outcomes, max_iteration=11) @@ -326,14 +460,32 @@ def test_stale_pattern(self) -> None: def test_confidence_transitions(self) -> None: patterns = [ - {"id": "P-001", "summary": "some summary", "confidence": "low", - "success_rate": "0.20", "flags": "[REVIEW]", - "category": "pattern", "seen_count": "5", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "some summary", + "confidence": "low", + "success_rate": "0.20", + "flags": "[REVIEW]", + "category": "pattern", + "seen_count": "5", + "applies_to": "*", + "context": "test", + }, ] # All successful -> high confidence outcomes = [ - {"pattern_trigger": "some summary", "status": "applied", "unverified": "", "iteration": "1"}, - {"pattern_trigger": "some summary", "status": "applied", "unverified": "", "iteration": "2"}, + { + "pattern_trigger": "some summary", + "status": "applied", + "unverified": "", + "iteration": "1", + }, + { + "pattern_trigger": "some summary", + "status": "applied", + "unverified": "", + "iteration": "2", + }, ] result = compute_rates(patterns, outcomes, max_iteration=2) assert result[0]["confidence"] == "high" @@ -342,13 +494,25 @@ def test_confidence_transitions(self) -> None: def test_fuzzy_matching_across_patterns(self) -> None: patterns = [ - {"id": "P-001", "summary": "check token expiry", "confidence": "medium", - "success_rate": "", "flags": "[UNTESTED]", - "category": "pattern", "seen_count": "1", "applies_to": "*", "context": "auth"}, + { + "id": "P-001", + "summary": "check token expiry", + "confidence": "medium", + "success_rate": "", + "flags": "[UNTESTED]", + "category": "pattern", + "seen_count": "1", + "applies_to": "*", + "context": "auth", + }, ] outcomes = [ - {"pattern_trigger": "always check token expiry before API", "status": "applied", - "unverified": "", "iteration": "1"}, + { + "pattern_trigger": "always check token expiry before API", + "status": "applied", + "unverified": "", + "iteration": "1", + }, ] result = compute_rates(patterns, outcomes, max_iteration=1) # "check token expiry" is a substring of "always check token expiry before API" @@ -357,17 +521,37 @@ def test_fuzzy_matching_across_patterns(self) -> None: def test_injected_outcomes_count_as_unverified(self) -> None: """Injected (not explicitly applied) outcomes should lower success rate.""" patterns = [ - {"id": "P-001", "summary": "test trigger summary", "confidence": "medium", - "success_rate": "", "flags": "[UNTESTED]", - "category": "pattern", "seen_count": "1", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "test trigger summary", + "confidence": "medium", + "success_rate": "", + "flags": "[UNTESTED]", + "category": "pattern", + "seen_count": "1", + "applies_to": "*", + "context": "test", + }, ] outcomes = [ - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "", - "iteration": "1"}, - {"pattern_trigger": "test trigger summary", "status": "injected", "unverified": "1", - "iteration": "2"}, - {"pattern_trigger": "test trigger summary", "status": "injected", "unverified": "1", - "iteration": "3"}, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "", + "iteration": "1", + }, + { + "pattern_trigger": "test trigger summary", + "status": "injected", + "unverified": "1", + "iteration": "2", + }, + { + "pattern_trigger": "test trigger summary", + "status": "injected", + "unverified": "1", + "iteration": "3", + }, ] result = compute_rates(patterns, outcomes, max_iteration=3) # 1 applied + 2 injected (unverified) = 1/3 = 0.33 @@ -377,12 +561,25 @@ def test_injected_outcomes_count_as_unverified(self) -> None: def test_idempotent_double_run(self) -> None: """Running compute_rates twice should produce the same result.""" patterns = [ - {"id": "P-001", "summary": "test trigger summary", "confidence": "medium", - "success_rate": "", "flags": "[UNTESTED]", - "category": "pattern", "seen_count": "1", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "test trigger summary", + "confidence": "medium", + "success_rate": "", + "flags": "[UNTESTED]", + "category": "pattern", + "seen_count": "1", + "applies_to": "*", + "context": "test", + }, ] outcomes = [ - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "", "iteration": "1"}, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "", + "iteration": "1", + }, ] result1 = compute_rates(patterns, outcomes, max_iteration=1) result2 = compute_rates(result1, outcomes, max_iteration=1) @@ -391,13 +588,14 @@ def test_idempotent_double_run(self) -> None: # --- TOON round-trip --- + class TestToonRoundTrip: def test_round_trip_preserves_content(self, tmp_path: Path) -> None: toon = tmp_path / "org-patterns.toon" original = ( - '# Organization Patterns\n' - '# Last updated: 2024-01-15T10:30:00Z\n' - 'patterns[2]{id,category,summary,confidence,seen_count,success_rate,flags,applies_to,context,repo}:\n' + "# Organization Patterns\n" + "# Last updated: 2024-01-15T10:30:00Z\n" + "patterns[2]{id,category,summary,confidence,seen_count,success_rate,flags,applies_to,context,repo}:\n" ' P-001,pattern,"Always run tests",high,5,0.85,,*,test|CI,*\n' ' P-002,mistake,"Check for None before accessing",medium,3,0.60,[REVIEW],agent1,python|safety,my-repo\n' ) @@ -424,7 +622,7 @@ def test_round_trip_with_embedded_quotes(self, tmp_path: Path) -> None: toon = tmp_path / "org-patterns.toon" # RFC 4180: embedded quotes are doubled ("") inside quoted fields toon.write_text( - 'patterns[1]{id,category,summary,confidence,seen_count,success_rate,flags,applies_to,context,repo}:\n' + "patterns[1]{id,category,summary,confidence,seen_count,success_rate,flags,applies_to,context,repo}:\n" ' P-001,pattern,"Use ""strict"" mode for TypeScript",high,3,0.90,,*,typescript,*\n' ) @@ -444,12 +642,14 @@ def test_round_trip_with_embedded_quotes(self, tmp_path: Path) -> None: def test_quote_if_needed_with_quotes(self, tmp_path: Path) -> None: """Flags containing quotes should use RFC 4180 double-quote escaping.""" from compute_success_rates import _quote_if_needed + # Value with embedded quote result = _quote_if_needed('value with "quotes"') assert result == '"value with ""quotes"""' # Round-trip through csv.reader import csv import io + reader = csv.reader(io.StringIO(result)) parsed = next(reader) assert parsed[0] == 'value with "quotes"' @@ -457,26 +657,53 @@ def test_quote_if_needed_with_quotes(self, tmp_path: Path) -> None: # --- Flag assignment --- + class TestFlagAssignment: def test_untested_no_applications(self) -> None: patterns = [ - {"id": "P-001", "summary": "unique summary xyz", "confidence": "medium", - "success_rate": "", "flags": "[UNTESTED]", - "category": "pattern", "seen_count": "1", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "unique summary xyz", + "confidence": "medium", + "success_rate": "", + "flags": "[UNTESTED]", + "category": "pattern", + "seen_count": "1", + "applies_to": "*", + "context": "test", + }, ] result = compute_rates(patterns, [], max_iteration=5) assert result[0]["flags"] == "[UNTESTED]" def test_review_flag_low_rate(self) -> None: patterns = [ - {"id": "P-001", "summary": "test trigger summary", "confidence": "high", - "success_rate": "0.80", "flags": "", - "category": "pattern", "seen_count": "5", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "test trigger summary", + "confidence": "high", + "success_rate": "0.80", + "flags": "", + "category": "pattern", + "seen_count": "5", + "applies_to": "*", + "context": "test", + }, ] # All unverified -> 0% success outcomes = [ - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "1", "iteration": "1"}, - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "1", "iteration": "2"}, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "1", + "iteration": "1", + }, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "1", + "iteration": "2", + }, ] result = compute_rates(patterns, outcomes, max_iteration=2) assert result[0]["flags"] == "[REVIEW]" @@ -484,12 +711,25 @@ def test_review_flag_low_rate(self) -> None: def test_stale_overrides_review(self) -> None: """[STALE] takes precedence when both stale and low rate.""" patterns = [ - {"id": "P-001", "summary": "test trigger summary", "confidence": "high", - "success_rate": "0.80", "flags": "", - "category": "pattern", "seen_count": "5", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "test trigger summary", + "confidence": "high", + "success_rate": "0.80", + "flags": "", + "category": "pattern", + "seen_count": "5", + "applies_to": "*", + "context": "test", + }, ] outcomes = [ - {"pattern_trigger": "test trigger summary", "status": "applied", "unverified": "1", "iteration": "1"}, + { + "pattern_trigger": "test trigger summary", + "status": "applied", + "unverified": "1", + "iteration": "1", + }, ] # Stale: iteration 1, max_iteration 11 result = compute_rates(patterns, outcomes, max_iteration=11) @@ -498,14 +738,26 @@ def test_stale_overrides_review(self) -> None: def test_prune_flag_high_count_low_rate(self) -> None: """[PRUNE] when applied_count > 20 and success_rate < 0.40.""" patterns = [ - {"id": "P-001", "summary": "prune me trigger", "confidence": "high", - "success_rate": "0.80", "flags": "", - "category": "pattern", "seen_count": "25", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "prune me trigger", + "confidence": "high", + "success_rate": "0.80", + "flags": "", + "category": "pattern", + "seen_count": "25", + "applies_to": "*", + "context": "test", + }, ] # 21 outcomes, all unverified (0% success) outcomes = [ - {"pattern_trigger": "prune me trigger", "status": "applied", - "unverified": "1", "iteration": str(i)} + { + "pattern_trigger": "prune me trigger", + "status": "applied", + "unverified": "1", + "iteration": str(i), + } for i in range(1, 22) ] result = compute_rates(patterns, outcomes, max_iteration=21) @@ -516,14 +768,26 @@ def test_prune_flag_high_count_low_rate(self) -> None: def test_no_prune_below_threshold_count(self) -> None: """No [PRUNE] when applied_count <= 20 even with low success.""" patterns = [ - {"id": "P-001", "summary": "not enough applications trigger", "confidence": "high", - "success_rate": "0.80", "flags": "", - "category": "pattern", "seen_count": "20", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "not enough applications trigger", + "confidence": "high", + "success_rate": "0.80", + "flags": "", + "category": "pattern", + "seen_count": "20", + "applies_to": "*", + "context": "test", + }, ] # 20 outcomes, all unverified (0% success) - at threshold, not above outcomes = [ - {"pattern_trigger": "not enough applications trigger", "status": "applied", - "unverified": "1", "iteration": str(i)} + { + "pattern_trigger": "not enough applications trigger", + "status": "applied", + "unverified": "1", + "iteration": str(i), + } for i in range(1, 21) ] result = compute_rates(patterns, outcomes, max_iteration=20) @@ -532,25 +796,40 @@ def test_no_prune_below_threshold_count(self) -> None: def test_no_prune_above_success_threshold(self) -> None: """No [PRUNE] when success_rate >= 0.40 even with many applications.""" patterns = [ - {"id": "P-001", "summary": "above success threshold trigger", "confidence": "medium", - "success_rate": "0.50", "flags": "", - "category": "pattern", "seen_count": "25", "applies_to": "*", "context": "test"}, + { + "id": "P-001", + "summary": "above success threshold trigger", + "confidence": "medium", + "success_rate": "0.50", + "flags": "", + "category": "pattern", + "seen_count": "25", + "applies_to": "*", + "context": "test", + }, ] # 21 outcomes: 9 verified (success) + 12 unverified => 9/21 = 0.43 outcomes = [ - {"pattern_trigger": "above success threshold trigger", "status": "applied", - "unverified": "", "iteration": str(i)} + { + "pattern_trigger": "above success threshold trigger", + "status": "applied", + "unverified": "", + "iteration": str(i), + } for i in range(1, 10) ] + [ - {"pattern_trigger": "above success threshold trigger", "status": "applied", - "unverified": "1", "iteration": str(i)} + { + "pattern_trigger": "above success threshold trigger", + "status": "applied", + "unverified": "1", + "iteration": str(i), + } for i in range(10, 22) ] result = compute_rates(patterns, outcomes, max_iteration=21) assert result[0]["flags"] != "[PRUNE]" - def test_main_ignores_legacy_home_toon(tmp_path: Path) -> None: """CLI should not fall back to `~/.claude/.learnings/org-patterns.toon`.""" workdir = tmp_path / "workdir" @@ -576,5 +855,5 @@ def test_main_ignores_legacy_home_toon(tmp_path: Path) -> None: ) assert result.returncode == 0 - expected = home_dir / ".closedloop-ai" / "learnings" / "org-patterns.toon" + expected = home_dir / CLOSEDLOOP_STATE_DIR / "learnings" / "org-patterns.toon" assert f"No TOON file found at {expected}" in result.stderr diff --git a/plugins/self-learning/tools/python/write_merged_patterns.py b/plugins/self-learning/tools/python/write_merged_patterns.py index 9cefb835..dfea3832 100644 --- a/plugins/self-learning/tools/python/write_merged_patterns.py +++ b/plugins/self-learning/tools/python/write_merged_patterns.py @@ -20,7 +20,8 @@ # --- Constants --- -DEFAULT_TOON_PATH = Path.home() / ".closedloop-ai" / "learnings" / "org-patterns.toon" +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" +DEFAULT_TOON_PATH = Path.home() / CLOSEDLOOP_STATE_DIR / "learnings" / "org-patterns.toon" PATTERN_CAP = 50 VALID_CATEGORIES = {"mistake", "pattern", "convention", "insight"}