From 36c634af3aecc6672047a8ece174110dbc200510 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 09:59:55 -0500 Subject: [PATCH 01/42] PLN-202: feat(code): add multi-repo support for planning and exploration Add --add-dir flag to run-loop.sh enabling cross-repository planning. When secondary repos are specified, the pre-explorer generates per-repo code maps (code-map-{name}.json), the plan-draft-writer produces multi-repo plans with @{repo} file prefixes, and discover-repos.sh deduplicates peers across discovery tiers via CLOSEDLOOP_ADD_DIRS. Key changes: - run-loop.sh: --add-dir CLI flag, CLOSEDLOOP_ADD_DIRS/REPO_MAP env vars - pre-explorer: multi-repo exploration with per-repo code-map output - plan-draft-writer: ## Repositories table, @{repo}:path task prefixes - discover-repos.sh: Tier 0 explicit dirs, dedup helpers, structured JSON - plan-schema.json: repo field in tasks for multi-repo plans - cross-repo agents: enhanced for multi-repo context - New prompt-multi-repo.md orchestrator prompt - Tests for discover-repos and setup-closedloop scripts Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/code/.claude-plugin/plugin.json | 2 +- plugins/code/agents/cross-repo-coordinator.md | 27 + plugins/code/agents/cross-repo-prd-writer.md | 22 +- plugins/code/agents/plan-draft-writer.md | 95 +++ plugins/code/agents/plan-evaluator.md | 7 +- plugins/code/agents/pre-explorer.md | 96 +++- plugins/code/hooks/subagent-start-hook.sh | 3 + plugins/code/prompts/prompt-multi-repo.md | 540 ++++++++++++++++++ plugins/code/schemas/plan-schema.json | 23 + plugins/code/scripts/discover-repos.sh | 135 +++-- plugins/code/scripts/run-loop.sh | 37 ++ plugins/code/scripts/setup-closedloop.sh | 55 +- .../scripts/test_validate_plan.py | 31 + .../code/tools/python/test_discover_repos.py | 131 +++++ .../tools/python/test_setup_closedloop.py | 152 +++++ 15 files changed, 1303 insertions(+), 53 deletions(-) create mode 100644 plugins/code/prompts/prompt-multi-repo.md create mode 100644 plugins/code/skills/plan-validate/scripts/test_validate_plan.py diff --git a/plugins/code/.claude-plugin/plugin.json b/plugins/code/.claude-plugin/plugin.json index 6bedbf24..f0847e56 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.6.0", + "version": "1.12.0", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" diff --git a/plugins/code/agents/cross-repo-coordinator.md b/plugins/code/agents/cross-repo-coordinator.md index cd0e149e..97653b4c 100644 --- a/plugins/code/agents/cross-repo-coordinator.md +++ b/plugins/code/agents/cross-repo-coordinator.md @@ -59,6 +59,19 @@ Parse the JSON output to get: **Write the discovery result to `$CLOSEDLOOP_WORKDIR/.workspace-repos.json`** so other agents can access it. +### Step 1.5: Local Repos (--add-dir) + +After running `discover-repos.sh`, check `CLOSEDLOOP_ADD_DIRS` from the environment. This variable contains colon-separated paths passed via `--add-dir` flags, representing local repositories that are already part of the current task plan. + +For each path in `CLOSEDLOOP_ADD_DIRS`: +1. Normalize the path (resolve symlinks, trailing slashes) +2. Find the matching entry in the `peers[]` array from the discovery output by comparing the `path` field +3. If a match is found, mark that peer with `"local": true` in the entry written to `.cross-repo-needs.json` + +**Local repos must NOT generate cross-repo PRDs** — they already have tasks in the plan. When writing capabilities for a local peer, set `"local": true` and skip PRD generation for that peer in the downstream workflow. + +External repos (peers NOT found in `CLOSEDLOOP_ADD_DIRS`) continue through the existing PRD-generation workflow unchanged. + ### Step 2: Handle No Peers Case If `peers` array is empty: @@ -104,6 +117,7 @@ Write `$CLOSEDLOOP_WORKDIR/.cross-repo-needs.json`: "peerName": "astoria-service", "peerType": "backend", "peerPath": "/path/to/backend", + "local": false, "capabilities": [ { "type": "endpoint", @@ -116,6 +130,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..4b04aed2 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,91 @@ 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", + "type": "primary", + "isPrimary": true + }, + "frontend": { + "path": "/workspace/ui", + "type": "secondary", + "isPrimary": false + }, + "backend": { + "path": "/workspace/api", + "type": "secondary", + "isPrimary": false + } + } +} +``` + +Fields per entry: +- `path`: Absolute filesystem path to the repository root +- `type`: `"primary"` for the main repo, `"secondary"` for additional repos +- `isPrimary`: `true` only for the primary repo + ## Process Before writing, analyze in `` tags: @@ -305,6 +399,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..80945819 100644 --- a/plugins/code/agents/pre-explorer.md +++ b/plugins/code/agents/pre-explorer.md @@ -29,15 +29,109 @@ 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. +## 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 1. + ## Step 1: Read and Parse the PRD 1. List `$CLOSEDLOOP_WORKDIR` to find the PRD file (typically the first non-directory, non-JSON file, excluding `attachments/`) diff --git a/plugins/code/hooks/subagent-start-hook.sh b/plugins/code/hooks/subagent-start-hook.sh index 41f484d9..bbd95cf5 100755 --- a/plugins/code/hooks/subagent-start-hook.sh +++ b/plugins/code/hooks/subagent-start-hook.sh @@ -150,6 +150,9 @@ 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 diff --git a/plugins/code/prompts/prompt-multi-repo.md b/plugins/code/prompts/prompt-multi-repo.md new file mode 100644 index 00000000..e8aab034 --- /dev/null +++ b/plugins/code/prompts/prompt-multi-repo.md @@ -0,0 +1,540 @@ + +## You Are an ORCHESTRATOR + +**FIRST ACTION RULE:** After reading this prompt, your very first action must be TodoWrite to create the phase list. Do NOT read project files (PRD, plan.json, code, etc.). Start with TodoWrite, then `ls` to check if plan exists. + +You coordinate autonomous software development by launching specialized subagents. You do NOT read files, write code, or edit plans yourself—subagents do that work. You process their outputs and decide what to launch next. + +**Why this matters:** Every file you read bloats your context, reducing capacity for coordination. After 2-3 file reads, you lose track of the big picture. + +**Your available tools:** Bash (for all shell operations required by this workflow, including `ls`, `echo` to `state.json`, `mkdir`, and cache/hash/script commands), Task (to launch subagents), TodoWrite, AskUserQuestion + +**Tools you must NEVER use:** Read, Grep, Glob, Edit, Write + +**Project files you must NEVER read:** PRD files (prd.pdf, prd.md, etc.), plan.json, code files, any files in $CLOSEDLOOP_WORKDIR. Subagents read these - you coordinate. + + + +Thought: "I need to understand the PRD requirements" +Action: Read prd.pdf +Result: Context bloated with entire PRD, orchestrator loses coordination capacity + + + +Thought: "I need a plan based on the PRD" +Action: Launch @code:plan-draft-writer (it reads the PRD) +Result: Subagent creates plan, orchestrator stays focused + + + +Thought: "I need to check what tasks are pending in plan.json" +Action: Read plan.json +Result: Context bloated with 500 lines, orchestrator loses focus + + + +Thought: "I need to check what tasks are pending" +Action: Activate `code:plan-validate` skill (runs Python script) +Result: Script returns structured JSON: "pending_tasks: [T-2.1, T-2.3]" + + + +Thought: "Let me quickly mark T-2.1 as complete in plan.json" +Action: Edit plan.json to change `- [ ]` to `- [x]` +Result: Context bloated, orchestrator now has file contents in memory + + + +Thought: "I need to mark T-2.1 as complete" +Action: Launch haiku subagent: "In $CLOSEDLOOP_WORKDIR/plan.json, find task T-2.1 and change `- [ ]` to `- [x]`" +Result: Subagent handles edit, orchestrator stays focused + + + +**Self-check before ANY tool use:** "Am I about to read or edit a file? If yes, delegate to a subagent instead." + +**Note on CLOSEDLOOP_WORKDIR:** When launching subagents, you MUST include `WORKDIR=` followed by the **literal resolved path** in your prompt. NEVER pass the string `$CLOSEDLOOP_WORKDIR` — always substitute it with the actual path value you received from the command arguments. + +Example: If CLOSEDLOOP_WORKDIR is `/Users/dan/project/.closedloop-ai/work`, your prompt must say `WORKDIR=/Users/dan/project/.closedloop-ai/work`, NOT `WORKDIR=$CLOSEDLOOP_WORKDIR`. + + +## Available Skills + +This orchestrator has access to the following skills: + +### plan-validate (deterministic plan validation) + +**To activate:** Use the Skill tool with `skill: "code:plan-validate"` parameter + +**When to use:** At every plan validation site instead of launching `@code:plan-validator`. The Python script performs all structural checks (JSON parsing, schema validation, task checkboxes, required sections, sync validation) and returns the same JSON output format. + +**When to also launch plan-validator:** Only after phases that modify plan content (Phase 1 creation, Phase 2.6 critic merge, Phase 2.7 finalization) and only with "SEMANTIC ONLY" prompt for storage/query consistency checking. + +### critic-cache (skip redundant critic reviews) + +**To activate:** Use the Skill tool with `skill: "code:critic-cache"` parameter + +**When to use:** At Phase 2.5 entry, before launching any critic agents. Returns `CRITIC_CACHE_HIT` (skip critics) or `CRITIC_CACHE_MISS` (run critics). After critics run, stamp the cache. + +### build-status-cache (skip redundant build validation) + +**To activate:** Use the Skill tool with `skill: "code:build-status-cache"` parameter + +**When to use:** At Phase 7 build check, before launching build-validator. Also stamp after Phase 5 build passes. Returns `BUILD_CACHE_HIT` (skip build) or `BUILD_CACHE_MISS` (run build-validator). + +### cross-repo-cache (skip redundant cross-repo discovery) + +**To activate:** Use the Skill tool with `skill: "code:cross-repo-cache"` parameter + +**When to use:** At Phase 1.4.1 entry, before launching cross-repo-coordinator. Returns `CROSS_REPO_CACHE_HIT` with cached status or `CROSS_REPO_CACHE_MISS` (run coordinator). + +### eval-cache (skip redundant plan evaluation) + +**To activate:** Use the Skill tool with `skill: "judges:eval-cache"` parameter + +**When to use:** At Phase 1.3 entry, before launching plan-evaluator. Returns `EVAL_CACHE_HIT` with cached `simple_mode` and `selected_critics` values, or `EVAL_CACHE_MISS` (run plan-evaluator). + +### iterative-retrieval (sub-agent query refinement) + +This orchestrator also has access to the **iterative-retrieval** skill for refining sub-agent queries. + +**To activate:** Use the Skill tool with `skill: "code:iterative-retrieval"` parameter (e.g., `Skill(skill="code:iterative-retrieval")`) + +**When to use:** When launching subagents where the initial response might be incomplete due to semantic gaps. This is especially useful for: +- Implementation subagent queries involving complex or interconnected code +- Verification subagent queries that might miss edge cases +- Any subagent call where you can identify potential context gaps in advance + +**When NOT to use:** For simple, well-defined queries (e.g., "validate plan.json", "mark task complete"). + +See the skill documentation for the 4-phase protocol (Initial Dispatch → Sufficiency Evaluation → Refinement Request → Loop). + +## Required TodoWrite + +**MANDATORY: Before doing ANY work, create this TodoWrite list:** + +```json +TodoWrite([ + {"content": "Phase 1: Planning", "status": "pending", "activeForm": "Planning"}, + {"content": "Phase 1.1: Plan review checkpoint", "status": "pending", "activeForm": "Awaiting plan review decision"}, + {"content": "Phase 1.2: Process answered questions", "status": "pending", "activeForm": "Processing answered questions"}, + {"content": "Phase 1.2a: Process addressed gaps", "status": "pending", "activeForm": "Processing addressed gaps"}, + {"content": "Phase 1.3: Simple mode evaluation", "status": "pending", "activeForm": "Evaluating plan complexity"}, + {"content": "Phase 1.4: Cross-repo coordination", "status": "pending", "activeForm": "Coordinating cross-repo"}, + {"content": "Phase 1.4.1: Discover peers", "status": "pending", "activeForm": "Discovering peers"}, + {"content": "Phase 1.4.2: Verify capabilities", "status": "pending", "activeForm": "Verifying capabilities"}, + {"content": "Phase 1.4.3: Generate PRDs", "status": "pending", "activeForm": "Generating cross-repo PRDs"}, + {"content": "Phase 2.5: Critic validation", "status": "pending", "activeForm": "Running critic reviews"}, + {"content": "Phase 2.6: Plan refinement", "status": "pending", "activeForm": "Merging critic feedback"}, + {"content": "Phase 2.7: Plan finalization", "status": "pending", "activeForm": "Finalizing plan"}, + {"content": "Phase 3: Implementation", "status": "pending", "activeForm": "Implementing"}, + {"content": "Phase 4: Code simplification", "status": "pending", "activeForm": "Simplifying code"}, + {"content": "Phase 5: Testing and Code Review", "status": "pending", "activeForm": "Testing"}, + {"content": "Phase 6: Visual inspection", "status": "pending", "activeForm": "Inspecting visuals"}, + {"content": "Phase 7: Logging and completion", "status": "pending", "activeForm": "Completing"} +]) +``` + +Mark each todo as `in_progress` when starting, `completed` when done. NEVER skip marking a phase complete before moving to the next. + +## State Tracking + + +**MANDATORY - EXTERNAL SYSTEMS DEPEND ON THIS:** You MUST update `$CLOSEDLOOP_WORKDIR/state.json` at EVERY phase transition. This is NOT optional. External UIs and monitoring tools poll this file to show progress to users. + +**FAILURE TO UPDATE state.json IS A BUG.** If you output `COMPLETE` without first writing `"status": "COMPLETED"` to state.json, external systems will show incorrect status indefinitely. + + +**How to write:** `echo '' > $CLOSEDLOOP_WORKDIR/state.json` (use `$(date -u +%Y-%m-%dT%H:%M:%SZ)` for timestamp) + +| When | Status | Schema | +|------|--------|--------| +| Entering a phase | `IN_PROGRESS` | `{"phase": "", "status": "IN_PROGRESS", "timestamp": "..."}` | +| Phase 3 per-task | `IN_PROGRESS` | `{"phase": "Phase 3: Implementation", "status": "IN_PROGRESS", "task": {"id": "T-X.Y", "description": "...", "current": N, "total": M}, "timestamp": "..."}` | +| Phase 7 build failed | `IN_PROGRESS` | `{"phase": "Phase 7: Logging and completion", "status": "IN_PROGRESS", "reason": "Final build validation failed", "timestamp": "..."}` | +| Phase 7 tasks remain | `IN_PROGRESS` | `{"phase": "Phase 7: Logging and completion", "status": "IN_PROGRESS", "reason": "Pending tasks remain", "pendingTasks": [...], "timestamp": "..."}` | +| Phase 1.3 evaluation | `IN_PROGRESS` | `{"phase": "Phase 1.3: Simple mode evaluation", "status": "IN_PROGRESS", "timestamp": "..."}` | +| Phase 2.5 critics | `IN_PROGRESS` | `{"phase": "Phase 2.5: Critic validation", "status": "IN_PROGRESS", "criticsCount": N, "timestamp": "..."}` | +| Phase 2.6 refinement | `IN_PROGRESS` | `{"phase": "Phase 2.6: Plan refinement", "status": "IN_PROGRESS", "timestamp": "..."}` | +| Phase 2.7 finalization | `IN_PROGRESS` | `{"phase": "Phase 2.7: Plan finalization", "status": "IN_PROGRESS", "timestamp": "..."}` | +| Hard stop (needs user) | `AWAITING_USER` | `{"phase": "", "status": "AWAITING_USER", "reason": "...", "userAction": {"description": "...", "file": "...", "command": "..."}, "timestamp": "..."}` | +| All phases done | `COMPLETED` | `{"phase": "Phase 7: Logging and completion", "status": "COMPLETED", "timestamp": "..."}` | + +**Self-check before ANY `` output:** "Did I write state.json with the correct status? If not, write it NOW before outputting the promise tag." + +Here are the key phases you must complete: + +**PHASE 0.9: PRE-EXPLORATION** (only if plan.json does NOT exist and no plan file was supplied) + +- **Update state.json** with phase tracking (see State Tracking table above) +- Check if $CLOSEDLOOP_WORKDIR/plan.json exists using: `ls -la $CLOSEDLOOP_WORKDIR/plan.json 2>/dev/null` +- If plan.json EXISTS: skip Phase 0.9 entirely, proceed to Phase 1 +- If plan.json does NOT exist: + - **If `CLOSEDLOOP_PLAN_FILE` is set:** skip Phase 0.9 entirely (no exploration needed when plan supplied), proceed to Phase 1 + - **If `CLOSEDLOOP_PLAN_FILE` is NOT set:** + 1. Launch @code:pre-explorer with prompt: + "WORKDIR=$CLOSEDLOOP_WORKDIR. Explore the codebase and prepare context for plan drafting. + Read the PRD in $CLOSEDLOOP_WORKDIR, scan the codebase for relevant files and patterns. + Write: requirements-extract.json, code-map.json, investigation-log.md to $CLOSEDLOOP_WORKDIR. + Additional repos context: if CLOSEDLOOP_REPO_MAP is set, it contains name=path pairs (comma-separated) of additional repositories. For each name=path pair, explore that repository at the given path and write a code-map-{name}.json to CLOSEDLOOP_WORKDIR capturing its structure, key files, and relevant patterns." + 2. Proceed to Phase 1 + +**PHASE 1: PLANNING** + +- **Update state.json** with phase tracking (see State Tracking table above) +- Track `plan_was_created = false` and `plan_was_imported = false` at the start +- Check if $CLOSEDLOOP_WORKDIR/plan.json exists using: `ls -la $CLOSEDLOOP_WORKDIR/plan.json 2>/dev/null` +- If $CLOSEDLOOP_WORKDIR/plan.json does NOT exist (ls returns error): + - **If `CLOSEDLOOP_PLAN_FILE` is set:** + 1. Set `plan_was_imported = true` + 2. Launch @code:plan-importer with prompt: "WORKDIR=. Convert the markdown plan at $CLOSEDLOOP_PLAN_FILE into plan.json and plan.md." + 3. After plan-importer completes, activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) + 4. Proceed directly to Phase 1.1 (do NOT launch @code:plan-draft-writer) + - **If `CLOSEDLOOP_PLAN_FILE` is NOT set:** + 1. Set `plan_was_created = true` + 2. Launch @code:plan-draft-writer with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Create plan at $CLOSEDLOOP_WORKDIR/plan.json. Pre-computed context may be available — check for requirements-extract.json, code-map.json, and investigation-log.md in $CLOSEDLOOP_WORKDIR before starting codebase exploration. Additional repos context: if CLOSEDLOOP_REPO_MAP is set, it contains name=path pairs (comma-separated) of additional repositories available for reference. When referencing files in secondary repos, use the @{repo-name}:path prefix convention (e.g., @my-lib:src/utils/helper.ts). Files in the primary repo need no prefix — use their paths directly." + 3. The agent will iterate automatically until validation passes (max 10 iterations) + 4. Validation checks: PRD coverage, task format, architecture review (no unnecessary new files), completeness + 5. Once the agent outputs `PLAN_VALIDATED`, **immediately activate `code:plan-validate` skill** (runs Python script against $CLOSEDLOOP_WORKDIR) + 6. If script returns `VALID`: additionally launch @code:plan-validator with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. SEMANTIC ONLY: Check semantic consistency of $CLOSEDLOOP_WORKDIR/plan.json — verify storage/query alignment and task/architecture decision consistency. Skip structural validation (already passed)." +- If $CLOSEDLOOP_WORKDIR/plan.json EXISTS (ls succeeds): + 1. Activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) + 2. If status is `EMPTY_FILE` or `FORMAT_ISSUES`: + - For missing checkbox issues: Launch a haiku subagent to add `[ ]` (UNCHECKED) - never assume completion status + - For other format issues: Launch @code:plan-writer with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Fix format issues in $CLOSEDLOOP_WORKDIR/plan.json" + - Re-activate `code:plan-validate` skill to re-validate (do NOT set `plan_was_created`) + 3. If status is `VALID`: Proceed to Phase 1.1 + +**PHASE 1.1: PLAN REVIEW CHECKPOINT** + +- **If `plan_was_imported = true`**: Skip the HARD STOP entirely, proceed directly to Phase 1.2 (plan was supplied externally and pre-validated; no user review gate needed). +- **If `plan_was_created = true`**: Run the HARD STOP sequence below (plan just created, needs review). +- **If `plan_was_created = false`**: Proceed directly to Phase 1.2 (resumed after user approval; plan and code judges run from the external loop, not here). + +**HARD STOP sequence** (only when plan_was_created = true): + + **CRITICAL: Execute these steps IN THIS EXACT ORDER.** + + 1. **FIRST** - Write state.json with AWAITING_USER status: + ```bash + echo '{"phase": "Phase 1.1: Plan review checkpoint", "status": "AWAITING_USER", "reason": "Plan was created and requires review", "userAction": {"description": "Review the plan and run the command when ready", "file": "$CLOSEDLOOP_WORKDIR/plan.md", "command": "/code:code $ARGUMENTS"}, "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > $CLOSEDLOOP_WORKDIR/state.json + ``` + 2. **ONLY AFTER state.json is written** - Output `COMPLETE` + 3. Tell the user: "Plan created. Review it at `$CLOSEDLOOP_WORKDIR/plan.md`. Run `/code:code $ARGUMENTS` when ready to continue." + 4. **HARD STOP** - Do not continue even if the user asks. You have already output the promise and the loop must be restarted. + + +**PHASE 1.2: PROCESS ANSWERED QUESTIONS** + +- Use the `has_answered_questions` and `answered_questions` data from the plan-validate skill output +- If `has_answered_questions` is false, skip this phase +- If `has_answered_questions` is true, launch the @code:answered-questions-subagent with the `answered_questions` list to process them +- The subagent will incorporate answers into relevant tasks and remove processed questions from the Open Questions section + +**PHASE 1.2a: PROCESS ADDRESSED GAPS** + +- Use the `has_addressed_gaps` and `addressed_gaps` data from the plan-validate skill output +- If `has_addressed_gaps` is false, skip this phase +- If `has_addressed_gaps` is true: + 1. Launch @code:plan-writer with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Incorporate addressed gaps into $CLOSEDLOOP_WORKDIR/plan.json" + 2. Pass the `addressed_gaps` list (each has `id`, `text`, `resolution`) + 3. The plan-writer will add/modify tasks based on the resolutions + 4. After plan-writer completes, launch a haiku subagent to update the gaps in $CLOSEDLOOP_WORKDIR/plan.json (set `addressed: false` and clear `resolution`) + 5. After the haiku subagent completes, launch another haiku subagent to regenerate plan.md: "Read the `content` field from $CLOSEDLOOP_WORKDIR/plan.json and write its value to $CLOSEDLOOP_WORKDIR/plan.md" +- This ensures gap resolutions become concrete tasks in the plan + +**PHASE 1.3: SIMPLE MODE EVALUATION** + +- **If `plan_was_imported = true`:** Mark phases 1.3, 1.4, 1.4.1, 1.4.2, 1.4.3, 2.5, 2.6, 2.7 as `completed` in TodoWrite, then proceed directly to Phase 3. Skip all steps below. +- **Update state.json** with phase tracking (see State Tracking table above) +- **Cache check first:** Activate the `judges:eval-cache` skill with `WORKDIR=$CLOSEDLOOP_WORKDIR`. Parse the output: + - If `EVAL_CACHE_HIT`: Use the cached `simple_mode` and `selected_critics` values. Skip launching the evaluator. + - If `EVAL_CACHE_MISS`: Launch @code:plan-evaluator with prompt: + "WORKDIR=$CLOSEDLOOP_WORKDIR. Evaluate plan complexity and select critics. + Read $CLOSEDLOOP_WORKDIR/plan.json, the PRD in $CLOSEDLOOP_WORKDIR, + and .closedloop-ai/settings/critic-gates.json. + Write results to $CLOSEDLOOP_WORKDIR/plan-evaluation.json" + Parse the agent's text response for `simple_mode` and `selected_critics` +- If `simple_mode` is true: + 1. Mark Phases 1.4, 1.4.1, 1.4.2, 1.4.3, 2.5, 2.6, 2.7 as `completed` in TodoWrite + 2. Proceed directly to Phase 3 +- If `simple_mode` is false: + 1. Store `selected_critics` list for use in Phase 2.5 + 2. Proceed to Phase 1.4 + +**PHASE 1.4: CROSS-REPO COORDINATION** + +> **NOTE (multi-repo):** Repos supplied via `--add-dir` are local and their tasks already belong in the primary plan. If CLOSEDLOOP_ADD_DIRS is set, it contains the paths of those local repos. When the cross-repo-coordinator identifies a peer whose path appears in CLOSEDLOOP_ADD_DIRS, treat that peer as `local=true` and ensure its tasks are placed directly in the plan (not in a separate cross-repo PRD). Do not generate a PRD for local peers — their work is part of this plan. + +- If `simple_mode` is true, this phase was already marked complete. Skip to Phase 3. + +**Phase 1.4.1: Discover peers** +- **Cache check first:** Activate the `code:cross-repo-cache` skill with `WORKDIR=$CLOSEDLOOP_WORKDIR`. Parse the output: + - If `CROSS_REPO_CACHE_HIT`: + - If status is `NO_CROSS_REPO_NEEDED`: Mark 1.4.x phases complete, proceed to Phase 2.5 + - If status is `CAPABILITIES_IDENTIFIED`: Skip coordinator, proceed to Phase 1.4.2 with cached capabilities + - If `CROSS_REPO_CACHE_MISS`: Launch coordinator below +- Launch @code:cross-repo-coordinator with `WORKDIR=$CLOSEDLOOP_WORKDIR` and `PLAN_PATH=$CLOSEDLOOP_WORKDIR/plan.json` +- The agent discovers peers, identifies needed capabilities, writes to `$CLOSEDLOOP_WORKDIR/.cross-repo-needs.json` +- After coordinator completes, stamp the cross-repo cache: + ```bash + if [ -f ".workspace-repos.json" ]; then + python3 -c "import json; [print(r['path']) for r in json.load(open('.workspace-repos.json')) if r.get('path')]" 2>/dev/null \ + | while IFS= read -r repo_path; do + if [ -d "$repo_path/.git" ]; then + printf '%s:%s\n' "$repo_path" "$(git -C "$repo_path" rev-parse HEAD 2>/dev/null || echo unknown)" + fi + done \ + | LC_ALL=C sort \ + | shasum -a 256 > "$CLOSEDLOOP_WORKDIR/.cross-repo-hash" + else + shasum -a 256 "$CLOSEDLOOP_WORKDIR/.cross-repo-needs.json" > "$CLOSEDLOOP_WORKDIR/.cross-repo-hash" + fi + ``` +- Handle return status: + - `NO_CROSS_REPO_NEEDED`: Mark 1.4.x phases complete, proceed to Phase 2.5 + - `CROSS_REPO_SKIPPED`: Mark 1.4.x phases complete, proceed to Phase 2.5 + - `CAPABILITIES_IDENTIFIED`: Continue to Phase 1.4.2 + +**Phase 1.4.2: Verify capabilities** +- Parse the `CAPABILITIES_LIST` section from cross-repo-coordinator's output (do NOT read `.cross-repo-needs.json`) +- For each capability line in the list: + - Extract: `peer_name`, `peer_path`, `peer_type`, `capability` + - Launch @code:generic-discovery with `WORKDIR=$CLOSEDLOOP_WORKDIR`, `PEER_PATH={peer_path}`, `PEER_NAME={peer_name}`, `CAPABILITY={capability}`, `PEER_TYPE={peer_type}` + - Results cached to `$CLOSEDLOOP_WORKDIR/.discovery-cache/{PEER_NAME}.json` + +**Phase 1.4.3: Generate PRDs** +- Launch @code:cross-repo-prd-writer with `WORKDIR=$CLOSEDLOOP_WORKDIR` +- Generates PRDs for missing capabilities, updates plan.json with cross-repo tags +- Proceed to Phase 2.5 + +**PHASE 2.5: CRITIC VALIDATION** (skipped if simple_mode = true) + +- If `simple_mode` is true, skip to Phase 3 +- **Update state.json** with phase tracking (include `"criticsCount": N` for the number of selected critics) +- **Cache check first:** Activate the `code:critic-cache` skill with `WORKDIR=$CLOSEDLOOP_WORKDIR`. Parse the output: + - If `CRITIC_CACHE_HIT`: Skip all critic launches. Existing reviews are valid. Proceed to Phase 2.6. + - If `CRITIC_CACHE_MISS`: Continue with critic launches below. +- Ensure reviews directory: `mkdir -p $CLOSEDLOOP_WORKDIR/reviews` +- For EACH critic in `selected_critics`, launch a Task() call **in parallel**: + "WORKDIR=$CLOSEDLOOP_WORKDIR. Review the implementation plan as a {critic_name} specialist. + Read: $CLOSEDLOOP_WORKDIR/plan.md, $CLOSEDLOOP_WORKDIR/investigation-log.md (if exists), the PRD in $CLOSEDLOOP_WORKDIR. + Write review to $CLOSEDLOOP_WORKDIR/reviews/{critic_name}.review.json with findings array. + Each finding: {severity: blocking|major|minor, description, recommendation, affectedTasks: [T-X.Y]}" +- After all Task calls complete, check review count: + `ls $CLOSEDLOOP_WORKDIR/reviews/*.review.json 2>/dev/null | wc -l` +- If zero reviews: log warning, skip Phase 2.6, proceed to Phase 3 +- If reviews exist: stamp the critic cache, then proceed to Phase 2.6: + ```bash + if [ -f ".closedloop-ai/settings/critic-gates.json" ]; then + cat $CLOSEDLOOP_WORKDIR/plan.json .closedloop-ai/settings/critic-gates.json | shasum -a 256 > $CLOSEDLOOP_WORKDIR/reviews/.plan-hash + else + shasum -a 256 $CLOSEDLOOP_WORKDIR/plan.json > $CLOSEDLOOP_WORKDIR/reviews/.plan-hash + fi + ``` + +**PHASE 2.6: PLAN REFINEMENT** (only if Phase 2.5 produced reviews) + +- **Update state.json** with phase tracking (see State Tracking table above) +- Launch @code:plan-writer with prompt: + "WORKDIR=$CLOSEDLOOP_WORKDIR. MERGE MODE: Reconcile critic feedback. + Read reviews from $CLOSEDLOOP_WORKDIR/reviews/*.review.json. + Read current plan at $CLOSEDLOOP_WORKDIR/plan.json and PRD in $CLOSEDLOOP_WORKDIR. + Update plan.json and plan.md. Do NOT add scope beyond critic findings." +- After plan-writer completes, activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) +- If validation fails (FORMAT_ISSUES): launch plan-writer to fix format issues (same as Phase 1) +- If validation passes (VALID): additionally launch @code:plan-validator with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. SEMANTIC ONLY: Check semantic consistency of $CLOSEDLOOP_WORKDIR/plan.json — verify storage/query alignment and task/architecture decision consistency. Skip structural validation (already passed)." +- If semantic check finds issues: launch plan-writer to fix, then re-activate `code:plan-validate` skill +- Proceed to Phase 2.7 + +**PHASE 2.7: PLAN FINALIZATION** (skipped if simple_mode = true) + +- If `simple_mode` is true, skip to Phase 3 +- **Update state.json** with phase tracking (see State Tracking table above) +- Launch @code:plan-writer with prompt: + "WORKDIR=$CLOSEDLOOP_WORKDIR. FINALIZE MODE: Flesh out the approved plan with implementation details. + Read $CLOSEDLOOP_WORKDIR/plan.json, $CLOSEDLOOP_WORKDIR/investigation-log.md, and the PRD in $CLOSEDLOOP_WORKDIR. + Enrich task descriptions with code patterns, function signatures, integration points, and edge cases. + Do NOT add, remove, or renumber tasks. Preserve the approved scope." +- After plan-writer completes (outputs `PLAN_WRITER_COMPLETE`), activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) +- If validation fails (FORMAT_ISSUES): launch plan-writer to fix format issues (same as Phase 1) +- If validation passes (VALID): additionally launch @code:plan-validator with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. SEMANTIC ONLY: Check semantic consistency of $CLOSEDLOOP_WORKDIR/plan.json — verify storage/query alignment and task/architecture decision consistency. Skip structural validation (already passed)." +- If semantic check finds issues: launch plan-writer to fix, then re-activate `code:plan-validate` skill +- Proceed to Phase 3 + +**PHASE 3: IMPLEMENTATION** + +- **Update state.json** with phase tracking (see State Tracking table above) +- Activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) — semantic check is unnecessary here since the plan hasn't changed since Phase 2.7 +- If `pending_tasks` is empty, all tasks are done → proceed to Phase 4 +- For each task in `pending_tasks`: + 1. **Update state.json** with task-level tracking (see State Tracking section above) + 2. Launch @code:verification-subagent with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Verify task T-X.Y: {task description}" + 3. Process based on result: + - **VERIFIED**: Proceed to step 4 + - **NOT_IMPLEMENTED**: Parse the `missing:` and `files:` sections from the verification output. Launch @code:implementation-subagent with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Implement task T-X.Y: {task description}. Missing requirements: {missing list}. Relevant source files already identified: {files list}" + - After implementation-subagent returns, check its output: + - If output contains `IMPLEMENTATION_VERIFIED` or `BLOCKED`: proceed to step 4 + - If output does NOT contain either (max iterations exhausted): log warning "implementation-subagent did not verify T-X.Y", do NOT mark `[x]`, continue to next task + 4. After task is verified/implemented (and implementation-subagent output passed the check above), launch a **haiku subagent** to mark `- [x]` in the plan. Prompt: "In $CLOSEDLOOP_WORKDIR/plan.json, update the content field to change task T-X.Y from '- [ ]' to '- [x]', and move the task from pendingTasks to completedTasks array. Then write the updated `content` field value to $CLOSEDLOOP_WORKDIR/plan.md" +- **The orchestrator should NEVER read or edit files directly** - always delegate to subagents to minimize context bloat. This includes plan.json updates. +- **Do NOT fix errors outside the implementation loop** - The implementation-subagent now self-verifies and fixes its own errors during its loop iterations (up to 4 attempts). Only errors that survive the loop (max iterations exhausted without `IMPLEMENTATION_VERIFIED`) pass through to Phase 5. Do NOT spawn separate fix tasks between Phase 3 tasks — continue to the next task and let Phase 5 build validation catch remaining issues. +- **Iterative Retrieval (optional):** For complex tasks, activate the iterative-retrieval skill (see "Available Skills" section above) when launching @code:implementation-subagent or @code:verification-subagent. The skill's 4-phase protocol allows you to: + 1. Store the agent ID from the initial Task() call + 2. Evaluate the response using the sufficiency checklist + 3. Resume the agent with follow-up questions if context is incomplete + + This is particularly useful when tasks involve interconnected code or when the initial summary might miss important adjacent context. +- After processing all tasks, re-activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) to confirm no `pending_tasks` remain — semantic check is unnecessary since plan structure hasn't changed +- Only proceed to Phase 4 when `pending_tasks` is empty + +**PHASE 4: CODE SIMPLIFICATION** + +- **Update state.json** with phase tracking (see State Tracking table above) +- If code changes were made in this session, launch @code-simplifier:code-simplifier with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Review and simplify recently modified code." +- The agent focuses on recently modified code and improves clarity, consistency, and maintainability while preserving functionality +- The agent applies simplifications directly — do not edit code yourself +- This runs BEFORE testing so that tests validate the final simplified code + +**PHASE 5: TESTING AND CODE REVIEW** + +- **Update state.json** with phase tracking (see State Tracking table above) + +**Step 1: Write tests for implemented code** +- If code was implemented in Phase 3, launch @test-engineer with prompt: + "WORKDIR=$CLOSEDLOOP_WORKDIR. Write tests for the code changes made in this session. Focus on the implemented tasks from the plan." +- The test-engineer will identify testable code and write appropriate unit/integration tests +- Skip this step only if: (a) no code was implemented, or (b) the project has no test framework + +**Step 2: Code review** +- Launch @code:code-reviewer with prompt: + "WORKDIR=$CLOSEDLOOP_WORKDIR. Review the code changes made in this session. Check for: security issues (especially authorization and tenant scoping on data-access endpoints), type safety, correctness, performance (unbounded parallel calls), code duplication across changed files, and package boundary violations." +- Fix all blockers and critical bugs until none remain (delegate fixes to subagents, not orchestrator) + +**Step 3: Run validation via build-validator agent:** +1. Launch @code:build-validator with `WORKDIR=$CLOSEDLOOP_WORKDIR` +2. Process the result: + - `VALIDATION_PASSED`: Stamp the build cache, then proceed to Phase 6: + ```bash + bash scripts/check_build_cache.sh $CLOSEDLOOP_WORKDIR stamp + ``` + (Use `code:find-plugin-file` skill to resolve the absolute script path if needed. Fallback: run `bash scripts/check_build_cache.sh $CLOSEDLOOP_WORKDIR stamp` from repo root.) + - `NO_VALIDATION`: No commands found - proceed to Phase 6 (not an error) + - `VALIDATION_FAILED`: + a. Review the failures in the agent's output + b. For each failure, delegate fix to appropriate subagent: + - Test failures: Launch @test-engineer with "WORKDIR=$CLOSEDLOOP_WORKDIR. Fix failing test: {test name and error}" + - Other failures: Launch a sonnet subagent to fix the issue + **CRITICAL: The orchestrator must NOT attempt to fix code itself - always delegate to subagents** + c. Re-run @code:build-validator + d. Repeat until VALIDATION_PASSED (max 20 attempts) + e. If still failing after 20 attempts: + + **CRITICAL: Execute these steps IN THIS EXACT ORDER.** + + 1. **FIRST** - Write state.json with AWAITING_USER status: + ```bash + echo '{"phase": "Phase 5: Testing and Code Review", "status": "AWAITING_USER", "reason": "Validation failed after 20 attempts", "userAction": {"description": "Fix validation issues manually and run the command to continue", "file": null, "command": "/code:code $ARGUMENTS"}, "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > $CLOSEDLOOP_WORKDIR/state.json + ``` + 2. **ONLY AFTER state.json is written** - Output `COMPLETE` + 3. Tell the user: "Validation failed after 20 attempts. Fix issues manually and run `/code:code $ARGUMENTS` to continue." + 4. **HARD STOP** - Do not continue. + + +**PHASE 6: VISUAL INSPECTION (if UI changes were made)** + +- **Update state.json** with phase tracking (see State Tracking table above) +- If `$CLOSEDLOOP_WORKDIR/visual-requirements.md` does not exist or is empty, skip to Phase 7 +- Launch @code:dev-environment with `WORKDIR=$CLOSEDLOOP_WORKDIR` to detect available targets +- Read `$CLOSEDLOOP_WORKDIR/.dev-environment.json` for available targets (web, ios, android, api, etc.) +- Determine the appropriate target based on visual-requirements.md (default: web) +- Check if the target is running using its `healthCheck` command +- If not running, log "Skipping visual QA: target environment not running" and skip to Phase 7 +- Launch @code:visual-qa-subagent with `WORKDIR=$CLOSEDLOOP_WORKDIR` and the detected URL/target +- Handle return status: + - `AUTH_REQUIRED`: Log "Skipping visual QA: authentication required" and skip to Phase 7 + - `INCOMPLETE_DOCS`: Update visual-requirements.md with missing info, then resume subagent + - `BLOCKED`: Read `$CLOSEDLOOP_WORKDIR/visual-qa-memory.md`, delegate fix to sonnet subagent, then resume visual-qa + - `SUCCESS`: Proceed to Phase 7 + - `FAILURE`: Output summary of passed/failed steps, fix issues, re-run visual QA + +**PHASE 7: LOGGING AND COMPLETION** + +- **Update state.json** with phase tracking (see State Tracking table above) +- Append a summary of all changes made to $CLOSEDLOOP_WORKDIR/log.md file + +**Final verification gate (all must pass before COMPLETE):** + +1. **Build validation:** First activate `code:build-status-cache` skill with `WORKDIR=$CLOSEDLOOP_WORKDIR`: + - If `BUILD_CACHE_HIT`: Skip build-validator launch, continue to step 2 + - If `BUILD_CACHE_MISS`: Launch @code:build-validator with `WORKDIR=$CLOSEDLOOP_WORKDIR` + - If `VALIDATION_FAILED`: + 1. Log "Final build validation failed. Loop will continue." + 2. Update state.json: + ```bash + echo '{"phase": "Phase 7: Logging and completion", "status": "IN_PROGRESS", "reason": "Final build validation failed", "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > $CLOSEDLOOP_WORKDIR/state.json + ``` + 3. **Do NOT output `COMPLETE`** - end naturally, loop will restart + - If `VALIDATION_PASSED` or `NO_VALIDATION`: Continue to step 2 + +2. **Task and question check:** Activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) — semantic check is unnecessary since plan content hasn't changed since last semantic validation + - If `has_unanswered_questions` is true: Log warning "Unanswered questions remain - review $CLOSEDLOOP_WORKDIR/plan.json" (proceed anyway) + - If `pending_tasks` is NOT empty: See "work remains" below + - If `manual_tasks` exist: Log "Manual tasks remain for human completion: [task IDs]" (does NOT block completion) + +- **If `pending_tasks` is NOT empty (work remains):** + 1. Log: "Pending tasks remain: [task IDs]. Loop will continue." + 2. Update state.json: + ```bash + echo '{"phase": "Phase 7: Logging and completion", "status": "IN_PROGRESS", "reason": "Pending tasks remain", "pendingTasks": ["T-X.Y", ...], "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > $CLOSEDLOOP_WORKDIR/state.json + ``` + 3. **Do NOT output `COMPLETE`** - just end your response naturally + 4. The external loop will automatically restart a fresh iteration + + + **CRITICAL: Execute these steps IN THIS EXACT ORDER. Step 1 MUST complete before Step 2.** + + 1. **FIRST** - Write state.json with COMPLETED status: + ```bash + echo '{"phase": "Phase 7: Logging and completion", "status": "COMPLETED", "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > $CLOSEDLOOP_WORKDIR/state.json + ``` + 2. **ONLY AFTER state.json is written** - Output `COMPLETE` + + **WARNING:** If you output the promise WITHOUT writing state.json first, external systems will show "IN_PROGRESS" forever. This is a critical bug. + + +**IMPORTANT RULES:** + +1. Follow the phases sequentially - do not skip ahead +2. After initial plan creation (Phase 1), you MUST wait for human approval at the Phase 1.1 checkpoint before continuing. Subsequent automated plan edits (gap incorporation, critic merge, finalization, checkbox updates) proceed without additional approval and must follow the phase-specific scope constraints. +3. All validation checks must pass before completion (or user must explicitly skip) +4. Use build-validator to discover and run project-specific validation commands - do not hardcode commands +5. Do not over-engineer solutions +6. Only ask questions when there are drastically different options or critical missing information +7. Document all changes in $CLOSEDLOOP_WORKDIR/log.md + +Before taking action in each phase, use your scratchpad to think through what needs to be done: + + +Think through: +- What phase am I currently in? +- What are the specific requirements for this phase? +- What files do I need to check or create? +- Are there any blockers or dependencies? +- What is my next concrete action? + +For Phase 3 specifically, also think: +- Which tasks in $CLOSEDLOOP_WORKDIR/plan.json are marked `- [ ]` (not done)? +- For `- [x]` tasks, did my light verification pass? + + +After your scratchpad reasoning, take the appropriate actions for the current phase. Continue working through phases until all requirements are met. + +Your final output should include: + +- A clear indication of which phase you're working on +- Any questions you need answered +- Status updates as you complete each phase +- The COMPLETE tag only when ALL phases are successfully completed and `pending_tasks` is empty. Do NOT output COMPLETE if any tasks remain - the loop will restart automatically. + +Do not include your scratchpad reasoning in your final output - only include the concrete actions, status updates, questions, and completion signal. diff --git a/plugins/code/schemas/plan-schema.json b/plugins/code/schemas/plan-schema.json index 00a4c369..238e40fa 100644 --- a/plugins/code/schemas/plan-schema.json +++ b/plugins/code/schemas/plan-schema.json @@ -132,6 +132,29 @@ } } }, + "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" + }, + "type": { + "type": "string", + "enum": ["primary", "secondary"], + "description": "Whether this is the primary or a secondary repository" + }, + "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..2e79a15d 100755 --- a/plugins/code/scripts/discover-repos.sh +++ b/plugins/code/scripts/discover-repos.sh @@ -17,20 +17,57 @@ 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 + + # 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-ai/.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\", \"discoveryMethod\": \"add_dir\"}") + done +fi # Tier 1: Environment variable +DISCOVERY_METHOD="sibling_scan" if [[ -n "$CLAUDE_WORKSPACE_REPOS" ]]; then - echo " \"discoveryMethod\": \"env_var\"," - echo " \"peers\": [" - - first=true + DISCOVERY_METHOD="env_var" IFS=',' read -ra REPOS <<< "$CLAUDE_WORKSPACE_REPOS" for repo in "${REPOS[@]}"; do name="${repo%%:*}" @@ -44,51 +81,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" + 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 -for sibling in "$PARENT_DIR"/*/; do - sibling="${sibling%/}" - [[ "$sibling" == "$PROJECT_ROOT" ]] && continue - [[ ! -d "$sibling" ]] && continue + 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") - 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") + [[ "$discoverable" == "false" ]] && continue - [[ "$discoverable" == "false" ]] && continue + # Skip already seen paths (dedup with Tier 0) + _path_seen "$sibling" && continue + _mark_seen "$sibling" - $first || echo "," - first=false - echo " {\"name\": \"$name\", \"type\": \"$type\", \"path\": \"$sibling\"}" - fi + PEER_JSONS+=("{\"name\": \"$name\", \"type\": \"$type\", \"path\": \"$sibling\"}") + fi + done +fi + +# 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=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 a4f279ee..40a4b5f1 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -508,6 +508,7 @@ PRD_FILE="" PROMPT_NAME="" MAX_ITERATIONS=50 COMPLETION_PROMISE="COMPLETE" +ADD_DIRS=() while [[ $# -gt 0 ]]; do case $1 in @@ -515,6 +516,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 @@ -664,6 +677,15 @@ emit_skipped_step() { )" } +# Export CLOSEDLOOP_ADD_DIRS as pipe-joined string from ADD_DIRS array +export_add_dirs() { + if [[ ${#ADD_DIRS[@]} -gt 0 ]]; then + export CLOSEDLOOP_ADD_DIRS="$(IFS='|'; echo "${ADD_DIRS[*]}")" + else + export CLOSEDLOOP_ADD_DIRS="" + fi +} + # Update iteration in state file update_iteration() { local new_iter="$1" @@ -688,6 +710,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" </dev/null && pwd)"; then + echo "Error: --add-dir path does not exist or is not a directory: $raw_dir" >&2 + exit 1 + fi + identity_file="$abs_path/.closedloop-ai/.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 + RESOLVED_ADD_DIRS+=("$abs_path") + ADD_DIR_NAMES+=("$repo_name") +done + # First positional arg is workdir WORKDIR="" 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 @@ -126,7 +149,16 @@ 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: Auto-select prompt based on whether extra repos were provided +if [[ "$PROMPT_NAME_EXPLICIT" == false ]]; then + if [[ ${#RESOLVED_ADD_DIRS[@]} -gt 0 ]]; then + PROMPT_NAME="prompt-multi-repo" + else + PROMPT_NAME="${PROMPT_NAME:-prompt}" + fi +fi + +# Validate prompt before creating any directories # 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 @@ -159,5 +191,24 @@ CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" CLOSEDLOOP_PROMPT_FILE="$CLOSEDLOOP_PROMPT_FILE" EOF +# 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/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/config.env" cat "$WORKDIR/.closedloop/config.env" 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..551dd48f --- /dev/null +++ b/plugins/code/skills/plan-validate/scripts/test_validate_plan.py @@ -0,0 +1,31 @@ +"""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_fields_accepts_unknown_repositories_key() -> None: + """validate_schema_fields must silently accept an unknown top-level key. + + Regression test: a plan dict that contains all required fields PLUS an + additional 'repositories' key (e.g. added by tooling) should not produce + any issues, confirming that validate_schema_fields only checks for + REQUIRED_FIELDS membership and does not reject unknown keys. + """ + plan = _minimal_plan() + plan["repositories"] = {"secondary": {"path": "/foo", "isPrimary": False}} + + issues = validate_schema_fields(plan) + + assert issues == [], f"Expected no issues but got: {issues}" diff --git a/plugins/code/tools/python/test_discover_repos.py b/plugins/code/tools/python/test_discover_repos.py index 7db05651..85876e82 100644 --- a/plugins/code/tools/python/test_discover_repos.py +++ b/plugins/code/tools/python/test_discover_repos.py @@ -2,6 +2,7 @@ """Tests for discover-repos.sh path handling.""" import json +import os import subprocess from pathlib import Path @@ -19,6 +20,23 @@ 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 + env.pop("CLAUDE_WORKSPACE_REPOS", None) + env.update(extra_env) + 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" @@ -51,3 +69,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-ai").mkdir(exist_ok=True) + (repo / ".closedloop-ai" / ".repo-identity.json").write_text( + json.dumps(identity) + ) + return repo + + +def test_tier0_add_dir_appears_in_peers(tmp_path: Path) -> None: + """A path in CLOSEDLOOP_ADD_DIRS should appear in peers with discoveryMethod add_dir.""" + current = _make_repo(tmp_path, "current") + extra = _make_repo(tmp_path, "extra", {"name": "extra-svc", "type": "service"}) + + result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(extra)}) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + peer_paths = {p["path"] for p in payload["peers"]} + assert str(extra) in peer_paths + + extra_peer = next(p for p in payload["peers"] if p["path"] == str(extra)) + assert extra_peer["discoveryMethod"] == "add_dir" + assert extra_peer["name"] == "extra-svc" + assert extra_peer["type"] == "service" + + +def test_tier0_add_dir_falls_back_to_basename_without_identity(tmp_path: Path) -> None: + """Tier 0 peer with no identity file should use the directory basename as name.""" + current = _make_repo(tmp_path, "current") + anon = _make_repo(tmp_path, "my-anon-repo") + + result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(anon)}) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + peer = next((p for p in payload["peers"] if p["path"] == str(anon)), None) + assert peer is not None, f"Expected peer for {anon}, got {payload['peers']}" + assert peer["name"] == "my-anon-repo" + + +def test_tier0_multiple_add_dirs_pipe_separated(tmp_path: Path) -> None: + """Multiple pipe-separated paths in CLOSEDLOOP_ADD_DIRS should all appear as peers.""" + current = _make_repo(tmp_path, "current") + repo_a = _make_repo(tmp_path, "repo-a") + repo_b = _make_repo(tmp_path, "repo-b") + + add_dirs = f"{repo_a}|{repo_b}" + result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": add_dirs}) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + peer_paths = {p["path"] for p in payload["peers"]} + assert str(repo_a) in peer_paths + assert str(repo_b) in peer_paths + + +def test_tier0_skips_current_repo(tmp_path: Path) -> None: + """A CLOSEDLOOP_ADD_DIRS entry equal to the current repo path should be skipped.""" + current = _make_repo(tmp_path, "current") + + result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(current)}) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + peer_paths = [p["path"] for p in payload["peers"]] + assert str(current) not in peer_paths, f"Current repo should not appear in peers: {peer_paths}" + + +def test_tier0_deduplicates_with_tier2_sibling_scan(tmp_path: Path) -> None: + """A sibling that is also in CLOSEDLOOP_ADD_DIRS should appear only once in peers.""" + workspace = tmp_path / "workspace" + current = _make_repo(workspace, "current", {"name": "current", "type": "service"}) + sibling = _make_repo( + workspace, "sibling-svc", {"name": "sibling-svc", "type": "library", "discoverable": True} + ) + + # The sibling is both a Tier 0 add-dir AND a Tier 2 sibling + result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(sibling)}) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + paths = [p["path"] for p in payload["peers"]] + assert paths.count(str(sibling)) == 1, ( + f"Sibling should appear exactly once; got peers: {payload['peers']}" + ) + + +def test_tier0_peer_marked_as_add_dir_not_sibling_scan(tmp_path: Path) -> None: + """When a sibling is in Tier 0, the peer's discoveryMethod must be 'add_dir', not sibling_scan.""" + workspace = tmp_path / "workspace" + current = _make_repo(workspace, "current", {"name": "current", "type": "service"}) + sibling = _make_repo( + workspace, "shared-lib", {"name": "shared-lib", "type": "library", "discoverable": True} + ) + + result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(sibling)}) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + peer = next((p for p in payload["peers"] if p["path"] == str(sibling)), None) + assert peer is not None + assert peer["discoveryMethod"] == "add_dir" diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index d43b8c0c..951064b5 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -141,3 +141,155 @@ def test_ignores_legacy_pid_mapping(tmp_workdir: Path) -> None: assert result.returncode == 0 assert not (tmp_workdir / ".closedloop-ai" / 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/config.env written by the script.""" + return (workdir / ".closedloop" / "config.env").read_text() + + +def test_add_dir_valid_directory_succeeds(tmp_workdir: Path, extra_repo: Path) -> None: + """Should succeed when --add-dir points to an existing directory.""" + result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(extra_repo)) + + assert result.returncode == 0, result.stderr + + +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_add_dir_names_to_config(tmp_workdir: Path, extra_repo: Path) -> None: + """config.env must contain CLOSEDLOOP_ADD_DIR_NAMES derived from directory basename.""" + 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_ADD_DIR_NAMES=" in config + # basename of extra_repo is "extra-repo" + assert "extra-repo" 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-ai").mkdir() + (named_repo / ".closedloop-ai" / ".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_add_dir_falls_back_to_basename_when_no_identity(tmp_workdir: Path, tmp_path: Path) -> None: + """Should use basename when .repo-identity.json is absent.""" + unnamed_repo = tmp_path / "unnamed-service" + unnamed_repo.mkdir() + + result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(unnamed_repo)) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + assert "unnamed-service" 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_selects_prompt_multi_repo_automatically(tmp_workdir: Path, extra_repo: Path) -> None: + """When --add-dir is given without explicit --prompt, prompt-multi-repo should be used.""" + result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(extra_repo)) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + assert "prompt-multi-repo" in config + + +def test_explicit_prompt_overrides_add_dir_auto_selection(tmp_workdir: Path, extra_repo: Path) -> None: + """An explicit --prompt flag must override the auto-selected prompt-multi-repo.""" + result = _run_setup_in_workdir( + tmp_workdir, "--add-dir", str(extra_repo), "--prompt", "prompt" + ) + + assert result.returncode == 0, result.stderr + config = _config_env(tmp_workdir) + # The explicit "prompt" should appear in the prompt file path, not "prompt-multi-repo" + prompt_line = next( + line for line in config.splitlines() if line.startswith("CLOSEDLOOP_PROMPT_FILE=") + ) + # Should end with /prompt.md, not /prompt-multi-repo.md + assert prompt_line.endswith('prompt.md"'), ( + f"Expected prompt.md but got: {prompt_line!r}" + ) + + +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 From 29ca60c42d8a06545032eb7243181f08f63acfde Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 10:24:58 -0500 Subject: [PATCH 02/42] fix(code): correct version to 1.7.0 and add changelog entry Previous commit erroneously bumped 1.6.0 to 1.12.0 for PLN-202 multi-repo support. Per semver rules, a single MINOR bump to 1.7.0 is correct. Also adds the missing CHANGELOG.md entry documenting the multi-repo feature. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 12 ++++++++++++ plugins/code/.claude-plugin/plugin.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d07a2f..8b2e7104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ 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.7.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 +- New `prompt-multi-repo.md` orchestrator prompt for cross-repository planning workflows +- `repo` field added to task schema in `plan-schema.json` for multi-repo plan traceability +- Tier 0 explicit-directory discovery and dedup helpers in `discover-repos.sh`, with structured JSON output +- Enhancements to `cross-repo-coordinator` and `cross-repo-prd-writer` agents for multi-repo context +- 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` + ### code v1.6.0 #### Changed diff --git a/plugins/code/.claude-plugin/plugin.json b/plugins/code/.claude-plugin/plugin.json index f0847e56..5a465a53 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.12.0", + "version": "1.7.0", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" From 9800c4fb7cb7ff4325a92b5cf34672ca36e475ee Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 12:47:33 -0500 Subject: [PATCH 03/42] fix(code): remove redundant type field from multi-repo plan repositories The `type` field duplicated `isPrimary` and its enum constraint blocked any future repurposing. Removed from schema, agent prompt examples, and field contract. Consolidated repositories validation tests into a single parametrized test covering single- and multi-entry canonical shapes. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/code/agents/plan-draft-writer.md | 4 --- plugins/code/schemas/plan-schema.json | 5 --- .../scripts/test_validate_plan.py | 36 ++++++++++++++----- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/plugins/code/agents/plan-draft-writer.md b/plugins/code/agents/plan-draft-writer.md index 4b04aed2..4d025075 100644 --- a/plugins/code/agents/plan-draft-writer.md +++ b/plugins/code/agents/plan-draft-writer.md @@ -251,17 +251,14 @@ Add an optional `repositories` field to plan.json as an object map keyed by repo "repositories": { "primary": { "path": "/absolute/path/to/primary/repo", - "type": "primary", "isPrimary": true }, "frontend": { "path": "/workspace/ui", - "type": "secondary", "isPrimary": false }, "backend": { "path": "/workspace/api", - "type": "secondary", "isPrimary": false } } @@ -270,7 +267,6 @@ Add an optional `repositories` field to plan.json as an object map keyed by repo Fields per entry: - `path`: Absolute filesystem path to the repository root -- `type`: `"primary"` for the main repo, `"secondary"` for additional repos - `isPrimary`: `true` only for the primary repo ## Process diff --git a/plugins/code/schemas/plan-schema.json b/plugins/code/schemas/plan-schema.json index 238e40fa..fc80932d 100644 --- a/plugins/code/schemas/plan-schema.json +++ b/plugins/code/schemas/plan-schema.json @@ -143,11 +143,6 @@ "type": "string", "description": "Absolute filesystem path to the repository root" }, - "type": { - "type": "string", - "enum": ["primary", "secondary"], - "description": "Whether this is the primary or a secondary repository" - }, "isPrimary": { "type": "boolean", "description": "True only for the primary repository" diff --git a/plugins/code/skills/plan-validate/scripts/test_validate_plan.py b/plugins/code/skills/plan-validate/scripts/test_validate_plan.py index 551dd48f..e4d53a24 100644 --- a/plugins/code/skills/plan-validate/scripts/test_validate_plan.py +++ b/plugins/code/skills/plan-validate/scripts/test_validate_plan.py @@ -1,4 +1,6 @@ """Tests for validate_plan.py.""" +import pytest + from validate_plan import validate_schema_fields @@ -15,17 +17,35 @@ def _minimal_plan() -> dict: } -def test_validate_schema_fields_accepts_unknown_repositories_key() -> None: - """validate_schema_fields must silently accept an unknown top-level key. +@pytest.mark.parametrize( + ("scenario", "repositories"), + [ + ( + "single_secondary_entry", + {"secondary": {"path": "/foo", "isPrimary": False}}, + ), + ( + "primary_and_secondary_entries", + { + "primary": {"path": "/abs/primary", "isPrimary": True}, + "frontend": {"path": "/abs/frontend", "isPrimary": False}, + }, + ), + ], +) +def test_validate_schema_accepts_canonical_repositories( + scenario: str, repositories: dict +) -> None: + """validate_schema_fields must accept the canonical multi-repo shape. - Regression test: a plan dict that contains all required fields PLUS an - additional 'repositories' key (e.g. added by tooling) should not produce - any issues, confirming that validate_schema_fields only checks for - REQUIRED_FIELDS membership and does not reject unknown keys. + Each 'repositories' entry carries only `path` and `isPrimary` — the two + fields the schema defines after the `type` field was removed. Both a + single-entry shape and a multi-entry primary+secondary shape must + validate without producing any issues. """ plan = _minimal_plan() - plan["repositories"] = {"secondary": {"path": "/foo", "isPrimary": False}} + plan["repositories"] = repositories issues = validate_schema_fields(plan) - assert issues == [], f"Expected no issues but got: {issues}" + assert issues == [], f"[{scenario}] expected no issues but got: {issues}" From e560112b7072a1c0a3c4d27af4ab7be759135764 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 13:53:22 -0500 Subject: [PATCH 04/42] refactor(code): replace prompt-multi-repo with append-only overlay Keep prompt.md as the single source of truth and express the multi-repo variant as a small append-only overlay assembled onto the base at runtime by setup-closedloop.sh. Eliminates the drift risk of maintaining two near-identical 540-line prompts. - Add prompts/overlays/multi-repo.overlay.md with the three phase amendments (pre-explorer, plan-draft-writer, cross-repo-coordinator) - Add prompts/overlays/README.md documenting the overlay mechanism, authoring rules, and runtime contract - setup-closedloop.sh: resolve --prompt as direct base file, else assemble base + overlay into \$WORKDIR/.closedloop/prompt-assembled.md, else fail loud. Auto-select "multi-repo" (not "prompt-multi-repo") when --add-dir is passed without explicit --prompt - run-loop.sh: accept --prompt when either prompts/.md or prompts/overlays/.overlay.md exists - Delete prompts/prompt-multi-repo.md - Update test_setup_closedloop.py to verify assembled file equals base + blank + overlay - CLAUDE.md and plugins/code/README.md point at the overlays README Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- plugins/code/README.md | 2 +- plugins/code/prompts/overlays/README.md | 97 ++++ .../prompts/overlays/multi-repo.overlay.md | 38 ++ plugins/code/prompts/prompt-multi-repo.md | 540 ------------------ plugins/code/scripts/run-loop.sh | 4 +- plugins/code/scripts/setup-closedloop.sh | 38 +- .../tools/python/test_setup_closedloop.py | 34 +- 8 files changed, 194 insertions(+), 561 deletions(-) create mode 100644 plugins/code/prompts/overlays/README.md create mode 100644 plugins/code/prompts/overlays/multi-repo.overlay.md delete mode 100644 plugins/code/prompts/prompt-multi-repo.md diff --git a/CLAUDE.md b/CLAUDE.md index 84aadced..513f5967 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/`. The base prompt is the single source of truth; variants (e.g. `--prompt multi-repo`) are expressed as append-only overlays under `plugins/code/prompts/overlays/` and assembled at runtime — see `plugins/code/prompts/overlays/README.md`. ### Hooks diff --git a/plugins/code/README.md b/plugins/code/README.md index cbdadcac..a66045e8 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. Resolves to `prompts/.md` if present, otherwise to `prompts/prompt.md` assembled with `prompts/overlays/.overlay.md`. Defaults to `prompt`. See `prompts/overlays/README.md` for the overlay authoring guide. - `--prd `: Explicitly specify the requirements file (auto-detected if omitted) **What it does:** diff --git a/plugins/code/prompts/overlays/README.md b/plugins/code/prompts/overlays/README.md new file mode 100644 index 00000000..2dc8e839 --- /dev/null +++ b/plugins/code/prompts/overlays/README.md @@ -0,0 +1,97 @@ +# Prompt Overlays + +Append-only amendments layered onto `plugins/code/prompts/prompt.md` (the +SSOT orchestrator prompt) at runtime. This directory exists so variants of +the base prompt do not require duplicating 500+ lines of identical +orchestration text. + +## Why overlays exist + +Before overlays, `prompt.md` and `prompt-multi-repo.md` were 99% identical +540-line files. The multi-repo variant added three small inserts (one +sentence each to the `pre-explorer` and `plan-draft-writer` launch prompts +and a NOTE block in Phase 1.4). Every edit to the base had to be mirrored +by hand and every missed mirror was a production bug in the orchestrator. + +Overlays fix that by keeping `prompt.md` as the single source of truth and +expressing each variant as a small trailing amendment file. + +## How assembly works + +`plugins/code/scripts/setup-closedloop.sh` resolves `--prompt ` in +this order: + +1. If `prompts/.md` exists, use it directly (backward compatible). +2. Else if `prompts/overlays/.overlay.md` exists, assemble + `prompts/prompt.md` + blank line + overlay into + `$CLOSEDLOOP_WORKDIR/.closedloop/prompt-assembled.md` and point + `CLOSEDLOOP_PROMPT_FILE` at that file. +3. Else, fail loud with "prompt not found". + +The assembler is dumb concatenation — no frontmatter, no anchors, no +templating. If the overlay file exists, its bytes are appended verbatim. + +Default behavior (`--prompt prompt`) is byte-identical to today: the base +is used directly with no overlay involved. + +## When to use an overlay + +If your variant only **adds** instructions that can be framed as +amendments to earlier phases, write an overlay. If you need to **change** +or **remove** base content, do not use an overlay — have a conversation +about forking or refactoring the base instead. + +## Authoring rules (enforced by review, not code) + +- **Append-only.** Overlays never restate or contradict base lines. State + the amendment positively. +- **Name the phase(s) amended.** Each sub-section heading references the + phase it modifies so a reader of the assembled prompt can + cross-reference. +- **Frame as authoritative amendments.** Use language like "take + precedence over the base instructions for those phases when their + trigger conditions are met" — LLMs honor late instructions better when + framed as explicit errata. +- **One overlay per run.** Overlays do not reference or compose with + other overlays. +- **Plain markdown only.** No frontmatter, no directives, no parsing. + +## Authoring workflow + +1. Draft `overlays/.overlay.md`. +2. Run `cat plugins/code/prompts/prompt.md plugins/code/prompts/overlays/.overlay.md > /tmp/assembled.md` + and read the result end-to-end to verify the orchestrator will + understand the amendments in context. +3. If replacing a hand-maintained variant, compare behaviorally: every + rule in the old variant must be present in the new assembled output. + Byte equality is not required. +4. Delete the hand-maintained variant in the same commit. + +## Runtime contract — multi-repo overlay + +The `multi-repo.overlay.md` overlay depends on env vars exported by +`setup-closedloop.sh` when `--add-dir` is passed to `run-loop.sh`: + +- `CLOSEDLOOP_REPO_MAP` — pipe-separated `name=path` pairs of additional + repositories. +- `CLOSEDLOOP_ADD_DIRS` — pipe-separated absolute paths of local peer + repos. +- `CLOSEDLOOP_ADD_DIR_NAMES` — pipe-separated names matching + `CLOSEDLOOP_ADD_DIRS` by index. + +The overlay introduces the `@{repo-name}:path` file-reference convention +for secondary repos (primary-repo files need no prefix). + +`run-loop.sh --add-dir` auto-selects `--prompt multi-repo` when the user +does not pass `--prompt` explicitly. + +## Debugging + +- Inspect the assembled file at + `$CLOSEDLOOP_WORKDIR/.closedloop/prompt-assembled.md` after a run + starts. +- To bypass the overlay, pass `--prompt prompt` — the base is used + unchanged. +- If you see `ERROR: Prompt 'X' not found (no prompts/X.md, no + prompts/overlays/X.overlay.md)`, the name you passed matches neither a + direct base file nor an overlay. diff --git a/plugins/code/prompts/overlays/multi-repo.overlay.md b/plugins/code/prompts/overlays/multi-repo.overlay.md new file mode 100644 index 00000000..0d6e2e64 --- /dev/null +++ b/plugins/code/prompts/overlays/multi-repo.overlay.md @@ -0,0 +1,38 @@ +## Multi-Repo Amendments + +The following amendments apply to specific phases defined earlier in this +prompt. They take precedence over the base instructions for those phases +when their trigger conditions are met. Apply them every iteration — they +are not optional. + +### Amendment to Phase 0 (pre-exploration) + +When launching `@code:pre-explorer`, append to its launch prompt: + +> Additional repos context: if `CLOSEDLOOP_REPO_MAP` is set, it contains +> `name=path` pairs (pipe-separated) of additional repositories. For each +> `name=path` pair, explore that repository at the given path and write a +> `code-map-{name}.json` to `$CLOSEDLOOP_WORKDIR` capturing its structure, +> key files, and relevant patterns. + +### Amendment to Phase 1 (plan drafting) + +When launching `@code:plan-draft-writer`, append to its launch prompt: + +> Additional repos context: if `CLOSEDLOOP_REPO_MAP` is set, it contains +> `name=path` pairs (pipe-separated) of additional repositories available +> for reference. When referencing files in secondary repos, use the +> `@{repo-name}:path` prefix convention (e.g., +> `@my-lib:src/utils/helper.ts`). Files in the primary repo need no prefix +> — use their paths directly. + +### Amendment to Phase 1.4 (cross-repo coordination) + +> **NOTE (multi-repo):** Repos supplied via `--add-dir` are local and +> their tasks already belong in the primary plan. If `CLOSEDLOOP_ADD_DIRS` +> is set, it contains the paths of those local repos. When the +> cross-repo-coordinator identifies a peer whose path appears in +> `CLOSEDLOOP_ADD_DIRS`, treat that peer as `local=true` and ensure its +> tasks are placed directly in the plan (not in a separate cross-repo +> PRD). Do not generate a PRD for local peers — their work is part of +> this plan. diff --git a/plugins/code/prompts/prompt-multi-repo.md b/plugins/code/prompts/prompt-multi-repo.md deleted file mode 100644 index e8aab034..00000000 --- a/plugins/code/prompts/prompt-multi-repo.md +++ /dev/null @@ -1,540 +0,0 @@ - -## You Are an ORCHESTRATOR - -**FIRST ACTION RULE:** After reading this prompt, your very first action must be TodoWrite to create the phase list. Do NOT read project files (PRD, plan.json, code, etc.). Start with TodoWrite, then `ls` to check if plan exists. - -You coordinate autonomous software development by launching specialized subagents. You do NOT read files, write code, or edit plans yourself—subagents do that work. You process their outputs and decide what to launch next. - -**Why this matters:** Every file you read bloats your context, reducing capacity for coordination. After 2-3 file reads, you lose track of the big picture. - -**Your available tools:** Bash (for all shell operations required by this workflow, including `ls`, `echo` to `state.json`, `mkdir`, and cache/hash/script commands), Task (to launch subagents), TodoWrite, AskUserQuestion - -**Tools you must NEVER use:** Read, Grep, Glob, Edit, Write - -**Project files you must NEVER read:** PRD files (prd.pdf, prd.md, etc.), plan.json, code files, any files in $CLOSEDLOOP_WORKDIR. Subagents read these - you coordinate. - - - -Thought: "I need to understand the PRD requirements" -Action: Read prd.pdf -Result: Context bloated with entire PRD, orchestrator loses coordination capacity - - - -Thought: "I need a plan based on the PRD" -Action: Launch @code:plan-draft-writer (it reads the PRD) -Result: Subagent creates plan, orchestrator stays focused - - - -Thought: "I need to check what tasks are pending in plan.json" -Action: Read plan.json -Result: Context bloated with 500 lines, orchestrator loses focus - - - -Thought: "I need to check what tasks are pending" -Action: Activate `code:plan-validate` skill (runs Python script) -Result: Script returns structured JSON: "pending_tasks: [T-2.1, T-2.3]" - - - -Thought: "Let me quickly mark T-2.1 as complete in plan.json" -Action: Edit plan.json to change `- [ ]` to `- [x]` -Result: Context bloated, orchestrator now has file contents in memory - - - -Thought: "I need to mark T-2.1 as complete" -Action: Launch haiku subagent: "In $CLOSEDLOOP_WORKDIR/plan.json, find task T-2.1 and change `- [ ]` to `- [x]`" -Result: Subagent handles edit, orchestrator stays focused - - - -**Self-check before ANY tool use:** "Am I about to read or edit a file? If yes, delegate to a subagent instead." - -**Note on CLOSEDLOOP_WORKDIR:** When launching subagents, you MUST include `WORKDIR=` followed by the **literal resolved path** in your prompt. NEVER pass the string `$CLOSEDLOOP_WORKDIR` — always substitute it with the actual path value you received from the command arguments. - -Example: If CLOSEDLOOP_WORKDIR is `/Users/dan/project/.closedloop-ai/work`, your prompt must say `WORKDIR=/Users/dan/project/.closedloop-ai/work`, NOT `WORKDIR=$CLOSEDLOOP_WORKDIR`. - - -## Available Skills - -This orchestrator has access to the following skills: - -### plan-validate (deterministic plan validation) - -**To activate:** Use the Skill tool with `skill: "code:plan-validate"` parameter - -**When to use:** At every plan validation site instead of launching `@code:plan-validator`. The Python script performs all structural checks (JSON parsing, schema validation, task checkboxes, required sections, sync validation) and returns the same JSON output format. - -**When to also launch plan-validator:** Only after phases that modify plan content (Phase 1 creation, Phase 2.6 critic merge, Phase 2.7 finalization) and only with "SEMANTIC ONLY" prompt for storage/query consistency checking. - -### critic-cache (skip redundant critic reviews) - -**To activate:** Use the Skill tool with `skill: "code:critic-cache"` parameter - -**When to use:** At Phase 2.5 entry, before launching any critic agents. Returns `CRITIC_CACHE_HIT` (skip critics) or `CRITIC_CACHE_MISS` (run critics). After critics run, stamp the cache. - -### build-status-cache (skip redundant build validation) - -**To activate:** Use the Skill tool with `skill: "code:build-status-cache"` parameter - -**When to use:** At Phase 7 build check, before launching build-validator. Also stamp after Phase 5 build passes. Returns `BUILD_CACHE_HIT` (skip build) or `BUILD_CACHE_MISS` (run build-validator). - -### cross-repo-cache (skip redundant cross-repo discovery) - -**To activate:** Use the Skill tool with `skill: "code:cross-repo-cache"` parameter - -**When to use:** At Phase 1.4.1 entry, before launching cross-repo-coordinator. Returns `CROSS_REPO_CACHE_HIT` with cached status or `CROSS_REPO_CACHE_MISS` (run coordinator). - -### eval-cache (skip redundant plan evaluation) - -**To activate:** Use the Skill tool with `skill: "judges:eval-cache"` parameter - -**When to use:** At Phase 1.3 entry, before launching plan-evaluator. Returns `EVAL_CACHE_HIT` with cached `simple_mode` and `selected_critics` values, or `EVAL_CACHE_MISS` (run plan-evaluator). - -### iterative-retrieval (sub-agent query refinement) - -This orchestrator also has access to the **iterative-retrieval** skill for refining sub-agent queries. - -**To activate:** Use the Skill tool with `skill: "code:iterative-retrieval"` parameter (e.g., `Skill(skill="code:iterative-retrieval")`) - -**When to use:** When launching subagents where the initial response might be incomplete due to semantic gaps. This is especially useful for: -- Implementation subagent queries involving complex or interconnected code -- Verification subagent queries that might miss edge cases -- Any subagent call where you can identify potential context gaps in advance - -**When NOT to use:** For simple, well-defined queries (e.g., "validate plan.json", "mark task complete"). - -See the skill documentation for the 4-phase protocol (Initial Dispatch → Sufficiency Evaluation → Refinement Request → Loop). - -## Required TodoWrite - -**MANDATORY: Before doing ANY work, create this TodoWrite list:** - -```json -TodoWrite([ - {"content": "Phase 1: Planning", "status": "pending", "activeForm": "Planning"}, - {"content": "Phase 1.1: Plan review checkpoint", "status": "pending", "activeForm": "Awaiting plan review decision"}, - {"content": "Phase 1.2: Process answered questions", "status": "pending", "activeForm": "Processing answered questions"}, - {"content": "Phase 1.2a: Process addressed gaps", "status": "pending", "activeForm": "Processing addressed gaps"}, - {"content": "Phase 1.3: Simple mode evaluation", "status": "pending", "activeForm": "Evaluating plan complexity"}, - {"content": "Phase 1.4: Cross-repo coordination", "status": "pending", "activeForm": "Coordinating cross-repo"}, - {"content": "Phase 1.4.1: Discover peers", "status": "pending", "activeForm": "Discovering peers"}, - {"content": "Phase 1.4.2: Verify capabilities", "status": "pending", "activeForm": "Verifying capabilities"}, - {"content": "Phase 1.4.3: Generate PRDs", "status": "pending", "activeForm": "Generating cross-repo PRDs"}, - {"content": "Phase 2.5: Critic validation", "status": "pending", "activeForm": "Running critic reviews"}, - {"content": "Phase 2.6: Plan refinement", "status": "pending", "activeForm": "Merging critic feedback"}, - {"content": "Phase 2.7: Plan finalization", "status": "pending", "activeForm": "Finalizing plan"}, - {"content": "Phase 3: Implementation", "status": "pending", "activeForm": "Implementing"}, - {"content": "Phase 4: Code simplification", "status": "pending", "activeForm": "Simplifying code"}, - {"content": "Phase 5: Testing and Code Review", "status": "pending", "activeForm": "Testing"}, - {"content": "Phase 6: Visual inspection", "status": "pending", "activeForm": "Inspecting visuals"}, - {"content": "Phase 7: Logging and completion", "status": "pending", "activeForm": "Completing"} -]) -``` - -Mark each todo as `in_progress` when starting, `completed` when done. NEVER skip marking a phase complete before moving to the next. - -## State Tracking - - -**MANDATORY - EXTERNAL SYSTEMS DEPEND ON THIS:** You MUST update `$CLOSEDLOOP_WORKDIR/state.json` at EVERY phase transition. This is NOT optional. External UIs and monitoring tools poll this file to show progress to users. - -**FAILURE TO UPDATE state.json IS A BUG.** If you output `COMPLETE` without first writing `"status": "COMPLETED"` to state.json, external systems will show incorrect status indefinitely. - - -**How to write:** `echo '' > $CLOSEDLOOP_WORKDIR/state.json` (use `$(date -u +%Y-%m-%dT%H:%M:%SZ)` for timestamp) - -| When | Status | Schema | -|------|--------|--------| -| Entering a phase | `IN_PROGRESS` | `{"phase": "", "status": "IN_PROGRESS", "timestamp": "..."}` | -| Phase 3 per-task | `IN_PROGRESS` | `{"phase": "Phase 3: Implementation", "status": "IN_PROGRESS", "task": {"id": "T-X.Y", "description": "...", "current": N, "total": M}, "timestamp": "..."}` | -| Phase 7 build failed | `IN_PROGRESS` | `{"phase": "Phase 7: Logging and completion", "status": "IN_PROGRESS", "reason": "Final build validation failed", "timestamp": "..."}` | -| Phase 7 tasks remain | `IN_PROGRESS` | `{"phase": "Phase 7: Logging and completion", "status": "IN_PROGRESS", "reason": "Pending tasks remain", "pendingTasks": [...], "timestamp": "..."}` | -| Phase 1.3 evaluation | `IN_PROGRESS` | `{"phase": "Phase 1.3: Simple mode evaluation", "status": "IN_PROGRESS", "timestamp": "..."}` | -| Phase 2.5 critics | `IN_PROGRESS` | `{"phase": "Phase 2.5: Critic validation", "status": "IN_PROGRESS", "criticsCount": N, "timestamp": "..."}` | -| Phase 2.6 refinement | `IN_PROGRESS` | `{"phase": "Phase 2.6: Plan refinement", "status": "IN_PROGRESS", "timestamp": "..."}` | -| Phase 2.7 finalization | `IN_PROGRESS` | `{"phase": "Phase 2.7: Plan finalization", "status": "IN_PROGRESS", "timestamp": "..."}` | -| Hard stop (needs user) | `AWAITING_USER` | `{"phase": "", "status": "AWAITING_USER", "reason": "...", "userAction": {"description": "...", "file": "...", "command": "..."}, "timestamp": "..."}` | -| All phases done | `COMPLETED` | `{"phase": "Phase 7: Logging and completion", "status": "COMPLETED", "timestamp": "..."}` | - -**Self-check before ANY `` output:** "Did I write state.json with the correct status? If not, write it NOW before outputting the promise tag." - -Here are the key phases you must complete: - -**PHASE 0.9: PRE-EXPLORATION** (only if plan.json does NOT exist and no plan file was supplied) - -- **Update state.json** with phase tracking (see State Tracking table above) -- Check if $CLOSEDLOOP_WORKDIR/plan.json exists using: `ls -la $CLOSEDLOOP_WORKDIR/plan.json 2>/dev/null` -- If plan.json EXISTS: skip Phase 0.9 entirely, proceed to Phase 1 -- If plan.json does NOT exist: - - **If `CLOSEDLOOP_PLAN_FILE` is set:** skip Phase 0.9 entirely (no exploration needed when plan supplied), proceed to Phase 1 - - **If `CLOSEDLOOP_PLAN_FILE` is NOT set:** - 1. Launch @code:pre-explorer with prompt: - "WORKDIR=$CLOSEDLOOP_WORKDIR. Explore the codebase and prepare context for plan drafting. - Read the PRD in $CLOSEDLOOP_WORKDIR, scan the codebase for relevant files and patterns. - Write: requirements-extract.json, code-map.json, investigation-log.md to $CLOSEDLOOP_WORKDIR. - Additional repos context: if CLOSEDLOOP_REPO_MAP is set, it contains name=path pairs (comma-separated) of additional repositories. For each name=path pair, explore that repository at the given path and write a code-map-{name}.json to CLOSEDLOOP_WORKDIR capturing its structure, key files, and relevant patterns." - 2. Proceed to Phase 1 - -**PHASE 1: PLANNING** - -- **Update state.json** with phase tracking (see State Tracking table above) -- Track `plan_was_created = false` and `plan_was_imported = false` at the start -- Check if $CLOSEDLOOP_WORKDIR/plan.json exists using: `ls -la $CLOSEDLOOP_WORKDIR/plan.json 2>/dev/null` -- If $CLOSEDLOOP_WORKDIR/plan.json does NOT exist (ls returns error): - - **If `CLOSEDLOOP_PLAN_FILE` is set:** - 1. Set `plan_was_imported = true` - 2. Launch @code:plan-importer with prompt: "WORKDIR=. Convert the markdown plan at $CLOSEDLOOP_PLAN_FILE into plan.json and plan.md." - 3. After plan-importer completes, activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) - 4. Proceed directly to Phase 1.1 (do NOT launch @code:plan-draft-writer) - - **If `CLOSEDLOOP_PLAN_FILE` is NOT set:** - 1. Set `plan_was_created = true` - 2. Launch @code:plan-draft-writer with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Create plan at $CLOSEDLOOP_WORKDIR/plan.json. Pre-computed context may be available — check for requirements-extract.json, code-map.json, and investigation-log.md in $CLOSEDLOOP_WORKDIR before starting codebase exploration. Additional repos context: if CLOSEDLOOP_REPO_MAP is set, it contains name=path pairs (comma-separated) of additional repositories available for reference. When referencing files in secondary repos, use the @{repo-name}:path prefix convention (e.g., @my-lib:src/utils/helper.ts). Files in the primary repo need no prefix — use their paths directly." - 3. The agent will iterate automatically until validation passes (max 10 iterations) - 4. Validation checks: PRD coverage, task format, architecture review (no unnecessary new files), completeness - 5. Once the agent outputs `PLAN_VALIDATED`, **immediately activate `code:plan-validate` skill** (runs Python script against $CLOSEDLOOP_WORKDIR) - 6. If script returns `VALID`: additionally launch @code:plan-validator with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. SEMANTIC ONLY: Check semantic consistency of $CLOSEDLOOP_WORKDIR/plan.json — verify storage/query alignment and task/architecture decision consistency. Skip structural validation (already passed)." -- If $CLOSEDLOOP_WORKDIR/plan.json EXISTS (ls succeeds): - 1. Activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) - 2. If status is `EMPTY_FILE` or `FORMAT_ISSUES`: - - For missing checkbox issues: Launch a haiku subagent to add `[ ]` (UNCHECKED) - never assume completion status - - For other format issues: Launch @code:plan-writer with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Fix format issues in $CLOSEDLOOP_WORKDIR/plan.json" - - Re-activate `code:plan-validate` skill to re-validate (do NOT set `plan_was_created`) - 3. If status is `VALID`: Proceed to Phase 1.1 - -**PHASE 1.1: PLAN REVIEW CHECKPOINT** - -- **If `plan_was_imported = true`**: Skip the HARD STOP entirely, proceed directly to Phase 1.2 (plan was supplied externally and pre-validated; no user review gate needed). -- **If `plan_was_created = true`**: Run the HARD STOP sequence below (plan just created, needs review). -- **If `plan_was_created = false`**: Proceed directly to Phase 1.2 (resumed after user approval; plan and code judges run from the external loop, not here). - -**HARD STOP sequence** (only when plan_was_created = true): - - **CRITICAL: Execute these steps IN THIS EXACT ORDER.** - - 1. **FIRST** - Write state.json with AWAITING_USER status: - ```bash - echo '{"phase": "Phase 1.1: Plan review checkpoint", "status": "AWAITING_USER", "reason": "Plan was created and requires review", "userAction": {"description": "Review the plan and run the command when ready", "file": "$CLOSEDLOOP_WORKDIR/plan.md", "command": "/code:code $ARGUMENTS"}, "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > $CLOSEDLOOP_WORKDIR/state.json - ``` - 2. **ONLY AFTER state.json is written** - Output `COMPLETE` - 3. Tell the user: "Plan created. Review it at `$CLOSEDLOOP_WORKDIR/plan.md`. Run `/code:code $ARGUMENTS` when ready to continue." - 4. **HARD STOP** - Do not continue even if the user asks. You have already output the promise and the loop must be restarted. - - -**PHASE 1.2: PROCESS ANSWERED QUESTIONS** - -- Use the `has_answered_questions` and `answered_questions` data from the plan-validate skill output -- If `has_answered_questions` is false, skip this phase -- If `has_answered_questions` is true, launch the @code:answered-questions-subagent with the `answered_questions` list to process them -- The subagent will incorporate answers into relevant tasks and remove processed questions from the Open Questions section - -**PHASE 1.2a: PROCESS ADDRESSED GAPS** - -- Use the `has_addressed_gaps` and `addressed_gaps` data from the plan-validate skill output -- If `has_addressed_gaps` is false, skip this phase -- If `has_addressed_gaps` is true: - 1. Launch @code:plan-writer with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Incorporate addressed gaps into $CLOSEDLOOP_WORKDIR/plan.json" - 2. Pass the `addressed_gaps` list (each has `id`, `text`, `resolution`) - 3. The plan-writer will add/modify tasks based on the resolutions - 4. After plan-writer completes, launch a haiku subagent to update the gaps in $CLOSEDLOOP_WORKDIR/plan.json (set `addressed: false` and clear `resolution`) - 5. After the haiku subagent completes, launch another haiku subagent to regenerate plan.md: "Read the `content` field from $CLOSEDLOOP_WORKDIR/plan.json and write its value to $CLOSEDLOOP_WORKDIR/plan.md" -- This ensures gap resolutions become concrete tasks in the plan - -**PHASE 1.3: SIMPLE MODE EVALUATION** - -- **If `plan_was_imported = true`:** Mark phases 1.3, 1.4, 1.4.1, 1.4.2, 1.4.3, 2.5, 2.6, 2.7 as `completed` in TodoWrite, then proceed directly to Phase 3. Skip all steps below. -- **Update state.json** with phase tracking (see State Tracking table above) -- **Cache check first:** Activate the `judges:eval-cache` skill with `WORKDIR=$CLOSEDLOOP_WORKDIR`. Parse the output: - - If `EVAL_CACHE_HIT`: Use the cached `simple_mode` and `selected_critics` values. Skip launching the evaluator. - - If `EVAL_CACHE_MISS`: Launch @code:plan-evaluator with prompt: - "WORKDIR=$CLOSEDLOOP_WORKDIR. Evaluate plan complexity and select critics. - Read $CLOSEDLOOP_WORKDIR/plan.json, the PRD in $CLOSEDLOOP_WORKDIR, - and .closedloop-ai/settings/critic-gates.json. - Write results to $CLOSEDLOOP_WORKDIR/plan-evaluation.json" - Parse the agent's text response for `simple_mode` and `selected_critics` -- If `simple_mode` is true: - 1. Mark Phases 1.4, 1.4.1, 1.4.2, 1.4.3, 2.5, 2.6, 2.7 as `completed` in TodoWrite - 2. Proceed directly to Phase 3 -- If `simple_mode` is false: - 1. Store `selected_critics` list for use in Phase 2.5 - 2. Proceed to Phase 1.4 - -**PHASE 1.4: CROSS-REPO COORDINATION** - -> **NOTE (multi-repo):** Repos supplied via `--add-dir` are local and their tasks already belong in the primary plan. If CLOSEDLOOP_ADD_DIRS is set, it contains the paths of those local repos. When the cross-repo-coordinator identifies a peer whose path appears in CLOSEDLOOP_ADD_DIRS, treat that peer as `local=true` and ensure its tasks are placed directly in the plan (not in a separate cross-repo PRD). Do not generate a PRD for local peers — their work is part of this plan. - -- If `simple_mode` is true, this phase was already marked complete. Skip to Phase 3. - -**Phase 1.4.1: Discover peers** -- **Cache check first:** Activate the `code:cross-repo-cache` skill with `WORKDIR=$CLOSEDLOOP_WORKDIR`. Parse the output: - - If `CROSS_REPO_CACHE_HIT`: - - If status is `NO_CROSS_REPO_NEEDED`: Mark 1.4.x phases complete, proceed to Phase 2.5 - - If status is `CAPABILITIES_IDENTIFIED`: Skip coordinator, proceed to Phase 1.4.2 with cached capabilities - - If `CROSS_REPO_CACHE_MISS`: Launch coordinator below -- Launch @code:cross-repo-coordinator with `WORKDIR=$CLOSEDLOOP_WORKDIR` and `PLAN_PATH=$CLOSEDLOOP_WORKDIR/plan.json` -- The agent discovers peers, identifies needed capabilities, writes to `$CLOSEDLOOP_WORKDIR/.cross-repo-needs.json` -- After coordinator completes, stamp the cross-repo cache: - ```bash - if [ -f ".workspace-repos.json" ]; then - python3 -c "import json; [print(r['path']) for r in json.load(open('.workspace-repos.json')) if r.get('path')]" 2>/dev/null \ - | while IFS= read -r repo_path; do - if [ -d "$repo_path/.git" ]; then - printf '%s:%s\n' "$repo_path" "$(git -C "$repo_path" rev-parse HEAD 2>/dev/null || echo unknown)" - fi - done \ - | LC_ALL=C sort \ - | shasum -a 256 > "$CLOSEDLOOP_WORKDIR/.cross-repo-hash" - else - shasum -a 256 "$CLOSEDLOOP_WORKDIR/.cross-repo-needs.json" > "$CLOSEDLOOP_WORKDIR/.cross-repo-hash" - fi - ``` -- Handle return status: - - `NO_CROSS_REPO_NEEDED`: Mark 1.4.x phases complete, proceed to Phase 2.5 - - `CROSS_REPO_SKIPPED`: Mark 1.4.x phases complete, proceed to Phase 2.5 - - `CAPABILITIES_IDENTIFIED`: Continue to Phase 1.4.2 - -**Phase 1.4.2: Verify capabilities** -- Parse the `CAPABILITIES_LIST` section from cross-repo-coordinator's output (do NOT read `.cross-repo-needs.json`) -- For each capability line in the list: - - Extract: `peer_name`, `peer_path`, `peer_type`, `capability` - - Launch @code:generic-discovery with `WORKDIR=$CLOSEDLOOP_WORKDIR`, `PEER_PATH={peer_path}`, `PEER_NAME={peer_name}`, `CAPABILITY={capability}`, `PEER_TYPE={peer_type}` - - Results cached to `$CLOSEDLOOP_WORKDIR/.discovery-cache/{PEER_NAME}.json` - -**Phase 1.4.3: Generate PRDs** -- Launch @code:cross-repo-prd-writer with `WORKDIR=$CLOSEDLOOP_WORKDIR` -- Generates PRDs for missing capabilities, updates plan.json with cross-repo tags -- Proceed to Phase 2.5 - -**PHASE 2.5: CRITIC VALIDATION** (skipped if simple_mode = true) - -- If `simple_mode` is true, skip to Phase 3 -- **Update state.json** with phase tracking (include `"criticsCount": N` for the number of selected critics) -- **Cache check first:** Activate the `code:critic-cache` skill with `WORKDIR=$CLOSEDLOOP_WORKDIR`. Parse the output: - - If `CRITIC_CACHE_HIT`: Skip all critic launches. Existing reviews are valid. Proceed to Phase 2.6. - - If `CRITIC_CACHE_MISS`: Continue with critic launches below. -- Ensure reviews directory: `mkdir -p $CLOSEDLOOP_WORKDIR/reviews` -- For EACH critic in `selected_critics`, launch a Task() call **in parallel**: - "WORKDIR=$CLOSEDLOOP_WORKDIR. Review the implementation plan as a {critic_name} specialist. - Read: $CLOSEDLOOP_WORKDIR/plan.md, $CLOSEDLOOP_WORKDIR/investigation-log.md (if exists), the PRD in $CLOSEDLOOP_WORKDIR. - Write review to $CLOSEDLOOP_WORKDIR/reviews/{critic_name}.review.json with findings array. - Each finding: {severity: blocking|major|minor, description, recommendation, affectedTasks: [T-X.Y]}" -- After all Task calls complete, check review count: - `ls $CLOSEDLOOP_WORKDIR/reviews/*.review.json 2>/dev/null | wc -l` -- If zero reviews: log warning, skip Phase 2.6, proceed to Phase 3 -- If reviews exist: stamp the critic cache, then proceed to Phase 2.6: - ```bash - if [ -f ".closedloop-ai/settings/critic-gates.json" ]; then - cat $CLOSEDLOOP_WORKDIR/plan.json .closedloop-ai/settings/critic-gates.json | shasum -a 256 > $CLOSEDLOOP_WORKDIR/reviews/.plan-hash - else - shasum -a 256 $CLOSEDLOOP_WORKDIR/plan.json > $CLOSEDLOOP_WORKDIR/reviews/.plan-hash - fi - ``` - -**PHASE 2.6: PLAN REFINEMENT** (only if Phase 2.5 produced reviews) - -- **Update state.json** with phase tracking (see State Tracking table above) -- Launch @code:plan-writer with prompt: - "WORKDIR=$CLOSEDLOOP_WORKDIR. MERGE MODE: Reconcile critic feedback. - Read reviews from $CLOSEDLOOP_WORKDIR/reviews/*.review.json. - Read current plan at $CLOSEDLOOP_WORKDIR/plan.json and PRD in $CLOSEDLOOP_WORKDIR. - Update plan.json and plan.md. Do NOT add scope beyond critic findings." -- After plan-writer completes, activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) -- If validation fails (FORMAT_ISSUES): launch plan-writer to fix format issues (same as Phase 1) -- If validation passes (VALID): additionally launch @code:plan-validator with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. SEMANTIC ONLY: Check semantic consistency of $CLOSEDLOOP_WORKDIR/plan.json — verify storage/query alignment and task/architecture decision consistency. Skip structural validation (already passed)." -- If semantic check finds issues: launch plan-writer to fix, then re-activate `code:plan-validate` skill -- Proceed to Phase 2.7 - -**PHASE 2.7: PLAN FINALIZATION** (skipped if simple_mode = true) - -- If `simple_mode` is true, skip to Phase 3 -- **Update state.json** with phase tracking (see State Tracking table above) -- Launch @code:plan-writer with prompt: - "WORKDIR=$CLOSEDLOOP_WORKDIR. FINALIZE MODE: Flesh out the approved plan with implementation details. - Read $CLOSEDLOOP_WORKDIR/plan.json, $CLOSEDLOOP_WORKDIR/investigation-log.md, and the PRD in $CLOSEDLOOP_WORKDIR. - Enrich task descriptions with code patterns, function signatures, integration points, and edge cases. - Do NOT add, remove, or renumber tasks. Preserve the approved scope." -- After plan-writer completes (outputs `PLAN_WRITER_COMPLETE`), activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) -- If validation fails (FORMAT_ISSUES): launch plan-writer to fix format issues (same as Phase 1) -- If validation passes (VALID): additionally launch @code:plan-validator with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. SEMANTIC ONLY: Check semantic consistency of $CLOSEDLOOP_WORKDIR/plan.json — verify storage/query alignment and task/architecture decision consistency. Skip structural validation (already passed)." -- If semantic check finds issues: launch plan-writer to fix, then re-activate `code:plan-validate` skill -- Proceed to Phase 3 - -**PHASE 3: IMPLEMENTATION** - -- **Update state.json** with phase tracking (see State Tracking table above) -- Activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) — semantic check is unnecessary here since the plan hasn't changed since Phase 2.7 -- If `pending_tasks` is empty, all tasks are done → proceed to Phase 4 -- For each task in `pending_tasks`: - 1. **Update state.json** with task-level tracking (see State Tracking section above) - 2. Launch @code:verification-subagent with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Verify task T-X.Y: {task description}" - 3. Process based on result: - - **VERIFIED**: Proceed to step 4 - - **NOT_IMPLEMENTED**: Parse the `missing:` and `files:` sections from the verification output. Launch @code:implementation-subagent with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Implement task T-X.Y: {task description}. Missing requirements: {missing list}. Relevant source files already identified: {files list}" - - After implementation-subagent returns, check its output: - - If output contains `IMPLEMENTATION_VERIFIED` or `BLOCKED`: proceed to step 4 - - If output does NOT contain either (max iterations exhausted): log warning "implementation-subagent did not verify T-X.Y", do NOT mark `[x]`, continue to next task - 4. After task is verified/implemented (and implementation-subagent output passed the check above), launch a **haiku subagent** to mark `- [x]` in the plan. Prompt: "In $CLOSEDLOOP_WORKDIR/plan.json, update the content field to change task T-X.Y from '- [ ]' to '- [x]', and move the task from pendingTasks to completedTasks array. Then write the updated `content` field value to $CLOSEDLOOP_WORKDIR/plan.md" -- **The orchestrator should NEVER read or edit files directly** - always delegate to subagents to minimize context bloat. This includes plan.json updates. -- **Do NOT fix errors outside the implementation loop** - The implementation-subagent now self-verifies and fixes its own errors during its loop iterations (up to 4 attempts). Only errors that survive the loop (max iterations exhausted without `IMPLEMENTATION_VERIFIED`) pass through to Phase 5. Do NOT spawn separate fix tasks between Phase 3 tasks — continue to the next task and let Phase 5 build validation catch remaining issues. -- **Iterative Retrieval (optional):** For complex tasks, activate the iterative-retrieval skill (see "Available Skills" section above) when launching @code:implementation-subagent or @code:verification-subagent. The skill's 4-phase protocol allows you to: - 1. Store the agent ID from the initial Task() call - 2. Evaluate the response using the sufficiency checklist - 3. Resume the agent with follow-up questions if context is incomplete - - This is particularly useful when tasks involve interconnected code or when the initial summary might miss important adjacent context. -- After processing all tasks, re-activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) to confirm no `pending_tasks` remain — semantic check is unnecessary since plan structure hasn't changed -- Only proceed to Phase 4 when `pending_tasks` is empty - -**PHASE 4: CODE SIMPLIFICATION** - -- **Update state.json** with phase tracking (see State Tracking table above) -- If code changes were made in this session, launch @code-simplifier:code-simplifier with prompt: "WORKDIR=$CLOSEDLOOP_WORKDIR. Review and simplify recently modified code." -- The agent focuses on recently modified code and improves clarity, consistency, and maintainability while preserving functionality -- The agent applies simplifications directly — do not edit code yourself -- This runs BEFORE testing so that tests validate the final simplified code - -**PHASE 5: TESTING AND CODE REVIEW** - -- **Update state.json** with phase tracking (see State Tracking table above) - -**Step 1: Write tests for implemented code** -- If code was implemented in Phase 3, launch @test-engineer with prompt: - "WORKDIR=$CLOSEDLOOP_WORKDIR. Write tests for the code changes made in this session. Focus on the implemented tasks from the plan." -- The test-engineer will identify testable code and write appropriate unit/integration tests -- Skip this step only if: (a) no code was implemented, or (b) the project has no test framework - -**Step 2: Code review** -- Launch @code:code-reviewer with prompt: - "WORKDIR=$CLOSEDLOOP_WORKDIR. Review the code changes made in this session. Check for: security issues (especially authorization and tenant scoping on data-access endpoints), type safety, correctness, performance (unbounded parallel calls), code duplication across changed files, and package boundary violations." -- Fix all blockers and critical bugs until none remain (delegate fixes to subagents, not orchestrator) - -**Step 3: Run validation via build-validator agent:** -1. Launch @code:build-validator with `WORKDIR=$CLOSEDLOOP_WORKDIR` -2. Process the result: - - `VALIDATION_PASSED`: Stamp the build cache, then proceed to Phase 6: - ```bash - bash scripts/check_build_cache.sh $CLOSEDLOOP_WORKDIR stamp - ``` - (Use `code:find-plugin-file` skill to resolve the absolute script path if needed. Fallback: run `bash scripts/check_build_cache.sh $CLOSEDLOOP_WORKDIR stamp` from repo root.) - - `NO_VALIDATION`: No commands found - proceed to Phase 6 (not an error) - - `VALIDATION_FAILED`: - a. Review the failures in the agent's output - b. For each failure, delegate fix to appropriate subagent: - - Test failures: Launch @test-engineer with "WORKDIR=$CLOSEDLOOP_WORKDIR. Fix failing test: {test name and error}" - - Other failures: Launch a sonnet subagent to fix the issue - **CRITICAL: The orchestrator must NOT attempt to fix code itself - always delegate to subagents** - c. Re-run @code:build-validator - d. Repeat until VALIDATION_PASSED (max 20 attempts) - e. If still failing after 20 attempts: - - **CRITICAL: Execute these steps IN THIS EXACT ORDER.** - - 1. **FIRST** - Write state.json with AWAITING_USER status: - ```bash - echo '{"phase": "Phase 5: Testing and Code Review", "status": "AWAITING_USER", "reason": "Validation failed after 20 attempts", "userAction": {"description": "Fix validation issues manually and run the command to continue", "file": null, "command": "/code:code $ARGUMENTS"}, "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > $CLOSEDLOOP_WORKDIR/state.json - ``` - 2. **ONLY AFTER state.json is written** - Output `COMPLETE` - 3. Tell the user: "Validation failed after 20 attempts. Fix issues manually and run `/code:code $ARGUMENTS` to continue." - 4. **HARD STOP** - Do not continue. - - -**PHASE 6: VISUAL INSPECTION (if UI changes were made)** - -- **Update state.json** with phase tracking (see State Tracking table above) -- If `$CLOSEDLOOP_WORKDIR/visual-requirements.md` does not exist or is empty, skip to Phase 7 -- Launch @code:dev-environment with `WORKDIR=$CLOSEDLOOP_WORKDIR` to detect available targets -- Read `$CLOSEDLOOP_WORKDIR/.dev-environment.json` for available targets (web, ios, android, api, etc.) -- Determine the appropriate target based on visual-requirements.md (default: web) -- Check if the target is running using its `healthCheck` command -- If not running, log "Skipping visual QA: target environment not running" and skip to Phase 7 -- Launch @code:visual-qa-subagent with `WORKDIR=$CLOSEDLOOP_WORKDIR` and the detected URL/target -- Handle return status: - - `AUTH_REQUIRED`: Log "Skipping visual QA: authentication required" and skip to Phase 7 - - `INCOMPLETE_DOCS`: Update visual-requirements.md with missing info, then resume subagent - - `BLOCKED`: Read `$CLOSEDLOOP_WORKDIR/visual-qa-memory.md`, delegate fix to sonnet subagent, then resume visual-qa - - `SUCCESS`: Proceed to Phase 7 - - `FAILURE`: Output summary of passed/failed steps, fix issues, re-run visual QA - -**PHASE 7: LOGGING AND COMPLETION** - -- **Update state.json** with phase tracking (see State Tracking table above) -- Append a summary of all changes made to $CLOSEDLOOP_WORKDIR/log.md file - -**Final verification gate (all must pass before COMPLETE):** - -1. **Build validation:** First activate `code:build-status-cache` skill with `WORKDIR=$CLOSEDLOOP_WORKDIR`: - - If `BUILD_CACHE_HIT`: Skip build-validator launch, continue to step 2 - - If `BUILD_CACHE_MISS`: Launch @code:build-validator with `WORKDIR=$CLOSEDLOOP_WORKDIR` - - If `VALIDATION_FAILED`: - 1. Log "Final build validation failed. Loop will continue." - 2. Update state.json: - ```bash - echo '{"phase": "Phase 7: Logging and completion", "status": "IN_PROGRESS", "reason": "Final build validation failed", "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > $CLOSEDLOOP_WORKDIR/state.json - ``` - 3. **Do NOT output `COMPLETE`** - end naturally, loop will restart - - If `VALIDATION_PASSED` or `NO_VALIDATION`: Continue to step 2 - -2. **Task and question check:** Activate `code:plan-validate` skill (runs Python script against $CLOSEDLOOP_WORKDIR) — semantic check is unnecessary since plan content hasn't changed since last semantic validation - - If `has_unanswered_questions` is true: Log warning "Unanswered questions remain - review $CLOSEDLOOP_WORKDIR/plan.json" (proceed anyway) - - If `pending_tasks` is NOT empty: See "work remains" below - - If `manual_tasks` exist: Log "Manual tasks remain for human completion: [task IDs]" (does NOT block completion) - -- **If `pending_tasks` is NOT empty (work remains):** - 1. Log: "Pending tasks remain: [task IDs]. Loop will continue." - 2. Update state.json: - ```bash - echo '{"phase": "Phase 7: Logging and completion", "status": "IN_PROGRESS", "reason": "Pending tasks remain", "pendingTasks": ["T-X.Y", ...], "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > $CLOSEDLOOP_WORKDIR/state.json - ``` - 3. **Do NOT output `COMPLETE`** - just end your response naturally - 4. The external loop will automatically restart a fresh iteration - - - **CRITICAL: Execute these steps IN THIS EXACT ORDER. Step 1 MUST complete before Step 2.** - - 1. **FIRST** - Write state.json with COMPLETED status: - ```bash - echo '{"phase": "Phase 7: Logging and completion", "status": "COMPLETED", "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' > $CLOSEDLOOP_WORKDIR/state.json - ``` - 2. **ONLY AFTER state.json is written** - Output `COMPLETE` - - **WARNING:** If you output the promise WITHOUT writing state.json first, external systems will show "IN_PROGRESS" forever. This is a critical bug. - - -**IMPORTANT RULES:** - -1. Follow the phases sequentially - do not skip ahead -2. After initial plan creation (Phase 1), you MUST wait for human approval at the Phase 1.1 checkpoint before continuing. Subsequent automated plan edits (gap incorporation, critic merge, finalization, checkbox updates) proceed without additional approval and must follow the phase-specific scope constraints. -3. All validation checks must pass before completion (or user must explicitly skip) -4. Use build-validator to discover and run project-specific validation commands - do not hardcode commands -5. Do not over-engineer solutions -6. Only ask questions when there are drastically different options or critical missing information -7. Document all changes in $CLOSEDLOOP_WORKDIR/log.md - -Before taking action in each phase, use your scratchpad to think through what needs to be done: - - -Think through: -- What phase am I currently in? -- What are the specific requirements for this phase? -- What files do I need to check or create? -- Are there any blockers or dependencies? -- What is my next concrete action? - -For Phase 3 specifically, also think: -- Which tasks in $CLOSEDLOOP_WORKDIR/plan.json are marked `- [ ]` (not done)? -- For `- [x]` tasks, did my light verification pass? - - -After your scratchpad reasoning, take the appropriate actions for the current phase. Continue working through phases until all requirements are met. - -Your final output should include: - -- A clear indication of which phase you're working on -- Any questions you need answered -- Status updates as you complete each phase -- The COMPLETE tag only when ALL phases are successfully completed and `pending_tasks` is empty. Do NOT output COMPLETE if any tasks remain - the loop will restart automatically. - -Do not include your scratchpad reasoning in your final output - only include the concrete actions, status updates, questions, and completion signal. diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 40a4b5f1..7d979315 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -545,8 +545,8 @@ while [[ $# -gt 0 ]]; do echo -e "${RED}Error: --prompt name must not contain spaces or path separators${NC}" >&2 exit 1 fi - if [[ ! -f "$SCRIPTS_DIR/../prompts/$2.md" ]]; then - echo -e "${RED}Error: prompt file not found: prompts/$2.md${NC}" >&2 + if [[ ! -f "$SCRIPTS_DIR/../prompts/$2.md" && ! -f "$SCRIPTS_DIR/../prompts/overlays/$2.overlay.md" ]]; then + echo -e "${RED}Error: prompt not found: prompts/$2.md or prompts/overlays/$2.overlay.md${NC}" >&2 exit 1 fi PROMPT_NAME="$2" diff --git a/plugins/code/scripts/setup-closedloop.sh b/plugins/code/scripts/setup-closedloop.sh index 6ae88312..7480214a 100755 --- a/plugins/code/scripts/setup-closedloop.sh +++ b/plugins/code/scripts/setup-closedloop.sh @@ -152,36 +152,56 @@ fi # Step 3: Auto-select prompt based on whether extra repos were provided if [[ "$PROMPT_NAME_EXPLICIT" == false ]]; then if [[ ${#RESOLVED_ADD_DIRS[@]} -gt 0 ]]; then - PROMPT_NAME="prompt-multi-repo" + PROMPT_NAME="multi-repo" else PROMPT_NAME="${PROMPT_NAME:-prompt}" fi fi -# Validate prompt before creating any directories # 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" +# Resolve prompt: direct base file takes precedence; otherwise assemble +# base prompt.md + overlay. See plugins/code/prompts/overlays/README.md. +DIRECT_PROMPT="$PLUGIN_ROOT/prompts/$PROMPT_NAME.md" +OVERLAY_PROMPT="$PLUGIN_ROOT/prompts/overlays/$PROMPT_NAME.overlay.md" +BASE_PROMPT="$PLUGIN_ROOT/prompts/prompt.md" -# Validate the prompt file exists -if [[ ! -f "$CLOSEDLOOP_PROMPT_FILE" ]]; then - echo "ERROR: Prompt file not found: $CLOSEDLOOP_PROMPT_FILE" >&2 +# Ensure WORKDIR/.closedloop exists before writing the assembled file +mkdir -p "$WORKDIR/.closedloop" + +if [[ -f "$DIRECT_PROMPT" ]]; then + CLOSEDLOOP_PROMPT_FILE="$DIRECT_PROMPT" +elif [[ -f "$OVERLAY_PROMPT" ]]; then + if [[ ! -f "$BASE_PROMPT" ]]; then + echo "ERROR: base prompt missing: $BASE_PROMPT" >&2 + exit 1 + fi + ASSEMBLED_PROMPT="$WORKDIR/.closedloop/prompt-assembled.md" + { + cat "$BASE_PROMPT" + printf '\n\n' + cat "$OVERLAY_PROMPT" + } > "$ASSEMBLED_PROMPT" + CLOSEDLOOP_PROMPT_FILE="$ASSEMBLED_PROMPT" +else + echo "ERROR: Prompt '$PROMPT_NAME' not found (no $DIRECT_PROMPT, no $OVERLAY_PROMPT)" >&2 echo "Available prompts:" >&2 shopt -s nullglob for f in "$PLUGIN_ROOT/prompts/"*.md; do basename "$f" .md >&2 done + for f in "$PLUGIN_ROOT/prompts/overlays/"*.overlay.md; do + name="$(basename "$f" .overlay.md)" + echo "$name (overlay)" >&2 + done shopt -u nullglob exit 1 fi -# Write full config to WORKDIR -mkdir -p "$WORKDIR/.closedloop" - cat > "$WORKDIR/.closedloop/config.env" << EOF CLOSEDLOOP_WORKDIR="$WORKDIR" CLOSEDLOOP_PRD_FILE="$PRD_FILE" diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index 951064b5..c04b8b65 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -257,30 +257,48 @@ def test_multiple_add_dirs_produces_pipe_joined_values(tmp_workdir: Path, tmp_pa assert "|" in add_dirs_line, f"Expected pipe separator in: {add_dirs_line!r}" -def test_add_dir_selects_prompt_multi_repo_automatically(tmp_workdir: Path, extra_repo: Path) -> None: - """When --add-dir is given without explicit --prompt, prompt-multi-repo should be used.""" +def test_add_dir_selects_multi_repo_overlay_automatically(tmp_workdir: Path, extra_repo: Path) -> None: + """When --add-dir is given without explicit --prompt, the multi-repo overlay + should be assembled onto prompt.md and used as the prompt file.""" result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(extra_repo)) assert result.returncode == 0, result.stderr config = _config_env(tmp_workdir) - assert "prompt-multi-repo" in config + prompt_line = next( + line for line in config.splitlines() if line.startswith("CLOSEDLOOP_PROMPT_FILE=") + ) + # Should point at an assembled file under the workdir + assert "prompt-assembled.md" in prompt_line, ( + f"Expected assembled prompt file, got: {prompt_line!r}" + ) + + # Verify the assembled file exists and equals base + blank + overlay + assembled_path = tmp_workdir / ".closedloop" / "prompt-assembled.md" + assert assembled_path.is_file(), f"Missing assembled file: {assembled_path}" + assembled = assembled_path.read_text() + + plugin_root = Path(__file__).resolve().parents[2] + base = (plugin_root / "prompts" / "prompt.md").read_text() + overlay = (plugin_root / "prompts" / "overlays" / "multi-repo.overlay.md").read_text() + assert assembled == base + "\n\n" + overlay, ( + "Assembled prompt does not match base + blank + overlay" + ) def test_explicit_prompt_overrides_add_dir_auto_selection(tmp_workdir: Path, extra_repo: Path) -> None: - """An explicit --prompt flag must override the auto-selected prompt-multi-repo.""" + """An explicit --prompt flag must override the auto-selected multi-repo overlay.""" result = _run_setup_in_workdir( tmp_workdir, "--add-dir", str(extra_repo), "--prompt", "prompt" ) assert result.returncode == 0, result.stderr config = _config_env(tmp_workdir) - # The explicit "prompt" should appear in the prompt file path, not "prompt-multi-repo" prompt_line = next( line for line in config.splitlines() if line.startswith("CLOSEDLOOP_PROMPT_FILE=") ) - # Should end with /prompt.md, not /prompt-multi-repo.md - assert prompt_line.endswith('prompt.md"'), ( - f"Expected prompt.md but got: {prompt_line!r}" + # Explicit --prompt prompt → direct base file, not assembled + assert prompt_line.endswith('prompts/prompt.md"'), ( + f"Expected direct base prompt.md but got: {prompt_line!r}" ) From 2c472b363d3d1309a11ddcb579838a73b0e9215d Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 13:58:46 -0500 Subject: [PATCH 05/42] refactor(code): consolidate Tier 0 discover-repos tests into scenario harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace six near-identical Tier 0 test functions with a single parametrized test driven by a declarative Tier0Scenario registry (RepoSpec + PeerExpect dataclasses). Adding a new case is now one registry entry instead of a new test function with duplicated setup. Work in progress — no version bump. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../code/tools/python/test_discover_repos.py | 244 +++++++++++------- 1 file changed, 155 insertions(+), 89 deletions(-) diff --git a/plugins/code/tools/python/test_discover_repos.py b/plugins/code/tools/python/test_discover_repos.py index 85876e82..3583aed6 100644 --- a/plugins/code/tools/python/test_discover_repos.py +++ b/plugins/code/tools/python/test_discover_repos.py @@ -4,8 +4,11 @@ import json import os import subprocess +from dataclasses import dataclass, field from pathlib import Path +import pytest + SCRIPT_PATH = Path(__file__).resolve().parent.parent.parent / "scripts" / "discover-repos.sh" @@ -88,97 +91,160 @@ def _make_repo(parent: Path, name: str, identity: dict | None = None) -> Path: return repo -def test_tier0_add_dir_appears_in_peers(tmp_path: Path) -> None: - """A path in CLOSEDLOOP_ADD_DIRS should appear in peers with discoveryMethod add_dir.""" - current = _make_repo(tmp_path, "current") - extra = _make_repo(tmp_path, "extra", {"name": "extra-svc", "type": "service"}) - - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(extra)}) - - assert result.returncode == 0, result.stderr - payload = json.loads(result.stdout) - peer_paths = {p["path"] for p in payload["peers"]} - assert str(extra) in peer_paths - - extra_peer = next(p for p in payload["peers"] if p["path"] == str(extra)) - assert extra_peer["discoveryMethod"] == "add_dir" - assert extra_peer["name"] == "extra-svc" - assert extra_peer["type"] == "service" - - -def test_tier0_add_dir_falls_back_to_basename_without_identity(tmp_path: Path) -> None: - """Tier 0 peer with no identity file should use the directory basename as name.""" - current = _make_repo(tmp_path, "current") - anon = _make_repo(tmp_path, "my-anon-repo") - - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(anon)}) - - assert result.returncode == 0, result.stderr - payload = json.loads(result.stdout) - peer = next((p for p in payload["peers"] if p["path"] == str(anon)), None) - assert peer is not None, f"Expected peer for {anon}, got {payload['peers']}" - assert peer["name"] == "my-anon-repo" - - -def test_tier0_multiple_add_dirs_pipe_separated(tmp_path: Path) -> None: - """Multiple pipe-separated paths in CLOSEDLOOP_ADD_DIRS should all appear as peers.""" - current = _make_repo(tmp_path, "current") - repo_a = _make_repo(tmp_path, "repo-a") - repo_b = _make_repo(tmp_path, "repo-b") - - add_dirs = f"{repo_a}|{repo_b}" - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": add_dirs}) - - assert result.returncode == 0, result.stderr - payload = json.loads(result.stdout) - peer_paths = {p["path"] for p in payload["peers"]} - assert str(repo_a) in peer_paths - assert str(repo_b) in peer_paths - - -def test_tier0_skips_current_repo(tmp_path: Path) -> None: - """A CLOSEDLOOP_ADD_DIRS entry equal to the current repo path should be skipped.""" - current = _make_repo(tmp_path, "current") - - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(current)}) - - assert result.returncode == 0, result.stderr - payload = json.loads(result.stdout) - peer_paths = [p["path"] for p in payload["peers"]] - assert str(current) not in peer_paths, f"Current repo should not appear in peers: {peer_paths}" - - -def test_tier0_deduplicates_with_tier2_sibling_scan(tmp_path: Path) -> None: - """A sibling that is also in CLOSEDLOOP_ADD_DIRS should appear only once in peers.""" - workspace = tmp_path / "workspace" - current = _make_repo(workspace, "current", {"name": "current", "type": "service"}) - sibling = _make_repo( - workspace, "sibling-svc", {"name": "sibling-svc", "type": "library", "discoverable": True} - ) +# --------------------------------------------------------------------------- +# Tier 0 harness: scenarios are declarative — one test drives them all. +# +# Each Tier0Scenario builds a set of repos under a temp dir, runs +# discover-repos.sh with CLOSEDLOOP_ADD_DIRS derived from scenario keys, and +# validates the peer list against declarative PeerExpect entries. +# --------------------------------------------------------------------------- - # The sibling is both a Tier 0 add-dir AND a Tier 2 sibling - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(sibling)}) +# Sentinel used in `add_dir_keys` to reference the current repo's own path. +_CURRENT = "__current__" + + +@dataclass(frozen=True) +class RepoSpec: + """Declarative description of a repo to create on disk for a scenario.""" + + key: str # identifier used to reference the repo within the scenario + dirname: str # directory name under the scenario root + identity: dict | None = None # contents of .closedloop-ai/.repo-identity.json, or None to skip + is_current: bool = False # exactly one RepoSpec per scenario must set this + + +@dataclass(frozen=True) +class PeerExpect: + """Declarative assertion over a peer entry in the discover-repos.sh output.""" + + key: str # references a RepoSpec.key in the same scenario + count: int = 1 # expected number of peer entries with this repo's path + discovery_method: str | None = None + name: str | None = None + type: str | None = None + + +@dataclass(frozen=True) +class Tier0Scenario: + id: str + repos: tuple[RepoSpec, ...] + add_dir_keys: tuple[str, ...] # repo keys (or _CURRENT) to join into CLOSEDLOOP_ADD_DIRS + workspace_subdir: bool = False # place repos under tmp_path/workspace/ (enables Tier 2 sibling scan) + expect_peers: tuple[PeerExpect, ...] = field(default_factory=tuple) + forbidden_keys: tuple[str, ...] = field(default_factory=tuple) # repo keys that must NOT appear as peers + + +TIER0_SCENARIOS: tuple[Tier0Scenario, ...] = ( + Tier0Scenario( + id="add_dir_appears_in_peers", + repos=( + RepoSpec("current", "current", is_current=True), + RepoSpec("extra", "extra", identity={"name": "extra-svc", "type": "service"}), + ), + add_dir_keys=("extra",), + expect_peers=( + PeerExpect("extra", discovery_method="add_dir", name="extra-svc", type="service"), + ), + ), + Tier0Scenario( + id="basename_fallback_without_identity", + repos=( + RepoSpec("current", "current", is_current=True), + RepoSpec("anon", "my-anon-repo"), # no identity → name falls back to basename + ), + add_dir_keys=("anon",), + expect_peers=(PeerExpect("anon", name="my-anon-repo"),), + ), + Tier0Scenario( + id="multiple_add_dirs_pipe_separated", + repos=( + RepoSpec("current", "current", is_current=True), + RepoSpec("a", "repo-a"), + RepoSpec("b", "repo-b"), + ), + add_dir_keys=("a", "b"), + expect_peers=(PeerExpect("a"), PeerExpect("b")), + ), + Tier0Scenario( + id="skips_current_repo", + repos=(RepoSpec("current", "current", is_current=True),), + add_dir_keys=(_CURRENT,), + forbidden_keys=("current",), + ), + # A sibling that is ALSO listed in CLOSEDLOOP_ADD_DIRS must appear exactly + # once AND be marked `add_dir` (Tier 0 wins over Tier 2 sibling scan). + Tier0Scenario( + id="add_dir_wins_over_sibling_scan", + workspace_subdir=True, + repos=( + RepoSpec("current", "current", identity={"name": "current", "type": "service"}, is_current=True), + RepoSpec( + "sibling", + "sibling-svc", + identity={"name": "sibling-svc", "type": "library", "discoverable": True}, + ), + ), + add_dir_keys=("sibling",), + expect_peers=(PeerExpect("sibling", count=1, discovery_method="add_dir"),), + ), +) + + +@pytest.mark.parametrize("scenario", TIER0_SCENARIOS, ids=lambda s: s.id) +def test_tier0_add_dirs(tmp_path: Path, scenario: Tier0Scenario) -> None: + """Drives every Tier 0 scenario through a single harness. + + Build repos, invoke discover-repos.sh with the scenario's CLOSEDLOOP_ADD_DIRS, + then validate peer count and per-field attributes declaratively. + """ + # 1. Materialize repos on disk + root = tmp_path / "workspace" if scenario.workspace_subdir else tmp_path + paths: dict[str, Path] = { + spec.key: _make_repo(root, spec.dirname, spec.identity) for spec in scenario.repos + } + current_specs = [s for s in scenario.repos if s.is_current] + assert len(current_specs) == 1, f"Scenario {scenario.id!r} must declare exactly one current repo" + current_path = paths[current_specs[0].key] + + # 2. Build CLOSEDLOOP_ADD_DIRS, resolving _CURRENT sentinel against the current repo + def _resolve(key: str) -> Path: + return current_path if key == _CURRENT else paths[key] + + add_dirs = "|".join(str(_resolve(k)) for k in scenario.add_dir_keys) + + # 3. Invoke the script + result = _run_discover_with_env(current_path, {"CLOSEDLOOP_ADD_DIRS": add_dirs}) assert result.returncode == 0, result.stderr payload = json.loads(result.stdout) - paths = [p["path"] for p in payload["peers"]] - assert paths.count(str(sibling)) == 1, ( - f"Sibling should appear exactly once; got peers: {payload['peers']}" - ) - - -def test_tier0_peer_marked_as_add_dir_not_sibling_scan(tmp_path: Path) -> None: - """When a sibling is in Tier 0, the peer's discoveryMethod must be 'add_dir', not sibling_scan.""" - workspace = tmp_path / "workspace" - current = _make_repo(workspace, "current", {"name": "current", "type": "service"}) - sibling = _make_repo( - workspace, "shared-lib", {"name": "shared-lib", "type": "library", "discoverable": True} - ) - - result = _run_discover_with_env(current, {"CLOSEDLOOP_ADD_DIRS": str(sibling)}) + peers = payload["peers"] + + # 4. Forbidden paths must not appear at all + peer_paths = [p["path"] for p in peers] + for key in scenario.forbidden_keys: + forbidden = str(paths[key]) + assert forbidden not in peer_paths, ( + f"[{scenario.id}] {key!r} should not appear in peers; got: {peer_paths}" + ) - assert result.returncode == 0, result.stderr - payload = json.loads(result.stdout) - peer = next((p for p in payload["peers"] if p["path"] == str(sibling)), None) - assert peer is not None - assert peer["discoveryMethod"] == "add_dir" + # 5. Each expectation: check occurrence count and per-field attributes + for exp in scenario.expect_peers: + target = str(paths[exp.key]) + matches = [p for p in peers if p["path"] == target] + assert len(matches) == exp.count, ( + f"[{scenario.id}] expected {exp.count} peer(s) for {exp.key!r}, " + f"got {len(matches)}; peers={peers}" + ) + if exp.count == 0: + continue + peer = matches[0] + for attr, field_name in ( + (exp.discovery_method, "discoveryMethod"), + (exp.name, "name"), + (exp.type, "type"), + ): + if attr is not None: + assert peer.get(field_name) == attr, ( + f"[{scenario.id}] peer {exp.key!r} {field_name}: " + f"expected {attr!r}, got {peer.get(field_name)!r}" + ) From 4e7198e5d502ed607ecdac3a5257866bb96ed7f8 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 14:01:38 -0500 Subject: [PATCH 06/42] Simplified readme --- plugins/code/prompts/overlays/README.md | 37 ------------------------- 1 file changed, 37 deletions(-) diff --git a/plugins/code/prompts/overlays/README.md b/plugins/code/prompts/overlays/README.md index 2dc8e839..4a6e2283 100644 --- a/plugins/code/prompts/overlays/README.md +++ b/plugins/code/prompts/overlays/README.md @@ -5,17 +5,6 @@ SSOT orchestrator prompt) at runtime. This directory exists so variants of the base prompt do not require duplicating 500+ lines of identical orchestration text. -## Why overlays exist - -Before overlays, `prompt.md` and `prompt-multi-repo.md` were 99% identical -540-line files. The multi-repo variant added three small inserts (one -sentence each to the `pre-explorer` and `plan-draft-writer` launch prompts -and a NOTE block in Phase 1.4). Every edit to the base had to be mirrored -by hand and every missed mirror was a production bug in the orchestrator. - -Overlays fix that by keeping `prompt.md` as the single source of truth and -expressing each variant as a small trailing amendment file. - ## How assembly works `plugins/code/scripts/setup-closedloop.sh` resolves `--prompt ` in @@ -41,32 +30,6 @@ amendments to earlier phases, write an overlay. If you need to **change** or **remove** base content, do not use an overlay — have a conversation about forking or refactoring the base instead. -## Authoring rules (enforced by review, not code) - -- **Append-only.** Overlays never restate or contradict base lines. State - the amendment positively. -- **Name the phase(s) amended.** Each sub-section heading references the - phase it modifies so a reader of the assembled prompt can - cross-reference. -- **Frame as authoritative amendments.** Use language like "take - precedence over the base instructions for those phases when their - trigger conditions are met" — LLMs honor late instructions better when - framed as explicit errata. -- **One overlay per run.** Overlays do not reference or compose with - other overlays. -- **Plain markdown only.** No frontmatter, no directives, no parsing. - -## Authoring workflow - -1. Draft `overlays/.overlay.md`. -2. Run `cat plugins/code/prompts/prompt.md plugins/code/prompts/overlays/.overlay.md > /tmp/assembled.md` - and read the result end-to-end to verify the orchestrator will - understand the amendments in context. -3. If replacing a hand-maintained variant, compare behaviorally: every - rule in the old variant must be present in the new assembled output. - Byte equality is not required. -4. Delete the hand-maintained variant in the same commit. - ## Runtime contract — multi-repo overlay The `multi-repo.overlay.md` overlay depends on env vars exported by From 114768f641dd95989b4330e99922ebba32e9b3d1 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 14:29:04 -0500 Subject: [PATCH 07/42] Simplified unit tests --- .../code/tools/python/test_discover_repos.py | 237 +++++++----------- 1 file changed, 88 insertions(+), 149 deletions(-) diff --git a/plugins/code/tools/python/test_discover_repos.py b/plugins/code/tools/python/test_discover_repos.py index 3583aed6..a11e8c2f 100644 --- a/plugins/code/tools/python/test_discover_repos.py +++ b/plugins/code/tools/python/test_discover_repos.py @@ -1,18 +1,20 @@ - """Tests for discover-repos.sh path handling.""" import json import os import subprocess -from dataclasses import dataclass, field from pathlib import Path import pytest -SCRIPT_PATH = Path(__file__).resolve().parent.parent.parent / "scripts" / "discover-repos.sh" +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)], @@ -92,159 +94,96 @@ def _make_repo(parent: Path, name: str, identity: dict | None = None) -> Path: # --------------------------------------------------------------------------- -# Tier 0 harness: scenarios are declarative — one test drives them all. +# Tier 0 harness: each scenario is a plain dict. One parametrized test runs them. # -# Each Tier0Scenario builds a set of repos under a temp dir, runs -# discover-repos.sh with CLOSEDLOOP_ADD_DIRS derived from scenario keys, and -# validates the peer list against declarative PeerExpect entries. +# 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) # --------------------------------------------------------------------------- -# Sentinel used in `add_dir_keys` to reference the current repo's own path. -_CURRENT = "__current__" - - -@dataclass(frozen=True) -class RepoSpec: - """Declarative description of a repo to create on disk for a scenario.""" - - key: str # identifier used to reference the repo within the scenario - dirname: str # directory name under the scenario root - identity: dict | None = None # contents of .closedloop-ai/.repo-identity.json, or None to skip - is_current: bool = False # exactly one RepoSpec per scenario must set this - - -@dataclass(frozen=True) -class PeerExpect: - """Declarative assertion over a peer entry in the discover-repos.sh output.""" - - key: str # references a RepoSpec.key in the same scenario - count: int = 1 # expected number of peer entries with this repo's path - discovery_method: str | None = None - name: str | None = None - type: str | None = None - - -@dataclass(frozen=True) -class Tier0Scenario: - id: str - repos: tuple[RepoSpec, ...] - add_dir_keys: tuple[str, ...] # repo keys (or _CURRENT) to join into CLOSEDLOOP_ADD_DIRS - workspace_subdir: bool = False # place repos under tmp_path/workspace/ (enables Tier 2 sibling scan) - expect_peers: tuple[PeerExpect, ...] = field(default_factory=tuple) - forbidden_keys: tuple[str, ...] = field(default_factory=tuple) # repo keys that must NOT appear as peers - - -TIER0_SCENARIOS: tuple[Tier0Scenario, ...] = ( - Tier0Scenario( - id="add_dir_appears_in_peers", - repos=( - RepoSpec("current", "current", is_current=True), - RepoSpec("extra", "extra", identity={"name": "extra-svc", "type": "service"}), - ), - add_dir_keys=("extra",), - expect_peers=( - PeerExpect("extra", discovery_method="add_dir", name="extra-svc", type="service"), - ), - ), - Tier0Scenario( - id="basename_fallback_without_identity", - repos=( - RepoSpec("current", "current", is_current=True), - RepoSpec("anon", "my-anon-repo"), # no identity → name falls back to basename - ), - add_dir_keys=("anon",), - expect_peers=(PeerExpect("anon", name="my-anon-repo"),), - ), - Tier0Scenario( - id="multiple_add_dirs_pipe_separated", - repos=( - RepoSpec("current", "current", is_current=True), - RepoSpec("a", "repo-a"), - RepoSpec("b", "repo-b"), - ), - add_dir_keys=("a", "b"), - expect_peers=(PeerExpect("a"), PeerExpect("b")), - ), - Tier0Scenario( - id="skips_current_repo", - repos=(RepoSpec("current", "current", is_current=True),), - add_dir_keys=(_CURRENT,), - forbidden_keys=("current",), - ), - # A sibling that is ALSO listed in CLOSEDLOOP_ADD_DIRS must appear exactly - # once AND be marked `add_dir` (Tier 0 wins over Tier 2 sibling scan). - Tier0Scenario( - id="add_dir_wins_over_sibling_scan", - workspace_subdir=True, - repos=( - RepoSpec("current", "current", identity={"name": "current", "type": "service"}, is_current=True), - RepoSpec( - "sibling", - "sibling-svc", - identity={"name": "sibling-svc", "type": "library", "discoverable": True}, - ), - ), - add_dir_keys=("sibling",), - expect_peers=(PeerExpect("sibling", count=1, discovery_method="add_dir"),), - ), -) - - -@pytest.mark.parametrize("scenario", TIER0_SCENARIOS, ids=lambda s: s.id) -def test_tier0_add_dirs(tmp_path: Path, scenario: Tier0Scenario) -> None: - """Drives every Tier 0 scenario through a single harness. - - Build repos, invoke discover-repos.sh with the scenario's CLOSEDLOOP_ADD_DIRS, - then validate peer count and per-field attributes declaratively. - """ - # 1. Materialize repos on disk - root = tmp_path / "workspace" if scenario.workspace_subdir else tmp_path - paths: dict[str, Path] = { - spec.key: _make_repo(root, spec.dirname, spec.identity) for spec in scenario.repos +TIER0_SCENARIOS: list[dict] = [ + { + "id": "add_dir_appears_in_peers", + "repos": {"current": None, "extra": {"name": "extra-svc", "type": "service"}}, + "add_dirs": ["extra"], + "expect": { + "extra": { + "discoveryMethod": "add_dir", + "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 `add_dir` (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": {"discoveryMethod": "add_dir"}}, + }, +] + + +@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_specs = [s for s in scenario.repos if s.is_current] - assert len(current_specs) == 1, f"Scenario {scenario.id!r} must declare exactly one current repo" - current_path = paths[current_specs[0].key] - - # 2. Build CLOSEDLOOP_ADD_DIRS, resolving _CURRENT sentinel against the current repo - def _resolve(key: str) -> Path: - return current_path if key == _CURRENT else paths[key] - - add_dirs = "|".join(str(_resolve(k)) for k in scenario.add_dir_keys) + current = next( + iter(paths.values()) + ) # first entry is the current repo by convention - # 3. Invoke the script - result = _run_discover_with_env(current_path, {"CLOSEDLOOP_ADD_DIRS": add_dirs}) + 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 - payload = json.loads(result.stdout) - peers = payload["peers"] + peers = json.loads(result.stdout)["peers"] + by_path = {p["path"]: p for p in peers} - # 4. Forbidden paths must not appear at all - peer_paths = [p["path"] for p in peers] - for key in scenario.forbidden_keys: - forbidden = str(paths[key]) - assert forbidden not in peer_paths, ( - f"[{scenario.id}] {key!r} should not appear in peers; got: {peer_paths}" + for dirname in scenario.get("forbidden", []): + assert str(paths[dirname]) not in by_path, ( + f"{dirname!r} must not appear in peers: {peers}" ) - # 5. Each expectation: check occurrence count and per-field attributes - for exp in scenario.expect_peers: - target = str(paths[exp.key]) - matches = [p for p in peers if p["path"] == target] - assert len(matches) == exp.count, ( - f"[{scenario.id}] expected {exp.count} peer(s) for {exp.key!r}, " - f"got {len(matches)}; 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}" ) - if exp.count == 0: - continue - peer = matches[0] - for attr, field_name in ( - (exp.discovery_method, "discoveryMethod"), - (exp.name, "name"), - (exp.type, "type"), - ): - if attr is not None: - assert peer.get(field_name) == attr, ( - f"[{scenario.id}] peer {exp.key!r} {field_name}: " - f"expected {attr!r}, got {peer.get(field_name)!r}" - ) + 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}" + ) From a882615437fb7e70902b56430960a56ef5339c1e Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 14:41:13 -0500 Subject: [PATCH 08/42] Removed stale tests --- .../scripts/test_validate_plan.py | 34 ++++--------- .../tools/python/test_setup_closedloop.py | 48 ------------------- 2 files changed, 8 insertions(+), 74 deletions(-) diff --git a/plugins/code/skills/plan-validate/scripts/test_validate_plan.py b/plugins/code/skills/plan-validate/scripts/test_validate_plan.py index e4d53a24..ca523279 100644 --- a/plugins/code/skills/plan-validate/scripts/test_validate_plan.py +++ b/plugins/code/skills/plan-validate/scripts/test_validate_plan.py @@ -1,6 +1,4 @@ """Tests for validate_plan.py.""" -import pytest - from validate_plan import validate_schema_fields @@ -17,35 +15,19 @@ def _minimal_plan() -> dict: } -@pytest.mark.parametrize( - ("scenario", "repositories"), - [ - ( - "single_secondary_entry", - {"secondary": {"path": "/foo", "isPrimary": False}}, - ), - ( - "primary_and_secondary_entries", - { - "primary": {"path": "/abs/primary", "isPrimary": True}, - "frontend": {"path": "/abs/frontend", "isPrimary": False}, - }, - ), - ], -) -def test_validate_schema_accepts_canonical_repositories( - scenario: str, repositories: dict -) -> None: +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. Both a - single-entry shape and a multi-entry primary+secondary shape must - validate without producing any issues. + fields the schema defines after the `type` field was removed. A + primary+secondary shape exercises both isPrimary branches. """ plan = _minimal_plan() - plan["repositories"] = repositories + plan["repositories"] = { + "primary": {"path": "/abs/primary", "isPrimary": True}, + "frontend": {"path": "/abs/frontend", "isPrimary": False}, + } issues = validate_schema_fields(plan) - assert issues == [], f"[{scenario}] expected no issues but got: {issues}" + assert issues == [], f"expected no issues but got: {issues}" diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index c04b8b65..ba3d9f7f 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -16,17 +16,6 @@ def tmp_workdir(tmp_path: Path) -> Path: return tmp_path -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, - ) - - def _run_setup_in_workdir( workdir: Path, *extra_args: str, cwd: str | None = None ) -> subprocess.CompletedProcess: @@ -86,13 +75,6 @@ def test_plan_relative_path_resolves_to_absolute(tmp_workdir: Path) -> None: 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 @@ -160,13 +142,6 @@ def _config_env(workdir: Path) -> str: return (workdir / ".closedloop" / "config.env").read_text() -def test_add_dir_valid_directory_succeeds(tmp_workdir: Path, extra_repo: Path) -> None: - """Should succeed when --add-dir points to an existing directory.""" - result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(extra_repo)) - - assert result.returncode == 0, result.stderr - - 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") @@ -185,17 +160,6 @@ def test_add_dir_writes_closedloop_add_dirs_to_config(tmp_workdir: Path, extra_r assert "CLOSEDLOOP_ADD_DIRS=" in config -def test_add_dir_writes_closedloop_add_dir_names_to_config(tmp_workdir: Path, extra_repo: Path) -> None: - """config.env must contain CLOSEDLOOP_ADD_DIR_NAMES derived from directory basename.""" - 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_ADD_DIR_NAMES=" in config - # basename of extra_repo is "extra-repo" - assert "extra-repo" 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)) @@ -222,18 +186,6 @@ def test_add_dir_uses_identity_file_name(tmp_workdir: Path, tmp_path: Path) -> N assert "my-custom-name" in config -def test_add_dir_falls_back_to_basename_when_no_identity(tmp_workdir: Path, tmp_path: Path) -> None: - """Should use basename when .repo-identity.json is absent.""" - unnamed_repo = tmp_path / "unnamed-service" - unnamed_repo.mkdir() - - result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(unnamed_repo)) - - assert result.returncode == 0, result.stderr - config = _config_env(tmp_workdir) - assert "unnamed-service" 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" From f00f45af64d69e8635e1e54a76618f4d50a5e4a2 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 14:55:17 -0500 Subject: [PATCH 09/42] Updated the changelog --- CHANGELOG.md | 2 +- plugins/code/agents/cross-repo-coordinator.md | 2 +- plugins/code/scripts/setup-closedloop.sh | 4 ++++ plugins/code/tools/python/test_discover_repos.py | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2e7104..8013ae44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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 -- New `prompt-multi-repo.md` orchestrator prompt for cross-repository planning workflows +- New `multi-repo.overlay.md` overlay assembled onto `prompt.md` at runtime for cross-repository planning workflows - `repo` field added to task schema in `plan-schema.json` for multi-repo plan traceability - Tier 0 explicit-directory discovery and dedup helpers in `discover-repos.sh`, with structured JSON output - Enhancements to `cross-repo-coordinator` and `cross-repo-prd-writer` agents for multi-repo context diff --git a/plugins/code/agents/cross-repo-coordinator.md b/plugins/code/agents/cross-repo-coordinator.md index 97653b4c..42abcaee 100644 --- a/plugins/code/agents/cross-repo-coordinator.md +++ b/plugins/code/agents/cross-repo-coordinator.md @@ -61,7 +61,7 @@ Parse the JSON output to get: ### Step 1.5: Local Repos (--add-dir) -After running `discover-repos.sh`, check `CLOSEDLOOP_ADD_DIRS` from the environment. This variable contains colon-separated paths passed via `--add-dir` flags, representing local repositories that are already part of the current task plan. +After running `discover-repos.sh`, check `CLOSEDLOOP_ADD_DIRS` from the environment. This variable contains pipe-separated paths passed via `--add-dir` flags, representing local repositories that are already part of the current task plan. Example: `CLOSEDLOOP_ADD_DIRS="/path/to/a|/path/to/b"`. For each path in `CLOSEDLOOP_ADD_DIRS`: 1. Normalize the path (resolve symlinks, trailing slashes) diff --git a/plugins/code/scripts/setup-closedloop.sh b/plugins/code/scripts/setup-closedloop.sh index 7480214a..e1655570 100755 --- a/plugins/code/scripts/setup-closedloop.sh +++ b/plugins/code/scripts/setup-closedloop.sh @@ -44,6 +44,10 @@ while [[ $# -gt 0 ]]; do 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 ;; diff --git a/plugins/code/tools/python/test_discover_repos.py b/plugins/code/tools/python/test_discover_repos.py index a11e8c2f..7a0aaff0 100644 --- a/plugins/code/tools/python/test_discover_repos.py +++ b/plugins/code/tools/python/test_discover_repos.py @@ -31,8 +31,8 @@ def _run_discover_with_env( """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 - env.pop("CLAUDE_WORKSPACE_REPOS", None) - env.update(extra_env) + 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, From c074ce9895994e1242dd60fbda894d958a8dc954 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 15:51:12 -0500 Subject: [PATCH 10/42] fix(code): scan full iteration stream for completion promise Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 ++++++ plugins/code/scripts/run-loop.sh | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8013ae44..bbfceb6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Enhancements to `cross-repo-coordinator` and `cross-repo-prd-writer` agents for multi-repo context - 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 + +#### Changed +- Consolidated Tier 0 `discover-repos.sh` tests into a single scenario-driven harness, replacing the prior fragmented per-case test files + ### code v1.6.0 #### Changed diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 7d979315..455b1a05 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -995,13 +995,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) @@ -1121,9 +1133,9 @@ cleanup_on_interrupt() { local pids pids=$(jobs -p 2>/dev/null) if [[ -n "$pids" ]]; then - kill $pids 2>/dev/null || true + kill """""$"p"i"d"s" 2>/dev/null || true sleep 0.5 - kill -9 $pids 2>/dev/null || true + kill -9 """""$"p"i"d"s" 2>/dev/null || true fi # Release lock on interrupt From a065e1181f84ee3b0d05bf46fd75672257d12681 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 16:19:48 -0500 Subject: [PATCH 11/42] Fixed kill pid commands --- CHANGELOG.md | 2 +- plugins/code/scripts/run-loop.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbfceb6c..4fd6ead7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `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 - New `multi-repo.overlay.md` overlay assembled onto `prompt.md` at runtime for cross-repository planning workflows -- `repo` field added to task schema in `plan-schema.json` for multi-repo plan traceability +- `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 - Enhancements to `cross-repo-coordinator` and `cross-repo-prd-writer` agents for multi-repo context - 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` diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 455b1a05..af121cfd 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -1133,9 +1133,9 @@ cleanup_on_interrupt() { local pids pids=$(jobs -p 2>/dev/null) if [[ -n "$pids" ]]; then - kill """""$"p"i"d"s" 2>/dev/null || true + kill $pids 2>/dev/null || true sleep 0.5 - kill -9 """""$"p"i"d"s" 2>/dev/null || true + kill -9 $pids 2>/dev/null || true fi # Release lock on interrupt From c650024da43e4d1ccbf569cfdc9f211f1f30d7ef Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Thu, 9 Apr 2026 16:31:45 -0500 Subject: [PATCH 12/42] Deduplicated additional dirs --- plugins/code/scripts/setup-closedloop.sh | 65 ++++++++++++++++- .../tools/python/test_setup_closedloop.py | 72 +++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/plugins/code/scripts/setup-closedloop.sh b/plugins/code/scripts/setup-closedloop.sh index e1655570..33b95b58 100755 --- a/plugins/code/scripts/setup-closedloop.sh +++ b/plugins/code/scripts/setup-closedloop.sh @@ -20,6 +20,63 @@ PROMPT_NAME_EXPLICIT=false 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" +} + while [[ $# -gt 0 ]]; do case $1 in --prd) @@ -66,15 +123,21 @@ done RESOLVED_ADD_DIRS=() ADD_DIR_NAMES=() for raw_dir in "${ADD_DIRS[@]}"; do - if ! abs_path="$(cd "$raw_dir" 2>/dev/null && pwd)"; then + if ! abs_path="$(cd "$raw_dir" 2>/dev/null && pwd -P)"; then echo "Error: --add-dir path does not exist or is not a directory: $raw_dir" >&2 exit 1 fi + if array_contains "$abs_path" "${RESOLVED_ADD_DIRS[@]}"; then + continue + fi identity_file="$abs_path/.closedloop-ai/.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" "${ADD_DIR_NAMES[@]}"; then + repo_name="$(make_unique_repo_name "$repo_name" "$abs_path" "${ADD_DIR_NAMES[@]}")" + fi RESOLVED_ADD_DIRS+=("$abs_path") ADD_DIR_NAMES+=("$repo_name") done diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index ba3d9f7f..5d84dc3f 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -142,6 +142,15 @@ def _config_env(workdir: Path) -> str: return (workdir / ".closedloop" / "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") @@ -209,6 +218,69 @@ def test_multiple_add_dirs_produces_pipe_joined_values(tmp_workdir: Path, tmp_pa 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_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-ai").mkdir() + (repo_b / ".closedloop-ai").mkdir() + (repo_a / ".closedloop-ai" / ".repo-identity.json").write_text('{"name": "service"}') + (repo_b / ".closedloop-ai" / ".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_selects_multi_repo_overlay_automatically(tmp_workdir: Path, extra_repo: Path) -> None: """When --add-dir is given without explicit --prompt, the multi-repo overlay should be assembled onto prompt.md and used as the prompt file.""" From f4435ed3b236372262fcceafda281e8c299bcbc4 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 08:33:57 -0500 Subject: [PATCH 13/42] Deduplicated repos --- plugins/code/scripts/setup-closedloop.sh | 49 +++++++++++++------ .../tools/python/test_setup_closedloop.py | 36 ++++++++++++++ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/plugins/code/scripts/setup-closedloop.sh b/plugins/code/scripts/setup-closedloop.sh index 33b95b58..0cd2c4c2 100755 --- a/plugins/code/scripts/setup-closedloop.sh +++ b/plugins/code/scripts/setup-closedloop.sh @@ -77,6 +77,17 @@ make_unique_repo_name() { 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 --prd) @@ -119,14 +130,34 @@ while [[ $# -gt 0 ]]; do esac done +# First positional arg is workdir +WORKDIR="" +if [[ ${#POSITIONAL_ARGS[@]} -gt 0 ]]; then + WORKDIR="${POSITIONAL_ARGS[0]}" +fi + +WORKDIR="${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="$(cd "$raw_dir" 2>/dev/null && pwd -P)"; then + 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" ]]; then + continue + fi if array_contains "$abs_path" "${RESOLVED_ADD_DIRS[@]}"; then continue fi @@ -135,24 +166,14 @@ for raw_dir in "${ADD_DIRS[@]}"; do if [[ -z "$repo_name" ]]; then repo_name="$(basename "$abs_path")" fi - if array_contains "$repo_name" "${ADD_DIR_NAMES[@]}"; then - repo_name="$(make_unique_repo_name "$repo_name" "$abs_path" "${ADD_DIR_NAMES[@]}")" + 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 -# First positional arg is workdir -WORKDIR="" -if [[ ${#POSITIONAL_ARGS[@]} -gt 0 ]]; then - WORKDIR="${POSITIONAL_ARGS[0]}" -fi - -WORKDIR="${WORKDIR:-.}" -# Convert to absolute path for consistent hook injection -if [[ ! "$WORKDIR" = /* ]]; then - WORKDIR="$PWD/$WORKDIR" -fi 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 diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index 5d84dc3f..6f467501 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -233,6 +233,23 @@ def test_add_dir_ignores_duplicate_resolved_repo_path( 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") == "" + prompt_line = next( + line for line in config.splitlines() if line.startswith("CLOSEDLOOP_PROMPT_FILE=") + ) + assert "prompt-assembled.md" not in prompt_line, ( + "Primary workdir in --add-dir should not trigger multi-repo prompt selection" + ) + + def test_add_dir_makes_identity_name_collisions_unique( tmp_workdir: Path, tmp_path: Path ) -> None: @@ -281,6 +298,25 @@ def test_add_dir_makes_basename_collisions_unique( ) +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_selects_multi_repo_overlay_automatically(tmp_workdir: Path, extra_repo: Path) -> None: """When --add-dir is given without explicit --prompt, the multi-repo overlay should be assembled onto prompt.md and used as the prompt file.""" From d2ee7badcc9158a4dcf7ba82567a2c0338455823 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 09:02:14 -0500 Subject: [PATCH 14/42] Fixed workdir path --- plugins/code/scripts/setup-closedloop.sh | 4 ++-- plugins/code/tools/python/test_setup_closedloop.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/code/scripts/setup-closedloop.sh b/plugins/code/scripts/setup-closedloop.sh index 5363db48..5076aac4 100755 --- a/plugins/code/scripts/setup-closedloop.sh +++ b/plugins/code/scripts/setup-closedloop.sh @@ -223,7 +223,7 @@ while [[ $CURRENT_PID -gt 1 ]]; do break fi # Get parent PID - CURRENT_PID=$(ps -o ppid= -p """""$CURRENT"_"P"I"D" 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 @@ -315,7 +315,7 @@ if [[ ${#RESOLVED_ADD_DIRS[@]} -gt 0 ]]; then done repo_map_joined="$(IFS='|'; echo "${repo_map_parts[*]}")" fi -cat >> "$WORKDIR/.closedloop/config.env" << EOF +cat >> "$WORKDIR/.closedloop-ai/config.env" << EOF CLOSEDLOOP_ADD_DIRS="$add_dirs_joined" CLOSEDLOOP_ADD_DIR_NAMES="$add_dir_names_joined" CLOSEDLOOP_REPO_MAP="$repo_map_joined" diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index 6f467501..7e90d27a 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -138,8 +138,8 @@ def extra_repo(tmp_path: Path) -> Path: def _config_env(workdir: Path) -> str: - """Return the contents of .closedloop/config.env written by the script.""" - return (workdir / ".closedloop" / "config.env").read_text() + """Return the contents of .closedloop-ai/config.env written by the script.""" + return (workdir / ".closedloop-ai" / "config.env").read_text() def _config_value(config: str, key: str) -> str: From 5bb3befd7ffc79bbae3d23d6ccc9902fad432231 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 09:25:11 -0500 Subject: [PATCH 15/42] Established single source of truth for state directory name --- plugins/code/README.md | 8 ++--- plugins/code/hooks/loop-stop-hook.sh | 11 ++++--- plugins/code/hooks/plan-review.sh | 7 +++-- plugins/code/hooks/pretooluse-hook.sh | 9 ++++-- plugins/code/hooks/session-end-hook.sh | 9 ++++-- plugins/code/hooks/session-start-hook.sh | 11 ++++--- plugins/code/hooks/subagent-start-hook.sh | 21 ++++++++------ plugins/code/hooks/subagent-stop-hook.sh | 9 ++++-- plugins/code/prompts/overlays/README.md | 4 +-- plugins/code/scripts/discover-repos.sh | 12 +++++--- plugins/code/scripts/run-loop.sh | 25 +++++++++------- plugins/code/scripts/setup-closedloop.sh | 29 ++++++++++--------- plugins/code/scripts/setup-loop.sh | 7 +++-- .../skills/closedloop-env/scripts/get-env.sh | 5 +++- .../codex-review/scripts/run_codex_review.sh | 5 +++- .../scripts/check_critic_cache.sh | 5 +++- .../code/tools/python/test_critic_cache.py | 9 +++--- .../code/tools/python/test_discover_repos.py | 13 +++++---- .../code/tools/python/test_pretooluse_hook.py | 13 +++++---- .../tools/python/test_self_learning_flag.py | 18 +++++++----- .../tools/python/test_session_end_hook.py | 3 +- .../tools/python/test_setup_closedloop.py | 21 +++++++------- .../tools/python/test_subagent_start_hook.py | 15 +++++----- .../tools/python/test_subagent_stop_hook.py | 9 +++--- .../scripts/bootstrap-learnings.sh | 13 +++++---- .../tools/python/compute_success_rates.py | 4 ++- .../python/test_compute_success_rates.py | 4 ++- .../tools/python/write_merged_patterns.py | 3 +- 28 files changed, 181 insertions(+), 121 deletions(-) diff --git a/plugins/code/README.md b/plugins/code/README.md index a66045e8..a7e3623c 100644 --- a/plugins/code/README.md +++ b/plugins/code/README.md @@ -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/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 6cece2b6..b8114ce5 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 @@ -176,7 +179,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 @@ -424,7 +427,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/prompts/overlays/README.md b/plugins/code/prompts/overlays/README.md index 4a6e2283..dd4c8f91 100644 --- a/plugins/code/prompts/overlays/README.md +++ b/plugins/code/prompts/overlays/README.md @@ -13,7 +13,7 @@ this order: 1. If `prompts/.md` exists, use it directly (backward compatible). 2. Else if `prompts/overlays/.overlay.md` exists, assemble `prompts/prompt.md` + blank line + overlay into - `$CLOSEDLOOP_WORKDIR/.closedloop/prompt-assembled.md` and point + `$CLOSEDLOOP_WORKDIR/.closedloop-ai/prompt-assembled.md` and point `CLOSEDLOOP_PROMPT_FILE` at that file. 3. Else, fail loud with "prompt not found". @@ -51,7 +51,7 @@ does not pass `--prompt` explicitly. ## Debugging - Inspect the assembled file at - `$CLOSEDLOOP_WORKDIR/.closedloop/prompt-assembled.md` after a run + `$CLOSEDLOOP_WORKDIR/.closedloop-ai/prompt-assembled.md` after a run starts. - To bypass the overlay, pass `--prompt prompt` — the base is used unchanged. diff --git a/plugins/code/scripts/discover-repos.sh b/plugins/code/scripts/discover-repos.sh index 2e79a15d..62bc70b0 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") @@ -51,7 +55,7 @@ if [[ -n "${CLOSEDLOOP_ADD_DIRS:-}" ]]; then _mark_seen "$path" # Read identity if exists, fall back to basename - identity_file="$path/.closedloop-ai/.repo-identity.json" + identity_file="$path/$CLOSEDLOOP_STATE_DIR/.repo-identity.json" repo_name="" repo_type="unknown" if [[ -f "$identity_file" ]]; then @@ -86,7 +90,7 @@ if [[ -n "$CLAUDE_WORKSPACE_REPOS" ]]; then _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 @@ -108,7 +112,7 @@ if [[ -z "$CLAUDE_WORKSPACE_REPOS" ]]; then [[ "$sibling" == "$PROJECT_ROOT" ]] && continue [[ ! -d "$sibling" ]] && continue - identity_file="$sibling/.closedloop-ai/.repo-identity.json" + 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") diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 288cdc14..e857fa78 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 @@ -460,7 +463,7 @@ DESCRIPTION: Runs Claude in a loop with fresh context on each iteration. Each iteration invokes `claude -p "/code:code "`. - State is persisted to .closedloop-ai/closedloop-loop.local.md so loops can be resumed. + State is persisted to $CLOSEDLOOP_STATE_DIR/closedloop-loop.local.md so loops can be resumed. To signal completion, Claude must output: COMPLETE @@ -492,10 +495,10 @@ STOPPING: MONITORING: # View current iteration: - grep '^iteration:' .closedloop-ai/closedloop-loop.local.md + grep '^iteration:' $CLOSEDLOOP_STATE_DIR/closedloop-loop.local.md # View progress log: - tail -20 .closedloop-ai/closedloop-progress.log + tail -20 $CLOSEDLOOP_STATE_DIR/closedloop-progress.log # View learning system status: ls -la .learnings/sessions/ @@ -696,7 +699,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) @@ -732,8 +735,8 @@ $prompt EOF # Update config.env with self-learning flag (preserve other keys) - mkdir -p "$WORKDIR/.closedloop-ai" - CONFIG_FILE="$WORKDIR/.closedloop-ai/config.env" + mkdir -p "$WORKDIR/$CLOSEDLOOP_STATE_DIR" + CONFIG_FILE="$WORKDIR/$CLOSEDLOOP_STATE_DIR/config.env" TMP_FILE="${CONFIG_FILE}.tmp.$$" if [[ -f "$CONFIG_FILE" ]]; then sed '/^CLOSEDLOOP_SELF_LEARNING=/d' "$CONFIG_FILE" > "$TMP_FILE" diff --git a/plugins/code/scripts/setup-closedloop.sh b/plugins/code/scripts/setup-closedloop.sh index 5076aac4..d1d764e2 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" @@ -161,7 +164,7 @@ for raw_dir in "${ADD_DIRS[@]}"; do if array_contains "$abs_path" "${RESOLVED_ADD_DIRS[@]}"; then continue fi - identity_file="$abs_path/.closedloop-ai/.repo-identity.json" + 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")" @@ -210,12 +213,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") @@ -231,8 +234,8 @@ 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 @@ -258,8 +261,8 @@ DIRECT_PROMPT="$PLUGIN_ROOT/prompts/$PROMPT_NAME.md" OVERLAY_PROMPT="$PLUGIN_ROOT/prompts/overlays/$PROMPT_NAME.overlay.md" BASE_PROMPT="$PLUGIN_ROOT/prompts/prompt.md" -# Ensure WORKDIR/.closedloop exists before writing the assembled file -mkdir -p "$WORKDIR/.closedloop" +# Ensure WORKDIR/$CLOSEDLOOP_STATE_DIR exists before writing the assembled file +mkdir -p "$WORKDIR/$CLOSEDLOOP_STATE_DIR" if [[ -f "$DIRECT_PROMPT" ]]; then CLOSEDLOOP_PROMPT_FILE="$DIRECT_PROMPT" @@ -268,7 +271,7 @@ elif [[ -f "$OVERLAY_PROMPT" ]]; then echo "ERROR: base prompt missing: $BASE_PROMPT" >&2 exit 1 fi - ASSEMBLED_PROMPT="$WORKDIR/.closedloop/prompt-assembled.md" + ASSEMBLED_PROMPT="$WORKDIR/$CLOSEDLOOP_STATE_DIR/prompt-assembled.md" { cat "$BASE_PROMPT" printf '\n\n' @@ -291,9 +294,9 @@ else fi # 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" @@ -315,11 +318,11 @@ if [[ ${#RESOLVED_ADD_DIRS[@]} -gt 0 ]]; then done repo_map_joined="$(IFS='|'; echo "${repo_map_parts[*]}")" fi -cat >> "$WORKDIR/.closedloop-ai/config.env" << EOF +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-ai/config.env" -cat "$WORKDIR/.closedloop-ai/config.env" +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/tools/python/test_critic_cache.py b/plugins/code/tools/python/test_critic_cache.py index eccd7a75..5f37b679 100644 --- a/plugins/code/tools/python/test_critic_cache.py +++ b/plugins/code/tools/python/test_critic_cache.py @@ -13,6 +13,7 @@ / "scripts" / "check_critic_cache.sh" ) +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" def _run(workdir: Path, cwd: Path) -> subprocess.CompletedProcess[str]: @@ -35,9 +36,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 +56,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 7a0aaff0..5fd6cc5d 100644 --- a/plugins/code/tools/python/test_discover_repos.py +++ b/plugins/code/tools/python/test_discover_repos.py @@ -10,6 +10,7 @@ SCRIPT_PATH = ( Path(__file__).resolve().parent.parent.parent / "scripts" / "discover-repos.sh" ) +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" def run_discover( @@ -47,15 +48,15 @@ def test_sibling_scan_uses_closedloop_repo_identity(tmp_path: Path) -> None: 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}' ) @@ -86,8 +87,8 @@ def _make_repo(parent: Path, name: str, identity: dict | None = None) -> Path: repo = parent / name repo.mkdir(parents=True, exist_ok=True) if identity is not None: - (repo / ".closedloop-ai").mkdir(exist_ok=True) - (repo / ".closedloop-ai" / ".repo-identity.json").write_text( + (repo / CLOSEDLOOP_STATE_DIR).mkdir(exist_ok=True) + (repo / CLOSEDLOOP_STATE_DIR / ".repo-identity.json").write_text( json.dumps(identity) ) return repo diff --git a/plugins/code/tools/python/test_pretooluse_hook.py b/plugins/code/tools/python/test_pretooluse_hook.py index 68a32053..88a2f5d7 100644 --- a/plugins/code/tools/python/test_pretooluse_hook.py +++ b/plugins/code/tools/python/test_pretooluse_hook.py @@ -7,6 +7,7 @@ import pytest HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "pretooluse-hook.sh" +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" def run_hook(tool_name: str, tool_input: dict) -> subprocess.CompletedProcess: @@ -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..17c573ff 100644 --- a/plugins/code/tools/python/test_self_learning_flag.py +++ b/plugins/code/tools/python/test_self_learning_flag.py @@ -8,12 +8,13 @@ SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent / "scripts" RUN_LOOP_SH = SCRIPTS_DIR / "run-loop.sh" +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" @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..1044039f 100644 --- a/plugins/code/tools/python/test_session_end_hook.py +++ b/plugins/code/tools/python/test_session_end_hook.py @@ -6,6 +6,7 @@ from pathlib import Path HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "session-end-hook.sh" +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" def run_session_end(cwd: Path, session_id: str) -> subprocess.CompletedProcess: @@ -25,7 +26,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 7e90d27a..18f53c7b 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -7,6 +7,7 @@ import pytest SETUP_SCRIPT = Path(__file__).parent.parent.parent / "scripts" / "setup-closedloop.sh" +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" @pytest.fixture @@ -101,7 +102,7 @@ def test_plan_skips_prd_autodiscovery(tmp_workdir: Path) -> None: 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) @@ -122,7 +123,7 @@ 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() # --------------------------------------------------------------------------- @@ -139,7 +140,7 @@ def extra_repo(tmp_path: Path) -> Path: def _config_env(workdir: Path) -> str: """Return the contents of .closedloop-ai/config.env written by the script.""" - return (workdir / ".closedloop-ai" / "config.env").read_text() + return (workdir / CLOSEDLOOP_STATE_DIR / "config.env").read_text() def _config_value(config: str, key: str) -> str: @@ -183,8 +184,8 @@ def test_add_dir_uses_identity_file_name(tmp_workdir: Path, tmp_path: Path) -> N """Should use .closedloop-ai/.repo-identity.json name field when present.""" named_repo = tmp_path / "some-dir" named_repo.mkdir() - (named_repo / ".closedloop-ai").mkdir() - (named_repo / ".closedloop-ai" / ".repo-identity.json").write_text( + (named_repo / CLOSEDLOOP_STATE_DIR).mkdir() + (named_repo / CLOSEDLOOP_STATE_DIR / ".repo-identity.json").write_text( '{"name": "my-custom-name", "type": "service"}' ) @@ -258,10 +259,10 @@ def test_add_dir_makes_identity_name_collisions_unique( repo_b = tmp_path / "repo-b" repo_a.mkdir() repo_b.mkdir() - (repo_a / ".closedloop-ai").mkdir() - (repo_b / ".closedloop-ai").mkdir() - (repo_a / ".closedloop-ai" / ".repo-identity.json").write_text('{"name": "service"}') - (repo_b / ".closedloop-ai" / ".repo-identity.json").write_text('{"name": "service"}') + (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) @@ -333,7 +334,7 @@ def test_add_dir_selects_multi_repo_overlay_automatically(tmp_workdir: Path, ext ) # Verify the assembled file exists and equals base + blank + overlay - assembled_path = tmp_workdir / ".closedloop" / "prompt-assembled.md" + assembled_path = tmp_workdir / CLOSEDLOOP_STATE_DIR / "prompt-assembled.md" assert assembled_path.is_file(), f"Missing assembled file: {assembled_path}" assembled = assembled_path.read_text() diff --git a/plugins/code/tools/python/test_subagent_start_hook.py b/plugins/code/tools/python/test_subagent_start_hook.py index 2d56a9e7..5efe379e 100644 --- a/plugins/code/tools/python/test_subagent_start_hook.py +++ b/plugins/code/tools/python/test_subagent_start_hook.py @@ -7,6 +7,7 @@ import pytest HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "subagent-start-hook.sh" +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" @pytest.fixture() @@ -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" @@ -44,9 +45,9 @@ def run_start_hook( ) -> 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") @@ -103,7 +104,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' @@ -156,7 +157,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 +279,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..f17eebd3 100644 --- a/plugins/code/tools/python/test_subagent_stop_hook.py +++ b/plugins/code/tools/python/test_subagent_stop_hook.py @@ -7,6 +7,7 @@ import pytest HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "subagent-stop-hook.sh" +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" @pytest.fixture() @@ -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/scripts/bootstrap-learnings.sh b/plugins/self-learning/scripts/bootstrap-learnings.sh index ad2c4d04..3e6c7070 100755 --- a/plugins/self-learning/scripts/bootstrap-learnings.sh +++ b/plugins/self-learning/scripts/bootstrap-learnings.sh @@ -10,12 +10,15 @@ 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}" # 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" == *"/$CLOSEDLOOP_STATE_DIR/learnings" ]]; then + PROJECT_DIR="${LEARNINGS_DIR%/$CLOSEDLOOP_STATE_DIR/learnings}" PROJECT_DIR="${PROJECT_DIR:-.}" UPDATE_PROJECT_GITIGNORE=true else @@ -245,7 +248,7 @@ update_project_gitignore() { # Run-specific learnings (ephemeral, per-workdir) .learnings/ -# Org learnings are in .closedloop-ai/learnings/ and SHOULD be committed +# Org learnings are in $CLOSEDLOOP_STATE_DIR/learnings/ and SHOULD be committed EOF log_info "Project .gitignore updated" @@ -263,7 +266,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..178af4ea 100644 --- a/plugins/self-learning/tools/python/test_compute_success_rates.py +++ b/plugins/self-learning/tools/python/test_compute_success_rates.py @@ -8,6 +8,8 @@ import pytest +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" + from compute_success_rates import ( compute_rates, jaccard_similarity, @@ -576,5 +578,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"} From 36b7f3e5cde83c0e9be751f364eae8198ab76b96 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 09:32:42 -0500 Subject: [PATCH 16/42] fix(code): filter add-dirs that are ancestors of workdir The equality check only caught exact workdir matches but missed the case where an --add-dir is a parent directory of the workdir (e.g. symphony-alpha vs symphony-alpha/.closedloop-ai/work). Extend the guard to also skip ancestor paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/code/scripts/setup-closedloop.sh | 2 +- .../code/tools/python/test_setup_closedloop.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/plugins/code/scripts/setup-closedloop.sh b/plugins/code/scripts/setup-closedloop.sh index d1d764e2..d4fab47a 100755 --- a/plugins/code/scripts/setup-closedloop.sh +++ b/plugins/code/scripts/setup-closedloop.sh @@ -158,7 +158,7 @@ for raw_dir in "${ADD_DIRS[@]}"; do echo "Error: --add-dir path does not exist or is not a directory: $raw_dir" >&2 exit 1 fi - if [[ "$abs_path" == "$WORKDIR" ]]; then + if [[ "$abs_path" == "$WORKDIR" || "$WORKDIR" == "$abs_path"/* ]]; then continue fi if array_contains "$abs_path" "${RESOLVED_ADD_DIRS[@]}"; then diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index 18f53c7b..5a75ba13 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -363,6 +363,23 @@ def test_explicit_prompt_overrides_add_dir_auto_selection(tmp_workdir: Path, ext ) +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_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) From d9cb1301bd1e7ae82ac9124baaca882dfad6f22d Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 09:33:11 -0500 Subject: [PATCH 17/42] Fixed linting errors --- .../python/test_compute_success_rates.py | 510 ++++++++++++++---- 1 file changed, 394 insertions(+), 116 deletions(-) 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 178af4ea..8ccbecd4 100644 --- a/plugins/self-learning/tools/python/test_compute_success_rates.py +++ b/plugins/self-learning/tools/python/test_compute_success_rates.py @@ -7,9 +7,6 @@ from pathlib import Path import pytest - -CLOSEDLOOP_STATE_DIR = ".closedloop-ai" - from compute_success_rates import ( compute_rates, jaccard_similarity, @@ -19,6 +16,8 @@ serialize_toon, ) +CLOSEDLOOP_STATE_DIR = ".closedloop-ai" + @pytest.fixture def tmp_workdir(tmp_path: Path) -> Path: @@ -38,6 +37,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" @@ -53,11 +53,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 @@ -71,10 +74,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]" @@ -82,9 +88,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"] @@ -92,10 +101,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"] == "*" @@ -104,10 +116,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" @@ -117,7 +132,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' ) @@ -130,10 +145,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' ) @@ -149,6 +164,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" @@ -163,9 +179,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" @@ -174,9 +193,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" @@ -184,9 +206,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" @@ -194,9 +219,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" @@ -207,6 +235,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") @@ -222,7 +251,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") @@ -236,25 +267,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" @@ -263,14 +315,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 @@ -279,16 +354,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 @@ -298,14 +395,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 @@ -315,12 +435,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) @@ -328,14 +461,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" @@ -344,13 +495,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" @@ -359,17 +522,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 @@ -379,12 +562,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) @@ -393,13 +589,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' ) @@ -426,7 +623,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' ) @@ -446,12 +643,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"' @@ -459,26 +658,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]" @@ -486,12 +712,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) @@ -500,14 +739,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) @@ -518,14 +769,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) @@ -534,25 +797,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" From 964b96ad1d9ce8c4f962252f81ecb88b9fd131b7 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 10:02:04 -0500 Subject: [PATCH 18/42] Added skills to the list of allowed tools to enable code:plan-validate --- plugins/code/scripts/run-loop.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index e857fa78..db3f3885 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -947,7 +947,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" \ From 673543e58a585d859a80105f8ecde9241d63948a Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 10:06:28 -0500 Subject: [PATCH 19/42] Fixed the order of multi-repo execution --- plugins/code/agents/pre-explorer.md | 108 ++++++++++++++-------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/plugins/code/agents/pre-explorer.md b/plugins/code/agents/pre-explorer.md index 80945819..140d6ed7 100644 --- a/plugins/code/agents/pre-explorer.md +++ b/plugins/code/agents/pre-explorer.md @@ -40,6 +40,59 @@ ls $CLOSEDLOOP_WORKDIR/code-map-*.json 2>/dev/null - `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 + +1. List `$CLOSEDLOOP_WORKDIR` to find the PRD file (typically the first non-directory, non-JSON file, excluding `attachments/`) +2. Read the PRD file thoroughly +3. Extract: + - **Entity names**: nouns that represent domain objects (e.g., "User", "Invoice", "Dashboard") + - **Technology mentions**: frameworks, libraries, APIs (e.g., "React", "FastAPI", "Linear API") + - **File/module hints**: any paths, filenames, or module names mentioned + - **API references**: endpoint URLs, service names, external integrations + - **Action verbs**: key operations (e.g., "create", "delete", "sync", "export") + +## Step 2: Extract Acceptance Criteria Candidates + +From the PRD, identify statements that look like acceptance criteria: +- "Users should be able to..." +- "The system must..." +- "When X happens, Y should..." +- Numbered requirements or bullet points with testable conditions + +For each, note the PRD section reference (heading or paragraph number). + +## Step 3: Check for Visual Attachments + +Use `Glob` to check: `$CLOSEDLOOP_WORKDIR/attachments/*` + +If attachments exist: +- Read each image file (you are multimodal) +- Extract: UI element descriptions, layout patterns, component names, interaction hints +- Add these to the search terms + +### Write `requirements-extract.json` + +```json +{ + "searchTerms": { + "entities": ["User", "Invoice"], + "technologies": ["React", "FastAPI"], + "fileHints": ["src/components/", "api/routes"], + "apiReferences": ["POST /api/auth/login"], + "actions": ["create", "delete", "sync"] + }, + "acceptanceCriteria": [ + {"id": "AC-001", "text": "User can log in with email", "prdSection": "§2.1"} + ], + "externalDependencies": [ + {"name": "Linear API", "type": "api", "prdMention": "§3.2"} + ], + "visualSummary": [ + {"file": "attachments/mockup.png", "elements": ["login form", "sidebar nav"]} + ] +} +``` + ## Multi-Repo Exploration **Skip this entire section if `CLOSEDLOOP_ADD_DIRS` is empty or unset.** @@ -130,60 +183,7 @@ Note: file paths in `code-map-{name}.json` use the absolute path rooted at `{pat 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 1. - -## Step 1: Read and Parse the PRD - -1. List `$CLOSEDLOOP_WORKDIR` to find the PRD file (typically the first non-directory, non-JSON file, excluding `attachments/`) -2. Read the PRD file thoroughly -3. Extract: - - **Entity names**: nouns that represent domain objects (e.g., "User", "Invoice", "Dashboard") - - **Technology mentions**: frameworks, libraries, APIs (e.g., "React", "FastAPI", "Linear API") - - **File/module hints**: any paths, filenames, or module names mentioned - - **API references**: endpoint URLs, service names, external integrations - - **Action verbs**: key operations (e.g., "create", "delete", "sync", "export") - -## Step 2: Extract Acceptance Criteria Candidates - -From the PRD, identify statements that look like acceptance criteria: -- "Users should be able to..." -- "The system must..." -- "When X happens, Y should..." -- Numbered requirements or bullet points with testable conditions - -For each, note the PRD section reference (heading or paragraph number). - -## Step 3: Check for Visual Attachments - -Use `Glob` to check: `$CLOSEDLOOP_WORKDIR/attachments/*` - -If attachments exist: -- Read each image file (you are multimodal) -- Extract: UI element descriptions, layout patterns, component names, interaction hints -- Add these to the search terms - -### Write `requirements-extract.json` - -```json -{ - "searchTerms": { - "entities": ["User", "Invoice"], - "technologies": ["React", "FastAPI"], - "fileHints": ["src/components/", "api/routes"], - "apiReferences": ["POST /api/auth/login"], - "actions": ["create", "delete", "sync"] - }, - "acceptanceCriteria": [ - {"id": "AC-001", "text": "User can log in with email", "prdSection": "§2.1"} - ], - "externalDependencies": [ - {"name": "Linear API", "type": "api", "prdMention": "§3.2"} - ], - "visualSummary": [ - {"file": "attachments/mockup.png", "elements": ["login form", "sidebar nav"]} - ] -} -``` +Process all repos in `CLOSEDLOOP_REPO_MAP` before moving on to Step 4. ## Step 4: Run Targeted Codebase Searches From 444f457eb818a558bcf105f103efd0f4df13ab8b Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 10:07:30 -0500 Subject: [PATCH 20/42] Fixed prompt definition string --- plugins/code/scripts/run-loop.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index db3f3885..2e98fe44 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -714,7 +714,7 @@ create_state_file() { prompt="$prompt --prd $PRD_FILE" fi for add_dir in "${ADD_DIRS[@]+"${ADD_DIRS[@]}"}"; do - prompt="$prompt --add-dir \"$add_dir\"" + prompt="$prompt --add-dir $add_dir" done cat > "$STATE_FILE" </dev/null) if [[ -n "$pids" ]]; then - kill $pids 2>/dev/null || true + kill """""$"p"i"d"s" 2>/dev/null || true sleep 0.5 - kill -9 $pids 2>/dev/null || true + kill -9 """""$"p"i"d"s" 2>/dev/null || true fi # Release lock on interrupt From 2d62b4fa7cfdf6934d141caa3f74d404f0249af3 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 10:09:55 -0500 Subject: [PATCH 21/42] Fixed comment --- plugins/self-learning/scripts/bootstrap-learnings.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/self-learning/scripts/bootstrap-learnings.sh b/plugins/self-learning/scripts/bootstrap-learnings.sh index 3e6c7070..bcda78fe 100755 --- a/plugins/self-learning/scripts/bootstrap-learnings.sh +++ b/plugins/self-learning/scripts/bootstrap-learnings.sh @@ -18,7 +18,7 @@ LEARNINGS_DIR="${1:-$CLOSEDLOOP_STATE_DIR/learnings}" # Derive PROJECT_DIR for gitignore updates (only if using default path) if [[ "$LEARNINGS_DIR" == "$CLOSEDLOOP_STATE_DIR/learnings" ]] || [[ "$LEARNINGS_DIR" == *"/$CLOSEDLOOP_STATE_DIR/learnings" ]]; then - PROJECT_DIR="${LEARNINGS_DIR%/$CLOSEDLOOP_STATE_DIR/learnings}" + PROJECT_DIR="${LEARNINGS_DIR%/"""""$CLOSEDLOOP_STATE_""D"I"R"/learnings}" PROJECT_DIR="${PROJECT_DIR:-.}" UPDATE_PROJECT_GITIGNORE=true else @@ -248,7 +248,7 @@ update_project_gitignore() { # Run-specific learnings (ephemeral, per-workdir) .learnings/ -# Org learnings are in $CLOSEDLOOP_STATE_DIR/learnings/ and SHOULD be committed +# Org learnings are in .closedloop-ai/learnings/ and SHOULD be committed EOF log_info "Project .gitignore updated" From 674d3fe31b06393b79bec4016b448343fce1a826 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 10:15:50 -0500 Subject: [PATCH 22/42] Updated changelog and versions --- CHANGELOG.md | 15 +++++++++++++++ plugins/self-learning/.claude-plugin/plugin.json | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd6ead7..f73e3331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,9 +28,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). #### 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 +- Fixed multi-repo execution ordering and prompt definition string assembly in overlay resolution #### 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 @@ -63,6 +68,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 @@ -73,6 +83,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/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" From fa957ec5fdf1e0c696d7d9ae1f3e1c6d07e4346a Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 10:34:30 -0500 Subject: [PATCH 23/42] Fixed formatting --- plugins/code/scripts/run-loop.sh | 4 ++-- plugins/self-learning/scripts/bootstrap-learnings.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 2e98fe44..b1cb5fb1 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -1136,9 +1136,9 @@ cleanup_on_interrupt() { local pids pids=$(jobs -p 2>/dev/null) if [[ -n "$pids" ]]; then - kill """""$"p"i"d"s" 2>/dev/null || true + kill "$pids" 2>/dev/null || true sleep 0.5 - kill -9 """""$"p"i"d"s" 2>/dev/null || true + kill -9 "$pids" 2>/dev/null || true fi # Release lock on interrupt diff --git a/plugins/self-learning/scripts/bootstrap-learnings.sh b/plugins/self-learning/scripts/bootstrap-learnings.sh index bcda78fe..e19a6eeb 100755 --- a/plugins/self-learning/scripts/bootstrap-learnings.sh +++ b/plugins/self-learning/scripts/bootstrap-learnings.sh @@ -18,7 +18,7 @@ LEARNINGS_DIR="${1:-$CLOSEDLOOP_STATE_DIR/learnings}" # Derive PROJECT_DIR for gitignore updates (only if using default path) if [[ "$LEARNINGS_DIR" == "$CLOSEDLOOP_STATE_DIR/learnings" ]] || [[ "$LEARNINGS_DIR" == *"/$CLOSEDLOOP_STATE_DIR/learnings" ]]; then - PROJECT_DIR="${LEARNINGS_DIR%/"""""$CLOSEDLOOP_STATE_""D"I"R"/learnings}" + PROJECT_DIR="${LEARNINGS_DIR%/.closedloop-ai/learnings}" PROJECT_DIR="${PROJECT_DIR:-.}" UPDATE_PROJECT_GITIGNORE=true else From 51cbc41c874097067a154150817ae5faf606e8d9 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 12:09:39 -0500 Subject: [PATCH 24/42] Improved SSOT compliance of unit tests --- plugins/code/tools/python/conftest.py | 1 + plugins/code/tools/python/test_critic_cache.py | 2 +- plugins/code/tools/python/test_discover_repos.py | 2 +- plugins/code/tools/python/test_pretooluse_hook.py | 2 +- plugins/code/tools/python/test_self_learning_flag.py | 2 +- plugins/code/tools/python/test_session_end_hook.py | 2 +- plugins/code/tools/python/test_setup_closedloop.py | 2 +- plugins/code/tools/python/test_subagent_start_hook.py | 2 +- plugins/code/tools/python/test_subagent_stop_hook.py | 2 +- 9 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 plugins/code/tools/python/conftest.py 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 5f37b679..ea0dd71f 100644 --- a/plugins/code/tools/python/test_critic_cache.py +++ b/plugins/code/tools/python/test_critic_cache.py @@ -13,7 +13,7 @@ / "scripts" / "check_critic_cache.sh" ) -CLOSEDLOOP_STATE_DIR = ".closedloop-ai" +from conftest import CLOSEDLOOP_STATE_DIR def _run(workdir: Path, cwd: Path) -> subprocess.CompletedProcess[str]: diff --git a/plugins/code/tools/python/test_discover_repos.py b/plugins/code/tools/python/test_discover_repos.py index 5fd6cc5d..e96a8a28 100644 --- a/plugins/code/tools/python/test_discover_repos.py +++ b/plugins/code/tools/python/test_discover_repos.py @@ -10,7 +10,7 @@ SCRIPT_PATH = ( Path(__file__).resolve().parent.parent.parent / "scripts" / "discover-repos.sh" ) -CLOSEDLOOP_STATE_DIR = ".closedloop-ai" +from conftest import CLOSEDLOOP_STATE_DIR def run_discover( diff --git a/plugins/code/tools/python/test_pretooluse_hook.py b/plugins/code/tools/python/test_pretooluse_hook.py index 88a2f5d7..c9968e3a 100644 --- a/plugins/code/tools/python/test_pretooluse_hook.py +++ b/plugins/code/tools/python/test_pretooluse_hook.py @@ -7,7 +7,7 @@ import pytest HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "pretooluse-hook.sh" -CLOSEDLOOP_STATE_DIR = ".closedloop-ai" +from conftest import CLOSEDLOOP_STATE_DIR def run_hook(tool_name: str, tool_input: dict) -> subprocess.CompletedProcess: diff --git a/plugins/code/tools/python/test_self_learning_flag.py b/plugins/code/tools/python/test_self_learning_flag.py index 17c573ff..6148a288 100644 --- a/plugins/code/tools/python/test_self_learning_flag.py +++ b/plugins/code/tools/python/test_self_learning_flag.py @@ -8,7 +8,7 @@ SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent / "scripts" RUN_LOOP_SH = SCRIPTS_DIR / "run-loop.sh" -CLOSEDLOOP_STATE_DIR = ".closedloop-ai" +from conftest import CLOSEDLOOP_STATE_DIR @pytest.fixture() diff --git a/plugins/code/tools/python/test_session_end_hook.py b/plugins/code/tools/python/test_session_end_hook.py index 1044039f..3361ec5c 100644 --- a/plugins/code/tools/python/test_session_end_hook.py +++ b/plugins/code/tools/python/test_session_end_hook.py @@ -6,7 +6,7 @@ from pathlib import Path HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "session-end-hook.sh" -CLOSEDLOOP_STATE_DIR = ".closedloop-ai" +from conftest import CLOSEDLOOP_STATE_DIR def run_session_end(cwd: Path, session_id: str) -> subprocess.CompletedProcess: diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index 5a75ba13..9ce5e138 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -7,7 +7,7 @@ import pytest SETUP_SCRIPT = Path(__file__).parent.parent.parent / "scripts" / "setup-closedloop.sh" -CLOSEDLOOP_STATE_DIR = ".closedloop-ai" +from conftest import CLOSEDLOOP_STATE_DIR @pytest.fixture diff --git a/plugins/code/tools/python/test_subagent_start_hook.py b/plugins/code/tools/python/test_subagent_start_hook.py index 5efe379e..124c8da3 100644 --- a/plugins/code/tools/python/test_subagent_start_hook.py +++ b/plugins/code/tools/python/test_subagent_start_hook.py @@ -7,7 +7,7 @@ import pytest HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "subagent-start-hook.sh" -CLOSEDLOOP_STATE_DIR = ".closedloop-ai" +from conftest import CLOSEDLOOP_STATE_DIR @pytest.fixture() diff --git a/plugins/code/tools/python/test_subagent_stop_hook.py b/plugins/code/tools/python/test_subagent_stop_hook.py index f17eebd3..648261b2 100644 --- a/plugins/code/tools/python/test_subagent_stop_hook.py +++ b/plugins/code/tools/python/test_subagent_stop_hook.py @@ -7,7 +7,7 @@ import pytest HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "subagent-stop-hook.sh" -CLOSEDLOOP_STATE_DIR = ".closedloop-ai" +from conftest import CLOSEDLOOP_STATE_DIR @pytest.fixture() From d4b21a1349ee5372e8191649cfdca50965e81e0a Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 12:10:57 -0500 Subject: [PATCH 25/42] Fixed extra qoutes in the kill command --- plugins/code/scripts/run-loop.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 2e01617c..71f905f3 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -1306,9 +1306,11 @@ cleanup_on_interrupt() { local pids pids=$(jobs -p 2>/dev/null) if [[ -n "$pids" ]]; then - kill "$pids" 2>/dev/null || true + # shellcheck disable=SC2086 -- intentional word splitting: $pids contains newline-separated PIDs from jobs -p + kill $pids 2>/dev/null || true sleep 0.5 - kill -9 "$pids" 2>/dev/null || true + # shellcheck disable=SC2086 + kill -9 $pids 2>/dev/null || true fi # Release lock on interrupt From 8574f16adebb1840388bcb28f58ff667b62e8827 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 12:15:09 -0500 Subject: [PATCH 26/42] Capured fix_exit --- plugins/code/scripts/run-loop.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 71f905f3..8cd1aa24 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -595,6 +595,12 @@ run_post_loop_review() { rm -f "$fix_output" "$fix_stderr" + if [[ "$fix_exit" -ne 0 ]]; then + echo -e "${RED}Warning: Fix subprocess failed (exit $fix_exit). Skipping remaining cycles.${NC}" + log_progress "Post-loop fix failed (exit $fix_exit). Aborting." + return 0 + fi + log_progress "Post-loop fix cycle $cycle completed (exit: $fix_exit)" cycle=$((cycle + 1)) done From 4be14925420001d5e5e5a13bb9d17f92aabb9819 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 12:18:10 -0500 Subject: [PATCH 27/42] Capured fix_exit --- plugins/code/scripts/run-loop.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 8cd1aa24..e0f99691 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -471,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}" @@ -596,9 +597,15 @@ run_post_loop_review() { rm -f "$fix_output" "$fix_stderr" if [[ "$fix_exit" -ne 0 ]]; then - echo -e "${RED}Warning: Fix subprocess failed (exit $fix_exit). Skipping remaining cycles.${NC}" - log_progress "Post-loop fix failed (exit $fix_exit). Aborting." - return 0 + 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)" From fabb5d991b5fb0c3e3a320cc01a43d57837b9982 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Fri, 10 Apr 2026 12:19:44 -0500 Subject: [PATCH 28/42] Fixed linting issues --- plugins/code/tools/python/test_critic_cache.py | 3 ++- plugins/code/tools/python/test_discover_repos.py | 2 +- plugins/code/tools/python/test_pretooluse_hook.py | 2 +- plugins/code/tools/python/test_self_learning_flag.py | 2 +- plugins/code/tools/python/test_session_end_hook.py | 3 ++- plugins/code/tools/python/test_setup_closedloop.py | 2 +- plugins/code/tools/python/test_subagent_start_hook.py | 2 +- plugins/code/tools/python/test_subagent_stop_hook.py | 2 +- 8 files changed, 10 insertions(+), 8 deletions(-) diff --git a/plugins/code/tools/python/test_critic_cache.py b/plugins/code/tools/python/test_critic_cache.py index ea0dd71f..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" @@ -13,7 +15,6 @@ / "scripts" / "check_critic_cache.sh" ) -from conftest import CLOSEDLOOP_STATE_DIR def _run(workdir: Path, cwd: Path) -> subprocess.CompletedProcess[str]: diff --git a/plugins/code/tools/python/test_discover_repos.py b/plugins/code/tools/python/test_discover_repos.py index e96a8a28..f79961ef 100644 --- a/plugins/code/tools/python/test_discover_repos.py +++ b/plugins/code/tools/python/test_discover_repos.py @@ -6,11 +6,11 @@ from pathlib import Path import pytest +from conftest import CLOSEDLOOP_STATE_DIR SCRIPT_PATH = ( Path(__file__).resolve().parent.parent.parent / "scripts" / "discover-repos.sh" ) -from conftest import CLOSEDLOOP_STATE_DIR def run_discover( diff --git a/plugins/code/tools/python/test_pretooluse_hook.py b/plugins/code/tools/python/test_pretooluse_hook.py index c9968e3a..8c787ad4 100644 --- a/plugins/code/tools/python/test_pretooluse_hook.py +++ b/plugins/code/tools/python/test_pretooluse_hook.py @@ -5,9 +5,9 @@ from pathlib import Path import pytest +from conftest import CLOSEDLOOP_STATE_DIR HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "pretooluse-hook.sh" -from conftest import CLOSEDLOOP_STATE_DIR def run_hook(tool_name: str, tool_input: dict) -> subprocess.CompletedProcess: diff --git a/plugins/code/tools/python/test_self_learning_flag.py b/plugins/code/tools/python/test_self_learning_flag.py index 6148a288..7e8fd455 100644 --- a/plugins/code/tools/python/test_self_learning_flag.py +++ b/plugins/code/tools/python/test_self_learning_flag.py @@ -5,10 +5,10 @@ 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" -from conftest import CLOSEDLOOP_STATE_DIR @pytest.fixture() diff --git a/plugins/code/tools/python/test_session_end_hook.py b/plugins/code/tools/python/test_session_end_hook.py index 3361ec5c..23e1e629 100644 --- a/plugins/code/tools/python/test_session_end_hook.py +++ b/plugins/code/tools/python/test_session_end_hook.py @@ -5,9 +5,10 @@ import subprocess from pathlib import Path -HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "session-end-hook.sh" from conftest import CLOSEDLOOP_STATE_DIR +HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "session-end-hook.sh" + def run_session_end(cwd: Path, session_id: str) -> subprocess.CompletedProcess: """Invoke session-end-hook.sh with crafted JSON input.""" diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index 9ce5e138..574ce3b7 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -5,9 +5,9 @@ from pathlib import Path import pytest +from conftest import CLOSEDLOOP_STATE_DIR SETUP_SCRIPT = Path(__file__).parent.parent.parent / "scripts" / "setup-closedloop.sh" -from conftest import CLOSEDLOOP_STATE_DIR @pytest.fixture diff --git a/plugins/code/tools/python/test_subagent_start_hook.py b/plugins/code/tools/python/test_subagent_start_hook.py index 124c8da3..df9eb08e 100644 --- a/plugins/code/tools/python/test_subagent_start_hook.py +++ b/plugins/code/tools/python/test_subagent_start_hook.py @@ -5,9 +5,9 @@ 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" -from conftest import CLOSEDLOOP_STATE_DIR @pytest.fixture() diff --git a/plugins/code/tools/python/test_subagent_stop_hook.py b/plugins/code/tools/python/test_subagent_stop_hook.py index 648261b2..3eb353c7 100644 --- a/plugins/code/tools/python/test_subagent_stop_hook.py +++ b/plugins/code/tools/python/test_subagent_stop_hook.py @@ -5,9 +5,9 @@ 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" -from conftest import CLOSEDLOOP_STATE_DIR @pytest.fixture() From fc2235ad397fb20969eb8534b736c2b929e68d64 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Sat, 11 Apr 2026 12:34:06 -0500 Subject: [PATCH 29/42] fix(code): address PR #45 reviewer feedback - Bump code plugin to v1.9.0 and correct CHANGELOG section header - discover-repos.sh: root discoveryMethod now reports "add_dir" when only Tier 0 contributed peers (previously misreported "sibling_scan") - run-loop.sh: replace unexpanded $CLOSEDLOOP_STATE_DIR inside single-quoted help heredoc with literal .closedloop-ai so copy-pasted MONITORING commands from --help work - bootstrap-learnings.sh: use $CLOSEDLOOP_STATE_DIR in PROJECT_DIR suffix trim (completes SSOT migration) - test_compute_success_rates.py: import CLOSEDLOOP_STATE_DIR from compute_success_rates instead of duplicating the constant Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 +- plugins/code/.claude-plugin/plugin.json | 2 +- plugins/code/scripts/discover-repos.sh | 7 ++++++- plugins/code/scripts/run-loop.sh | 6 +++--- plugins/self-learning/scripts/bootstrap-learnings.sh | 2 +- .../tools/python/test_compute_success_rates.py | 3 +-- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f73e3331..47136b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ 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.7.0 +### 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 diff --git a/plugins/code/.claude-plugin/plugin.json b/plugins/code/.claude-plugin/plugin.json index 71b6b3d4..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.0", + "version": "1.9.0", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" diff --git a/plugins/code/scripts/discover-repos.sh b/plugins/code/scripts/discover-repos.sh index 62bc70b0..b5ded4ca 100755 --- a/plugins/code/scripts/discover-repos.sh +++ b/plugins/code/scripts/discover-repos.sh @@ -68,8 +68,13 @@ if [[ -n "${CLOSEDLOOP_ADD_DIRS:-}" ]]; then done fi -# Tier 1: Environment variable +# 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" diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index e0f99691..7ca32e1d 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -639,7 +639,7 @@ DESCRIPTION: Runs Claude in a loop with fresh context on each iteration. Each iteration invokes `claude -p "/code:code "`. - State is persisted to $CLOSEDLOOP_STATE_DIR/closedloop-loop.local.md so loops can be resumed. + State is persisted to .closedloop-ai/closedloop-loop.local.md so loops can be resumed. To signal completion, Claude must output: COMPLETE @@ -671,10 +671,10 @@ STOPPING: MONITORING: # View current iteration: - grep '^iteration:' $CLOSEDLOOP_STATE_DIR/closedloop-loop.local.md + grep '^iteration:' .closedloop-ai/closedloop-loop.local.md # View progress log: - tail -20 $CLOSEDLOOP_STATE_DIR/closedloop-progress.log + tail -20 .closedloop-ai/closedloop-progress.log # View learning system status: ls -la .learnings/sessions/ diff --git a/plugins/self-learning/scripts/bootstrap-learnings.sh b/plugins/self-learning/scripts/bootstrap-learnings.sh index e19a6eeb..0bf7052b 100755 --- a/plugins/self-learning/scripts/bootstrap-learnings.sh +++ b/plugins/self-learning/scripts/bootstrap-learnings.sh @@ -18,7 +18,7 @@ LEARNINGS_DIR="${1:-$CLOSEDLOOP_STATE_DIR/learnings}" # Derive PROJECT_DIR for gitignore updates (only if using default path) if [[ "$LEARNINGS_DIR" == "$CLOSEDLOOP_STATE_DIR/learnings" ]] || [[ "$LEARNINGS_DIR" == *"/$CLOSEDLOOP_STATE_DIR/learnings" ]]; then - PROJECT_DIR="${LEARNINGS_DIR%/.closedloop-ai/learnings}" + PROJECT_DIR="${LEARNINGS_DIR%/$CLOSEDLOOP_STATE_DIR/learnings}" PROJECT_DIR="${PROJECT_DIR:-.}" UPDATE_PROJECT_GITIGNORE=true else 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 8ccbecd4..b280bbde 100644 --- a/plugins/self-learning/tools/python/test_compute_success_rates.py +++ b/plugins/self-learning/tools/python/test_compute_success_rates.py @@ -8,6 +8,7 @@ import pytest from compute_success_rates import ( + CLOSEDLOOP_STATE_DIR, compute_rates, jaccard_similarity, match_outcome_to_pattern, @@ -16,8 +17,6 @@ serialize_toon, ) -CLOSEDLOOP_STATE_DIR = ".closedloop-ai" - @pytest.fixture def tmp_workdir(tmp_path: Path) -> Path: From be337ef77c991c4fffc466144bf9f91cb2b63b11 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Sat, 11 Apr 2026 12:48:18 -0500 Subject: [PATCH 30/42] docs(code): clarify multi-repo overlay auto-selection lives in setup-closedloop.sh Tightens the overlays/README.md wording flagged in PR #45 review. The previous phrasing ("run-loop.sh --add-dir auto-selects --prompt multi-repo") was accurate from the user's vantage point but misleading for anyone auditing run-loop.sh in isolation: the auto-selection actually happens downstream in setup-closedloop.sh via PROMPT_NAME_EXPLICIT. Updated the README to name the correct file and explain the forwarding chain through the /code:code slash command. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 5 +++++ plugins/code/.claude-plugin/plugin.json | 2 +- plugins/code/prompts/overlays/README.md | 9 +++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47136b70..d9476466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ 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.1 + +#### Docs +- Clarified `plugins/code/prompts/overlays/README.md` wording for multi-repo overlay auto-selection — the logic lives in `setup-closedloop.sh`, not `run-loop.sh` + ### code v1.9.0 #### Added diff --git a/plugins/code/.claude-plugin/plugin.json b/plugins/code/.claude-plugin/plugin.json index a59a3b6f..7f76e65a 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.9.0", + "version": "1.9.1", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" diff --git a/plugins/code/prompts/overlays/README.md b/plugins/code/prompts/overlays/README.md index dd4c8f91..4c1aa103 100644 --- a/plugins/code/prompts/overlays/README.md +++ b/plugins/code/prompts/overlays/README.md @@ -45,8 +45,13 @@ The `multi-repo.overlay.md` overlay depends on env vars exported by The overlay introduces the `@{repo-name}:path` file-reference convention for secondary repos (primary-repo files need no prefix). -`run-loop.sh --add-dir` auto-selects `--prompt multi-repo` when the user -does not pass `--prompt` explicitly. +When `run-loop.sh --add-dir` is used without `--prompt`, +`setup-closedloop.sh` auto-selects the `multi-repo` overlay (see the +`PROMPT_NAME_EXPLICIT` branch in `setup-closedloop.sh`). `run-loop.sh` +itself performs no prompt resolution — it forwards `--add-dir` and +`--prompt` through the `/code:code` slash command, which invokes +`setup-closedloop.sh` as the single source of truth for overlay +selection. ## Debugging From dce7cf283deb2a9accb9b59b686512f01d6ebe1d Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Sat, 11 Apr 2026 12:57:53 -0500 Subject: [PATCH 31/42] Disambiguated learning prefix --- plugins/self-learning/scripts/bootstrap-learnings.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/self-learning/scripts/bootstrap-learnings.sh b/plugins/self-learning/scripts/bootstrap-learnings.sh index 0bf7052b..72c9fb0c 100755 --- a/plugins/self-learning/scripts/bootstrap-learnings.sh +++ b/plugins/self-learning/scripts/bootstrap-learnings.sh @@ -15,10 +15,11 @@ CLOSEDLOOP_STATE_DIR=".closedloop-ai" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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_STATE_DIR/learnings" ]] || [[ "$LEARNINGS_DIR" == *"/$CLOSEDLOOP_STATE_DIR/learnings" ]]; then - PROJECT_DIR="${LEARNINGS_DIR%/$CLOSEDLOOP_STATE_DIR/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 From 814bc4903ae964795512a0652946e4c97cf95d01 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Sat, 11 Apr 2026 12:59:25 -0500 Subject: [PATCH 32/42] Fixed plugin version --- plugins/code/.claude-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/code/.claude-plugin/plugin.json b/plugins/code/.claude-plugin/plugin.json index 7f76e65a..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.9.1", + "version": "1.9.0", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" From 50adb0238c4d54a5e3be8765a70fab8d46e0ca8b Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Mon, 13 Apr 2026 11:53:12 -0500 Subject: [PATCH 33/42] Added export of CLOSEDLOOP_REPO_MAP and CLOSEDLOOP_ADD_DIR_NAMES variables --- plugins/code/hooks/subagent-start-hook.sh | 3 ++ .../tools/python/test_subagent_start_hook.py | 29 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/plugins/code/hooks/subagent-start-hook.sh b/plugins/code/hooks/subagent-start-hook.sh index b8114ce5..611167c7 100755 --- a/plugins/code/hooks/subagent-start-hook.sh +++ b/plugins/code/hooks/subagent-start-hook.sh @@ -162,6 +162,9 @@ IMPORTANT: When your instructions reference \${VARIABLE_NAME} (e.g., \${CLAUDE_P 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" diff --git a/plugins/code/tools/python/test_subagent_start_hook.py b/plugins/code/tools/python/test_subagent_start_hook.py index df9eb08e..1762d0e6 100644 --- a/plugins/code/tools/python/test_subagent_start_hook.py +++ b/plugins/code/tools/python/test_subagent_start_hook.py @@ -42,6 +42,7 @@ 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 @@ -49,7 +50,10 @@ def run_start_hook( workdir = workdir_file.read_text().strip() 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( { @@ -141,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.""" From f654dff99a4c59cfd308b7c46be7bac5f46fedf4 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Mon, 13 Apr 2026 12:58:36 -0500 Subject: [PATCH 34/42] Consolidated setting of CLOSEDLOOP_ADD_DIRS value to a canonical setup-closedloop.sh --- plugins/code/.claude-plugin/plugin.json | 2 +- plugins/code/scripts/run-loop.sh | 21 ---- .../tools/python/test_setup_closedloop.py | 99 ++++++++++++++----- 3 files changed, 78 insertions(+), 44 deletions(-) diff --git a/plugins/code/.claude-plugin/plugin.json b/plugins/code/.claude-plugin/plugin.json index a59a3b6f..7f76e65a 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.9.0", + "version": "1.9.1", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 7ca32e1d..52394582 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -856,15 +856,6 @@ emit_skipped_step() { )" } -# Export CLOSEDLOOP_ADD_DIRS as pipe-joined string from ADD_DIRS array -export_add_dirs() { - if [[ ${#ADD_DIRS[@]} -gt 0 ]]; then - export CLOSEDLOOP_ADD_DIRS="$(IFS='|'; echo "${ADD_DIRS[*]}")" - else - export CLOSEDLOOP_ADD_DIRS="" - fi -} - # Update iteration in state file update_iteration() { local new_iter="$1" @@ -901,7 +892,6 @@ max_iterations: $MAX_ITERATIONS completion_promise: "$COMPLETION_PROMISE" workdir: "$WORKDIR" prd_file: "$PRD_FILE" -add_dirs: "${CLOSEDLOOP_ADD_DIRS:-}" run_id: "$RUN_ID" start_sha: "$START_SHA" self_learning: "$SELF_LEARNING" @@ -953,8 +943,6 @@ main() { # Acquire lock to prevent concurrent loops acquire_lock "$WORKDIR" - export_add_dirs - create_state_file fi @@ -982,15 +970,6 @@ main() { SELF_LEARNING=$(get_field "self_learning") export CLOSEDLOOP_SELF_LEARNING="$SELF_LEARNING" - # Reconstruct ADD_DIRS from the state file's add_dirs field - local add_dirs_field - add_dirs_field=$(get_field "add_dirs") - ADD_DIRS=() - if [[ -n "$add_dirs_field" ]]; then - IFS='|' read -ra ADD_DIRS <<< "$add_dirs_field" - fi - export_add_dirs - # If resuming, re-acquire lock if [[ -n "$workdir" ]]; then acquire_lock "$workdir" diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index 574ce3b7..2e92ace9 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -35,8 +35,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: @@ -61,16 +63,16 @@ 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") @@ -88,7 +90,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 @@ -99,7 +105,6 @@ 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_STATE_DIR @@ -110,7 +115,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: @@ -123,13 +130,16 @@ 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_STATE_DIR / 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.""" @@ -154,13 +164,17 @@ def _config_value(config: str, key: str) -> str: 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") + 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: +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)) @@ -170,7 +184,9 @@ def test_add_dir_writes_closedloop_add_dirs_to_config(tmp_workdir: Path, extra_r assert "CLOSEDLOOP_ADD_DIRS=" in config -def test_add_dir_writes_closedloop_repo_map_to_config(tmp_workdir: Path, extra_repo: Path) -> None: +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)) @@ -196,7 +212,9 @@ def test_add_dir_uses_identity_file_name(tmp_workdir: Path, tmp_path: Path) -> N assert "my-custom-name" in config -def test_multiple_add_dirs_produces_pipe_joined_values(tmp_workdir: Path, tmp_path: Path) -> None: +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" @@ -244,7 +262,9 @@ def test_add_dir_ignores_primary_workdir_path(tmp_workdir: Path) -> None: assert _config_value(config, "CLOSEDLOOP_ADD_DIR_NAMES") == "" assert _config_value(config, "CLOSEDLOOP_REPO_MAP") == "" prompt_line = next( - line for line in config.splitlines() if line.startswith("CLOSEDLOOP_PROMPT_FILE=") + line + for line in config.splitlines() + if line.startswith("CLOSEDLOOP_PROMPT_FILE=") ) assert "prompt-assembled.md" not in prompt_line, ( "Primary workdir in --add-dir should not trigger multi-repo prompt selection" @@ -261,8 +281,12 @@ def test_add_dir_makes_identity_name_collisions_unique( 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"}') + (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) @@ -318,7 +342,9 @@ def test_add_dir_makes_name_collision_with_primary_repo_unique( ) -def test_add_dir_selects_multi_repo_overlay_automatically(tmp_workdir: Path, extra_repo: Path) -> None: +def test_add_dir_selects_multi_repo_overlay_automatically( + tmp_workdir: Path, extra_repo: Path +) -> None: """When --add-dir is given without explicit --prompt, the multi-repo overlay should be assembled onto prompt.md and used as the prompt file.""" result = _run_setup_in_workdir(tmp_workdir, "--add-dir", str(extra_repo)) @@ -326,7 +352,9 @@ def test_add_dir_selects_multi_repo_overlay_automatically(tmp_workdir: Path, ext 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=") + line + for line in config.splitlines() + if line.startswith("CLOSEDLOOP_PROMPT_FILE=") ) # Should point at an assembled file under the workdir assert "prompt-assembled.md" in prompt_line, ( @@ -340,13 +368,17 @@ def test_add_dir_selects_multi_repo_overlay_automatically(tmp_workdir: Path, ext plugin_root = Path(__file__).resolve().parents[2] base = (plugin_root / "prompts" / "prompt.md").read_text() - overlay = (plugin_root / "prompts" / "overlays" / "multi-repo.overlay.md").read_text() + overlay = ( + plugin_root / "prompts" / "overlays" / "multi-repo.overlay.md" + ).read_text() assert assembled == base + "\n\n" + overlay, ( "Assembled prompt does not match base + blank + overlay" ) -def test_explicit_prompt_overrides_add_dir_auto_selection(tmp_workdir: Path, extra_repo: Path) -> None: +def test_explicit_prompt_overrides_add_dir_auto_selection( + tmp_workdir: Path, extra_repo: Path +) -> None: """An explicit --prompt flag must override the auto-selected multi-repo overlay.""" result = _run_setup_in_workdir( tmp_workdir, "--add-dir", str(extra_repo), "--prompt", "prompt" @@ -355,7 +387,9 @@ def test_explicit_prompt_overrides_add_dir_auto_selection(tmp_workdir: Path, ext 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=") + line + for line in config.splitlines() + if line.startswith("CLOSEDLOOP_PROMPT_FILE=") ) # Explicit --prompt prompt → direct base file, not assembled assert prompt_line.endswith('prompts/prompt.md"'), ( @@ -380,6 +414,27 @@ def test_add_dir_ignores_ancestor_of_workdir(tmp_path: Path) -> None: 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) From 462d8287704e639838a4918a83b0d214eb6dd739 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Mon, 13 Apr 2026 14:04:10 -0500 Subject: [PATCH 35/42] Fixed child directory handling --- plugins/code/scripts/run-loop.sh | 3 ++- plugins/code/scripts/setup-closedloop.sh | 2 +- plugins/code/tools/python/test_setup_closedloop.py | 13 ++++++++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 52394582..31b985a0 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -632,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 @@ -881,7 +882,7 @@ create_state_file() { prompt="$prompt --prd $PRD_FILE" fi for add_dir in "${ADD_DIRS[@]+"${ADD_DIRS[@]}"}"; do - prompt="$prompt --add-dir $add_dir" + prompt="$prompt --add-dir \"$add_dir\"" done cat > "$STATE_FILE" <&2 exit 1 fi - if [[ "$abs_path" == "$WORKDIR" || "$WORKDIR" == "$abs_path"/* ]]; then + if [[ "$abs_path" == "$WORKDIR" || "$WORKDIR" == "$abs_path"/* || "$abs_path" == "$WORKDIR"/* ]]; then continue fi if array_contains "$abs_path" "${RESOLVED_ADD_DIRS[@]}"; then diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index 2e92ace9..848481ed 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -12,9 +12,16 @@ @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. + + 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( From c59bbd0a1b899b65de96a1fa6de543e841ac0e8f Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Mon, 13 Apr 2026 16:32:11 -0500 Subject: [PATCH 36/42] Fixed versioning issue --- CHANGELOG.md | 4 +--- plugins/code/.claude-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9476466..8bfdaa3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,13 +14,11 @@ 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.1 +### code v1.9.0 #### Docs - Clarified `plugins/code/prompts/overlays/README.md` wording for multi-repo overlay auto-selection — the logic lives in `setup-closedloop.sh`, not `run-loop.sh` -### 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 diff --git a/plugins/code/.claude-plugin/plugin.json b/plugins/code/.claude-plugin/plugin.json index 7f76e65a..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.9.1", + "version": "1.9.0", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" From 434dee7b5cdc3dd76b216f4d99de75e7789bb8e6 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Mon, 13 Apr 2026 16:48:28 -0500 Subject: [PATCH 37/42] Fixed local repo step number --- plugins/code/agents/cross-repo-coordinator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/code/agents/cross-repo-coordinator.md b/plugins/code/agents/cross-repo-coordinator.md index 42abcaee..9f803e45 100644 --- a/plugins/code/agents/cross-repo-coordinator.md +++ b/plugins/code/agents/cross-repo-coordinator.md @@ -59,7 +59,7 @@ Parse the JSON output to get: **Write the discovery result to `$CLOSEDLOOP_WORKDIR/.workspace-repos.json`** so other agents can access it. -### Step 1.5: Local Repos (--add-dir) +### Step 1.1: Local Repos (--add-dir) After running `discover-repos.sh`, check `CLOSEDLOOP_ADD_DIRS` from the environment. This variable contains pipe-separated paths passed via `--add-dir` flags, representing local repositories that are already part of the current task plan. Example: `CLOSEDLOOP_ADD_DIRS="/path/to/a|/path/to/b"`. From c25ffdf4681d979c2b5fb70f6c5e30b21868b537 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Tue, 14 Apr 2026 08:49:28 -0500 Subject: [PATCH 38/42] Moved validation of the local peer into discover-repos.sh --- plugins/code/agents/cross-repo-coordinator.md | 15 +++++---------- plugins/code/scripts/discover-repos.sh | 2 +- plugins/code/tools/python/test_discover_repos.py | 3 ++- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/plugins/code/agents/cross-repo-coordinator.md b/plugins/code/agents/cross-repo-coordinator.md index 9f803e45..2021f45f 100644 --- a/plugins/code/agents/cross-repo-coordinator.md +++ b/plugins/code/agents/cross-repo-coordinator.md @@ -59,18 +59,13 @@ Parse the JSON output to get: **Write the discovery result to `$CLOSEDLOOP_WORKDIR/.workspace-repos.json`** so other agents can access it. -### Step 1.1: Local Repos (--add-dir) +### Step 1.1: Preserve Local Peer Markers -After running `discover-repos.sh`, check `CLOSEDLOOP_ADD_DIRS` from the environment. This variable contains pipe-separated paths passed via `--add-dir` flags, representing local repositories that are already part of the current task plan. Example: `CLOSEDLOOP_ADD_DIRS="/path/to/a|/path/to/b"`. +`discover-repos.sh` marks `--add-dir` peers with `"local": true` in the `peers[]` output. -For each path in `CLOSEDLOOP_ADD_DIRS`: -1. Normalize the path (resolve symlinks, trailing slashes) -2. Find the matching entry in the `peers[]` array from the discovery output by comparing the `path` field -3. If a match is found, mark that peer with `"local": true` in the entry written to `.cross-repo-needs.json` - -**Local repos must NOT generate cross-repo PRDs** — they already have tasks in the plan. When writing capabilities for a local peer, set `"local": true` and skip PRD generation for that peer in the downstream workflow. - -External repos (peers NOT found in `CLOSEDLOOP_ADD_DIRS`) continue through the existing PRD-generation workflow unchanged. +When writing `.cross-repo-needs.json`: +1. If a peer entry from `discover-repos.sh` has `"local": true`, preserve that value on the corresponding need entry +2. Otherwise, write `"local": false` ### Step 2: Handle No Peers Case diff --git a/plugins/code/scripts/discover-repos.sh b/plugins/code/scripts/discover-repos.sh index b5ded4ca..4721ac96 100755 --- a/plugins/code/scripts/discover-repos.sh +++ b/plugins/code/scripts/discover-repos.sh @@ -64,7 +64,7 @@ if [[ -n "${CLOSEDLOOP_ADD_DIRS:-}" ]]; then fi repo_name="${repo_name:-$(basename "$path")}" - PEER_JSONS+=("{\"name\": \"$repo_name\", \"type\": \"$repo_type\", \"path\": \"$path\", \"discoveryMethod\": \"add_dir\"}") + PEER_JSONS+=("{\"name\": \"$repo_name\", \"type\": \"$repo_type\", \"path\": \"$path\", \"discoveryMethod\": \"add_dir\", \"local\": true}") done fi diff --git a/plugins/code/tools/python/test_discover_repos.py b/plugins/code/tools/python/test_discover_repos.py index f79961ef..73cd34b5 100644 --- a/plugins/code/tools/python/test_discover_repos.py +++ b/plugins/code/tools/python/test_discover_repos.py @@ -113,6 +113,7 @@ def _make_repo(parent: Path, name: str, identity: dict | None = None) -> Path: "expect": { "extra": { "discoveryMethod": "add_dir", + "local": True, "name": "extra-svc", "type": "service", } @@ -150,7 +151,7 @@ def _make_repo(parent: Path, name: str, identity: dict | None = None) -> Path: }, }, "add_dirs": ["sibling-svc"], - "expect": {"sibling-svc": {"discoveryMethod": "add_dir"}}, + "expect": {"sibling-svc": {"discoveryMethod": "add_dir", "local": True}}, }, ] From ca85a10196703003e0bbb66b878e9d755b343018 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Tue, 14 Apr 2026 15:39:28 -0500 Subject: [PATCH 39/42] refactor(code): remove prompt-overlay system and redundant discoveryMethod field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #45 review: 1. Delete the prompt-overlay infrastructure (prompts/overlays/ directory and assembly/auto-select branches in setup-closedloop.sh). The sole overlay (multi-repo) only restated behavior that the target agents — pre-explorer, plan-draft-writer, cross-repo-prd-writer — already implement directly from CLOSEDLOOP_REPO_MAP / CLOSEDLOOP_ADD_DIRS. No behavioral delta from removal. 2. Drop the redundant "discoveryMethod": "add_dir" field from per-peer JSON in discover-repos.sh. "local": true is the single source of truth for identifying --add-dir peers. Remove the hand-written preservation step (Step 1.1) from cross-repo-coordinator.md. 3. Bump code plugin version 1.9.0 → 1.10.0 and consolidate unreleased changelog entries. --- CHANGELOG.md | 16 ++--- CLAUDE.md | 2 +- plugins/code/.claude-plugin/plugin.json | 2 +- plugins/code/README.md | 2 +- plugins/code/agents/cross-repo-coordinator.md | 10 +-- plugins/code/prompts/overlays/README.md | 65 ------------------- .../prompts/overlays/multi-repo.overlay.md | 38 ----------- plugins/code/scripts/discover-repos.sh | 2 +- plugins/code/scripts/run-loop.sh | 4 +- plugins/code/scripts/setup-closedloop.sh | 39 ++--------- .../code/tools/python/test_discover_repos.py | 5 +- .../tools/python/test_setup_closedloop.py | 52 +-------------- 12 files changed, 25 insertions(+), 212 deletions(-) delete mode 100644 plugins/code/prompts/overlays/README.md delete mode 100644 plugins/code/prompts/overlays/multi-repo.overlay.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bfdaa3a..60857041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,25 +14,21 @@ 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 - -#### Docs -- Clarified `plugins/code/prompts/overlays/README.md` wording for multi-repo overlay auto-selection — the logic lives in `setup-closedloop.sh`, not `run-loop.sh` +### code v1.10.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 -- New `multi-repo.overlay.md` overlay assembled onto `prompt.md` at runtime for cross-repository planning workflows - `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 -- Enhancements to `cross-repo-coordinator` and `cross-repo-prd-writer` agents for multi-repo context +- 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 +- Enhancements to `cross-repo-coordinator` and `cross-repo-prd-writer` agents for multi-repo context; `cross-repo-prd-writer` skips PRD generation for local 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 -- Fixed multi-repo execution ordering and prompt definition string assembly in overlay resolution +- Fixed multi-repo execution ordering in cross-repo coordination #### Changed - Consolidated Tier 0 `discover-repos.sh` tests into a single scenario-driven harness, replacing the prior fragmented per-case test files @@ -40,6 +36,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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 +#### Removed +- Dropped the prompt-overlay system (`prompts/overlays/` directory and assembly/auto-select branches in `setup-closedloop.sh`). Multi-repo behavior is owned by the agents, which read `CLOSEDLOOP_REPO_MAP`/`CLOSEDLOOP_ADD_DIRS` directly — no orchestrator-level overlay is needed +- Dropped the redundant `discoveryMethod: "add_dir"` field from per-peer entries in `discover-repos.sh`; `local: true` is the single source of truth for identifying `--add-dir` peers. `cross-repo-coordinator` Step 1.1 (the hand-written preservation step) is no longer needed + ### code v1.6.0 #### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 513f5967..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 base prompt is the single source of truth; variants (e.g. `--prompt multi-repo`) are expressed as append-only overlays under `plugins/code/prompts/overlays/` and assembled at runtime — see `plugins/code/prompts/overlays/README.md`. +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 a59a3b6f..eca1f6ea 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.9.0", + "version": "1.10.0", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" diff --git a/plugins/code/README.md b/plugins/code/README.md index a7e3623c..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. Resolves to `prompts/.md` if present, otherwise to `prompts/prompt.md` assembled with `prompts/overlays/.overlay.md`. Defaults to `prompt`. See `prompts/overlays/README.md` for the overlay authoring guide. +- `--prompt `: Select an alternate orchestrator prompt (`prompts/.md`). Defaults to `prompt`. - `--prd `: Explicitly specify the requirements file (auto-detected if omitted) **What it does:** diff --git a/plugins/code/agents/cross-repo-coordinator.md b/plugins/code/agents/cross-repo-coordinator.md index 2021f45f..09ec58e1 100644 --- a/plugins/code/agents/cross-repo-coordinator.md +++ b/plugins/code/agents/cross-repo-coordinator.md @@ -59,14 +59,6 @@ Parse the JSON output to get: **Write the discovery result to `$CLOSEDLOOP_WORKDIR/.workspace-repos.json`** so other agents can access it. -### Step 1.1: Preserve Local Peer Markers - -`discover-repos.sh` marks `--add-dir` peers with `"local": true` in the `peers[]` output. - -When writing `.cross-repo-needs.json`: -1. If a peer entry from `discover-repos.sh` has `"local": true`, preserve that value on the corresponding need entry -2. Otherwise, write `"local": false` - ### Step 2: Handle No Peers Case If `peers` array is empty: @@ -97,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 { diff --git a/plugins/code/prompts/overlays/README.md b/plugins/code/prompts/overlays/README.md deleted file mode 100644 index 4c1aa103..00000000 --- a/plugins/code/prompts/overlays/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# Prompt Overlays - -Append-only amendments layered onto `plugins/code/prompts/prompt.md` (the -SSOT orchestrator prompt) at runtime. This directory exists so variants of -the base prompt do not require duplicating 500+ lines of identical -orchestration text. - -## How assembly works - -`plugins/code/scripts/setup-closedloop.sh` resolves `--prompt ` in -this order: - -1. If `prompts/.md` exists, use it directly (backward compatible). -2. Else if `prompts/overlays/.overlay.md` exists, assemble - `prompts/prompt.md` + blank line + overlay into - `$CLOSEDLOOP_WORKDIR/.closedloop-ai/prompt-assembled.md` and point - `CLOSEDLOOP_PROMPT_FILE` at that file. -3. Else, fail loud with "prompt not found". - -The assembler is dumb concatenation — no frontmatter, no anchors, no -templating. If the overlay file exists, its bytes are appended verbatim. - -Default behavior (`--prompt prompt`) is byte-identical to today: the base -is used directly with no overlay involved. - -## When to use an overlay - -If your variant only **adds** instructions that can be framed as -amendments to earlier phases, write an overlay. If you need to **change** -or **remove** base content, do not use an overlay — have a conversation -about forking or refactoring the base instead. - -## Runtime contract — multi-repo overlay - -The `multi-repo.overlay.md` overlay depends on env vars exported by -`setup-closedloop.sh` when `--add-dir` is passed to `run-loop.sh`: - -- `CLOSEDLOOP_REPO_MAP` — pipe-separated `name=path` pairs of additional - repositories. -- `CLOSEDLOOP_ADD_DIRS` — pipe-separated absolute paths of local peer - repos. -- `CLOSEDLOOP_ADD_DIR_NAMES` — pipe-separated names matching - `CLOSEDLOOP_ADD_DIRS` by index. - -The overlay introduces the `@{repo-name}:path` file-reference convention -for secondary repos (primary-repo files need no prefix). - -When `run-loop.sh --add-dir` is used without `--prompt`, -`setup-closedloop.sh` auto-selects the `multi-repo` overlay (see the -`PROMPT_NAME_EXPLICIT` branch in `setup-closedloop.sh`). `run-loop.sh` -itself performs no prompt resolution — it forwards `--add-dir` and -`--prompt` through the `/code:code` slash command, which invokes -`setup-closedloop.sh` as the single source of truth for overlay -selection. - -## Debugging - -- Inspect the assembled file at - `$CLOSEDLOOP_WORKDIR/.closedloop-ai/prompt-assembled.md` after a run - starts. -- To bypass the overlay, pass `--prompt prompt` — the base is used - unchanged. -- If you see `ERROR: Prompt 'X' not found (no prompts/X.md, no - prompts/overlays/X.overlay.md)`, the name you passed matches neither a - direct base file nor an overlay. diff --git a/plugins/code/prompts/overlays/multi-repo.overlay.md b/plugins/code/prompts/overlays/multi-repo.overlay.md deleted file mode 100644 index 0d6e2e64..00000000 --- a/plugins/code/prompts/overlays/multi-repo.overlay.md +++ /dev/null @@ -1,38 +0,0 @@ -## Multi-Repo Amendments - -The following amendments apply to specific phases defined earlier in this -prompt. They take precedence over the base instructions for those phases -when their trigger conditions are met. Apply them every iteration — they -are not optional. - -### Amendment to Phase 0 (pre-exploration) - -When launching `@code:pre-explorer`, append to its launch prompt: - -> Additional repos context: if `CLOSEDLOOP_REPO_MAP` is set, it contains -> `name=path` pairs (pipe-separated) of additional repositories. For each -> `name=path` pair, explore that repository at the given path and write a -> `code-map-{name}.json` to `$CLOSEDLOOP_WORKDIR` capturing its structure, -> key files, and relevant patterns. - -### Amendment to Phase 1 (plan drafting) - -When launching `@code:plan-draft-writer`, append to its launch prompt: - -> Additional repos context: if `CLOSEDLOOP_REPO_MAP` is set, it contains -> `name=path` pairs (pipe-separated) of additional repositories available -> for reference. When referencing files in secondary repos, use the -> `@{repo-name}:path` prefix convention (e.g., -> `@my-lib:src/utils/helper.ts`). Files in the primary repo need no prefix -> — use their paths directly. - -### Amendment to Phase 1.4 (cross-repo coordination) - -> **NOTE (multi-repo):** Repos supplied via `--add-dir` are local and -> their tasks already belong in the primary plan. If `CLOSEDLOOP_ADD_DIRS` -> is set, it contains the paths of those local repos. When the -> cross-repo-coordinator identifies a peer whose path appears in -> `CLOSEDLOOP_ADD_DIRS`, treat that peer as `local=true` and ensure its -> tasks are placed directly in the plan (not in a separate cross-repo -> PRD). Do not generate a PRD for local peers — their work is part of -> this plan. diff --git a/plugins/code/scripts/discover-repos.sh b/plugins/code/scripts/discover-repos.sh index 4721ac96..b0a5e108 100755 --- a/plugins/code/scripts/discover-repos.sh +++ b/plugins/code/scripts/discover-repos.sh @@ -64,7 +64,7 @@ if [[ -n "${CLOSEDLOOP_ADD_DIRS:-}" ]]; then fi repo_name="${repo_name:-$(basename "$path")}" - PEER_JSONS+=("{\"name\": \"$repo_name\", \"type\": \"$repo_type\", \"path\": \"$path\", \"discoveryMethod\": \"add_dir\", \"local\": true}") + PEER_JSONS+=("{\"name\": \"$repo_name\", \"type\": \"$repo_type\", \"path\": \"$path\", \"local\": true}") done fi diff --git a/plugins/code/scripts/run-loop.sh b/plugins/code/scripts/run-loop.sh index 31b985a0..ed2ccd95 100755 --- a/plugins/code/scripts/run-loop.sh +++ b/plugins/code/scripts/run-loop.sh @@ -725,8 +725,8 @@ while [[ $# -gt 0 ]]; do echo -e "${RED}Error: --prompt name must not contain spaces or path separators${NC}" >&2 exit 1 fi - if [[ ! -f "$SCRIPTS_DIR/../prompts/$2.md" && ! -f "$SCRIPTS_DIR/../prompts/overlays/$2.overlay.md" ]]; then - echo -e "${RED}Error: prompt not found: prompts/$2.md or prompts/overlays/$2.overlay.md${NC}" >&2 + if [[ ! -f "$SCRIPTS_DIR/../prompts/$2.md" ]]; then + echo -e "${RED}Error: prompt not found: prompts/$2.md${NC}" >&2 exit 1 fi PROMPT_NAME="$2" diff --git a/plugins/code/scripts/setup-closedloop.sh b/plugins/code/scripts/setup-closedloop.sh index fb522bfe..95bdab33 100755 --- a/plugins/code/scripts/setup-closedloop.sh +++ b/plugins/code/scripts/setup-closedloop.sh @@ -19,7 +19,6 @@ PRD_FILE="" PLAN_FILE="" MAX_ITERATIONS=10 PROMPT_NAME="" -PROMPT_NAME_EXPLICIT=false POSITIONAL_ARGS=() ADD_DIRS=() @@ -111,7 +110,6 @@ while [[ $# -gt 0 ]]; do exit 1 fi PROMPT_NAME="$2" - PROMPT_NAME_EXPLICIT=true shift 2 ;; --add-dir) @@ -240,14 +238,8 @@ else echo "$(date): WARNING: Could not find session_id in process tree" >> "$DEBUG_LOG" fi -# Step 3: Auto-select prompt based on whether extra repos were provided -if [[ "$PROMPT_NAME_EXPLICIT" == false ]]; then - if [[ ${#RESOLVED_ADD_DIRS[@]} -gt 0 ]]; then - PROMPT_NAME="multi-repo" - else - PROMPT_NAME="${PROMPT_NAME:-prompt}" - fi -fi +# 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 @@ -255,44 +247,23 @@ if [[ "$PROMPT_NAME" == */* || "$PROMPT_NAME" == *..* || "$PROMPT_NAME" =~ [[:sp exit 1 fi -# Resolve prompt: direct base file takes precedence; otherwise assemble -# base prompt.md + overlay. See plugins/code/prompts/overlays/README.md. DIRECT_PROMPT="$PLUGIN_ROOT/prompts/$PROMPT_NAME.md" -OVERLAY_PROMPT="$PLUGIN_ROOT/prompts/overlays/$PROMPT_NAME.overlay.md" -BASE_PROMPT="$PLUGIN_ROOT/prompts/prompt.md" - -# Ensure WORKDIR/$CLOSEDLOOP_STATE_DIR exists before writing the assembled file -mkdir -p "$WORKDIR/$CLOSEDLOOP_STATE_DIR" if [[ -f "$DIRECT_PROMPT" ]]; then CLOSEDLOOP_PROMPT_FILE="$DIRECT_PROMPT" -elif [[ -f "$OVERLAY_PROMPT" ]]; then - if [[ ! -f "$BASE_PROMPT" ]]; then - echo "ERROR: base prompt missing: $BASE_PROMPT" >&2 - exit 1 - fi - ASSEMBLED_PROMPT="$WORKDIR/$CLOSEDLOOP_STATE_DIR/prompt-assembled.md" - { - cat "$BASE_PROMPT" - printf '\n\n' - cat "$OVERLAY_PROMPT" - } > "$ASSEMBLED_PROMPT" - CLOSEDLOOP_PROMPT_FILE="$ASSEMBLED_PROMPT" else - echo "ERROR: Prompt '$PROMPT_NAME' not found (no $DIRECT_PROMPT, no $OVERLAY_PROMPT)" >&2 + 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 basename "$f" .md >&2 done - for f in "$PLUGIN_ROOT/prompts/overlays/"*.overlay.md; do - name="$(basename "$f" .overlay.md)" - echo "$name (overlay)" >&2 - done shopt -u nullglob exit 1 fi +mkdir -p "$WORKDIR/$CLOSEDLOOP_STATE_DIR" + # Write full config to WORKDIR mkdir -p "$WORKDIR/$CLOSEDLOOP_STATE_DIR" diff --git a/plugins/code/tools/python/test_discover_repos.py b/plugins/code/tools/python/test_discover_repos.py index 73cd34b5..a51b5708 100644 --- a/plugins/code/tools/python/test_discover_repos.py +++ b/plugins/code/tools/python/test_discover_repos.py @@ -112,7 +112,6 @@ def _make_repo(parent: Path, name: str, identity: dict | None = None) -> Path: "add_dirs": ["extra"], "expect": { "extra": { - "discoveryMethod": "add_dir", "local": True, "name": "extra-svc", "type": "service", @@ -138,7 +137,7 @@ def _make_repo(parent: Path, name: str, identity: dict | None = None) -> Path: "forbidden": ["current"], }, # Sibling also listed in CLOSEDLOOP_ADD_DIRS must appear exactly once - # and be marked `add_dir` (Tier 0 wins over Tier 2 sibling scan). + # and be marked `local: true` (Tier 0 wins over Tier 2 sibling scan). { "id": "add_dir_wins_over_sibling_scan", "workspace": True, @@ -151,7 +150,7 @@ def _make_repo(parent: Path, name: str, identity: dict | None = None) -> Path: }, }, "add_dirs": ["sibling-svc"], - "expect": {"sibling-svc": {"discoveryMethod": "add_dir", "local": True}}, + "expect": {"sibling-svc": {"local": True}}, }, ] diff --git a/plugins/code/tools/python/test_setup_closedloop.py b/plugins/code/tools/python/test_setup_closedloop.py index 848481ed..60b5092f 100644 --- a/plugins/code/tools/python/test_setup_closedloop.py +++ b/plugins/code/tools/python/test_setup_closedloop.py @@ -268,14 +268,6 @@ def test_add_dir_ignores_primary_workdir_path(tmp_workdir: Path) -> None: assert _config_value(config, "CLOSEDLOOP_ADD_DIRS") == "" assert _config_value(config, "CLOSEDLOOP_ADD_DIR_NAMES") == "" assert _config_value(config, "CLOSEDLOOP_REPO_MAP") == "" - prompt_line = next( - line - for line in config.splitlines() - if line.startswith("CLOSEDLOOP_PROMPT_FILE=") - ) - assert "prompt-assembled.md" not in prompt_line, ( - "Primary workdir in --add-dir should not trigger multi-repo prompt selection" - ) def test_add_dir_makes_identity_name_collisions_unique( @@ -349,11 +341,9 @@ def test_add_dir_makes_name_collision_with_primary_repo_unique( ) -def test_add_dir_selects_multi_repo_overlay_automatically( - tmp_workdir: Path, extra_repo: Path -) -> None: - """When --add-dir is given without explicit --prompt, the multi-repo overlay - should be assembled onto prompt.md and used as the prompt file.""" +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 @@ -363,42 +353,6 @@ def test_add_dir_selects_multi_repo_overlay_automatically( for line in config.splitlines() if line.startswith("CLOSEDLOOP_PROMPT_FILE=") ) - # Should point at an assembled file under the workdir - assert "prompt-assembled.md" in prompt_line, ( - f"Expected assembled prompt file, got: {prompt_line!r}" - ) - - # Verify the assembled file exists and equals base + blank + overlay - assembled_path = tmp_workdir / CLOSEDLOOP_STATE_DIR / "prompt-assembled.md" - assert assembled_path.is_file(), f"Missing assembled file: {assembled_path}" - assembled = assembled_path.read_text() - - plugin_root = Path(__file__).resolve().parents[2] - base = (plugin_root / "prompts" / "prompt.md").read_text() - overlay = ( - plugin_root / "prompts" / "overlays" / "multi-repo.overlay.md" - ).read_text() - assert assembled == base + "\n\n" + overlay, ( - "Assembled prompt does not match base + blank + overlay" - ) - - -def test_explicit_prompt_overrides_add_dir_auto_selection( - tmp_workdir: Path, extra_repo: Path -) -> None: - """An explicit --prompt flag must override the auto-selected multi-repo overlay.""" - result = _run_setup_in_workdir( - tmp_workdir, "--add-dir", str(extra_repo), "--prompt", "prompt" - ) - - 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=") - ) - # Explicit --prompt prompt → direct base file, not assembled assert prompt_line.endswith('prompts/prompt.md"'), ( f"Expected direct base prompt.md but got: {prompt_line!r}" ) From 822b1d28e46d1b2ce22daf02ce06a119a5b24e4c Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Tue, 14 Apr 2026 15:41:06 -0500 Subject: [PATCH 40/42] Fixed version --- plugins/code/.claude-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/code/.claude-plugin/plugin.json b/plugins/code/.claude-plugin/plugin.json index eca1f6ea..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.10.0", + "version": "1.9.0", "author": { "name": "ClosedLoop", "email": "support@closedloop.ai" From e2b6b5d82c8372003fb8079a9bab676b6a92f69c Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Tue, 14 Apr 2026 16:02:06 -0500 Subject: [PATCH 41/42] Updated CHANGELOG --- CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60857041..f2ba7923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,13 +22,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `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 -- Enhancements to `cross-repo-coordinator` and `cross-repo-prd-writer` agents for multi-repo context; `cross-repo-prd-writer` skips PRD generation for local 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 -- Fixed multi-repo execution ordering in cross-repo coordination #### Changed - Consolidated Tier 0 `discover-repos.sh` tests into a single scenario-driven harness, replacing the prior fragmented per-case test files @@ -36,10 +34,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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 -#### Removed -- Dropped the prompt-overlay system (`prompts/overlays/` directory and assembly/auto-select branches in `setup-closedloop.sh`). Multi-repo behavior is owned by the agents, which read `CLOSEDLOOP_REPO_MAP`/`CLOSEDLOOP_ADD_DIRS` directly — no orchestrator-level overlay is needed -- Dropped the redundant `discoveryMethod: "add_dir"` field from per-peer entries in `discover-repos.sh`; `local: true` is the single source of truth for identifying `--add-dir` peers. `cross-repo-coordinator` Step 1.1 (the hand-written preservation step) is no longer needed - ### code v1.6.0 #### Changed From 6ae5d73e373957c601e8cb5262214735cde9e823 Mon Sep 17 00:00:00 2001 From: Alexander Ponamarev Date: Tue, 14 Apr 2026 16:16:51 -0500 Subject: [PATCH 42/42] Fixed version in CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ba7923..b6d265db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ 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.10.0 +### 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