diff --git a/.gitignore b/.gitignore index c4dab2d..1b53c03 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,12 @@ .env .*.env .forgectl +forgectl-state.json + +# Python +__pycache__/ +*.py[cod] +.venv/ +*.egg-info/ +dist/ +.pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md index 2d59e6c..5c11d1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,9 @@ Spec-driven development harness. Compiles planning documents into production cod - [go-yaml](https://context7.com/goccy/go-yaml) - [golangci-lint](https://context7.com/golangci/golangci-lint) - [go-git](https://context7.com/go-git/go-git) Version 5 only (Version 6 is in pre-release at this moment) -- [toml] (https://context7.com/burntsushi/toml) +- [toml](https://context7.com/burntsushi/toml) +- [claude-agent-sdk](https://context7.com/anthropics/claude-agent-sdk-python) +- [pydantic](https://context7.com/pydantic/pydantic) | [pydantic web wiki (latest version)](https://context7.com/websites/pydantic_dev) ## Skills diff --git a/docs/reverse-engineering.md b/docs/reverse-engineering.md new file mode 100644 index 0000000..9fd47d3 --- /dev/null +++ b/docs/reverse-engineering.md @@ -0,0 +1,324 @@ +# Reverse Engineering Guide + +How to use forgectl's reverse engineering phase to derive specifications from existing code. + +## Overview + +The reverse engineering workflow extracts implicit contracts from code and makes them explicit as spec files. It is the inverse of the normal spec-first flow: instead of code implementing specs, specs are derived from code. + +**When to use:** When a codebase has implemented behavior that was never captured in specifications, and you need specs before making changes. + +## Prerequisites + +- forgectl installed and `.forgectl/` directory exists in the project root +- `.forgectl/config` contains a `[reverse_engineering]` section (or defaults are acceptable) +- You have a general concept of the work you will be performing +- You know which domains in the codebase are relevant to the work + +## Quick Start + +### 1. Create the init input file + +```json +{ + "concept": "auth middleware refactor", + "domains": ["optimizer", "api", "portal"] +} +``` + +- `concept`: A description of the work. This scopes everything — only code and specs relevant to this concept are examined. +- `domains`: Ordered list of domains to process. Each domain goes through SURVEY → GAP_ANALYSIS → DECOMPOSE → QUEUE before the next domain starts. Order matters — put the most foundational domain first. + +### 2. Initialize the session + +```bash +forgectl init --phase reverse_engineering --from input.json +``` + +### 3. Follow the state machine + +Run `forgectl status` at any point to see the current state and what action is needed. Run `forgectl advance` to move to the next state. + +## State Machine + +``` +ORIENT + ↓ +SURVEY (domain 1) → GAP_ANALYSIS (domain 1) → DECOMPOSE (domain 1) → QUEUE (domain 1) + ↓ +SURVEY (domain 2) → GAP_ANALYSIS (domain 2) → DECOMPOSE (domain 2) → QUEUE (domain 2) + ↓ + ... (repeat for each domain) + ↓ +EXECUTE + ↓ +RECONCILE (domain 1) → RECONCILE_EVAL (domain 1) → [COLLEAGUE_REVIEW (domain 1)] → RECONCILE_ADVANCE + ↓ +RECONCILE (domain 2) → RECONCILE_EVAL (domain 2) → [COLLEAGUE_REVIEW (domain 2)] → RECONCILE_ADVANCE + ↓ + ... (repeat for each domain) + ↓ +DONE +``` + +Note: RECONCILE_EVAL can loop back to RECONCILE on FAIL (up to `max_rounds`). COLLEAGUE_REVIEW (in brackets) is disabled by default — when disabled, RECONCILE_EVAL advances directly to RECONCILE_ADVANCE. + +## States in Detail + +### ORIENT + +**What happens:** Forgectl displays the concept, domain list, and order. You confirm readiness. + +**What you do:** Review the domain order. Advance when ready. + +### SURVEY + +**What happens:** Forgectl tells you to survey existing specs in the current domain's `specs/` directory. + +**What you do:** +1. Spawn the configured sub-agents (default: 2 haiku explorers) scoped to `{domain}/specs/`. +2. Read all spec files. For each, extract the topic of concern, behaviors, integration points, and dependencies. +3. Identify which specs pertain to your concept. +4. Advance when complete. + +### GAP_ANALYSIS + +**What happens:** Forgectl tells you to examine the current domain's source code for unspecified behavior. + +**What you do:** +1. Spawn the configured sub-agents (default: 5 sonnet explorers) scoped to the domain source code. +2. For each behavior found in code that is not covered by an existing spec: + - Describe what it does + - Formulate a topic of concern (single sentence, no "and", describes an activity) + - Note the code location + - Note if an existing spec partially covers it +3. Advance when complete. + +### DECOMPOSE + +**What happens:** Forgectl tells you to synthesize your SURVEY and GAP_ANALYSIS findings for this domain. + +**What you do:** +1. Decide which gaps warrant new specs vs. updates to existing specs. +2. Group related behaviors into single-topic specs. +3. For each spec, define: name, topic of concern, file path, action (create/update), code search roots, dependencies. +4. Advance when the spec list for this domain is finalized. + +### QUEUE + +**What happens:** You write the queue JSON file (or add to it for subsequent domains). + +**What you do:** +- **First domain:** Write the queue JSON file and advance with: `forgectl advance --file queue.json` +- **Subsequent domains:** Add entries to the existing file and advance with: `forgectl advance` + +Forgectl validates the JSON schema and verifies that all `code_search_roots` directories exist on disk. + +#### Queue JSON schema + +```json +{ + "specs": [ + { + "name": "Auth Middleware Validation", + "domain": "optimizer", + "topic": "The optimizer validates authentication tokens before processing requests", + "file": "specs/auth-middleware-validation.md", + "action": "create", + "code_search_roots": ["src/middleware/", "src/auth/"], + "depends_on": [] + } + ] +} +``` + +All paths are relative to the domain root (`//`). + +| Field | Description | +|-------|-------------| +| `name` | Display name for the spec | +| `domain` | Which domain this spec belongs to | +| `topic` | One-sentence topic of concern | +| `file` | Spec file path relative to domain root (e.g., `specs/my-spec.md`) | +| `action` | `"create"` for new specs, `"update"` for existing specs with gaps | +| `code_search_roots` | Directories the agent examines (relative to domain root) | +| `depends_on` | Names of specs this one depends on. Used by RECONCILE for cross-referencing. | + +### EXECUTE + +**What happens:** Forgectl generates `execute.json`, invokes the Python subprocess, and parallel agents draft/update spec files. + +**What you do:** Wait. Forgectl handles this automatically. + +The Python subprocess runs one Claude Agent SDK session per spec entry. The execution mode (configured in `.forgectl/config`) determines how agents refine their work: + +| Mode | What happens | +|------|-------------| +| `single_shot` | Agent drafts once. Done. | +| `self_refine` | Agent drafts, then reviews its own work N times. | +| `multi_pass` | Full batch of agents runs N times. Creates become updates after pass 1. | +| `peer_review` | Agent drafts, then spawns reviewer sub-agents to evaluate. N rounds. | + +After the subprocess completes, forgectl reads results from `execute.json`. If all succeed, it advances to RECONCILE. If any fail, it reports which entries failed. + +### RECONCILE + +Runs per domain — each domain goes through RECONCILE → RECONCILE_EVAL → (optional COLLEAGUE_REVIEW) → RECONCILE_ADVANCE before the next domain starts. + +**What happens:** Forgectl lists the specs created/updated for this domain (with their `depends_on`) and tells you to wire up cross-references. + +**What you do:** +1. For every spec created or updated, use its `depends_on` to add cross-references to the corresponding specs. +2. Update both the new/updated spec AND the referenced spec (bidirectional). +3. Verify: no dangling references, symmetric integration points, consistent naming, no circular dependencies. +4. Stage changes (`git add`) and advance. + +On round 1, forgectl lists all spec files to confirm they exist on disk. On subsequent rounds (after RECONCILE_EVAL FAIL), it tells you to address the evaluation findings. + +### RECONCILE_EVAL + +**What happens:** You spawn sub-agents (default: 1 opus general-purpose) to evaluate cross-spec consistency. + +**What you do:** +1. Spawn the configured sub-agents. +2. Tell them to run `forgectl eval` — this outputs the evaluation prompt with the full spec file list, depends_on references, and the 7-dimension consistency checklist. +3. The sub-agents read the specs, evaluate, and write a report. +4. Advance with the verdict: + - `forgectl advance --verdict PASS --eval-report ` + - `forgectl advance --verdict FAIL --eval-report ` + +If FAIL: returns to RECONCILE (up to `max_rounds`). If PASS: advances to COLLEAGUE_REVIEW (if enabled) or RECONCILE_ADVANCE (if disabled). + +Eval reports go to: `{domain}/specs/.eval/reconciliation-r{round}.md` + +### COLLEAGUE_REVIEW + +Disabled by default. Enable with `colleague_review = true` in config. + +**What happens:** Forgectl pauses the workflow for a human review gate. + +**What you do:** Review the specifications for this domain with your colleague. Advance when complete: `forgectl advance` + +### RECONCILE_ADVANCE + +**What happens:** Explicit transition between domains. + +**What you do:** Advance to proceed to the next domain's RECONCILE, or DONE if all domains are complete. + +### DONE + +The workflow is complete. All spec files have been produced, verified, and reconciled across all domains. + +## Configuration + +All configuration lives in `.forgectl/config` under the `[reverse_engineering]` section. + +### Full reference + +```toml +[reverse_engineering] +# Execution mode: "single_shot", "self_refine", "multi_pass", "peer_review" +mode = "self_refine" + +[reverse_engineering.self_refine] +rounds = 2 # Self-review rounds (default: 2) + +[reverse_engineering.multi_pass] +passes = 2 # Full batch re-runs (default: 2) + +[reverse_engineering.peer_review] +reviewers = 3 # Reviewer sub-agents per drafter (default: 3) +rounds = 1 # Peer review cycles (default: 1) + +# Primary agent model +[reverse_engineering.drafter] +model = "opus" + +# Reconciliation eval rounds +[reverse_engineering.reconcile] +min_rounds = 1 # Minimum eval rounds (default: 1) +max_rounds = 3 # Maximum eval rounds (default: 3) +colleague_review = false # Disabled by default; enable to add a human review gate + +# Sub-agents for reconciliation evaluation +[reverse_engineering.reconcile.eval] +count = 1 # Number of evaluator sub-agents (default: 1) +model = "opus" # Evaluator model (default: opus) +type = "general-purpose" # Evaluator type (default: general-purpose) + +# Sub-agents for code exploration during drafting +[reverse_engineering.drafter.subagents] +model = "opus" +type = "explorer" +count = 3 + +# Sub-agents for peer review (peer_review mode only) +[reverse_engineering.peer_review.subagents] +model = "opus" +type = "explorer" + +# Sub-agents displayed in SURVEY action output +[reverse_engineering.survey] +model = "haiku" +type = "explorer" +count = 2 + +# Sub-agents displayed in GAP_ANALYSIS action output +[reverse_engineering.gap_analysis] +model = "sonnet" +type = "explorer" +count = 5 +``` + +### Execution modes + +Defaults for each mode are only applied when that mode is selected. Forgectl does not store or pass configuration for inactive modes. + +**`single_shot`** — Fastest. Agent drafts once with no review. Use when you trust the output or want speed. +- No mode-specific parameters. + +**`self_refine`** (default) — Agent drafts, then critiques and refines its own output N times within the same session. Good balance of quality and cost. +- `rounds = 2` — number of self-review follow-ups after the initial draft (3 total actions: draft + 2 reviews) + +**`multi_pass`** — Entire batch of agents runs N times. Each pass builds on the previous output (creates become updates after pass 1). Fresh sessions each pass provide a different perspective. +- `passes = 2` — number of full batch re-runs + +**`peer_review`** — Agent drafts, then spawns reviewer sub-agents in parallel to evaluate the spec against the code and format. Multiple rounds allow feedback to compound. Highest quality, highest cost. +- `reviewers = 3` — number of reviewer sub-agents per drafter +- `rounds = 1` — number of peer review cycles +- `subagents.model = "opus"` — model for reviewer sub-agents +- `subagents.type = "explorer"` — role for reviewer sub-agents + +### Sub-agent config independence + +The system has four independent sub-agent configurations: + +| Config | Purpose | When used | +|--------|---------|-----------| +| `drafter.subagents` | Code exploration during spec drafting | EXECUTE — initial prompt | +| `peer_review.subagents` | Spec review during peer review | EXECUTE — peer review follow-up | +| `survey` | Spec directory exploration | SURVEY — action output | +| `gap_analysis` | Source code analysis | GAP_ANALYSIS — action output | + +Each can use a different model, type, and count. Code exploration sub-agents and peer review sub-agents are independent — they serve different purposes. + +## File Artifacts + +| File | Created by | Purpose | +|------|-----------|---------| +| `input.json` | User | Init input with concept and domains | +| `queue.json` | User | Reverse engineering queue with spec entries | +| `execute.json` | Forgectl | Handoff to Python subprocess with config and entries | +| `forgectl-state.json` | Forgectl | Session state tracking | +| `/specs/*.md` | Python subprocess (agents) | The produced spec files | + +## Prompts + +The Python package bundles four prompt files. These are not user-configurable — they ship with the package. + +| Prompt | Mode | Purpose | +|--------|------|---------| +| `reverse-engineering-prompt.md` | All | Initial draft instructions with interpolation fields | +| `spec-format-reference.md` | All | Spec format structure, principles, anti-patterns | +| `review-work-prompt.md` | `self_refine` | Self-critique follow-up with round awareness | +| `peer-review-prompt.md` | `peer_review` | Spawn reviewer sub-agents in parallel | diff --git a/forgectl/.gitignore b/forgectl/.gitignore index d383be8..c8818de 100644 --- a/forgectl/.gitignore +++ b/forgectl/.gitignore @@ -1 +1,3 @@ -forgectl \ No newline at end of file +forgectl +.forge_workspace +.forgectl_workspace \ No newline at end of file diff --git a/forgectl/cmd/adddomain.go b/forgectl/cmd/adddomain.go new file mode 100644 index 0000000..3916898 --- /dev/null +++ b/forgectl/cmd/adddomain.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "fmt" + + "forgectl/state" + + "github.com/spf13/cobra" +) + +var addDomainCmd = &cobra.Command{ + Use: "add-domain ", + Short: "Add a domain during reverse engineering QUEUE state", + Args: cobra.ExactArgs(1), + RunE: runAddDomain, +} + +func init() { + rootCmd.AddCommand(addDomainCmd) +} + +func runAddDomain(cmd *cobra.Command, args []string) error { + _, stateDir, _, err := resolveSession() + if err != nil { + return err + } + s, err := state.Load(stateDir) + if err != nil { + return err + } + + // State gate: only available during reverse_engineering QUEUE. + if s.Phase != state.PhaseReverseEngineering || s.State != state.StateQueue { + return fmt.Errorf("forgectl add-domain is only available during the QUEUE state.") + } + + domain := args[0] + re := s.ReverseEngineering + + // Reject duplicates. + for _, d := range re.Domains { + if d == domain { + return fmt.Errorf("domain %q already exists", domain) + } + } + + re.Domains = append(re.Domains, domain) + re.TotalDomains++ + + if err := state.Save(stateDir, s); err != nil { + return fmt.Errorf("saving state: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Added domain %q. Total domains: %d.\n", domain, re.TotalDomains) + return nil +} diff --git a/forgectl/cmd/advance.go b/forgectl/cmd/advance.go index 0086382..5d9536f 100644 --- a/forgectl/cmd/advance.go +++ b/forgectl/cmd/advance.go @@ -77,6 +77,9 @@ func runAdvance(cmd *cobra.Command, args []string) error { prevState := string(s.State) prevPhase := string(s.Phase) + // Attach logger so state transitions can write phase-specific detail. + s.Logger = state.NewLogger(s.Config.Logs, s.StartedAtPhase, s.SessionID) + err = state.Advance(s, in, projectRoot) if err != nil { // Check if it's a validation error — still save state if VALIDATE was entered. @@ -100,17 +103,20 @@ func runAdvance(cmd *cobra.Command, args []string) error { return fmt.Errorf("saving state: %w", err) } - // Activity logging. - detail := buildAdvanceDetail(in) - logger := state.NewLogger(s.Config.Logs, s.StartedAtPhase, s.SessionID) - logger.Write(state.LogEntry{ - TS: state.LogNow(), - Cmd: "advance", - Phase: prevPhase, - PrevState: prevState, - State: string(s.State), - Detail: detail, - }) + // Activity logging. RE phase writes its own log entries inside state.Advance + // (with richer domain/round context), so skip the generic cmd-level entry for that phase. + if prevPhase != string(state.PhaseReverseEngineering) { + detail := buildAdvanceDetail(in) + logger := state.NewLogger(s.Config.Logs, s.StartedAtPhase, s.SessionID) + logger.Write(state.LogEntry{ + TS: state.LogNow(), + Cmd: "advance", + Phase: prevPhase, + PrevState: prevState, + State: string(s.State), + Detail: detail, + }) + } // Archive session at terminal states. if isTerminalState(s) { @@ -166,9 +172,13 @@ func sessionDomain(s *state.ForgeState) string { } func validateAdvanceFlags(s *state.ForgeState) error { - // --file only valid in specifying DRAFT. - if advanceFile != "" && !(s.Phase == state.PhaseSpecifying && s.State == state.StateDraft) { - return fmt.Errorf("--file is only valid in specifying DRAFT state (current: %s %s)", s.Phase, s.State) + // --file valid in specifying DRAFT or reverse_engineering QUEUE. + if advanceFile != "" { + specifyingDraft := s.Phase == state.PhaseSpecifying && s.State == state.StateDraft + reQueue := s.Phase == state.PhaseReverseEngineering && s.State == state.StateQueue + if !specifyingDraft && !reQueue { + return fmt.Errorf("--file is only valid in specifying DRAFT or reverse_engineering QUEUE state (current: %s %s)", s.Phase, s.State) + } } // --verdict only valid in eval states. diff --git a/forgectl/cmd/commands_test.go b/forgectl/cmd/commands_test.go index aecdc91..de19a1f 100644 --- a/forgectl/cmd/commands_test.go +++ b/forgectl/cmd/commands_test.go @@ -195,6 +195,155 @@ max_rounds = 2 } } +// --- reverse engineering init tests --- + +func TestInitReverseEngineering(t *testing.T) { + dir := setupProjectDir(t) + + input := state.ReverseEngineeringInitInput{ + Concept: "auth middleware refactor", + Domains: []string{"optimizer", "api"}, + } + data, _ := json.Marshal(input) + inputFile := filepath.Join(dir, "re-init.json") + os.WriteFile(inputFile, data, 0644) + + initFrom = inputFile + initPhase = "reverse_engineering" + + var buf bytes.Buffer + rootCmd.SetOut(&buf) + + err := runInit(initCmd, nil) + if err != nil { + t.Fatalf("init: %v", err) + } + + sd := resolvedStateDir(dir) + s, err := state.Load(sd) + if err != nil { + t.Fatalf("load: %v", err) + } + + if s.Phase != state.PhaseReverseEngineering { + t.Errorf("phase = %s, want reverse_engineering", s.Phase) + } + if s.State != state.StateOrient { + t.Errorf("state = %s, want ORIENT", s.State) + } + if s.ReverseEngineering == nil { + t.Fatal("reverse_engineering state must not be nil") + } + if s.ReverseEngineering.Concept != "auth middleware refactor" { + t.Errorf("concept = %q, want %q", s.ReverseEngineering.Concept, "auth middleware refactor") + } + if len(s.ReverseEngineering.Domains) != 2 { + t.Errorf("domains len = %d, want 2", len(s.ReverseEngineering.Domains)) + } + if s.ReverseEngineering.TotalDomains != 2 { + t.Errorf("total_domains = %d, want 2", s.ReverseEngineering.TotalDomains) + } + if s.ReverseEngineering.CurrentDomain != 0 { + t.Errorf("current_domain = %d, want 0", s.ReverseEngineering.CurrentDomain) + } + if s.SessionID == "" { + t.Error("session_id must be set") + } +} + +func TestInitReverseEngineeringLocksConfig(t *testing.T) { + dir := setupProjectDir(t) + + // Write config with peer_review mode. + tomlContent := ` +[reverse_engineering] +mode = "peer_review" + +[reverse_engineering.peer_review] +reviewers = 3 +rounds = 1 + +[reverse_engineering.peer_review.subagents] +model = "opus" +type = "explorer" +` + os.WriteFile(filepath.Join(dir, ".forgectl", "config"), []byte(tomlContent), 0644) + + input := state.ReverseEngineeringInitInput{ + Concept: "test", + Domains: []string{"api"}, + } + data, _ := json.Marshal(input) + inputFile := filepath.Join(dir, "re-init.json") + os.WriteFile(inputFile, data, 0644) + + initFrom = inputFile + initPhase = "reverse_engineering" + + var buf bytes.Buffer + rootCmd.SetOut(&buf) + err := runInit(initCmd, nil) + if err != nil { + t.Fatalf("init: %v", err) + } + + sd := resolvedStateDir(dir) + s, err := state.Load(sd) + if err != nil { + t.Fatalf("load: %v", err) + } + + if s.Config.ReverseEngineering.Mode != "peer_review" { + t.Errorf("config.reverse_engineering.mode = %q, want peer_review", s.Config.ReverseEngineering.Mode) + } + // Inactive mode configs must not be present. + if s.Config.ReverseEngineering.SelfRefine != nil { + t.Error("self_refine must be nil when mode=peer_review") + } + if s.Config.ReverseEngineering.MultiPass != nil { + t.Error("multi_pass must be nil when mode=peer_review") + } +} + +func TestInitReverseEngineeringRejectsInvalidInput(t *testing.T) { + dir := setupProjectDir(t) + + // Missing concept. + bad := []byte(`{"domains": ["optimizer"]}`) + inputFile := filepath.Join(dir, "bad.json") + os.WriteFile(inputFile, bad, 0644) + + initFrom = inputFile + initPhase = "reverse_engineering" + + err := runInit(initCmd, nil) + if err == nil { + t.Error("expected error for missing concept") + } + if !strings.Contains(err.Error(), "input validation failed") { + t.Errorf("expected 'input validation failed', got: %v", err) + } +} + +func TestInitReverseEngineeringRejectsDuplicateDomains(t *testing.T) { + dir := setupProjectDir(t) + + bad, _ := json.Marshal(state.ReverseEngineeringInitInput{ + Concept: "test", + Domains: []string{"optimizer", "optimizer"}, + }) + inputFile := filepath.Join(dir, "bad.json") + os.WriteFile(inputFile, bad, 0644) + + initFrom = inputFile + initPhase = "reverse_engineering" + + err := runInit(initCmd, nil) + if err == nil { + t.Error("expected error for duplicate domains") + } +} + // --- add-queue-item tests --- // setupSpecifyingState saves a specifying state at the given state and returns the state dir. @@ -1154,6 +1303,86 @@ func TestValidateTypePlanExplicit(t *testing.T) { } } +// TestAddDomainAddsNewDomain verifies that add-domain appends the domain and increments TotalDomains. +func TestAddDomainAddsNewDomain(t *testing.T) { + dir := setupProjectDir(t) + sd := resolvedStateDir(dir) + os.MkdirAll(sd, 0755) + + s := &state.ForgeState{ + Phase: state.PhaseReverseEngineering, + State: state.StateQueue, + Config: state.DefaultForgeConfig(), + ReverseEngineering: state.NewReverseEngineeringState("test concept", []string{"optimizer", "api"}, false), + } + state.Save(sd, s) + + var buf bytes.Buffer + addDomainCmd.SetOut(&buf) + + if err := runAddDomain(addDomainCmd, []string{"portal"}); err != nil { + t.Fatalf("add-domain: %v", err) + } + + loaded, _ := state.Load(sd) + re := loaded.ReverseEngineering + if re.TotalDomains != 3 { + t.Fatalf("TotalDomains = %d, want 3", re.TotalDomains) + } + if re.Domains[2] != "portal" { + t.Fatalf("Domains[2] = %q, want %q", re.Domains[2], "portal") + } + if !strings.Contains(buf.String(), "portal") { + t.Errorf("expected confirmation output mentioning 'portal', got: %s", buf.String()) + } +} + +// TestAddDomainRejectsDuplicate verifies that add-domain rejects an already-existing domain. +func TestAddDomainRejectsDuplicate(t *testing.T) { + dir := setupProjectDir(t) + sd := resolvedStateDir(dir) + os.MkdirAll(sd, 0755) + + s := &state.ForgeState{ + Phase: state.PhaseReverseEngineering, + State: state.StateQueue, + Config: state.DefaultForgeConfig(), + ReverseEngineering: state.NewReverseEngineeringState("test concept", []string{"optimizer", "api"}, false), + } + state.Save(sd, s) + + err := runAddDomain(addDomainCmd, []string{"api"}) + if err == nil { + t.Fatal("expected error for duplicate domain") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("unexpected error: %v", err) + } +} + +// TestAddDomainBlockedOutsideQueue verifies that add-domain is rejected outside QUEUE state. +func TestAddDomainBlockedOutsideQueue(t *testing.T) { + dir := setupProjectDir(t) + sd := resolvedStateDir(dir) + os.MkdirAll(sd, 0755) + + s := &state.ForgeState{ + Phase: state.PhaseReverseEngineering, + State: state.StateSurvey, + Config: state.DefaultForgeConfig(), + ReverseEngineering: state.NewReverseEngineeringState("test concept", []string{"optimizer"}, false), + } + state.Save(sd, s) + + err := runAddDomain(addDomainCmd, []string{"portal"}) + if err == nil { + t.Fatal("expected error outside QUEUE state") + } + if !strings.Contains(err.Error(), "only available during the QUEUE state") { + t.Errorf("unexpected error: %v", err) + } +} + func TestValidateEmptyObjectFailsAutoDetect(t *testing.T) { dir := t.TempDir() data := []byte(`{}`) diff --git a/forgectl/cmd/eval.go b/forgectl/cmd/eval.go index 8526ac1..6fba16e 100644 --- a/forgectl/cmd/eval.go +++ b/forgectl/cmd/eval.go @@ -34,6 +34,8 @@ func runEval(cmd *cobra.Command, args []string) error { return state.PrintReconcileEvalOutput(cmd.OutOrStdout(), s) case s.Phase == state.PhaseSpecifying && s.State == state.StateCrossReferenceEval: return state.PrintCrossRefEvalOutput(cmd.OutOrStdout(), s) + case s.Phase == state.PhaseReverseEngineering && s.State == state.StateReconcileEval: + return state.PrintReverseEngineeringEvalOutput(cmd.OutOrStdout(), s) case s.Phase == state.PhasePlanning || s.Phase == state.PhaseImplementing: return state.PrintEvalOutput(cmd.OutOrStdout(), s, projectRoot) default: diff --git a/forgectl/cmd/init.go b/forgectl/cmd/init.go index a74581c..a9e0c2b 100644 --- a/forgectl/cmd/init.go +++ b/forgectl/cmd/init.go @@ -35,9 +35,11 @@ func runInit(cmd *cobra.Command, args []string) error { return fmt.Errorf("generate_planning_queue requires a completed specifying phase. Use --phase specifying instead.") } - validPhases := map[string]bool{"specifying": true, "planning": true, "implementing": true} + validPhases := map[string]bool{ + "specifying": true, "planning": true, "implementing": true, "reverse_engineering": true, + } if !validPhases[initPhase] { - return fmt.Errorf("--phase must be specifying, planning, or implementing") + return fmt.Errorf("--phase must be specifying, planning, implementing, or reverse_engineering") } // Discover project root, load and validate config. @@ -170,6 +172,35 @@ func runInit(cmd *cobra.Command, args []string) error { File: initFrom, }, } + + case state.PhaseReverseEngineering: + validationErrs := state.ValidateReverseEngineeringInit(data) + if len(validationErrs) > 0 { + printValidationErrors(out, validationErrs) + fmt.Fprintln(out, "\nExpected schema:") + fmt.Fprintln(out, state.ReverseEngineeringInitSchema()) + return fmt.Errorf("input validation failed") + } + var input state.ReverseEngineeringInitInput + if err := json.Unmarshal(data, &input); err != nil { + return fmt.Errorf("parsing input: %w", err) + } + + // Validate mode is one of the four valid values. + validModes := map[string]bool{ + "single_shot": true, "self_refine": true, "multi_pass": true, "peer_review": true, + } + if !validModes[cfg.ReverseEngineering.Mode] { + return fmt.Errorf("reverse_engineering.mode %q is invalid; must be one of: single_shot, self_refine, multi_pass, peer_review", cfg.ReverseEngineering.Mode) + } + + s.ReverseEngineering = &state.ReverseEngineeringState{ + Concept: input.Concept, + Domains: input.Domains, + CurrentDomain: 0, + TotalDomains: len(input.Domains), + ColleagueReview: cfg.ReverseEngineering.Reconcile.ColleagueReview, + } } if err := os.MkdirAll(stateDir, 0755); err != nil { @@ -211,6 +242,8 @@ func phaseRoundConfig(cfg state.ForgeConfig, phase state.PhaseName) (batchSize, return cfg.Planning.Batch, cfg.Planning.Eval.MinRounds, cfg.Planning.Eval.MaxRounds case state.PhaseImplementing: return cfg.Implementing.Batch, cfg.Implementing.Eval.MinRounds, cfg.Implementing.Eval.MaxRounds + case state.PhaseReverseEngineering: + return 1, cfg.ReverseEngineering.Reconcile.MinRounds, cfg.ReverseEngineering.Reconcile.MaxRounds default: return 0, 0, 0 } diff --git a/forgectl/cmd/validate.go b/forgectl/cmd/validate.go index 3033f52..735a493 100644 --- a/forgectl/cmd/validate.go +++ b/forgectl/cmd/validate.go @@ -25,7 +25,36 @@ func init() { rootCmd.AddCommand(validateCmd) } +// detectFileType inspects parsed JSON to determine file type. For "specs" keys, +// it checks the first entry for an "action" field to disambiguate RE queue from spec queue. +func detectFileType(raw map[string]json.RawMessage) string { + if _, ok := raw["concept"]; ok { + return "re-init" + } + if _, ok := raw["plans"]; ok { + return "plan-queue" + } + if _, ok := raw["context"]; ok { + return "plan" + } + if specsRaw, ok := raw["specs"]; ok { + // Disambiguate: RE queue entries have "action" field; spec queue entries have "planning_sources". + var specs []json.RawMessage + if json.Unmarshal(specsRaw, &specs) == nil && len(specs) > 0 { + var first map[string]json.RawMessage + if json.Unmarshal(specs[0], &first) == nil { + if _, hasAction := first["action"]; hasAction { + return "re-queue" + } + } + } + return "spec-queue" + } + return "" +} + // topKeyType maps a top-level JSON key to a known file type name. +// Used for --type hint when auto-detection fails. func topKeyType(key string) string { switch key { case "specs": @@ -34,6 +63,8 @@ func topKeyType(key string) string { return "plan-queue" case "context": return "plan" + case "concept": + return "re-init" } return "" } @@ -47,6 +78,10 @@ func typeExpectedKey(t string) string { return "plans" case "plan": return "context" + case "re-init": + return "concept" + case "re-queue": + return "specs" } return "" } @@ -108,20 +143,12 @@ func runValidate(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid JSON: %s", jsonErrorWithLocation(data, err)) } - // Find the first known key (in priority order). - detectedKey := "" - for _, k := range []string{"specs", "plans", "context"} { - if _, ok := raw[k]; ok { - detectedKey = k - break - } - } - fileType := validateType if fileType == "" { - // Auto-detect. - if detectedKey == "" { + // Auto-detect using file content. + fileType = detectFileType(raw) + if fileType == "" { // Find any top-level key for the error message. found := "" for k := range raw { @@ -130,26 +157,42 @@ func runValidate(cmd *cobra.Command, args []string) error { } fmt.Fprintf(out, "Error: cannot detect file type.\n") fmt.Fprintf(out, " Expected one of these top-level keys:\n") - fmt.Fprintf(out, " \"specs\" → spec-queue\n") - fmt.Fprintf(out, " \"plans\" → plan-queue\n") - fmt.Fprintf(out, " \"context\" → plan\n") + fmt.Fprintf(out, " \"specs\" → spec-queue (used in specifying phase)\n") + fmt.Fprintf(out, " \"plans\" → plan-queue (used in planning phase)\n") + fmt.Fprintf(out, " \"context\" → plan.json (used in planning/implementing phases)\n") + fmt.Fprintf(out, " \"concept\" → re-init (used in reverse engineering phase)\n") if found != "" { fmt.Fprintf(out, " Found: %q\n", found) } fmt.Fprintf(out, " Hint: use --type to specify the file type explicitly.\n") return fmt.Errorf("cannot detect file type") } - fileType = topKeyType(detectedKey) - fmt.Fprintf(out, "Detected: %s (top-level key: %q)\n\n", fileType, detectedKey) + // Derive the display key for the detected type. + displayKey := typeExpectedKey(fileType) + if fileType == "re-queue" { + fmt.Fprintf(out, "Detected: %s (top-level key: %q with \"action\" field)\n\n", fileType, displayKey) + } else { + fmt.Fprintf(out, "Detected: %s (top-level key: %q)\n\n", fileType, displayKey) + } } else { - // Verify --type matches actual key. + // Verify --type is valid. expected := typeExpectedKey(fileType) if expected == "" { - return fmt.Errorf("--type must be spec-queue, plan-queue, or plan (got %q)", fileType) + return fmt.Errorf("--type must be spec-queue, plan-queue, plan, re-init, or re-queue (got %q)", fileType) } - if detectedKey != expected { - errMsg := fmt.Sprintf("Error: --type %s expects top-level key %q, found %q.", fileType, expected, detectedKey) - hint := didYouMean(detectedKey) + // Find the actual detected type to check for mismatches. + actualType := detectFileType(raw) + if actualType != fileType { + // Determine actual top-level key for the error message. + actualKey := "" + for _, k := range []string{"concept", "specs", "plans", "context"} { + if _, ok := raw[k]; ok { + actualKey = k + break + } + } + errMsg := fmt.Sprintf("Error: --type %s expects top-level key %q, found %q.", fileType, expected, actualKey) + hint := didYouMean(actualKey) if hint != "" { fmt.Fprintf(out, "%s\n%s\n", errMsg, hint) } else { @@ -157,7 +200,7 @@ func runValidate(cmd *cobra.Command, args []string) error { } return fmt.Errorf("type mismatch") } - fmt.Fprintf(out, "Detected: %s (top-level key: %q)\n\n", fileType, detectedKey) + fmt.Fprintf(out, "Detected: %s (top-level key: %q)\n\n", fileType, expected) } // Run validation. @@ -185,6 +228,17 @@ func runValidate(cmd *cobra.Command, args []string) error { case "plan": baseDir := filepath.Dir(filePath) errs = state.ValidatePlanJSON(data, baseDir) + case "re-init": + errs = state.ValidateReverseEngineeringInit(data) + case "re-queue": + // No project root or domain list available outside a session. + errs = state.ValidateReverseEngineeringQueue(data, "", nil) + if len(errs) == 0 { + var input state.ReverseEngineeringQueueInput + json.Unmarshal(data, &input) + entryCount = len(input.Specs) + } + entryLabel = "entries" } filename := filepath.Base(filePath) diff --git a/forgectl/cmd/validate_test.go b/forgectl/cmd/validate_test.go index 93a6753..bbd76ad 100644 --- a/forgectl/cmd/validate_test.go +++ b/forgectl/cmd/validate_test.go @@ -205,6 +205,68 @@ func TestValidateWithValidationErrors(t *testing.T) { } } +func TestValidateAutoDetectsREInit(t *testing.T) { + dir := t.TempDir() + data := []byte(`{"concept": "auth refactor", "domains": ["optimizer", "api"]}`) + p := filepath.Join(dir, "re-init.json") + os.WriteFile(p, data, 0644) + + validateType = "" + var buf bytes.Buffer + validateCmd.SetOut(&buf) + err := runValidate(validateCmd, []string{p}) + if err != nil { + t.Fatalf("expected success, got: %v — output: %s", err, buf.String()) + } + out := buf.String() + if !strings.Contains(out, "re-init") { + t.Errorf("output should mention re-init, got: %q", out) + } +} + +func TestValidateAutoDetectsREQueue(t *testing.T) { + dir := t.TempDir() + data := []byte(`{"specs": [{"name":"Auth Spec","domain":"optimizer","topic":"auth","file":"specs/auth.md","action":"create","code_search_roots":["src/"],"depends_on":[]}]}`) + p := filepath.Join(dir, "re-queue.json") + os.WriteFile(p, data, 0644) + + validateType = "" + var buf bytes.Buffer + validateCmd.SetOut(&buf) + err := runValidate(validateCmd, []string{p}) + if err != nil { + t.Fatalf("expected success, got: %v — output: %s", err, buf.String()) + } + out := buf.String() + if !strings.Contains(out, "re-queue") { + t.Errorf("output should mention re-queue, got: %q", out) + } +} + +func TestValidateSpecQueueNotMistakenForREQueue(t *testing.T) { + dir := t.TempDir() + // Spec queue entries have "planning_sources", not "action". + p := writeSpecQueue(t, dir, []state.SpecQueueEntry{ + {Name: "A", Domain: "d", Topic: "t", File: "a.md", PlanningSources: []string{}, DependsOn: []string{}}, + }) + + validateType = "" + var buf bytes.Buffer + validateCmd.SetOut(&buf) + err := runValidate(validateCmd, []string{p}) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + out := buf.String() + // Should be detected as spec-queue, not re-queue. + if !strings.Contains(out, "spec-queue") { + t.Errorf("output should mention spec-queue, got: %q", out) + } + if strings.Contains(out, "re-queue") { + t.Errorf("should not mention re-queue for spec queue files, got: %q", out) + } +} + func TestValidatePlanResolvesRefsRelativeToFile(t *testing.T) { dir := t.TempDir() // Create a ref file at a known path relative to the plan file. diff --git a/forgectl/evaluators/evaluators.go b/forgectl/evaluators/evaluators.go index 36b7058..0f191ec 100644 --- a/forgectl/evaluators/evaluators.go +++ b/forgectl/evaluators/evaluators.go @@ -26,3 +26,8 @@ var ReconcileEval string // //go:embed cross-reference-eval.md var CrossRefEval string + +// ReverseEngineeringReconcileEval contains the reverse engineering reconciliation evaluation prompt. +// +//go:embed reverse-engineering-reconcile-eval.md +var ReverseEngineeringReconcileEval string diff --git a/forgectl/evaluators/reconcile-eval.md b/forgectl/evaluators/reconcile-eval.md index f2d61af..129538d 100644 --- a/forgectl/evaluators/reconcile-eval.md +++ b/forgectl/evaluators/reconcile-eval.md @@ -1,7 +1,111 @@ # Reconciliation Evaluation Prompt -Evaluates cross-domain consistency during the specifying phase. +> Instructions for the evaluation sub-agent spawned during the reverse engineering RECONCILE_EVAL phase. -Purpose: "Do the specs across different domains agree where they reference each other?" Checks that cross-domain boundaries are consistent — Depends On references, Integration Points symmetry, naming consistency, and no circular dependencies. +--- -TODO: Full evaluation criteria, report format, and verdict rules to be defined. +## Role + +You are an evaluator. You read all spec files produced or affected by reverse engineering for the current domain and assess whether cross-references are consistent and complete. You do NOT modify any spec files — you only write the evaluation report. + +## Inputs + +You will be given: + +1. **Specs created or updated** — listed below with their depends_on references +2. **All spec files** in the domain's `specs/` directory +3. **Any referenced spec files** in other domains (if depends_on crosses domain boundaries) + +Read all spec files before evaluating. + +## Specs to Evaluate + +{{SPEC_LIST}} + +## Evaluation Dimensions + +Check every dimension. Do not limit evaluation to a subset — narrow evaluation gives false confidence. + +| # | Dimension | What to Check | +|---|-----------|---------------| +| 1 | Completeness | Every spec in the list has a file on disk | +| 2 | Depends On validity | Every Depends On reference points to a spec file that exists | +| 3 | Integration Points symmetry | If spec A references spec B in Integration Points, spec B references spec A | +| 4 | Depends On ↔ Integration Points | Every Depends On entry has a corresponding Integration Points row in the referenced spec | +| 5 | Naming consistency | Spec names are consistent across all references — no aliases, abbreviations, or stale names | +| 6 | No circular dependencies | The Depends On graph has no cycles | +| 7 | Topic of concern | Each spec's topic of concern is a single sentence, does not contain "and" conjoining unrelated capabilities, and describes an activity | + +## Procedure + +For each dimension: + +1. Open the relevant spec section(s). +2. Read each reference, dependency, or integration point line by line. +3. Cross-check against the referenced spec file. +4. Record PASS if every requirement is met, FAIL if any are missing. +5. For FAIL: list every specific deficiency (spec name + section + what is wrong). + +## Report Format + +Write the report to: + +``` +{{EVAL_REPORT_PATH}} +``` + +### Report Structure + +```markdown +# Reconciliation Evaluation Report — Round N + +## Verdict: PASS | FAIL + +## Summary +- Dimensions passed: X/7 +- Specs evaluated: N +- Cross-references checked: M +- Deficiencies: K + +## Dimension Results + +### 1. Completeness — PASS | FAIL +[specifics] + +### 2. Depends On validity — PASS | FAIL +[specifics] + +### 3. Integration Points symmetry — PASS | FAIL +[specifics] + +### 4. Depends On ↔ Integration Points — PASS | FAIL +[specifics] + +### 5. Naming consistency — PASS | FAIL +[specifics] + +### 6. No circular dependencies — PASS | FAIL +[specifics] + +### 7. Topic of concern — PASS | FAIL +[specifics] + +## Deficiency List (FAIL only) + +| # | Dimension | Spec | Section | Missing or Incorrect | +|---|-----------|------|---------|---------------------| +| 1 | ... | ... | ... | ... | +``` + +## Verdict Rules + +- **PASS**: ALL 7 dimensions pass. Every cross-reference is valid, symmetric, and consistent. +- **FAIL**: ANY dimension has one or more deficiencies. + +There is no partial pass. A single missing cross-reference or asymmetric integration point means FAIL. + +## Important + +- Do NOT modify any spec files. You are read-only. +- Do NOT skip dimensions. +- Be specific in deficiency descriptions. Name the exact spec, the exact section, and the exact reference that is wrong or missing. diff --git a/forgectl/evaluators/reverse-engineering-reconcile-eval.md b/forgectl/evaluators/reverse-engineering-reconcile-eval.md new file mode 100644 index 0000000..cf253c0 --- /dev/null +++ b/forgectl/evaluators/reverse-engineering-reconcile-eval.md @@ -0,0 +1,111 @@ +# Reconciliation Evaluation Prompt — Reverse Engineering + +> Instructions for the evaluation sub-agent spawned during the reverse engineering RECONCILE_EVAL phase. + +--- + +## Role + +You are an evaluator. You read all spec files produced or affected by reverse engineering for the current domain and assess whether cross-references are consistent and complete. You do NOT modify any spec files — you only write the evaluation report. + +## Inputs + +You will be given: + +1. **Specs created or updated** — listed below with their depends_on references +2. **All spec files** in the domain's `specs/` directory +3. **Any referenced spec files** in other domains (if depends_on crosses domain boundaries) + +Read all spec files before evaluating. + +## Specs to Evaluate + +{{SPEC_LIST}} + +## Evaluation Dimensions + +Check every dimension. Do not limit evaluation to a subset — narrow evaluation gives false confidence. + +| # | Dimension | What to Check | +|---|-----------|---------------| +| 1 | Completeness | Every spec in the list has a file on disk | +| 2 | Depends On validity | Every Depends On reference points to a spec file that exists | +| 3 | Integration Points symmetry | If spec A references spec B in Integration Points, spec B references spec A | +| 4 | Depends On ↔ Integration Points | Every Depends On entry has a corresponding Integration Points row in the referenced spec | +| 5 | Naming consistency | Spec names are consistent across all references — no aliases, abbreviations, or stale names | +| 6 | No circular dependencies | The Depends On graph has no cycles | +| 7 | Topic of concern | Each spec's topic of concern is a single sentence, does not contain "and" conjoining unrelated capabilities, and describes an activity | + +## Procedure + +For each dimension: + +1. Open the relevant spec section(s). +2. Read each reference, dependency, or integration point line by line. +3. Cross-check against the referenced spec file. +4. Record PASS if every requirement is met, FAIL if any are missing. +5. For FAIL: list every specific deficiency (spec name + section + what is wrong). + +## Report Format + +Write the report to: + +``` +{{EVAL_REPORT_PATH}} +``` + +### Report Structure + +```markdown +# Reconciliation Evaluation Report — Round N + +## Verdict: PASS | FAIL + +## Summary +- Dimensions passed: X/7 +- Specs evaluated: N +- Cross-references checked: M +- Deficiencies: K + +## Dimension Results + +### 1. Completeness — PASS | FAIL +[specifics] + +### 2. Depends On validity — PASS | FAIL +[specifics] + +### 3. Integration Points symmetry — PASS | FAIL +[specifics] + +### 4. Depends On ↔ Integration Points — PASS | FAIL +[specifics] + +### 5. Naming consistency — PASS | FAIL +[specifics] + +### 6. No circular dependencies — PASS | FAIL +[specifics] + +### 7. Topic of concern — PASS | FAIL +[specifics] + +## Deficiency List (FAIL only) + +| # | Dimension | Spec | Section | Missing or Incorrect | +|---|-----------|------|---------|---------------------| +| 1 | ... | ... | ... | ... | +``` + +## Verdict Rules + +- **PASS**: ALL 7 dimensions pass. Every cross-reference is valid, symmetric, and consistent. +- **FAIL**: ANY dimension has one or more deficiencies. + +There is no partial pass. A single missing cross-reference or asymmetric integration point means FAIL. + +## Important + +- Do NOT modify any spec files. You are read-only. +- Do NOT skip dimensions. +- Be specific in deficiency descriptions. Name the exact spec, the exact section, and the exact reference that is wrong or missing. diff --git a/forgectl/prompts/peer-review-prompt.md b/forgectl/prompts/peer-review-prompt.md new file mode 100644 index 0000000..9077b3b --- /dev/null +++ b/forgectl/prompts/peer-review-prompt.md @@ -0,0 +1,43 @@ +# Peer Review Prompt + +## Purpose +Sent as a follow-up to the primary agent after the initial draft. Instructs the agent to spawn reviewer sub-agents in parallel to evaluate the spec and then synthesize their feedback into the spec file. + +## Used By +EXECUTE state — primary agent follow-up, when `peer_review` mode is enabled. Sent `peer_review.rounds` times after the initial draft. + +## Interpolation Fields +- `{peer_review.reviewers}` — number of reviewer sub-agents to spawn +- `{peer_review.subagents.model}` — model for reviewer sub-agents +- `{peer_review.subagents.type}` — type for reviewer sub-agents +- `{file}` — the spec file path to review +- `{code_search_roots}` — directories to verify spec accuracy against + +## Prompt + +TODO: Write the full peer review prompt content. The prompt must cover: + +- You have drafted a specification at `{file}` +- Spawn `{peer_review.reviewers}` `{peer_review.subagents.model}` `{peer_review.subagents.type}` sub-agents in parallel to review your work +- Each sub-agent receives: + - The spec file to review: `{file}` + - The source code to verify against: `{code_search_roots}` + - The spec format reference (included below) +- Each reviewer evaluates the spec against: + - Topic of concern: single sentence, no "and", describes an activity + - Declarative voice: no "should", "could", "might" + - Every behavior has testing criteria (Given/When/Then) + - Error handling is exhaustive: every failure mode named + - Edge cases have scenario, expected behavior, and rationale + - Invariants are always-true, testable properties + - Observability: INFO for success, ERROR for failures, DEBUG for diagnostics + - No open questions or TBDs + - Code accuracy: does the spec match what the code actually does? +- Each reviewer reports back: + - Issues found (with specific section references) + - Missing behaviors found in code but not in spec + - Suggested corrections +- After all reviewers report back, synthesize their feedback and update the spec file at `{file}` +- Resolve conflicting feedback using your judgment +- Do not add reviewer notes to the spec — incorporate the fixes directly +- The spec format reference is appended below for reviewer context diff --git a/forgectl/prompts/reverse-engineering-prompt.md b/forgectl/prompts/reverse-engineering-prompt.md new file mode 100644 index 0000000..303a385 --- /dev/null +++ b/forgectl/prompts/reverse-engineering-prompt.md @@ -0,0 +1,35 @@ +# Reverse Engineering Prompt + +## Purpose +Instructs the primary Claude Agent SDK session to read code within its assigned code search roots and produce or update a single spec file for the given topic of concern. + +## Used By +EXECUTE state — primary agent. Concatenated with `spec-format-reference.md` to form the complete agent prompt. + +## Interpolation Fields +- `{name}` — display name of the spec +- `{topic}` — one-sentence topic of concern +- `{file}` — target spec file path (relative to domain root) +- `{action}` — "create" or "update" +- `{code_search_roots}` — directories to examine (relative to domain root) +- `{existing_spec_content}` — current spec content (populated for updates, empty for creates) +- `{subagent_type}` — role for sub-agents (e.g., "explorer") +- `{subagent_model}` — model for sub-agents +- `{subagent_count}` — number of sub-agents to use + +## Prompt + +TODO: Write the full prompt content. The prompt must cover: + +- The agent's role: reverse-engineer a specification from existing code +- The assigned topic of concern: `{topic}` +- The target output file: `{file}` +- Whether this is a create or update: `{action}` +- Where to look in the codebase: `{code_search_roots}` +- For updates: the existing spec content to revise: `{existing_spec_content}` +- Constraint: write or edit only the single file at `{file}` — no other files +- Constraint: read-only codebase — do not modify source code +- Constraint: capture what the code *does*, not what it *should* do +- Constraint: the Implements section references the reverse-engineered topic, not a planning document +- Sub-agent usage: spawn `{subagent_count}` `{subagent_type}` sub-agents at `{subagent_model}` to assist with code exploration +- The spec format is provided in a separate concatenated file — follow it exactly diff --git a/forgectl/prompts/review-work-prompt.md b/forgectl/prompts/review-work-prompt.md new file mode 100644 index 0000000..4fdb392 --- /dev/null +++ b/forgectl/prompts/review-work-prompt.md @@ -0,0 +1,34 @@ +# Review Work Prompt + +## Purpose +Sent as a follow-up to the primary agent after the initial draft. Instructs the agent to re-read its spec file and critique it against the spec format, topic-of-concern rules, completeness, and common evaluation findings. The agent refines the spec in place. + +## Used By +EXECUTE state — primary agent follow-up, when `review_work` is enabled. Sent `review_work.number_of_times` times after the initial draft. + +## Interpolation Fields +None. This prompt is sent as-is after the initial drafting prompt completes. + +## Prompt + +TODO: Write the full review prompt content. The prompt must cover: + +- Re-read the spec file you just wrote or updated +- Critique the spec against these checklists: + - Topic of concern: single sentence, no "and", describes an activity + - Declarative voice throughout: no "should", "could", "might" + - Every behavior has testing criteria (Given/When/Then) + - Error handling is exhaustive: every failure mode named with a specific response + - Edge cases capture judgment calls with scenario, expected behavior, rationale + - Invariants are always-true properties, not postconditions — each has a Given/When/Then test + - No references to planning file paths + - No open questions or TBDs + - Observability section present: INFO for success, ERROR for failures, DEBUG for diagnostics +- Check for common evaluation findings: + - Phantom observability entries: log entry references a behavior not defined in any Behavior section + - Unverifiable invariants: invariant describes intent rather than a testable property + - Untested invariants: every invariant needs a Given/When/Then test + - Internal architecture as invariants: don't prescribe concurrency or data structures — reformulate as externally observable properties + - Silent omissions: every identified behavior must be covered, explicitly excluded, or marked out of scope +- Fix any issues found by editing the spec file in place +- If no issues found, confirm the spec passes review diff --git a/forgectl/prompts/spec-format-reference.md b/forgectl/prompts/spec-format-reference.md new file mode 100644 index 0000000..9fac553 --- /dev/null +++ b/forgectl/prompts/spec-format-reference.md @@ -0,0 +1,48 @@ +# Spec Format Reference + +## Purpose +Provides the full specification format structure so the agent knows exactly how to structure its output. Sections, ordering, principles, topic-of-concern rules, and common evaluation findings to avoid. + +## Used By +EXECUTE state — primary agent. Concatenated with `reverse-engineering-prompt.md` to form the complete agent prompt. + +## Interpolation Fields +None. This is static content. + +## Content + +TODO: Write the full spec format reference. The content must cover: + +- What a spec is: a permanent, authoritative contract for a single topic of concern +- What a spec is not: not a plan, not code documentation, not a tutorial +- The complete spec structure in order: + - Title (activity-oriented) + - Topic of Concern (one sentence, no "and", describes an activity) + - Context (why the spec exists) + - Depends On (upstream spec dependencies) + - Integration Points (relationships with other specs) + - Interface (inputs, outputs, rejection) + - Behavior (preconditions, steps, postconditions, error handling) + - Configuration (parameters, types, defaults) + - Observability (logging levels, metrics) + - Invariants (always-true properties) + - Edge Cases (scenario, expected behavior, rationale) + - Testing Criteria (Given/When/Then) + - Implements (what this spec covers) +- Principles: + - One topic of concern per spec + - No codebase references (file paths, module names) + - Declarative voice ("the system does", not "the system should") + - No open questions or TBDs + - Technology-aware, not technology-coupled + - Error handling is exhaustive + - Invariants are always true, not postconditions + - Every behavior has testing criteria + - Edge cases capture judgment calls +- Common evaluation findings to avoid: + - Phantom observability entries (log entry with no corresponding behavior) + - Unverifiable invariants (intent, not testable property) + - Missing observability section + - Untested invariants + - Internal architecture prescribed as invariants + - Silent omissions (behavior not covered, excluded, or marked out of scope) diff --git a/forgectl/specs/activity-logging.md b/forgectl/specs/activity-logging.md index 4a03fad..ff342a7 100644 --- a/forgectl/specs/activity-logging.md +++ b/forgectl/specs/activity-logging.md @@ -25,6 +25,7 @@ Log files accumulate over time. Pruning runs at `init` to clean up old files bas | phase-transitions | `advance` in generate_planning_queue phase produces log entries with state context | | plan-production | `advance` in planning phase produces log entries with plan/round context | | batch-implementation | `advance` in implementing phase produces log entries with item/layer context | +| reverse-engineering | `advance` in reverse_engineering phase produces log entries with domain/state context | --- diff --git a/forgectl/specs/agent-construction.md b/forgectl/specs/agent-construction.md new file mode 100644 index 0000000..8e2f212 --- /dev/null +++ b/forgectl/specs/agent-construction.md @@ -0,0 +1,490 @@ +# Agent Construction + +## Topic of Concern +> The Python package constructs and configures Claude Agent SDK sessions from an execution file, using bundled prompts and execution file configuration. + +## Context + +The EXECUTE state of the reverse engineering workflow delegates spec drafting to parallel Claude Agent SDK sessions. Each session is a Claude Code agent with specific tool permissions, a working directory scoped to a domain root, and a prompt assembled from bundled template files. The agent construction system is responsible for building these configured sessions from execution file entries and running them according to the configured execution mode. + +The system uses a factory pattern. Certain values are constants owned by the Python package and never supplied externally: tool list, permission mode, CLAUDE.md reading, and prompt files. The factory takes configurable values from the execution file (model, sub-agent config, execution mode) and a queue entry, assembles the prompt from bundled templates, and produces a fully configured `ClaudeAgentOptions`. + +The factory does not manage the state machine or communicate with forgectl. It reads `execute.json`, runs agents, and writes results back to `execute.json`. + +**Prompt ownership is split across the system:** +- Forgectl embeds its own prompts (evaluators, etc.) via `go:embed` in the Go binary. +- The reverse engineering prompts are owned and bundled by the Python package. The Python script resolves them from its own package resources. Forgectl does not know or supply prompt file paths. + +**Scope exclusions:** +- The reverse engineering workflow states other than EXECUTE (covered by the reverse-engineering spec). +- Forgectl's own embedded prompts (separate concern). +- The content of prompt templates (authored separately, bundled with the package). + +## Depends On +None. This spec defines a standalone construction utility consumed by the reverse engineering workflow. + +## Integration Points + +| Spec | Relationship | +|------|-------------| +| reverse-engineering | EXECUTE generates `execute.json`; this package reads it, builds agent sessions, runs them, and writes results back | + +--- + +## Interface + +### Inputs + +#### Execution File (`execute.json`) + +Generated by forgectl at EXECUTE start. Contains runtime-specific data that the Python package cannot know on its own. Only the active execution mode's config block is present. + +Note: This example shows `self_refine` mode. The `self_refine` block appears only because it is the active mode. For other modes, a different mode-specific block appears in its place (e.g., `multi_pass`, `peer_review`), or none at all (`single_shot`). See the reverse-engineering spec for per-mode examples. + +```json +{ + "project_root": "/project/", + "config": { + "mode": "self_refine", + "drafter": { + "model": "opus", + "subagents": { + "model": "opus", + "type": "explorer", + "count": 3 + } + }, + "self_refine": { + "rounds": 2 + } + }, + "specs": [ + { + "name": "Auth Middleware Validation", + "domain": "optimizer", + "topic": "The optimizer validates authentication tokens before processing requests", + "file": "specs/auth-middleware-validation.md", + "action": "create", + "code_search_roots": ["src/middleware/", "src/auth/"], + "depends_on": [], + "result": null + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `project_root` | string | yes | Absolute path to the project root. Used to resolve domain roots. | +| `config.mode` | string | yes | One of: `single_shot`, `self_refine`, `multi_pass`, `peer_review` | +| `config.drafter.model` | string | yes | Anthropic model ID for the primary agent | +| `config.drafter.subagents.model` | string | yes | Anthropic model ID for code exploration sub-agents | +| `config.drafter.subagents.type` | string | yes | Role for code exploration sub-agents | +| `config.drafter.subagents.count` | integer | yes | Number of code exploration sub-agents. Minimum: 1. | +| `config.self_refine.rounds` | integer | when mode is `self_refine` | Number of self-review rounds. Minimum: 1. | +| `config.multi_pass.passes` | integer | when mode is `multi_pass` | Number of full batch re-runs. Minimum: 1. | +| `config.peer_review.reviewers` | integer | when mode is `peer_review` | Number of reviewer sub-agents spawned in parallel. Minimum: 1. | +| `config.peer_review.rounds` | integer | when mode is `peer_review` | Number of peer review cycles. Minimum: 1. | +| `config.peer_review.subagents.model` | string | when mode is `peer_review` | Anthropic model ID for reviewer sub-agents | +| `config.peer_review.subagents.type` | string | when mode is `peer_review` | Role for reviewer sub-agents | +| `specs` | array | yes | Entries from the queue file, each with a `result: null` field | +| `specs[].result` | object or null | yes | Initially null. Written by the Python package on completion. | + +### Outputs + +#### Updated Execution File + +The Python package writes results back into `execute.json` by updating the `result` field of each spec entry. + +Success: +```json +{ + "result": { + "status": "success", + "iterations_completed": 2 + } +} +``` + +Failure: +```json +{ + "result": { + "status": "failure", + "error": "Agent session timed out after 300s" + } +} +``` + +### Rejection + +| Condition | Signal | Rationale | +|-----------|--------|-----------| +| Execution file not found | Error with path. | Cannot operate without input. | +| Execution file fails schema validation | Error listing violations. | Incomplete execution file cannot produce valid agent configurations. | +| Domain root does not exist | Error: "Domain root not found: `{path}`." | CWD must exist before agent construction. | +| `action: "update"` but file does not exist | Error identifying the entry. | An update requires an existing file. | +| `drafter.subagents.count` < 1 | Error. | Minimum sub-agent count is 1. | +| `peer_review.reviewers` < 1 | Error. | At least one reviewer required. | +| Bundled prompt file missing from package | Error identifying the missing resource. | Package installation is corrupt. | +| Unknown `mode` value | Error listing valid modes. | Invalid execution mode. | + +--- + +## Behavior + +### Building an Agent Session + +#### Preconditions +- The execution file exists and passes schema validation. +- The domain root (`//`) exists. +- Bundled prompt files are resolvable from the Python package resources. + +#### Steps +1. Read and validate the execution file. +2. Resolve bundled prompt files from the Python package resources (not from external paths). +3. For each spec entry: + a. Resolve the domain root: `//`. + b. Read each bundled prompt file in order and concatenate their contents. + c. Interpolate entry fields into the concatenated prompt: + - `{topic}` — the topic of concern. + - `{file}` — the target spec file path. + - `{action}` — `"create"` or `"update"`. + - `{code_search_roots}` — the list of directories to examine. + - `{name}` — the display name. + d. For `action: "update"`: read the existing spec file at `/` and append its content to the prompt as context. + e. Embed the drafter sub-agent configuration into the prompt (model, type, count from `config.drafter.subagents`). + f. Construct `ClaudeAgentOptions`: + - `model`: from `config.drafter.model`. + - `cwd`: domain root. + - `allowed_tools`: from package constants. + - `permission_mode`: from package constants. + - `read_claude_md`: from package constants (`false`). + - `mcp_servers`: none. +4. Return the configured `ClaudeAgentOptions` and the assembled prompt. + +#### Postconditions +- A `ClaudeAgentOptions` instance is returned with all fields populated. +- The prompt contains the concatenated bundled template content with all entry fields interpolated. +- For updates, the existing spec content is included in the prompt. +- CWD is the domain root. +- CLAUDE.md reading is disabled. + +#### Error Handling +- Bundled prompt file not resolvable (corrupt installation): error identifying the missing resource. +- Existing spec file missing for `action: "update"`: error. Construction halts for this entry. +- Interpolation field missing from template (template references `{field}` not in the entry): error identifying the unresolvable field. + +--- + +### Executing Agent Sessions + +#### Preconditions +- All agent sessions have been constructed. +- Execution mode is determined from `config.mode`. + +#### Steps — `single_shot` + +``` +for each agent session (in parallel via asyncio.gather): + 1. Send initial prompt → agent drafts/updates spec file + 2. Write result to entry +``` + +#### Steps — `self_refine` + +``` +for each agent session (in parallel via asyncio.gather): + 1. Send initial prompt → agent drafts/updates spec file + 2. for round in range(config.self_refine.rounds): + Send bundled review-work-prompt.md as follow-up + (includes "This is review round {n} of {total}") + → agent re-reads and refines spec + 3. Write result to entry +``` + +All agents run in parallel. Each agent gets its initial prompt, then N review follow-ups sequentially within its own session. + +#### Steps — `multi_pass` + +``` +for pass in range(config.multi_pass.passes): + if pass > 0: + override all "create" actions to "update" + rebuild agent sessions with updated action + for each agent session (in parallel via asyncio.gather): + Send initial prompt → agent drafts/updates spec file + Write results for this pass +``` + +The entire batch reruns. After pass 1, all creates become updates. Agent sessions are reconstructed each pass because the action and existing file content change. Final results reflect the last pass. + +#### Steps — `peer_review` + +``` +for each agent session (in parallel via asyncio.gather): + 1. Send initial prompt → agent drafts/updates spec file + 2. for round in range(config.peer_review.rounds): + Send bundled peer-review-prompt.md as follow-up: + "Spawn {peer_review.reviewers} {peer_review.subagents.model} + {peer_review.subagents.type} sub-agents in parallel + to review your work." + → agent spawns reviewer sub-agents via Agent tool + → reviewers evaluate spec in parallel, report back + → agent synthesizes feedback and updates spec + 3. Write result to entry +``` + +All drafter agents run in parallel. Each drafter spawns its reviewer sub-agents in parallel within its session. Multiple rounds repeat the review cycle. + +#### Result Writing + +After all sessions complete (or fail), write the updated execution file with results for every entry. + +#### Postconditions +- Every spec entry in the execution file has a non-null `result`. +- The execution file is written to disk with all results. + +#### Error Handling +- Agent session fails (timeout, API error): write `status: "failure"` with error message for that entry. Other sessions continue. +- All sessions fail: all entries have `status: "failure"`. Execution file is still written. +- Execution file write failure: error. Results are lost. Log the error. + +--- + +## Configuration + +### Package Constants + +These are hardcoded in the Python package. They are never supplied by forgectl or the execution file. + +| Constant | Value | Rationale | +|----------|-------|-----------| +| `allowed_tools` | `["Read", "Glob", "Grep", "Edit", "Write", "Agent"]` | The reverse engineering agent always uses this tool set | +| `permission_mode` | `"acceptEdits"` | Always auto-accept file edits to the designated spec file | +| `read_claude_md` | `false` | Agents do not read project CLAUDE.md files | + +### Bundled Prompt Files + +Shipped with the Python package, resolved from package resources. CWD-independent. + +| File | Used By | Purpose | +|------|---------|---------| +| `reverse-engineering-prompt.md` | All modes — initial draft | Instructs agent to read code and produce/update a spec file. Contains interpolation placeholders. | +| `spec-format-reference.md` | All modes — concatenated with initial draft | Static spec format structure, principles, anti-patterns. | +| `review-work-prompt.md` | `self_refine` mode — follow-up | Instructs agent to self-critique and refine its spec. Sent N times. | +| `peer-review-prompt.md` | `peer_review` mode — follow-up | Instructs agent to spawn reviewer sub-agents in parallel. Contains interpolation placeholders for reviewer config. | + +### Configurable Values (from execution file) + +| Parameter | Source | Default | Description | +|-----------|--------|---------|-------------| +| `drafter.model` | `config.drafter.model` | `opus` | Model for the primary agent | +| `drafter.subagents.model` | `config.drafter.subagents.model` | `opus` | Model for code exploration sub-agents | +| `drafter.subagents.type` | `config.drafter.subagents.type` | `explorer` | Code exploration sub-agent role | +| `drafter.subagents.count` | `config.drafter.subagents.count` | `3` | Number of code exploration sub-agents. Minimum: 1. | +| `self_refine.rounds` | `config.self_refine.rounds` | `2` | Self-review rounds (self_refine mode only) | +| `multi_pass.passes` | `config.multi_pass.passes` | `2` | Full batch re-runs (multi_pass mode only) | +| `peer_review.reviewers` | `config.peer_review.reviewers` | `3` | Reviewer sub-agents per drafter (peer_review mode only) | +| `peer_review.rounds` | `config.peer_review.rounds` | `1` | Peer review cycles (peer_review mode only) | +| `peer_review.subagents.model` | `config.peer_review.subagents.model` | `opus` | Model for reviewer sub-agents | +| `peer_review.subagents.type` | `config.peer_review.subagents.type` | `explorer` | Reviewer sub-agent role | + +--- + +## Observability + +### Logging + +| Level | What is logged | +|-------|---------------| +| INFO | Execution file loaded (spec count, mode); agent session constructed for spec `{name}`; prompt assembled from bundled templates; agent completed (status); review round completed (self_refine); pass completed (multi_pass); peer review round completed; all sessions complete; execution file written | +| WARN | Existing spec content is empty for an update action (file exists but is blank) | +| ERROR | Execution file not found; schema validation failure; bundled prompt not resolvable; domain root not found; existing spec file missing for update; agent session failure (with error); execution file write failure | +| DEBUG | Resolved domain root path; package constants applied; interpolation field values; prompt character count; mode parameters; pass/round/reviewer counts | + +--- + +## Invariants + +1. **CLAUDE.md disabled.** No agent session constructed by the factory reads CLAUDE.md files. `read_claude_md` is always `false`. +2. **Domain-root CWD.** Every agent session's CWD is `//`. No agent operates from the project root or any other directory. +3. **Single writable file.** The agent is configured to write only to the `file` specified in the entry. The `allowed_tools` list enables `Edit` and `Write`, but the prompt constrains the agent to a single output file. +4. **Prompt determinism.** Given the same execution file and bundled prompt files, the factory produces identical `ClaudeAgentOptions` and prompt text every time. +5. **Prompts are package-owned.** Prompt files are resolved from Python package resources, never from external file paths. The factory does not accept prompt paths as input. +6. **CWD-independent prompt resolution.** Bundled prompts resolve correctly regardless of the working directory from which the Python script is invoked. +7. **Results written to execution file.** Every spec entry has a non-null `result` after execution completes, regardless of success or failure. +8. **Independent sub-agent configs.** Code exploration sub-agents (`drafter.subagents`) and peer review sub-agents (`peer_review.subagents`) are configured independently. They serve different purposes and may use different models. +9. **Mode isolation.** Only the active mode's execution logic runs. No mode logic from inactive modes is executed. + +--- + +## Edge Cases + +- **Scenario:** Entry has `action: "update"` but the file at `file` does not exist. + - **Expected behavior:** Error. The factory rejects the entry. An update requires an existing file. + - **Rationale:** Creating a file when `action` says update indicates a queue error. Fail fast rather than silently create. + +- **Scenario:** Multiple entries share the same domain. + - **Expected behavior:** Each gets its own agent session, all with the same CWD. They run in parallel without conflict because each writes to a different file. + - **Rationale:** Domain scoping is per-agent, not per-domain. Multiple agents can share a domain root since their write targets are distinct. + +- **Scenario:** Bundled prompt file is missing from the installed package. + - **Expected behavior:** Error identifying the missing resource. No sessions are constructed. + - **Rationale:** This indicates a corrupt installation. Fail immediately rather than producing agents with no instructions. + +- **Scenario:** Python script is invoked from a directory unrelated to the project. + - **Expected behavior:** All paths resolve correctly. `project_root` from the execution file is absolute. Domain roots are resolved from `project_root`. Bundled prompts resolve from package resources. + - **Rationale:** The script's CWD is irrelevant — all path resolution uses absolute paths or package resources. + +- **Scenario:** `multi_pass` with `passes: 1`. + - **Expected behavior:** The batch runs once. No action override occurs (only passes 2+ flip `create` to `update`). + - **Rationale:** A single pass is equivalent to `single_shot` with the multi_pass machinery. + +- **Scenario:** `peer_review` with `rounds: 2`. + - **Expected behavior:** After initial draft, the peer review follow-up prompt is sent twice. Each round's reviewers evaluate the current state of the spec (including edits from the previous round). + - **Rationale:** Multiple rounds allow reviewer feedback to compound. + +- **Scenario:** Agent session fails partway through `self_refine` rounds. + - **Expected behavior:** The entry's result records `status: "failure"` with the error. Other agents continue their review rounds. + - **Rationale:** One agent's failure does not affect others. + +- **Scenario:** `peer_review` reviewer sub-agent fails. + - **Expected behavior:** The drafter agent handles the sub-agent failure within its session. If the drafter can still synthesize feedback from the remaining reviewers, it does. If the drafter itself fails, the entry records `status: "failure"`. + - **Rationale:** Sub-agent failures are contained within the drafter's session. + +--- + +## Testing Criteria + +### Factory produces valid ClaudeAgentOptions +- **Verifies:** Core construction behavior. +- **Given:** A valid execution file with one spec entry, `action: "create"`, `mode: "single_shot"`. +- **When:** The factory builds the agent session. +- **Then:** `ClaudeAgentOptions` has CWD at domain root, `read_claude_md: false`, tools matching package constants, model matching `config.drafter.model`. + +### CWD resolves to domain root +- **Verifies:** Domain-root CWD invariant. +- **Given:** Entry with `domain: "optimizer"`, `project_root: "/project/"`. +- **When:** The factory builds the agent session. +- **Then:** CWD is `/project/optimizer/`. + +### Prompt is assembled from bundled files +- **Verifies:** Bundled prompt resolution and concatenation. +- **Given:** Package contains `reverse-engineering-prompt.md` and `spec-format-reference.md`. +- **When:** The factory builds the agent session. +- **Then:** The prompt contains the content of both files, in order. + +### Entry fields are interpolated into prompt +- **Verifies:** Interpolation behavior. +- **Given:** Entry with `topic: "The optimizer validates repository URLs"`. +- **When:** The factory builds the agent session. +- **Then:** The prompt contains the literal string "The optimizer validates repository URLs". + +### Update action includes existing spec content +- **Verifies:** Existing content loading for updates. +- **Given:** Entry with `action: "update"`, `file: "specs/repo-loading.md"`, and that file exists with content. +- **When:** The factory builds the agent session. +- **Then:** The prompt includes the existing spec content. + +### Update action rejects missing file +- **Verifies:** Error handling for missing update target. +- **Given:** Entry with `action: "update"`, `file: "specs/nonexistent.md"`, and no file at that path. +- **When:** The factory builds the agent session. +- **Then:** Error reported. Construction halts for this entry. + +### Drafter sub-agent config is embedded in prompt +- **Verifies:** Code exploration sub-agent config propagation. +- **Given:** `drafter.subagents`: model `opus`, type `explorer`, count `3`. +- **When:** The factory builds the agent session. +- **Then:** The prompt instructs the agent to spawn 3 opus explorer sub-agents for code exploration. + +### Peer review sub-agent config is embedded in follow-up prompt +- **Verifies:** Peer review sub-agent config propagation (independent from drafter sub-agents). +- **Given:** `peer_review.subagents`: model `haiku`, type `explorer`. `peer_review.reviewers: 2`. +- **When:** The peer review follow-up prompt is assembled. +- **Then:** The prompt instructs the agent to spawn 2 haiku explorer sub-agents for review. + +### Drafter and peer review sub-agents are independently configured +- **Verifies:** Independent sub-agent configs invariant. +- **Given:** `drafter.subagents.model: "opus"`, `peer_review.subagents.model: "haiku"`. +- **When:** Both prompts are assembled. +- **Then:** Initial prompt references opus for exploration. Follow-up prompt references haiku for review. + +### Package constants are applied +- **Verifies:** Constant isolation. +- **Given:** An execution file. +- **When:** The factory builds the agent session. +- **Then:** `allowed_tools` is `["Read", "Glob", "Grep", "Edit", "Write", "Agent"]`. `permission_mode` is `"acceptEdits"`. `read_claude_md` is `false`. + +### Prompt resolution is CWD-independent +- **Verifies:** CWD-independent prompt resolution invariant. +- **Given:** The Python script is invoked from `/tmp/`. +- **When:** The factory resolves bundled prompt files. +- **Then:** Prompts resolve correctly from package resources. + +### Corrupt installation detected +- **Verifies:** Missing bundled prompt handling. +- **Given:** The installed package is missing `reverse-engineering-prompt.md`. +- **When:** The factory attempts to resolve bundled prompts. +- **Then:** Error identifying the missing resource. No sessions are constructed. + +### single_shot runs once with no follow-up +- **Verifies:** single_shot mode. +- **Given:** `mode: "single_shot"`, one spec entry. +- **When:** Execution completes. +- **Then:** Agent received one prompt. No review or follow-up prompts sent. Result written. + +### self_refine sends review prompts with round awareness +- **Verifies:** self_refine mode with round tracking. +- **Given:** `mode: "self_refine"`, `rounds: 2`. +- **When:** An agent completes its initial draft. +- **Then:** The bundled review work prompt is sent 2 times. Each includes "review round {n} of 2". + +### multi_pass flips action after first pass +- **Verifies:** Action override on subsequent passes. +- **Given:** `mode: "multi_pass"`, `passes: 2`. Entry with `action: "create"`. +- **When:** Pass 2 begins. +- **Then:** The entry's action is overridden to `"update"` for session construction. + +### multi_pass reconstructs sessions each pass +- **Verifies:** Fresh sessions per pass. +- **Given:** `mode: "multi_pass"`, `passes: 2`. +- **When:** Pass 2 begins. +- **Then:** New `ClaudeAgentOptions` are constructed. The prompt for pass 2 includes the existing spec content (since action is now `update`). + +### peer_review spawns reviewers in parallel +- **Verifies:** peer_review mode parallel sub-agents. +- **Given:** `mode: "peer_review"`, `reviewers: 3`, `rounds: 1`. +- **When:** The peer review follow-up prompt is sent. +- **Then:** The prompt instructs the agent to spawn 3 sub-agents in parallel. + +### peer_review multiple rounds +- **Verifies:** peer_review round iteration. +- **Given:** `mode: "peer_review"`, `reviewers: 2`, `rounds: 2`. +- **When:** Execution completes. +- **Then:** The peer review follow-up prompt was sent 2 times. Each round's reviewers evaluate the current state of the spec. + +### Execution file results are written for all entries +- **Verifies:** Result writing behavior. +- **Given:** An execution file with 3 spec entries. +- **When:** All sessions complete (2 success, 1 failure). +- **Then:** Execution file has non-null `result` for all 3 entries. + +### Prompt determinism +- **Verifies:** Determinism invariant. +- **Given:** The same execution file. +- **When:** The factory builds the agent session twice. +- **Then:** Both invocations produce identical `ClaudeAgentOptions` and prompt text. + +--- + +## Implements +- Factory pattern for constructing Claude Agent SDK sessions from execution file entries +- Package-owned constants: tools, permission mode, CLAUDE.md, prompt files +- Configurable values from execution file: drafter model, drafter sub-agents, mode-specific settings, peer review sub-agents +- Independent sub-agent configs: drafter exploration vs. peer review (different purposes, different models allowed) +- Bundled prompt assembly with field interpolation, CWD-independent resolution +- Four execution modes: single_shot, self_refine (with round awareness), multi_pass (with action override), peer_review (with parallel reviewer sub-agents and multiple rounds) +- Result writing back to execution file +- execute.json schema: input from forgectl (active mode config only), output results from Python package diff --git a/forgectl/specs/reverse-engineering.md b/forgectl/specs/reverse-engineering.md new file mode 100644 index 0000000..4b796d6 --- /dev/null +++ b/forgectl/specs/reverse-engineering.md @@ -0,0 +1,1276 @@ +# Reverse Engineering Specifications from Code + +## Topic of Concern +> The scaffold reverse-engineers specifications from an existing codebase by surveying existing specs, identifying unspecified behavior, deriving topics of concern, and producing new spec files. + +## Context + +When a codebase has implemented behavior that predates or was never captured in specifications, the system needs a structured workflow to extract those implicit contracts and make them explicit. This is the inverse of the normal spec-first flow: instead of code implementing specs, specs are derived from code. + +The reverse engineering workflow takes a general idea of upcoming work as its starting point, uses that to scope which parts of the codebase are relevant, identifies gaps between existing specs and implemented behavior, and produces new spec files that close those gaps. The result is a spec corpus that accurately reflects what the code does, enabling future changes to proceed spec-first. + +This workflow is distinct from writing specs from plans. Plans propose new behavior; reverse engineering captures existing behavior. The output format is identical — both produce spec files conforming to the standard spec format — but the input is code, not planning documents. + +The user provides the domains and their order at init time. Forgectl loops SURVEY → GAP_ANALYSIS → DECOMPOSE → QUEUE per domain before advancing to EXECUTE. The user performs the analysis work; forgectl tracks state and tells the user what action to take next. Forgectl does not collect or store findings — the user holds context between states. + +A single reverse engineering queue JSON file accumulates entries across all domains. Each domain's QUEUE state adds that domain's entries to the file. + +**Scope exclusions:** +- Writing specs from planning documents (covered by the standard specifying workflow). +- Modifying source code. Reverse engineering is read-only with respect to the codebase. +- Constructing Claude Agent SDK sessions (covered by agent-construction spec). + +## Depends On +- **agent-construction** — provides the factory that builds configured Claude Agent SDK sessions for per-spec reverse engineering. +- **state-persistence** — provides the state file read/write mechanism for tracking reverse engineering workflow progress. +- **session-init** — populates the reverse engineering session during `init --phase reverse_engineering`. + +## Integration Points + +| Spec | Relationship | +|------|-------------| +| agent-construction | EXECUTE delegates agent construction to the factory; the factory receives execute.json and returns configured agent sessions | +| session-init | `init --phase reverse_engineering` creates the initial state with concept, domains, and configuration | +| state-persistence | State file tracks current state, domain index, queue file path, content hash, reconcile round, colleague_review flag | +| activity-logging | State advances produce log entries with domain and state context | +| validate-command | Init input and reverse engineering queue schemas available for standalone validation | + +--- + +## Interface + +### Inputs + +#### Init Input File + +The user provides this JSON file to `forgectl init --phase reverse_engineering`. + +```json +{ + "concept": "auth middleware refactor", + "domains": ["optimizer", "api", "portal"] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `concept` | string | yes | A general description of the work to be performed. Used to scope which areas of the codebase and which existing specs are relevant. | +| `domains` | string[] | yes | Ordered list of domains to process. Each domain is processed sequentially: SURVEY → GAP_ANALYSIS → DECOMPOSE → QUEUE per domain. Order determines processing sequence. | + +No additional fields are permitted. + +#### Reverse Engineering Queue (produced at QUEUE state) + +A JSON file listing every spec to be created or updated. All paths are relative to the domain root (`//`). + +```json +{ + "specs": [ + { + "name": "Repository Loading", + "domain": "optimizer", + "topic": "The optimizer clones or locates a repository and provides its path for downstream modules", + "file": "specs/repository-loading.md", + "action": "create", + "code_search_roots": ["src/repo/", "src/config/"], + "depends_on": [] + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `specs` | array | yes | Ordered list of specs to create or update | +| `specs[].name` | string | yes | Display name for the spec | +| `specs[].domain` | string | yes | Domain grouping. Determines the agent's working directory: `//` | +| `specs[].topic` | string | yes | One-sentence topic of concern | +| `specs[].file` | string | yes | Target spec file path, relative to the domain root | +| `specs[].action` | string | yes | `"create"` or `"update"`. For updates, `file` is both the source and destination. | +| `specs[].code_search_roots` | string[] | yes | Directories to examine, relative to the domain root; may not be empty | +| `specs[].depends_on` | string[] | yes | Names of specs this one depends on; may be empty array. Used by RECONCILE for cross-referencing. Ignored by EXECUTE. | + +No additional fields are permitted. + +### Outputs + +#### Action Output +At each state, forgectl outputs the current state, domain context (if applicable), and an action block telling the user what to do. See Behavior section for the action output per state. + +#### Execution File (`execute.json`) +Generated by forgectl at EXECUTE start. Contains runtime-specific data for the Python subprocess. See EXECUTE behavior and agent-construction spec for the full schema. + +#### New Spec Files +One spec file per identified topic of concern, written in the standard spec format. Each spec captures the contracts, behaviors, invariants, edge cases, and testing criteria that the code currently implements. + +### Rejection + +| Condition | Signal | Rationale | +|-----------|--------|-----------| +| No concept provided | Error: "A concept is required to scope the reverse engineering effort." | Without scope, the system cannot determine which code to examine. | +| Empty domains list | Error: "At least one domain is required." | Nothing to process. | +| Duplicate domain in list | Error identifying the duplicate. | Each domain is processed once. | +| `mode` not one of the four valid values | Error listing valid modes. | Invalid execution mode. | +| `code_search_roots` directory does not exist | Error identifying the invalid path. Validated at QUEUE. | Cannot reverse-engineer from a directory that does not exist. | + +--- + +## State Machine + +The reverse engineering workflow follows this state machine. SURVEY → GAP_ANALYSIS → DECOMPOSE → QUEUE loop per domain in the order provided at init. A single queue JSON file accumulates entries across all domains. RECONCILE → RECONCILE_EVAL → (optional COLLEAGUE_REVIEW) → RECONCILE_ADVANCE loop per domain after EXECUTE. + +``` +ORIENT + ↓ +SURVEY (domain 1) → GAP_ANALYSIS (domain 1) → DECOMPOSE (domain 1) → QUEUE (domain 1) + ↓ +SURVEY (domain 2) → GAP_ANALYSIS (domain 2) → DECOMPOSE (domain 2) → QUEUE (domain 2) + ↓ + ... (repeat for each domain) + ↓ +EXECUTE + ↓ +RECONCILE (domain 1, round 1) + ↓ +RECONCILE_EVAL (domain 1) ──FAIL──→ RECONCILE (domain 1, round N) + ↓ PASS (or max rounds) [loops until PASS or max rounds] + ↓ + ├── if colleague_review: COLLEAGUE_REVIEW (domain 1) + ↓ +RECONCILE_ADVANCE (domain 1 → domain 2) + ↓ +RECONCILE (domain 2, round 1) + ↓ + ... (repeat for each domain) + ↓ +RECONCILE_ADVANCE (domain N → DONE) + ↓ +DONE + +Note: COLLEAGUE_REVIEW is disabled by default. When disabled, RECONCILE_EVAL advances directly to RECONCILE_ADVANCE. +``` + +The state file tracks: current state, current domain index (used for both the SURVEY-QUEUE loop and the RECONCILE loop), total domain count, reconcile round, queue file path (set on first QUEUE advance), queue file content hash (for change detection on subsequent QUEUE advances), execute file path (set at EXECUTE start), colleague_review enabled flag. + +--- + +## Behavior + +### ORIENT + +#### Preconditions +- `forgectl init --phase reverse_engineering --from ` has completed successfully. +- The state file exists with concept, domains, and configuration locked in. + +#### Action Output + +``` +Phase: reverse_engineering +State: ORIENT +Concept: "{concept}" +Domains: {domain_1} (1/{N}), {domain_2} (2/{N}), ... {domain_N} ({N}/{N}) + +Action: + Prepare for reverse engineering across {N} domains. + Domain order: {domain_1} → {domain_2} → ... → {domain_N} + + Requirements before advancing: + - Confirm you are familiar with the work concept scope + - Confirm domain ordering is correct + (SURVEY → GAP_ANALYSIS → DECOMPOSE → QUEUE runs per domain in this order) + + Advance to begin SURVEY on domain: {domain_1} +``` + +#### Postconditions +- User has confirmed readiness. +- State advances to SURVEY with domain index set to 1. + +#### Error Handling +- None. ORIENT is informational. + +--- + +### SURVEY + +#### Preconditions +- Previous state is ORIENT (for domain 1) or QUEUE (for domains 2+). +- Current domain index is valid. + +#### Action Output + +``` +Phase: reverse_engineering +State: SURVEY +Domain: {domain} ({index}/{N}) +Concept: "{concept}" + +Action: + Survey existing specifications in {domain}/specs/. + + Spawn {survey.count} {survey.model} {survey.type} sub-agents + scoped to {domain}/specs/. + + Read all spec files in the directory to understand what is specified. + Identify which specs pertain to the concept. + + For each spec, extract: + - Spec file name + - Topic of concern + - Behaviors defined + - Integration points + - Dependencies + - Relevance: whether this spec pertains to the concept + + Disregard specs that do not pertain to the concept. + + Advance when complete. +``` + +#### Postconditions +- User has surveyed existing specs for the current domain. +- User understands which specs are relevant to the concept and which are not. +- State advances to GAP_ANALYSIS for the same domain. + +#### Error Handling +- Domain has no `specs/` directory: action output notes this. The user proceeds with an empty spec inventory for this domain. + +--- + +### GAP_ANALYSIS + +#### Preconditions +- SURVEY for the current domain is complete. +- User holds the spec inventory for this domain. + +#### Action Output + +``` +Phase: reverse_engineering +State: GAP_ANALYSIS +Domain: {domain} ({index}/{N}) +Concept: "{concept}" + +Action: + Identify unspecified behavior in the {domain} source code + that pertains to the concept. + + Spawn {gap_analysis.count} {gap_analysis.model} {gap_analysis.type} + sub-agents scoped to the {domain} source code. + + For each behavior found in code that is not covered by an existing spec: + - Describe what the behavior does + - Identify a topic of concern for it: + - Must be a single topic that fits in one sentence + - Must not contain "and" conjoining unrelated capabilities + - Must describe an activity, not a vague statement + - Valid: "The optimizer validates repository URLs before cloning" + - Invalid: "The optimizer handles repos, validation, and caching" + - Note where in the code it is implemented + - Note if an existing spec partially covers it (and what the gap is) + + Advance when complete. + Next: DECOMPOSE for domain {domain} +``` + +#### Postconditions +- User has identified unspecified behavior in the current domain's source code. +- Each identified behavior has a candidate topic of concern. +- State advances to DECOMPOSE for the current domain. + +#### Error Handling +- Domain source code directory is empty: action output notes this. User advances with no gaps found for this domain. + +--- + +### DECOMPOSE + +#### Preconditions +- SURVEY and GAP_ANALYSIS are complete for the current domain. +- User holds the spec inventory and gap findings for this domain. + +#### Action Output + +``` +Phase: reverse_engineering +State: DECOMPOSE +Domain: {domain} ({index}/{N}) +Concept: "{concept}" + +Action: + Synthesize findings from domain {domain}. + + From the SURVEY and GAP_ANALYSIS results for this domain, + determine which specifications need to be created or updated. + + For each spec, define: + - Name (display name) + - Domain: {domain} + - Topic of concern: + - Must be a single topic that fits in one sentence + - Must not contain "and" conjoining unrelated capabilities + - Must describe an activity, not a vague statement + - Valid: "The optimizer validates repository URLs before cloning" + - Invalid: "The optimizer handles repos, validation, and caching" + - File: target path relative to domain root (specs/.md) + - Action: "create" for new specs, "update" for existing specs with gaps + - Code search roots (directories relative to domain root) + - Dependencies on other specs + + Decide: + - Which gaps warrant new specs vs. updates to existing specs + - How to group related behaviors into single-topic specs + + Advance when the spec list for this domain is finalized. +``` + +#### Postconditions +- User has a finalized list of specs to create or update for the current domain. +- Each spec has a valid topic of concern. +- State advances to QUEUE for the current domain. + +#### Error Handling +- None. DECOMPOSE is user-driven synthesis. + +--- + +### QUEUE + +QUEUE accumulates entries into a single reverse engineering queue JSON file across all domains. + +#### Preconditions +- DECOMPOSE for the current domain is complete. +- User has a finalized spec list for this domain. + +#### First Advance (domain 1 — file path not yet set) + +The user provides the queue JSON file path on the first advance. Forgectl stores the path and a content hash in the state file. + +``` +Phase: reverse_engineering +State: QUEUE +Domain: {domain} ({index}/{N}) +Concept: "{concept}" + +Action: + Produce the reverse engineering queue JSON file with entries for domain {domain}. + + Requirements: + - All paths relative to domain root (/{domain}/) + - Order entries by dependency: specs with no dependencies first + - code_search_roots must be non-empty for every entry + - No circular dependencies + + Advance with the queue file: + forgectl advance --file +``` + +#### Subsequent Advances (domains 2+ — file path already set) + +The user updates the existing queue file by adding entries for the current domain. Forgectl re-reads from the stored path, checks for changes, and validates. + +``` +Phase: reverse_engineering +State: QUEUE +Domain: {domain} ({index}/{N}) +Concept: "{concept}" +Queue file: {stored_path} + +Action: + Add entries for domain {domain} to the existing queue file. + + Update the queue file at: {stored_path} + Add new entries for this domain alongside existing entries. + + Advance when the file is updated: + forgectl advance +``` + +#### Advance Behavior + +| Advance | `--file` flag | Behavior | +|---------|---------------|----------| +| First (no stored path) | Required | Store path, compute content hash, validate schema, validate `code_search_roots` directories exist. If valid → advance. If invalid → error with violations. | +| Subsequent (path stored) | Not accepted | Re-read file from stored path. Compare content hash. If unchanged → error: "Queue file has not changed. Update the file and retry." If changed → recompute hash, validate schema, validate `code_search_roots` directories exist. If valid → advance. If invalid → error with violations. | + +#### Domain Validation + +During QUEUE validation, forgectl verifies that every entry's `domain` field matches one of the domains provided at init. Entries with unrecognized domains are rejected. + +Error output: + +``` +Queue entry "{name}" has domain "{domain}" which is not in the +initialized domain list. + +Valid domains: {domain_1}, {domain_2}, ... {domain_N} + +To add a new domain, run: + forgectl add-domain +``` + +#### `forgectl add-domain` Command (active during QUEUE only) + +Adds a domain to the initialized domain list. The new domain is appended to the end of the domain order. This command is only available during the QUEUE state. + +``` +forgectl add-domain +``` + +- The domain must not already exist in the list (error if duplicate). +- The domain is added to the state file's domain list. +- The domain count updates. +- The SURVEY → GAP_ANALYSIS → DECOMPOSE → QUEUE loop does not re-run for the added domain — the user is responsible for having performed the analysis before adding entries for it. +- Called outside of QUEUE state: error: "forgectl add-domain is only available during the QUEUE state." + +#### Path Validation + +During QUEUE validation, forgectl resolves each entry's `code_search_roots` paths against the domain root (`//`) and verifies each directory exists. Missing directories are reported as validation errors. + +#### Postconditions +- Queue JSON file exists at the stored path with entries for all processed domains. +- All entries pass schema validation. +- All `code_search_roots` directories exist on disk. +- State advances to SURVEY for the next domain, or EXECUTE if all domains are complete. + +#### Error Handling +- First advance missing `--file`: error: "Queue file path required. Use: forgectl advance --file " +- Subsequent advance includes `--file`: error: "Queue file path already set to {stored_path}. Update that file and run: forgectl advance" +- Queue file not found at stored path: error with path. +- Schema validation failure: error listing violations. User corrects and retries `forgectl advance`. +- `code_search_roots` directory does not exist: error identifying the entry and the missing path. +- Content unchanged: error: "Queue file has not changed. Update the file and retry." + +--- + +### EXECUTE + +#### Preconditions +- The reverse engineering queue JSON file exists and is validated. +- All `code_search_roots` directories verified at QUEUE time. +- For each entry, the domain's `specs/` directory exists. Forgectl creates this directory before invoking the subprocess if it does not exist. +- The Python subprocess and Claude Agent SDK are available. + +#### Steps +1. Forgectl ensures `//specs/` exists for each domain in the queue. +2. Forgectl generates `execute.json` by: + a. Copying all spec entries from `queue.json` into the `specs` array, adding `result: null` to each. + b. Adding `project_root` (absolute path). + c. Adding `config` with drafter, mode-specific, and (if peer_review) peer review settings from the state file. Only the active mode's config block is included. +3. Forgectl invokes the Python subprocess: + ``` + python reverse_engineer.py --execute execute.json + ``` +4. The Python subprocess reads `execute.json`, constructs agent sessions using the agent construction factory (see agent-construction spec), runs them according to the configured mode, and writes results back into `execute.json`. +5. When the subprocess exits, forgectl reads `execute.json` and checks each entry's `result`: + - All `status: "success"` → advance to RECONCILE. + - Any `status: "failure"` → stay in EXECUTE, report which entries failed. + +#### Postconditions +- `execute.json` exists with a non-null `result` for every spec entry. +- A spec file exists at each `file` path for every successful entry. +- Each spec is written in the standard spec format. +- No files other than the designated spec files were modified by any agent. +- State advances to RECONCILE (if all succeed) or stays in EXECUTE (if any fail). + +#### Error Handling +- Queue contains zero entries: error: "Queue contains zero entries. Nothing to execute." State stays in EXECUTE. Subprocess is not invoked. +- Domain `specs/` directory creation fails (permissions, disk): error with path. Subprocess is not invoked. +- `execute.json` generation fails: error. Subprocess is not invoked. +- Subprocess exits with non-zero code: forgectl reads `execute.json` for per-entry results. If `execute.json` is unreadable, forgectl outputs the subprocess failure action: + +``` +Phase: reverse_engineering +State: EXECUTE + +STOP there was an issue with the subprocess for reverse engineering. +Please consult with your user and inform them that there was an issue +with the Python subprocess running Claude Agent SDK. + +{full stderr/stack trace from the subprocess} +``` + +- Individual agent failures are captured in `execute.json` per entry. Forgectl reports which entries failed. + +--- + +### RECONCILE + +RECONCILE runs per domain, looping with RECONCILE_EVAL until PASS or max rounds. + +#### Preconditions +- EXECUTE is complete (for domain 1) or RECONCILE_ADVANCE from previous domain. +- All spec files for the current domain have been produced. + +#### Action Output — Round 1 + +``` +Phase: reverse_engineering +State: RECONCILE +Domain: {domain} ({index}/{N}) +Concept: "{concept}" +Round: 1 + +Specs created or updated for this domain: + - {domain}/{file_1} ({action_1}) + depends_on: [{dep_1}, {dep_2}] + - {domain}/{file_2} ({action_2}) + depends_on: [] + - {domain}/{file_3} ({action_3}) + depends_on: [{dep_3}] + +Action: + Cross-reference specifications for domain {domain}. + + For every spec that was created or updated, use its depends_on + to add cross-references to the corresponding specs. + Update both the new/updated spec and the spec it references: + - Add Depends On entries in the new/updated spec + - Add Integration Points in both directions + (if A depends on B, both A and B reference each other) + + Verify consistency: + - Every Depends On reference points to a spec that exists + - Every Depends On has a corresponding Integration Points row + in the referenced spec + - Integration Points are symmetric (A ↔ B) + - Spec names are consistent across all references + - No circular dependencies in the Depends On graph + + Stage all changes: + git add the modified spec files. + + Advance when reconciliation is complete and changes are staged. +``` + +#### Action Output — Subsequent Rounds (after RECONCILE_EVAL FAIL) + +``` +Phase: reverse_engineering +State: RECONCILE +Domain: {domain} ({index}/{N}) +Concept: "{concept}" +Round: {round} + +Specs created or updated for this domain: + - {domain}/{file_1} ({action_1}) + depends_on: [{dep_1}, {dep_2}] + - {domain}/{file_2} ({action_2}) + depends_on: [] + - {domain}/{file_3} ({action_3}) + depends_on: [{dep_3}] + +Action: + Reconciliation evaluation failed on the previous round. + Address the findings from the evaluation report and re-reconcile. + + For every spec that was created or updated, use its depends_on + to add cross-references to the corresponding specs. + Update both the new/updated spec and the spec it references: + - Add Depends On entries in the new/updated spec + - Add Integration Points in both directions + (if A depends on B, both A and B reference each other) + + Verify consistency: + - Every Depends On reference points to a spec that exists + - Every Depends On has a corresponding Integration Points row + in the referenced spec + - Integration Points are symmetric (A ↔ B) + - Spec names are consistent across all references + - No circular dependencies in the Depends On graph + + Stage all changes: + git add the modified spec files. + + Advance when reconciliation is complete and changes are staged. +``` + +#### Postconditions +- All cross-references for the current domain are symmetric and valid. +- No dangling references exist. +- Changes are staged. +- State advances to RECONCILE_EVAL for the current domain. + +#### Error Handling +- A spec file from the queue is missing: report the gap. Do not fabricate a spec. +- Reconciliation introduces a conflict (e.g., adding an integration point to an existing spec changes its scope): flag for user review. + +--- + +### RECONCILE_EVAL + +#### Preconditions +- RECONCILE for the current domain is complete. Changes are staged. +- Reconcile round is within `max_rounds`. + +#### Action Output + +``` +Phase: reverse_engineering +State: RECONCILE_EVAL +Domain: {domain} ({index}/{N}) +Concept: "{concept}" +Round: {round} + +Action: + Evaluate cross-spec consistency for domain {domain}. + + Spawn {reconcile.eval.count} {reconcile.eval.model} {reconcile.eval.type} + sub-agents to evaluate the reconciliation. + + Instruct your sub-agents to run: + forgectl eval + + This outputs the evaluation prompt with the full spec files + and consistency checklist for the sub-agents to review. + + After the sub-agents complete their evaluation, advance with the verdict: + forgectl advance --verdict PASS --eval-report + forgectl advance --verdict FAIL --eval-report + + Eval reports are written to: {domain}/specs/.eval/reconciliation-r{round}.md +``` + +#### `forgectl eval` Command (active during RECONCILE_EVAL) + +When a sub-agent runs `forgectl eval`, forgectl outputs the evaluation prompt from the embedded evaluator file (`forgectl/evaluators/reconcile-eval.md`). The output is populated with: + +- The list of specs created or updated for this domain, with their `depends_on` references +- The eval report output path: `{domain}/specs/.eval/reconciliation-r{round}.md` +- The current round number + +The evaluator prompt instructs the sub-agents to: +1. Read each spec file listed in full +2. Read any spec referenced in `depends_on` that is not in the list +3. Evaluate against 7 dimensions: completeness, depends_on validity, integration points symmetry, depends_on ↔ integration points correspondence, naming consistency, no circular dependencies, topic of concern +4. Write the evaluation report with PASS/FAIL per dimension and an overall verdict + +See `forgectl/evaluators/reconcile-eval.md` for the full evaluator prompt. + +#### Postconditions +- Evaluation report exists at `{domain}/specs/.eval/reconciliation-r{round}.md`. +- If PASS and round >= `min_rounds`: if `colleague_review` is enabled, state advances to COLLEAGUE_REVIEW. If disabled, state advances to RECONCILE_ADVANCE. +- If PASS and round < `min_rounds`: state returns to RECONCILE for another round (minimum not yet met), round increments. +- If FAIL and round < `max_rounds`: state returns to RECONCILE for corrections, round increments. +- If FAIL and round >= `max_rounds`: if `colleague_review` is enabled, state advances to COLLEAGUE_REVIEW. If disabled, state advances to RECONCILE_ADVANCE. + +#### Error Handling +- Sub-agent fails to produce a report: user retries or manually evaluates. +- `forgectl eval` called outside of RECONCILE_EVAL state: error: "forgectl eval is only available during RECONCILE_EVAL." + +--- + +### COLLEAGUE_REVIEW + +Disabled by default. Enabled via `colleague_review = true` in config. When disabled, RECONCILE_EVAL advances directly to RECONCILE_ADVANCE, skipping this state entirely. + +#### Preconditions +- `colleague_review` is enabled in config. +- RECONCILE_EVAL has produced a PASS verdict, or `max_rounds` has been reached. + +#### Action Output + +``` +Phase: reverse_engineering +State: COLLEAGUE_REVIEW +Domain: {domain} ({index}/{N}) +Concept: "{concept}" + +Action: + STOP and review the specifications with your colleague. + + Advance when the review is complete: + forgectl advance +``` + +#### Postconditions +- User and colleague have reviewed the specifications for the current domain. +- State advances to RECONCILE_ADVANCE. + +#### Error Handling +- None. This is a human review gate. + +--- + +### RECONCILE_ADVANCE + +#### Preconditions +- COLLEAGUE_REVIEW for the current domain is complete (if enabled), or RECONCILE_EVAL has completed (if disabled). + +#### Action Output + +``` +Phase: reverse_engineering +State: RECONCILE_ADVANCE +Domain: {domain} ({index}/{N}) → {next_domain | DONE} + +Action: + Domain {domain} reconciliation complete. + + {Next: RECONCILE for domain {next_domain} ({next_index}/{N}) | All domains reconciled. Advancing to DONE.} + + Advance to proceed. +``` + +#### Postconditions +- If more domains remain: state advances to RECONCILE for the next domain, round resets to 1. +- If all domains are complete: state advances to DONE. + +#### Error Handling +- None. This is a transition state. + +--- + +### DONE + +The reverse engineering workflow is complete. All spec files have been produced, verified, and reconciled across all domains. + +--- + +## Configuration + +All configuration is read from `.forgectl/config` (TOML) and locked into the state file at init time. + +### Init Input Validation +- `concept` is non-empty. +- `domains` is non-empty, contains no duplicates. +- `mode` is one of: `single_shot`, `self_refine`, `multi_pass`, `peer_review`. + +### Mode Defaults + +Each mode has its own default values. Defaults are only applied when that mode is selected. Forgectl does not populate, store, or pass configuration for inactive modes. + +**`single_shot`** — No mode-specific parameters. + +**`self_refine`** (default mode): +| Parameter | Default | Description | +|-----------|---------|-------------| +| `rounds` | `2` | Number of self-review follow-ups after the initial draft | + +**`multi_pass`**: +| Parameter | Default | Description | +|-----------|---------|-------------| +| `passes` | `2` | Number of full batch re-runs. Creates become updates after pass 1. | + +**`peer_review`**: +| Parameter | Default | Description | +|-----------|---------|-------------| +| `reviewers` | `3` | Number of reviewer sub-agents spawned in parallel per drafter | +| `rounds` | `1` | Number of peer review cycles | +| `subagents.model` | `opus` | Model for reviewer sub-agents | +| `subagents.type` | `explorer` | Role for reviewer sub-agents | + +### Full Configuration Reference + +```toml +[reverse_engineering] +# Execution mode: exactly one of the four +# Options: "single_shot", "self_refine", "multi_pass", "peer_review" +mode = "self_refine" + +[reverse_engineering.self_refine] +rounds = 2 # only applied when mode = "self_refine" + +[reverse_engineering.multi_pass] +passes = 2 # only applied when mode = "multi_pass" + +[reverse_engineering.peer_review] +reviewers = 3 # only applied when mode = "peer_review" +rounds = 1 # only applied when mode = "peer_review" + +# Primary agent that drafts the spec +[reverse_engineering.drafter] +model = "opus" + +# Sub-agents the drafter spawns to explore code during drafting +[reverse_engineering.drafter.subagents] +model = "opus" +type = "explorer" +count = 3 + +# Sub-agents the drafter spawns for peer review (only used in peer_review mode) +[reverse_engineering.peer_review.subagents] +model = "opus" +type = "explorer" +# count comes from peer_review.reviewers + +# Reconciliation eval rounds +[reverse_engineering.reconcile] +min_rounds = 1 +max_rounds = 3 +colleague_review = false # disabled by default; enable to add a human review gate after reconciliation eval + +# Sub-agents for reconciliation evaluation +[reverse_engineering.reconcile.eval] +count = 1 +model = "opus" +type = "general-purpose" + +# Sub-agents the user spawns during SURVEY (action output only) +[reverse_engineering.survey] +model = "haiku" +type = "explorer" +count = 2 + +# Sub-agents the user spawns during GAP_ANALYSIS (action output only) +[reverse_engineering.gap_analysis] +model = "sonnet" +type = "explorer" +count = 5 +``` + +### Configuration Purpose Map + +| Config Block | Consumed By | When | Purpose | +|-------------|-------------|------|---------| +| `mode` | Forgectl + Python | Init validation, EXECUTE | Determines which execution flow runs | +| `self_refine.*` | Python (via execute.json) | EXECUTE | How many self-review rounds | +| `multi_pass.*` | Python (via execute.json) | EXECUTE | How many full re-runs | +| `peer_review.*` | Python (via execute.json) | EXECUTE | How many reviewers, how many rounds | +| `peer_review.subagents` | Primary agent (via prompt) | EXECUTE peer review | Reviewer sub-agent model and type | +| `drafter` | Python (via execute.json) | EXECUTE | Model for the primary SDK agent | +| `drafter.subagents` | Primary agent (via prompt) | EXECUTE drafting | Code exploration sub-agents during spec writing | +| `reconcile` | Forgectl | RECONCILE_EVAL | Min/max rounds for reconciliation eval loop | +| `reconcile.colleague_review` | Forgectl | After RECONCILE_EVAL | Whether COLLEAGUE_REVIEW gate is enabled (default: false) | +| `reconcile.eval` | Forgectl action output | RECONCILE_EVAL | Sub-agents for reconciliation evaluation | +| `survey` | Forgectl action output | SURVEY | Displayed to user — what sub-agents to spawn | +| `gap_analysis` | Forgectl action output | GAP_ANALYSIS | Displayed to user — what sub-agents to spawn | + +Note: tool list, permission mode, CLAUDE.md setting, and prompt files are constants owned by the Python package. They do not appear in forgectl config or the state file. + +### execute.json Config Structure (per mode) + +Only the active mode's config block is included in `execute.json`. + +**single_shot:** +```json +{ + "config": { + "mode": "single_shot", + "drafter": { + "model": "opus", + "subagents": { "model": "opus", "type": "explorer", "count": 3 } + } + } +} +``` + +**self_refine:** +```json +{ + "config": { + "mode": "self_refine", + "drafter": { + "model": "opus", + "subagents": { "model": "opus", "type": "explorer", "count": 3 } + }, + "self_refine": { "rounds": 2 } + } +} +``` + +**multi_pass:** +```json +{ + "config": { + "mode": "multi_pass", + "drafter": { + "model": "opus", + "subagents": { "model": "opus", "type": "explorer", "count": 3 } + }, + "multi_pass": { "passes": 2 } + } +} +``` + +**peer_review:** +```json +{ + "config": { + "mode": "peer_review", + "drafter": { + "model": "opus", + "subagents": { "model": "opus", "type": "explorer", "count": 3 } + }, + "peer_review": { + "reviewers": 3, + "rounds": 1, + "subagents": { "model": "opus", "type": "explorer" } + } + } +} +``` + +--- + +## Observability + +### Logging + +| Level | What is logged | +|-------|---------------| +| INFO | Workflow started with concept; domain processing started (domain name, index); SURVEY complete for domain; GAP_ANALYSIS complete for domain; QUEUE file produced (spec count); EXECUTE started (mode, agent count); each spec file produced; RECONCILE complete for domain; RECONCILE_EVAL verdict (PASS/FAIL, round); COLLEAGUE_REVIEW entered (when enabled); RECONCILE_ADVANCE domain transition; workflow DONE | +| WARN | Domain has no `specs/` directory during SURVEY; empty source directory during GAP_ANALYSIS; agent produced minimal spec due to irrelevant code | +| ERROR | Queue JSON validation failure; `code_search_roots` directory not found; circular dependency detected; agent session failure; missing spec file during RECONCILE verification | +| DEBUG | Domain index progression; sub-agent config applied; queue entry details; execution mode and parameters; execute.json generation | + +--- + +## Invariants + +1. **Read-only codebase.** The reverse engineering workflow never modifies source code. It reads code to produce specs. +2. **One topic per spec.** Every spec produced passes the topic-of-concern test. No spec covers multiple unrelated responsibilities. +3. **Spec format compliance.** Every spec produced conforms to the standard spec format, regardless of whether it was written from a plan or reverse-engineered from code. +4. **Dependency ordering.** The spec queue is ordered such that no spec appears before a spec it depends on. +5. **Domain-root scoping.** All paths in the queue (`file`, `code_search_roots`) are relative to `//`. Each agent's working directory is the domain root. +6. **Single-file write.** Each agent writes or edits exactly one file — the `file` specified in its queue entry. No other files are modified. +7. **Spec directory pre-exists.** The domain's `specs/` directory exists before any agent is invoked. Forgectl creates it if absent. +8. **Sequential domain processing.** Domains are processed in the order provided at init. SURVEY, GAP_ANALYSIS, DECOMPOSE, and QUEUE complete for domain N before domain N+1 begins. +9. **Single queue file.** One reverse engineering queue JSON file accumulates entries across all domains. The file path is set on the first QUEUE advance and reused for all subsequent domains. +10. **Queue file change detection.** On subsequent QUEUE advances, forgectl rejects unchanged files. The user must modify the file before advancing. +11. **Exactly one execution mode.** One of `single_shot`, `self_refine`, `multi_pass`, or `peer_review` is active per session. No combinations. +12. **Path validation at QUEUE.** All `code_search_roots` directories are verified to exist on disk during QUEUE validation. Invalid paths are rejected before EXECUTE. +13. **`depends_on` is RECONCILE metadata.** The `depends_on` field in queue entries is used by RECONCILE to wire up cross-references. It is ignored by EXECUTE and the Python subprocess. +14. **Per-domain reconciliation.** RECONCILE, RECONCILE_EVAL, and (if enabled) COLLEAGUE_REVIEW run per domain in the same order as the SURVEY-QUEUE loop. +15. **Reconcile eval bounded.** RECONCILE_EVAL loops at most `max_rounds` times per domain. At max rounds, the workflow advances to COLLEAGUE_REVIEW (if enabled) or RECONCILE_ADVANCE (if disabled) regardless of verdict. +16. **Colleague review is optional.** COLLEAGUE_REVIEW is disabled by default. When disabled, the state is skipped entirely — RECONCILE_EVAL advances directly to RECONCILE_ADVANCE. When enabled, it runs exactly once per domain. +17. **`forgectl eval` is state-gated.** The `forgectl eval` command is only active during RECONCILE_EVAL. It outputs the embedded evaluator prompt populated with the current domain's spec list. +18. **Inactive mode config is not applied.** Forgectl only populates, stores, and passes configuration for the active execution mode. Default values for inactive modes are not loaded into the state file or `execute.json`. +19. **Queue entries match initialized domains.** Every entry's `domain` field in the queue must match a domain in the initialized domain list. Unrecognized domains are rejected at QUEUE validation. +20. **`forgectl add-domain` is state-gated.** The `forgectl add-domain` command is only available during the QUEUE state. + +--- + +## Edge Cases + +- **Scenario:** The entire codebase is already fully specified. + - **Expected behavior:** GAP_ANALYSIS across all domains finds no unspecified behavior. DECOMPOSE produces an empty spec list. QUEUE produces a JSON with zero entries. When EXECUTE begins with an empty queue, forgectl errors: "Queue contains zero entries. Nothing to execute." State stays in EXECUTE. + - **Rationale:** An empty queue means no work to perform. Erroring is clearer than silently skipping to RECONCILE with nothing to reconcile. + +- **Scenario:** A behavior is split across multiple code directories with no single obvious home. + - **Expected behavior:** The behavior is assigned to the spec whose topic of concern most closely aligns. The code search roots for that spec include all relevant directories. + - **Rationale:** Code organization does not dictate spec organization. The spec reflects the logical concern, not the physical layout. + +- **Scenario:** Existing spec partially covers a behavior, but the code has diverged (code does more than the spec describes). + - **Expected behavior:** GAP_ANALYSIS records the divergence. The user decides during DECOMPOSE whether to update the existing spec or create a new spec for the additional behavior. + - **Rationale:** Reverse engineering identifies gaps but does not unilaterally modify existing specs — that is a design decision. + +- **Scenario:** A domain has no `specs/` directory. + - **Expected behavior:** SURVEY notes the absence. The user proceeds with an empty spec inventory for this domain. GAP_ANALYSIS treats all behavior as unspecified. + - **Rationale:** This is a valid starting state — the domain has never been specified. + +- **Scenario:** Two unspecified behaviors are tightly coupled but logically distinct. + - **Expected behavior:** Two separate specs are created during DECOMPOSE, each with its own topic of concern. Integration points link them. + - **Rationale:** Tight coupling in code does not justify combining specs. Each spec has one topic. + +- **Scenario:** Code contains dead code or unreachable paths. + - **Expected behavior:** Dead code is excluded from GAP_ANALYSIS findings. Only reachable, exercised behavior is reverse-engineered into specs. + - **Rationale:** Specs capture what the system does, not what it contains. Dead code does nothing. + +- **Scenario:** `multi_pass` with `passes: 3`. + - **Expected behavior:** The subprocess runs 3 times. Pass 1 uses the original queue actions. Passes 2 and 3 override all `action: "create"` entries to `action: "update"`. + - **Rationale:** Subsequent passes refine existing output rather than recreating from scratch. + +- **Scenario:** `peer_review` with `reviewers: 3` and `rounds: 2`. + - **Expected behavior:** After the initial draft, the drafter spawns 3 reviewer sub-agents in parallel. This review cycle repeats 2 times. Each round reviews the spec as it stands after the previous round's edits. + - **Rationale:** Multiple rounds allow reviewer feedback to compound. + +- **Scenario:** A single domain appears in the queue but the user provided three domains at init. + - **Expected behavior:** SURVEY, GAP_ANALYSIS, DECOMPOSE, and QUEUE still run for all three domains. The queue produced at QUEUE may only contain specs for the one domain where gaps were found. + - **Rationale:** All domains are surveyed and analyzed regardless of whether gaps are found. The queue reflects only actionable work. + +- **Scenario:** `code_search_roots` directory exists at QUEUE time but is deleted before EXECUTE. + - **Expected behavior:** The agent reports failure for that entry. The directory was validated at QUEUE but is no longer present at EXECUTE. Forgectl reports the failure from `execute.json`. + - **Rationale:** QUEUE validates what it can. Filesystem changes between states are runtime failures, not validation failures. + +--- + +## Testing Criteria + +### Init validates domains +- **Verifies:** Init input validation. +- **Given:** Input JSON with `domains: ["api", "api"]` (duplicate). +- **When:** `forgectl init --phase reverse_engineering --from input.json` +- **Then:** Error identifying the duplicate. Init fails. + +### Init validates execution mode +- **Verifies:** Mode validation. +- **Given:** Config with `mode = "invalid_mode"`. +- **When:** `forgectl init --phase reverse_engineering --from input.json` +- **Then:** Error listing valid modes. Init fails. + +### ORIENT displays domain order +- **Verifies:** ORIENT action output. +- **Given:** Init with `domains: ["optimizer", "api", "portal"]`. +- **When:** State is ORIENT, `forgectl status` is run. +- **Then:** Output lists all three domains in order with indices. + +### SURVEY action uses configured sub-agents +- **Verifies:** SURVEY action output reflects config. +- **Given:** Config with `survey.count = 4`, `survey.model = "sonnet"`, `survey.type = "explorer"`. +- **When:** State is SURVEY, `forgectl status` is run. +- **Then:** Action output says "Spawn 4 sonnet explorer sub-agents". + +### GAP_ANALYSIS action uses configured sub-agents +- **Verifies:** GAP_ANALYSIS action output reflects config. +- **Given:** Config with `gap_analysis.count = 3`, `gap_analysis.model = "opus"`, `gap_analysis.type = "explorer"`. +- **When:** State is GAP_ANALYSIS, `forgectl status` is run. +- **Then:** Action output says "Spawn 3 opus explorer sub-agents". + +### GAP_ANALYSIS action includes topic-of-concern rules +- **Verifies:** GAP_ANALYSIS action output includes topic formatting rules. +- **Given:** Current domain is "optimizer". +- **When:** State is GAP_ANALYSIS, `forgectl status` is run. +- **Then:** Action output includes topic-of-concern requirements: single sentence, no "and", describes an activity. Valid/invalid examples are shown. + +### Domain loop advances correctly +- **Verifies:** Sequential domain processing. +- **Given:** Domains: ["optimizer", "api"]. State: QUEUE, domain index 1. Queue file validated. +- **When:** User advances. +- **Then:** State becomes SURVEY, domain index advances to 2 (api). + +### Last domain advances to EXECUTE +- **Verifies:** Transition from last domain to EXECUTE. +- **Given:** Domains: ["optimizer", "api"]. State: QUEUE, domain index 2. Queue file validated. +- **When:** User advances. +- **Then:** State becomes EXECUTE. + +### QUEUE first advance requires --file +- **Verifies:** File path required on first QUEUE advance. +- **Given:** State is QUEUE, domain index 1. No queue file path stored. +- **When:** `forgectl advance` (no `--file` flag). +- **Then:** Error: "Queue file path required." + +### QUEUE validates reverse engineering queue schema +- **Verifies:** Queue JSON validation. +- **Given:** Queue JSON with an entry missing `code_search_roots`. +- **When:** `forgectl advance --file queue.json` +- **Then:** Error listing the validation violation. + +### QUEUE rejects entries with unrecognized domains +- **Verifies:** Domain validation at QUEUE. +- **Given:** Init with `domains: ["optimizer", "api"]`. Queue JSON contains an entry with `domain: "portal"`. +- **When:** `forgectl advance --file queue.json` +- **Then:** Error identifies the entry and lists valid domains ("optimizer", "api"). Error suggests `forgectl add-domain portal`. + +### forgectl add-domain adds a domain during QUEUE +- **Verifies:** add-domain command. +- **Given:** State is QUEUE. Init domains: ["optimizer", "api"]. +- **When:** `forgectl add-domain portal` +- **Then:** Domain list becomes ["optimizer", "api", "portal"]. Subsequent QUEUE validation accepts entries with `domain: "portal"`. + +### forgectl add-domain rejects duplicate domain +- **Verifies:** add-domain duplicate check. +- **Given:** State is QUEUE. Init domains: ["optimizer", "api"]. +- **When:** `forgectl add-domain api` +- **Then:** Error: domain "api" already exists. + +### forgectl add-domain blocked outside QUEUE +- **Verifies:** State-gating of add-domain. +- **Given:** State is SURVEY. +- **When:** `forgectl add-domain portal` +- **Then:** Error: "forgectl add-domain is only available during the QUEUE state." + +### EXECUTE rejects empty queue +- **Verifies:** Empty queue error. +- **Given:** Queue JSON has zero entries. +- **When:** EXECUTE begins. +- **Then:** Error: "Queue contains zero entries. Nothing to execute." State stays in EXECUTE. + +### QUEUE validates code_search_roots exist on disk +- **Verifies:** Path validation at QUEUE. +- **Given:** Queue JSON with `code_search_roots: ["src/nonexistent/"]`. Directory does not exist. +- **When:** `forgectl advance --file queue.json` +- **Then:** Error identifying the entry and the missing directory. + +### QUEUE subsequent advance rejects --file flag +- **Verifies:** File path cannot be changed after first QUEUE. +- **Given:** State is QUEUE, domain index 2. Queue file path already stored. +- **When:** `forgectl advance --file other-queue.json` +- **Then:** Error: "Queue file path already set." + +### QUEUE subsequent advance detects unchanged file +- **Verifies:** Change detection on subsequent QUEUE advances. +- **Given:** State is QUEUE, domain index 2. Queue file has not changed since last validation. +- **When:** `forgectl advance` +- **Then:** Error: "Queue file has not changed." + +### QUEUE subsequent advance validates changed file +- **Verifies:** Re-validation after file change. +- **Given:** State is QUEUE, domain index 2. User has added entries and the file content hash differs. +- **When:** `forgectl advance` +- **Then:** File is re-validated (schema + path existence). If valid, state advances. + +### EXECUTE creates specs directories +- **Verifies:** Spec directory pre-creation. +- **Given:** Queue contains domain "optimizer". `optimizer/specs/` does not exist. +- **When:** EXECUTE begins. +- **Then:** `optimizer/specs/` is created before the subprocess is invoked. + +### EXECUTE generates execute.json with active mode config only +- **Verifies:** Execution file contains only the active mode's config. +- **Given:** Config with `mode = "peer_review"`, `peer_review.reviewers = 3`, `self_refine.rounds = 2`. +- **When:** EXECUTE generates `execute.json`. +- **Then:** `execute.json` contains `config.peer_review` block. Does not contain `config.self_refine`. + +### Inactive mode defaults are not stored in state file +- **Verifies:** Inactive mode config isolation. +- **Given:** Config with `mode = "single_shot"`. Config file also has `[reverse_engineering.self_refine]` with `rounds = 5` and `[reverse_engineering.peer_review]` with `reviewers = 4`. +- **When:** `forgectl init --phase reverse_engineering --from input.json` +- **Then:** State file contains `mode: "single_shot"`. State file does not contain `self_refine` or `peer_review` config blocks. + +### Subprocess failure outputs full error +- **Verifies:** Subprocess failure action output. +- **Given:** The Python subprocess exits with non-zero code and `execute.json` is unreadable. +- **When:** Forgectl reads the subprocess result. +- **Then:** Forgectl outputs the STOP message with the full stderr/stack trace from the subprocess. + +### EXECUTE reads results from execute.json +- **Verifies:** Result consumption. +- **Given:** Subprocess has completed. `execute.json` has 2 entries with `status: "success"` and 1 with `status: "failure"`. +- **When:** Forgectl reads `execute.json` after subprocess exit. +- **Then:** Forgectl reports the failed entry. State stays in EXECUTE. + +### EXECUTE advances on all success +- **Verifies:** Transition to RECONCILE. +- **Given:** Subprocess has completed. All entries in `execute.json` have `status: "success"`. +- **When:** Forgectl reads `execute.json` after subprocess exit. +- **Then:** State advances to RECONCILE. + +### EXECUTE works from any working directory +- **Verifies:** CWD portability. +- **Given:** Forgectl is invoked from `/tmp/` (outside the project). Project root is `/project/`. +- **When:** EXECUTE generates `execute.json` and invokes the Python subprocess. +- **Then:** `project_root` in `execute.json` is the absolute path `/project/`. Domain roots resolve correctly. + +### multi_pass flips action after first pass +- **Verifies:** Action override on subsequent passes. +- **Given:** `mode: "multi_pass"`, `passes: 2`. Queue entry with `action: "create"`. +- **When:** Pass 2 begins. +- **Then:** The entry's action is overridden to `"update"`. + +### Fully specified codebase produces empty queue +- **Verifies:** Idempotency against already-specified behavior. +- **Given:** Every implemented behavior has a corresponding existing spec. +- **When:** GAP_ANALYSIS finds no gaps across all domains. User produces empty queue at QUEUE. +- **Then:** Queue JSON has zero entries. + +### Cross-referencing detects asymmetric integration points +- **Verifies:** RECONCILE catches missing bidirectional references. +- **Given:** New spec A lists existing spec B in Integration Points, but spec B does not reference A. +- **When:** RECONCILE runs. +- **Then:** The asymmetry is identified and corrected. + +### RECONCILE wires depends_on from queue +- **Verifies:** depends_on metadata used during reconciliation. +- **Given:** Queue entry for spec A has `depends_on: ["Spec B"]`. Both specs exist. +- **When:** RECONCILE runs. +- **Then:** Spec A's `Depends On` section references Spec B. Spec B's `Integration Points` references Spec A. + +### RECONCILE round 1 lists spec files +- **Verifies:** Spec file listing on first round. +- **Given:** Domain "optimizer" has 3 specs in the queue. +- **When:** RECONCILE round 1 action output is displayed. +- **Then:** All 3 spec file paths are listed with their action and depends_on. + +### RECONCILE subsequent round shows depends_on +- **Verifies:** depends_on shown on all rounds. +- **Given:** Domain "optimizer", round 2 after RECONCILE_EVAL FAIL. +- **When:** RECONCILE round 2 action output is displayed. +- **Then:** Spec file paths are listed with their depends_on. + +### RECONCILE_EVAL outputs forgectl eval instruction +- **Verifies:** RECONCILE_EVAL action output references forgectl eval. +- **Given:** State is RECONCILE_EVAL, domain "optimizer", round 1. +- **When:** `forgectl status` is run. +- **Then:** Action output instructs user to tell sub-agents to run `forgectl eval`. Sub-agent config is displayed. + +### forgectl eval outputs evaluator prompt +- **Verifies:** forgectl eval command during RECONCILE_EVAL. +- **Given:** State is RECONCILE_EVAL, domain "optimizer" with 3 specs. +- **When:** `forgectl eval` is run. +- **Then:** Outputs the reconcile-eval evaluator prompt populated with the 3 spec paths, depends_on, round number, and eval report path. + +### forgectl eval blocked outside RECONCILE_EVAL +- **Verifies:** State-gating of forgectl eval. +- **Given:** State is RECONCILE (not RECONCILE_EVAL). +- **When:** `forgectl eval` is run. +- **Then:** Error: "forgectl eval is only available during RECONCILE_EVAL." + +### RECONCILE_EVAL FAIL loops back to RECONCILE +- **Verifies:** FAIL loop behavior. +- **Given:** State is RECONCILE_EVAL, round 1, `max_rounds: 3`. Verdict is FAIL. +- **When:** `forgectl advance --verdict FAIL --eval-report report.md` +- **Then:** State returns to RECONCILE. Round increments to 2. + +### RECONCILE_EVAL PASS before min_rounds loops back +- **Verifies:** Minimum rounds enforcement. +- **Given:** State is RECONCILE_EVAL, round 1, `min_rounds: 2`. Verdict is PASS. +- **When:** `forgectl advance --verdict PASS --eval-report report.md` +- **Then:** State returns to RECONCILE. Round increments to 2. + +### RECONCILE_EVAL max rounds skips COLLEAGUE_REVIEW when disabled +- **Verifies:** Max rounds with colleague_review disabled. +- **Given:** State is RECONCILE_EVAL, round 3, `max_rounds: 3`, `colleague_review: false`. Verdict is FAIL. +- **When:** `forgectl advance --verdict FAIL --eval-report report.md` +- **Then:** State advances to RECONCILE_ADVANCE, skipping COLLEAGUE_REVIEW. + +### RECONCILE_EVAL max rounds advances to COLLEAGUE_REVIEW when enabled +- **Verifies:** Max rounds with colleague_review enabled. +- **Given:** State is RECONCILE_EVAL, round 3, `max_rounds: 3`, `colleague_review: true`. Verdict is FAIL. +- **When:** `forgectl advance --verdict FAIL --eval-report report.md` +- **Then:** State advances to COLLEAGUE_REVIEW despite FAIL verdict. + +### RECONCILE_EVAL PASS skips COLLEAGUE_REVIEW when disabled +- **Verifies:** Default flow skips colleague review. +- **Given:** State is RECONCILE_EVAL, round 1, `min_rounds: 1`, `colleague_review: false`. Verdict is PASS. +- **When:** `forgectl advance --verdict PASS --eval-report report.md` +- **Then:** State advances to RECONCILE_ADVANCE, skipping COLLEAGUE_REVIEW. + +### RECONCILE_EVAL PASS advances to COLLEAGUE_REVIEW when enabled +- **Verifies:** Enabled colleague review gate. +- **Given:** State is RECONCILE_EVAL, round 1, `min_rounds: 1`, `colleague_review: true`. Verdict is PASS. +- **When:** `forgectl advance --verdict PASS --eval-report report.md` +- **Then:** State advances to COLLEAGUE_REVIEW. + +### COLLEAGUE_REVIEW advances to RECONCILE_ADVANCE +- **Verifies:** COLLEAGUE_REVIEW transition. +- **Given:** State is COLLEAGUE_REVIEW, domain "optimizer". +- **When:** `forgectl advance` +- **Then:** State advances to RECONCILE_ADVANCE. + +### RECONCILE_ADVANCE transitions to next domain +- **Verifies:** Domain transition. +- **Given:** State is RECONCILE_ADVANCE, domains ["optimizer", "api"]. Current domain is "optimizer". +- **When:** `forgectl advance` +- **Then:** State advances to RECONCILE for domain "api". Round resets to 1. + +### RECONCILE_ADVANCE from last domain advances to DONE +- **Verifies:** Final domain transition. +- **Given:** State is RECONCILE_ADVANCE, domains ["optimizer", "api"]. Current domain is "api" (last). +- **When:** `forgectl advance` +- **Then:** State advances to DONE. + +--- + +## Implements +- Reverse engineering workflow for deriving specifications from existing code +- State machine: ORIENT → (SURVEY → GAP_ANALYSIS → DECOMPOSE → QUEUE per domain) → EXECUTE → (RECONCILE → RECONCILE_EVAL → optional COLLEAGUE_REVIEW → RECONCILE_ADVANCE per domain) → DONE +- Init input schema with concept and ordered domain list +- Reverse engineering queue JSON schema with domain-relative paths, single file accumulating across domains +- SURVEY and GAP_ANALYSIS action outputs with configurable sub-agent settings +- QUEUE advance logic: --file required on first advance, change detection and re-validation on subsequent advances +- QUEUE domain validation: entry domains must match initialized domain list +- QUEUE path validation: code_search_roots directories verified to exist on disk +- `forgectl add-domain` command: state-gated to QUEUE, appends a domain to the initialized list +- execute.json handoff: forgectl generates from queue + config (active mode only), Python subprocess reads and writes results back +- Prompt ownership split: forgectl embeds its own prompts (including reconcile-eval evaluator), Python package bundles reverse engineering prompts +- Four execution modes: single_shot, self_refine, multi_pass, peer_review (exactly one per session) +- Configurable sub-agents per purpose: drafter exploration, peer review, survey, gap analysis, reconciliation evaluation +- Per-domain reconciliation: RECONCILE → RECONCILE_EVAL (bounded loop) → optional COLLEAGUE_REVIEW → RECONCILE_ADVANCE +- COLLEAGUE_REVIEW: disabled by default, enabled via config, once per domain, human review gate +- `forgectl eval` command: state-gated to RECONCILE_EVAL, outputs embedded evaluator prompt with spec list and depends_on +- Reconciliation evaluation: 7-dimension checklist (completeness, depends_on validity, symmetry, correspondence, naming, no cycles, topic of concern) +- RECONCILE_ADVANCE: explicit domain transition state between reconciliation domains +- depends_on as RECONCILE metadata, ignored by EXECUTE +- CWD portability: all path resolution uses absolute paths or package resources diff --git a/forgectl/specs/session-init.md b/forgectl/specs/session-init.md index 90a5df8..4628db0 100644 --- a/forgectl/specs/session-init.md +++ b/forgectl/specs/session-init.md @@ -23,6 +23,7 @@ Sessions can begin at any of three phases — specifying, planning, or implement | batch-implementation | Consumes the plan.json validated during implementing init | | state-persistence | State file schema defines the structure created here; `session_id` stored at root | | activity-logging | `session_id` generated here; `[logs]` config validated here; pruning triggered here | +| reverse-engineering | Consumes the init input (concept + domains) populated during reverse_engineering init | --- diff --git a/forgectl/specs/state-persistence.md b/forgectl/specs/state-persistence.md index 68a8832..f93de7e 100644 --- a/forgectl/specs/state-persistence.md +++ b/forgectl/specs/state-persistence.md @@ -27,6 +27,7 @@ All scaffold output is to stdout. The scaffold writes state changes to `forgectl | batch-implementation | Reads and mutates implementing phase state and plan.json through the persistence layer | | activity-logging | `session_id` in state file root is used to name the session log file | | validate-command | Standalone validation command; does not use the persistence layer (no session required) | +| reverse-engineering | Reads and mutates reverse_engineering phase state through the persistence layer | --- diff --git a/forgectl/specs/validate-command.md b/forgectl/specs/validate-command.md index cbdcda6..daee095 100644 --- a/forgectl/specs/validate-command.md +++ b/forgectl/specs/validate-command.md @@ -18,6 +18,7 @@ The command auto-detects the file type by inspecting the top-level JSON key. A ` |------|-------------| | session-init | Same validation logic used at `init` and phase shifts; `validate` exposes it as a standalone command | | plan-production | Same 12-point plan.json validation used at the VALIDATE state | +| reverse-engineering | Init input and reverse engineering queue schemas validated using the same validation logic | --- diff --git a/forgectl/state/advance.go b/forgectl/state/advance.go index 0ecd1a0..67c2e39 100644 --- a/forgectl/state/advance.go +++ b/forgectl/state/advance.go @@ -1,13 +1,40 @@ package state import ( + "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "io" "os" + "os/exec" "path/filepath" "strings" ) +// pyRunner invokes the Python subprocess for EXECUTE state. +// Replaced in tests to avoid real subprocess invocation. +var pyRunner = func(executeFilePath, dir string) (string, int) { + cmd := exec.Command("python", "reverse_engineer.py", "--execute", executeFilePath) + cmd.Dir = dir + var stderr bytes.Buffer + cmd.Stderr = &stderr + err := cmd.Run() + stderrStr := stderr.String() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return stderrStr, exitErr.ExitCode() + } + return stderrStr, 1 + } + return stderrStr, 0 +} + +// executeOutput is the writer for EXECUTE state inline output (subprocess STOP messages). +// Replaced in tests to capture output. +var executeOutput io.Writer = os.Stdout + // Advance transitions the state machine forward based on current state and input. func Advance(s *ForgeState, in AdvanceInput, dir string) error { // Update guided setting if provided. @@ -30,6 +57,8 @@ func Advance(s *ForgeState, in AdvanceInput, dir string) error { return advancePlanning(s, in, dir) case PhaseImplementing: return advanceImplementing(s, in, dir) + case PhaseReverseEngineering: + return advanceReverseEngineering(s, in, dir) default: return fmt.Errorf("unknown phase %q", s.Phase) } @@ -1232,3 +1261,413 @@ type ValidationError struct { func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed: %d errors", len(e.Errors)) } + +// --- Reverse Engineering Phase --- + +// writeRELog writes an activity log entry for a reverse engineering state transition. +// When s.Logger is nil this is a no-op. +func writeRELog(s *ForgeState, prevState StateName, detail map[string]interface{}) { + if s.Logger == nil { + return + } + s.Logger.Write(LogEntry{ + TS: LogNow(), + Cmd: "advance", + Phase: string(PhaseReverseEngineering), + PrevState: string(prevState), + State: string(s.State), + Detail: detail, + }) +} + +func advanceReverseEngineering(s *ForgeState, in AdvanceInput, dir string) error { + re := s.ReverseEngineering + if re == nil { + return fmt.Errorf("reverse engineering state is nil") + } + + prevState := s.State + + switch s.State { + case StateOrient: + re.CurrentDomain = 0 + s.State = StateSurvey + writeRELog(s, prevState, map[string]interface{}{ + "domain": reDomainName(re, re.CurrentDomain), + "domain_index": re.CurrentDomain, + "total_domains": re.TotalDomains, + }) + + case StateSurvey: + s.State = StateGapAnalysis + writeRELog(s, prevState, map[string]interface{}{ + "domain": reDomainName(re, re.CurrentDomain), + "domain_index": re.CurrentDomain, + "total_domains": re.TotalDomains, + }) + + case StateGapAnalysis: + s.State = StateDecompose + writeRELog(s, prevState, map[string]interface{}{ + "domain": reDomainName(re, re.CurrentDomain), + "domain_index": re.CurrentDomain, + "total_domains": re.TotalDomains, + }) + + case StateDecompose: + s.State = StateQueue + writeRELog(s, prevState, map[string]interface{}{ + "domain": reDomainName(re, re.CurrentDomain), + "domain_index": re.CurrentDomain, + "total_domains": re.TotalDomains, + }) + + case StateQueue: + if err := advanceREFromQueue(s, re, in, dir); err != nil { + return err + } + queueDetail := map[string]interface{}{ + "domain": reDomainName(re, re.CurrentDomain), + "domain_index": re.CurrentDomain, + "total_domains": re.TotalDomains, + } + if re.QueueFile != "" { + queueDetail["queue_file"] = re.QueueFile + } + writeRELog(s, prevState, queueDetail) + return nil + + case StateExecute: + if err := advanceREFromExecute(s, re, dir); err != nil { + return err + } + execDetail := map[string]interface{}{ + "domain_index": 0, + "total_domains": re.TotalDomains, + "mode": s.Config.ReverseEngineering.Mode, + } + // Compute spec count from queue file. + if re.QueueFile != "" { + if data, err := os.ReadFile(re.QueueFile); err == nil { + var qi ReverseEngineeringQueueInput + if json.Unmarshal(data, &qi) == nil { + execDetail["spec_count"] = len(qi.Specs) + } + } + } + writeRELog(s, prevState, execDetail) + return nil + + case StateReconcile: + s.State = StateReconcileEval + writeRELog(s, prevState, map[string]interface{}{ + "domain": reDomainName(re, re.ReconcileDomain), + "domain_index": re.ReconcileDomain, + "total_domains": re.TotalDomains, + "round": re.Round, + "reconcile_domain": re.ReconcileDomain, + }) + + case StateReconcileEval: + if err := advanceREFromReconcileEval(s, re, in); err != nil { + return err + } + reconcileEvalDetail := map[string]interface{}{ + "domain": reDomainName(re, re.ReconcileDomain), + "domain_index": re.ReconcileDomain, + "total_domains": re.TotalDomains, + "round": re.Round, + "reconcile_domain": re.ReconcileDomain, + "verdict": in.Verdict, + } + writeRELog(s, prevState, reconcileEvalDetail) + return nil + + case StateColleagueReview: + s.State = StateReconcileAdvance + writeRELog(s, prevState, map[string]interface{}{ + "domain": reDomainName(re, re.ReconcileDomain), + "domain_index": re.ReconcileDomain, + "total_domains": re.TotalDomains, + }) + + case StateReconcileAdvance: + if re.ReconcileDomain+1 < re.TotalDomains { + re.ReconcileDomain++ + re.Round = 1 + re.Evals = nil + s.State = StateReconcile + } else { + s.State = StateDone + } + writeRELog(s, prevState, map[string]interface{}{ + "domain": reDomainName(re, re.ReconcileDomain), + "domain_index": re.ReconcileDomain, + "total_domains": re.TotalDomains, + }) + + default: + return fmt.Errorf("cannot advance from state %q in reverse_engineering phase", s.State) + } + + return nil +} + +// reDomainName returns the domain name at the given index, or empty string if out of range. +func reDomainName(re *ReverseEngineeringState, idx int) string { + if idx >= 0 && idx < len(re.Domains) { + return re.Domains[idx] + } + return "" +} + +func advanceREFromReconcileEval(s *ForgeState, re *ReverseEngineeringState, in AdvanceInput) error { + if in.Verdict == "" { + return fmt.Errorf("--verdict is required in RECONCILE_EVAL state") + } + if in.Verdict != "PASS" && in.Verdict != "FAIL" { + return fmt.Errorf("--verdict must be PASS or FAIL") + } + enableEvalOutput := s.Config.General.EnableEvalOutput + if enableEvalOutput && in.EvalReport == "" { + return fmt.Errorf("--eval-report is required in RECONCILE_EVAL state when enable_eval_output is true") + } + if !enableEvalOutput && in.EvalReport != "" { + fmt.Fprintf(os.Stderr, "warning: ignoring --eval-report: eval output is not enabled\n") + } + if in.EvalReport != "" { + if err := checkEvalReportExists(in.EvalReport); err != nil { + return err + } + } + + re.Evals = append(re.Evals, EvalRecord{ + Round: re.Round, + Verdict: in.Verdict, + EvalReport: in.EvalReport, + }) + + cfg := s.Config.ReverseEngineering.Reconcile + forced := in.Verdict == "FAIL" && re.Round >= cfg.MaxRounds + passed := in.Verdict == "PASS" && re.Round >= cfg.MinRounds + + if passed || forced { + if cfg.ColleagueReview { + s.State = StateColleagueReview + } else { + s.State = StateReconcileAdvance + } + } else { + // Loop back: increment round, return to RECONCILE. + re.Round++ + s.State = StateReconcile + } + + return nil +} + +func advanceREFromQueue(s *ForgeState, re *ReverseEngineeringState, in AdvanceInput, dir string) error { + if re.QueueFile == "" { + // First advance: --file required. + if in.File == "" { + return fmt.Errorf("Queue file path required. Use: forgectl advance --file ") + } + data, err := os.ReadFile(in.File) + if err != nil { + return fmt.Errorf("reading queue file %q: %w", in.File, err) + } + errs := ValidateReverseEngineeringQueue(data, dir, re.Domains) + if len(errs) > 0 { + return &ValidationError{Errors: errs} + } + // Store path and hash only after successful validation. + re.QueueFile = in.File + re.QueueHash = computeContentHash(data) + } else { + // Subsequent advance: --file not accepted. + if in.File != "" { + return fmt.Errorf("Queue file path already set to %q. Update that file and run: forgectl advance", re.QueueFile) + } + data, err := os.ReadFile(re.QueueFile) + if err != nil { + return fmt.Errorf("reading queue file %q: %w", re.QueueFile, err) + } + newHash := computeContentHash(data) + if newHash == re.QueueHash { + return fmt.Errorf("Queue file has not changed. Update the file and retry.") + } + errs := ValidateReverseEngineeringQueue(data, dir, re.Domains) + if len(errs) > 0 { + return &ValidationError{Errors: errs} + } + re.QueueHash = newHash + } + + // Determine next state. + if re.CurrentDomain < re.TotalDomains-1 { + re.CurrentDomain++ + s.State = StateSurvey + } else { + s.State = StateExecute + } + return nil +} + +// computeContentHash returns a hex-encoded SHA-256 hash of data. +func computeContentHash(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +// generateExecuteJSON builds an ExecuteJSONFile from queue specs and RE config. +// Only the active mode's config block is included; inactive mode blocks are omitted. +func generateExecuteJSON(specs []ReverseEngineeringQueueEntry, cfg ReverseEngineeringConfig, projectRoot string) ExecuteJSONFile { + config := ExecuteJSONConfig{ + Mode: cfg.Mode, + Drafter: cfg.Drafter, + } + switch cfg.Mode { + case "self_refine": + config.SelfRefine = cfg.SelfRefine + case "multi_pass": + config.MultiPass = cfg.MultiPass + case "peer_review": + config.PeerReview = cfg.PeerReview + } + + execSpecs := make([]ExecuteJSONSpec, len(specs)) + for i, s := range specs { + execSpecs[i] = ExecuteJSONSpec{ + Name: s.Name, + Domain: s.Domain, + Topic: s.Topic, + File: s.File, + Action: s.Action, + CodeSearchRoots: s.CodeSearchRoots, + DependsOn: s.DependsOn, + Result: nil, + } + } + + return ExecuteJSONFile{ + ProjectRoot: projectRoot, + Config: config, + Specs: execSpecs, + } +} + +func advanceREFromExecute(s *ForgeState, re *ReverseEngineeringState, dir string) error { + cfg := s.Config.ReverseEngineering + + // 1. Read queue file. + queueData, err := os.ReadFile(re.QueueFile) + if err != nil { + return fmt.Errorf("reading queue file %q: %w", re.QueueFile, err) + } + var qi ReverseEngineeringQueueInput + if err := json.Unmarshal(queueData, &qi); err != nil { + return fmt.Errorf("parsing queue file: %w", err) + } + + // 2. Reject empty queue. + if len(qi.Specs) == 0 { + return fmt.Errorf("Queue contains zero entries. Nothing to execute.") + } + + // 3. Create //specs/ for each unique domain. + seen := make(map[string]bool) + for _, spec := range qi.Specs { + if seen[spec.Domain] { + continue + } + seen[spec.Domain] = true + specsDir := filepath.Join(dir, spec.Domain, "specs") + if err := os.MkdirAll(specsDir, 0755); err != nil { + return fmt.Errorf("creating specs directory %q: %w", specsDir, err) + } + } + + // 4. Generate execute.json and write to state dir. + executeFile := generateExecuteJSON(qi.Specs, cfg, dir) + + stateDir := s.Config.Paths.StateDir + if !filepath.IsAbs(stateDir) && dir != "" { + stateDir = filepath.Join(dir, stateDir) + } + executeFilePath := filepath.Join(stateDir, "execute.json") + + executeData, err := json.MarshalIndent(executeFile, "", " ") + if err != nil { + return fmt.Errorf("marshaling execute.json: %w", err) + } + if err := os.WriteFile(executeFilePath, executeData, 0644); err != nil { + return fmt.Errorf("writing execute.json %q: %w", executeFilePath, err) + } + + // 5. Store execute file path in state. + re.ExecuteFile = executeFilePath + + // 6. Invoke subprocess. + stderrStr, exitCode := pyRunner(executeFilePath, dir) + + // 7. Read execute.json after subprocess exits. + updatedData, readErr := os.ReadFile(executeFilePath) + if exitCode != 0 && readErr != nil { + // Unreadable results after non-zero exit → STOP message. State stays in EXECUTE. + PrintExecuteFailureOutput(executeOutput, stderrStr) + return nil + } + + // Parse updated results. + var updated ExecuteJSONFile + if parseErr := json.Unmarshal(updatedData, &updated); parseErr != nil { + if exitCode != 0 { + PrintExecuteFailureOutput(executeOutput, stderrStr) + return nil + } + return fmt.Errorf("parsing execute.json results: %w", parseErr) + } + + // 8. All success → advance to RECONCILE. + allSuccess := true + for _, spec := range updated.Specs { + if spec.Result == nil || spec.Result.Status != "success" { + allSuccess = false + break + } + } + + if allSuccess { + re.ReconcileDomain = 0 + re.Round = 1 + s.State = StateReconcile + return nil + } + + // 9. Any failure → output per-entry results, stay in EXECUTE. + fmt.Fprintf(executeOutput, "Phase: reverse_engineering\n") + fmt.Fprintf(executeOutput, "State: EXECUTE\n\n") + fmt.Fprintf(executeOutput, "Some agent sessions failed. Results per entry:\n\n") + for _, spec := range updated.Specs { + if spec.Result == nil { + fmt.Fprintf(executeOutput, " [no result] %s/%s\n", spec.Domain, spec.File) + continue + } + switch spec.Result.Status { + case "success": + fmt.Fprintf(executeOutput, " [success] %s/%s\n", spec.Domain, spec.File) + case "failure": + errDetail := "" + if spec.Result.Error != nil { + errDetail = ": " + *spec.Result.Error + } + fmt.Fprintf(executeOutput, " [failure] %s/%s%s\n", spec.Domain, spec.File, errDetail) + default: + fmt.Fprintf(executeOutput, " [%s] %s/%s\n", spec.Result.Status, spec.Domain, spec.File) + } + } + fmt.Fprintln(executeOutput) + fmt.Fprintf(executeOutput, "Fix failures in execute.json and re-run: forgectl advance\n") + + return nil +} diff --git a/forgectl/state/advance_test.go b/forgectl/state/advance_test.go index 351f9be..39a92c4 100644 --- a/forgectl/state/advance_test.go +++ b/forgectl/state/advance_test.go @@ -2416,3 +2416,960 @@ func advanceImplToCommit(t *testing.T, s *ForgeState, dir string) { t.Fatalf("expected COMMIT, got %s", s.State) } } + +// --- Reverse Engineering Phase Tests --- + +func newReverseEngineeringState(domains []string) *ForgeState { + return &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateOrient, + Config: DefaultForgeConfig(), + ReverseEngineering: NewReverseEngineeringState("understand the codebase", domains, false), + } +} + +// validREQueueJSON returns a valid reverse engineering queue JSON for the given domain. +func validREQueueJSON(domain string) string { + return `{"specs":[{"name":"spec1","domain":"` + domain + `","topic":"topic-1","file":"` + domain + `/specs/spec1.md","action":"create","code_search_roots":["src/"],"depends_on":[]}]}` +} + +// TestReverseEngineeringOrientToSurvey verifies ORIENT sets domain index to 0 and advances to SURVEY. +func TestReverseEngineeringOrientToSurvey(t *testing.T) { + s := newReverseEngineeringState([]string{"optimizer", "api"}) + + if err := Advance(s, AdvanceInput{}, ""); err != nil { + t.Fatal(err) + } + + if s.State != StateSurvey { + t.Fatalf("expected SURVEY, got %s", s.State) + } + if s.ReverseEngineering.CurrentDomain != 0 { + t.Fatalf("expected domain index 0, got %d", s.ReverseEngineering.CurrentDomain) + } +} + +// TestReverseEngineeringPreExecuteSequence verifies SURVEY → GAP_ANALYSIS → DECOMPOSE → QUEUE transitions. +func TestReverseEngineeringPreExecuteSequence(t *testing.T) { + s := newReverseEngineeringState([]string{"optimizer"}) + s.State = StateSurvey + + steps := []StateName{StateGapAnalysis, StateDecompose, StateQueue} + for _, want := range steps { + if err := Advance(s, AdvanceInput{}, ""); err != nil { + t.Fatalf("advance to %s: %v", want, err) + } + if s.State != want { + t.Fatalf("expected %s, got %s", want, s.State) + } + } +} + +// TestReverseEngineeringQueueToSurveyNextDomain verifies QUEUE → SURVEY when more domains remain. +func TestReverseEngineeringQueueToSurveyNextDomain(t *testing.T) { + dir := t.TempDir() + queueFile := filepath.Join(dir, "queue.json") + os.WriteFile(queueFile, []byte(validREQueueJSON("optimizer")), 0644) + + s := newReverseEngineeringState([]string{"optimizer", "api"}) + s.State = StateQueue + s.ReverseEngineering.CurrentDomain = 0 + + // Pass dir="" to skip code_search_roots path validation. + if err := Advance(s, AdvanceInput{File: queueFile}, ""); err != nil { + t.Fatal(err) + } + + if s.State != StateSurvey { + t.Fatalf("expected SURVEY, got %s", s.State) + } + if s.ReverseEngineering.CurrentDomain != 1 { + t.Fatalf("expected domain index 1, got %d", s.ReverseEngineering.CurrentDomain) + } +} + +// TestReverseEngineeringQueueToExecuteLastDomain verifies QUEUE → EXECUTE when processing the last domain. +func TestReverseEngineeringQueueToExecuteLastDomain(t *testing.T) { + dir := t.TempDir() + queueFile := filepath.Join(dir, "queue.json") + initial := validREQueueJSON("optimizer") + os.WriteFile(queueFile, []byte(initial), 0644) + + s := newReverseEngineeringState([]string{"optimizer", "api"}) + s.State = StateQueue + s.ReverseEngineering.CurrentDomain = 1 // last domain (0-based) + // Simulate first domain already validated: QueueFile and QueueHash set with old hash. + s.ReverseEngineering.QueueFile = queueFile + s.ReverseEngineering.QueueHash = "old-hash-value" // differs from actual file content + + // domains: ["optimizer", "api"] — entry domain "optimizer" is valid. + if err := Advance(s, AdvanceInput{}, ""); err != nil { + t.Fatal(err) + } + + if s.State != StateExecute { + t.Fatalf("expected EXECUTE, got %s", s.State) + } +} + +// TestReverseEngineeringQueueFirstAdvanceRequiresFile verifies --file is required on first QUEUE advance. +func TestReverseEngineeringQueueFirstAdvanceRequiresFile(t *testing.T) { + s := newReverseEngineeringState([]string{"optimizer"}) + s.State = StateQueue + s.ReverseEngineering.CurrentDomain = 0 + + err := Advance(s, AdvanceInput{}, "") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "Queue file path required") { + t.Fatalf("unexpected error: %v", err) + } + // State must remain QUEUE. + if s.State != StateQueue { + t.Fatalf("expected state to remain QUEUE, got %s", s.State) + } +} + +// TestReverseEngineeringQueueFirstAdvanceStoresPathAndHash verifies that the first QUEUE advance +// stores the queue file path and content hash in state after successful validation. +func TestReverseEngineeringQueueFirstAdvanceStoresPathAndHash(t *testing.T) { + dir := t.TempDir() + queueFile := filepath.Join(dir, "queue.json") + content := validREQueueJSON("optimizer") + os.WriteFile(queueFile, []byte(content), 0644) + + s := newReverseEngineeringState([]string{"optimizer"}) + s.State = StateQueue + + if err := Advance(s, AdvanceInput{File: queueFile}, ""); err != nil { + t.Fatal(err) + } + + if s.ReverseEngineering.QueueFile != queueFile { + t.Fatalf("QueueFile = %q, want %q", s.ReverseEngineering.QueueFile, queueFile) + } + if s.ReverseEngineering.QueueHash == "" { + t.Fatal("QueueHash must be set after first advance") + } + expectedHash := computeContentHash([]byte(content)) + if s.ReverseEngineering.QueueHash != expectedHash { + t.Fatalf("QueueHash = %q, want %q", s.ReverseEngineering.QueueHash, expectedHash) + } +} + +// TestReverseEngineeringQueueSubsequentAdvanceWithChangedFile verifies that a subsequent QUEUE +// advance with a changed file re-validates and advances state. +func TestReverseEngineeringQueueSubsequentAdvanceWithChangedFile(t *testing.T) { + dir := t.TempDir() + queueFile := filepath.Join(dir, "queue.json") + initial := validREQueueJSON("optimizer") + os.WriteFile(queueFile, []byte(initial), 0644) + + s := newReverseEngineeringState([]string{"optimizer"}) + s.State = StateQueue + s.ReverseEngineering.QueueFile = queueFile + s.ReverseEngineering.QueueHash = "stale-hash" // differs from file content + + if err := Advance(s, AdvanceInput{}, ""); err != nil { + t.Fatal(err) + } + + if s.State != StateExecute { + t.Fatalf("expected EXECUTE, got %s", s.State) + } + // Hash must be updated to match file content. + if s.ReverseEngineering.QueueHash == "stale-hash" { + t.Fatal("QueueHash must be updated after successful subsequent advance") + } +} + +// TestReverseEngineeringQueueSubsequentAdvanceRejectsFile verifies that subsequent QUEUE +// advances reject the --file flag (path is already stored). +func TestReverseEngineeringQueueSubsequentAdvanceRejectsFile(t *testing.T) { + dir := t.TempDir() + queueFile := filepath.Join(dir, "queue.json") + os.WriteFile(queueFile, []byte(validREQueueJSON("optimizer")), 0644) + + s := newReverseEngineeringState([]string{"optimizer"}) + s.State = StateQueue + s.ReverseEngineering.QueueFile = queueFile + s.ReverseEngineering.QueueHash = "old-hash" + + err := Advance(s, AdvanceInput{File: "/other/queue.json"}, "") + if err == nil { + t.Fatal("expected error when --file provided on subsequent advance") + } + if !strings.Contains(err.Error(), "Queue file path already set") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestReverseEngineeringQueueSubsequentAdvanceRejectsUnchangedFile verifies that a subsequent +// QUEUE advance is rejected when the file content has not changed. +func TestReverseEngineeringQueueSubsequentAdvanceRejectsUnchangedFile(t *testing.T) { + dir := t.TempDir() + queueFile := filepath.Join(dir, "queue.json") + content := validREQueueJSON("optimizer") + os.WriteFile(queueFile, []byte(content), 0644) + + s := newReverseEngineeringState([]string{"optimizer"}) + s.State = StateQueue + s.ReverseEngineering.QueueFile = queueFile + s.ReverseEngineering.QueueHash = computeContentHash([]byte(content)) // matches file + + err := Advance(s, AdvanceInput{}, "") + if err == nil { + t.Fatal("expected error when queue file has not changed") + } + if !strings.Contains(err.Error(), "Queue file has not changed") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestReverseEngineeringQueueValidationFailureOnFirstAdvance verifies that schema validation +// errors block the first QUEUE advance and do not store the file path. +func TestReverseEngineeringQueueValidationFailureOnFirstAdvance(t *testing.T) { + dir := t.TempDir() + queueFile := filepath.Join(dir, "queue.json") + // Missing required "code_search_roots" field. + invalid := `{"specs":[{"name":"s1","domain":"optimizer","topic":"t","file":"f.md","action":"create","depends_on":[]}]}` + os.WriteFile(queueFile, []byte(invalid), 0644) + + s := newReverseEngineeringState([]string{"optimizer"}) + s.State = StateQueue + + err := Advance(s, AdvanceInput{File: queueFile}, "") + if err == nil { + t.Fatal("expected validation error") + } + // Path must NOT be stored on validation failure. + if s.ReverseEngineering.QueueFile != "" { + t.Fatalf("QueueFile must not be stored on validation failure, got %q", s.ReverseEngineering.QueueFile) + } +} + +// TestReverseEngineeringQueueDomainMembershipRejection verifies that entries with unrecognized +// domains are rejected at QUEUE advance. +func TestReverseEngineeringQueueDomainMembershipRejection(t *testing.T) { + dir := t.TempDir() + queueFile := filepath.Join(dir, "queue.json") + // Entry has domain "portal" which is not in initialized domains. + content := `{"specs":[{"name":"s1","domain":"portal","topic":"t","file":"f.md","action":"create","code_search_roots":["src/"],"depends_on":[]}]}` + os.WriteFile(queueFile, []byte(content), 0644) + + s := newReverseEngineeringState([]string{"optimizer", "api"}) + s.State = StateQueue + + err := Advance(s, AdvanceInput{File: queueFile}, "") + if err == nil { + t.Fatal("expected domain membership error") + } + // Should be a validation error mentioning the unrecognized domain. + if _, ok := err.(*ValidationError); !ok { + t.Fatalf("expected ValidationError, got %T: %v", err, err) + } +} + +// --- EXECUTE State Tests --- + +// setupREExecuteState creates a state at StateExecute with a valid queue file. +func setupREExecuteState(t *testing.T, dir string, specs []ReverseEngineeringQueueEntry, mode string) *ForgeState { + t.Helper() + domains := uniqueQueueDomains(specs) + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateExecute, + Config: DefaultForgeConfig(), + ReverseEngineering: NewReverseEngineeringState("test concept", domains, false), + } + s.Config.ReverseEngineering.Mode = mode + // Use a relative path so advanceREFromExecute joins it with dir correctly. + s.Config.Paths.StateDir = ".forgectl/state" + os.MkdirAll(filepath.Join(dir, ".forgectl", "state"), 0755) + + qi := ReverseEngineeringQueueInput{Specs: specs} + queueData, _ := json.Marshal(qi) + queueFile := filepath.Join(dir, "queue.json") + os.WriteFile(queueFile, queueData, 0644) + s.ReverseEngineering.QueueFile = queueFile + + return s +} + +func uniqueQueueDomains(specs []ReverseEngineeringQueueEntry) []string { + seen := make(map[string]bool) + var domains []string + for _, s := range specs { + if !seen[s.Domain] { + seen[s.Domain] = true + domains = append(domains, s.Domain) + } + } + return domains +} + +func makeRESpec(name, domain string) ReverseEngineeringQueueEntry { + return ReverseEngineeringQueueEntry{ + Name: name, + Domain: domain, + Topic: "test topic", + File: domain + "/specs/" + name + ".md", + Action: "create", + CodeSearchRoots: []string{"src/"}, + DependsOn: []string{}, + } +} + +// withSuccessRunner replaces pyRunner for the duration of the test, writing +// success results to execute.json before returning exit code 0. +func withSuccessRunner(t *testing.T) func() { + t.Helper() + old := pyRunner + pyRunner = func(executeFilePath, dir string) (string, int) { + data, err := os.ReadFile(executeFilePath) + if err != nil { + return "cannot read execute.json", 1 + } + var ef ExecuteJSONFile + if err := json.Unmarshal(data, &ef); err != nil { + return "cannot parse execute.json", 1 + } + for i := range ef.Specs { + status := "success" + iters := 1 + ef.Specs[i].Result = &ExecuteJSONSpecResult{Status: status, IterationsCompleted: &iters} + } + updated, _ := json.MarshalIndent(ef, "", " ") + os.WriteFile(executeFilePath, updated, 0644) + return "", 0 + } + return func() { pyRunner = old } +} + +// withPartialFailureRunner replaces pyRunner: first entry fails, rest succeed. +func withPartialFailureRunner(t *testing.T) func() { + t.Helper() + old := pyRunner + pyRunner = func(executeFilePath, dir string) (string, int) { + data, _ := os.ReadFile(executeFilePath) + var ef ExecuteJSONFile + json.Unmarshal(data, &ef) + for i := range ef.Specs { + if i == 0 { + errMsg := "agent timed out" + ef.Specs[i].Result = &ExecuteJSONSpecResult{Status: "failure", Error: &errMsg} + } else { + iters := 1 + ef.Specs[i].Result = &ExecuteJSONSpecResult{Status: "success", IterationsCompleted: &iters} + } + } + updated, _ := json.MarshalIndent(ef, "", " ") + os.WriteFile(executeFilePath, updated, 0644) + return "", 0 + } + return func() { pyRunner = old } +} + +// withNonZeroExitRunner replaces pyRunner: exits non-zero and does NOT write execute.json. +func withNonZeroExitRunner(t *testing.T, stderrMsg string) func() { + t.Helper() + old := pyRunner + pyRunner = func(executeFilePath, dir string) (string, int) { + // Remove execute.json to simulate unreadable results. + os.Remove(executeFilePath) + return stderrMsg, 1 + } + return func() { pyRunner = old } +} + +// TestReverseEngineeringExecuteGeneratesExecuteJSON verifies that EXECUTE writes execute.json +// with the correct structure: project_root, active mode config, and all queue entries. +func TestReverseEngineeringExecuteGeneratesExecuteJSON(t *testing.T) { + dir := t.TempDir() + specs := []ReverseEngineeringQueueEntry{makeRESpec("auth-handler", "api")} + s := setupREExecuteState(t, dir, specs, "self_refine") + s.Config.ReverseEngineering.SelfRefine = &SelfRefineConfig{Rounds: 2} + + defer withSuccessRunner(t)() + + if err := Advance(s, AdvanceInput{}, dir); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + // Read execute.json from state dir. + executeFilePath := filepath.Join(dir, ".forgectl", "state", "execute.json") + data, err := os.ReadFile(executeFilePath) + if err != nil { + t.Fatalf("execute.json not written: %v", err) + } + + var ef ExecuteJSONFile + if err := json.Unmarshal(data, &ef); err != nil { + t.Fatalf("invalid execute.json: %v", err) + } + + if ef.ProjectRoot != dir { + t.Errorf("project_root = %q, want %q", ef.ProjectRoot, dir) + } + if ef.Config.Mode != "self_refine" { + t.Errorf("config.mode = %q, want self_refine", ef.Config.Mode) + } + if ef.Config.SelfRefine == nil || ef.Config.SelfRefine.Rounds != 2 { + t.Errorf("config.self_refine not set correctly") + } + if ef.Config.MultiPass != nil { + t.Errorf("inactive multi_pass should be omitted, got %+v", ef.Config.MultiPass) + } + if len(ef.Specs) != 1 || ef.Specs[0].Name != "auth-handler" { + t.Errorf("unexpected specs: %+v", ef.Specs) + } +} + +// TestReverseEngineeringExecuteCreatesSpecsDirectories verifies that EXECUTE creates +// //specs/ directories for each unique domain before invoking the subprocess. +func TestReverseEngineeringExecuteCreatesSpecsDirectories(t *testing.T) { + dir := t.TempDir() + specs := []ReverseEngineeringQueueEntry{ + makeRESpec("spec-a", "api"), + makeRESpec("spec-b", "api"), // same domain — deduped + makeRESpec("spec-c", "billing"), + } + s := setupREExecuteState(t, dir, specs, "single_shot") + + defer withSuccessRunner(t)() + + if err := Advance(s, AdvanceInput{}, dir); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + for _, domain := range []string{"api", "billing"} { + specsDir := filepath.Join(dir, domain, "specs") + if _, err := os.Stat(specsDir); os.IsNotExist(err) { + t.Errorf("specs dir not created: %s", specsDir) + } + } +} + +// TestReverseEngineeringExecuteAllSuccessAdvancesToReconcile verifies that when all subprocess +// results are "success", state advances to RECONCILE with reconcile_domain=0 and round=1. +func TestReverseEngineeringExecuteAllSuccessAdvancesToReconcile(t *testing.T) { + dir := t.TempDir() + specs := []ReverseEngineeringQueueEntry{makeRESpec("spec-a", "api"), makeRESpec("spec-b", "api")} + s := setupREExecuteState(t, dir, specs, "single_shot") + + defer withSuccessRunner(t)() + + if err := Advance(s, AdvanceInput{}, dir); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateReconcile { + t.Errorf("state = %s, want RECONCILE", s.State) + } + if s.ReverseEngineering.ReconcileDomain != 0 { + t.Errorf("reconcile_domain = %d, want 0", s.ReverseEngineering.ReconcileDomain) + } + if s.ReverseEngineering.Round != 1 { + t.Errorf("round = %d, want 1", s.ReverseEngineering.Round) + } +} + +// TestReverseEngineeringExecutePartialFailureStaysInExecute verifies that when any subprocess +// result is "failure", state stays in EXECUTE and per-entry results are written to executeOutput. +func TestReverseEngineeringExecutePartialFailureStaysInExecute(t *testing.T) { + dir := t.TempDir() + specs := []ReverseEngineeringQueueEntry{makeRESpec("spec-a", "api"), makeRESpec("spec-b", "api")} + s := setupREExecuteState(t, dir, specs, "single_shot") + + defer withPartialFailureRunner(t)() + + var buf bytes.Buffer + oldOut := executeOutput + executeOutput = &buf + defer func() { executeOutput = oldOut }() + + if err := Advance(s, AdvanceInput{}, dir); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateExecute { + t.Errorf("state = %s, want EXECUTE (partial failure should stay in EXECUTE)", s.State) + } + + out := buf.String() + if !strings.Contains(out, "failure") { + t.Errorf("expected per-entry failure output, got:\n%s", out) + } + if !strings.Contains(out, "spec-a") { + t.Errorf("expected failed entry name in output, got:\n%s", out) + } + if !strings.Contains(out, "success") { + t.Errorf("expected successful entry in output, got:\n%s", out) + } +} + +// TestReverseEngineeringExecuteEmptyQueueRejected verifies that an empty queue returns an error +// before any subprocess invocation. +func TestReverseEngineeringExecuteEmptyQueueRejected(t *testing.T) { + dir := t.TempDir() + s := setupREExecuteState(t, dir, []ReverseEngineeringQueueEntry{}, "single_shot") + + subprocessCalled := false + old := pyRunner + pyRunner = func(_, _ string) (string, int) { + subprocessCalled = true + return "", 0 + } + defer func() { pyRunner = old }() + + err := Advance(s, AdvanceInput{}, dir) + if err == nil { + t.Fatal("expected error for empty queue") + } + if !strings.Contains(err.Error(), "zero entries") { + t.Errorf("expected 'zero entries' in error, got: %v", err) + } + if subprocessCalled { + t.Error("subprocess must not be invoked for empty queue") + } + if s.State != StateExecute { + t.Errorf("state should stay in EXECUTE, got %s", s.State) + } +} + +// TestReverseEngineeringExecuteSubprocessFailureOutputsStop verifies that when the subprocess +// exits non-zero and execute.json is unreadable, the STOP message is written to executeOutput. +func TestReverseEngineeringExecuteSubprocessFailureOutputsStop(t *testing.T) { + dir := t.TempDir() + specs := []ReverseEngineeringQueueEntry{makeRESpec("spec-a", "api")} + s := setupREExecuteState(t, dir, specs, "single_shot") + + stderrMsg := "Traceback: KeyError 'model'" + defer withNonZeroExitRunner(t, stderrMsg)() + + var buf bytes.Buffer + oldOut := executeOutput + executeOutput = &buf + defer func() { executeOutput = oldOut }() + + if err := Advance(s, AdvanceInput{}, dir); err != nil { + t.Fatalf("Advance should not return error for subprocess failure, got: %v", err) + } + + if s.State != StateExecute { + t.Errorf("state = %s, want EXECUTE after subprocess failure", s.State) + } + out := buf.String() + if !strings.Contains(out, "STOP") { + t.Errorf("expected STOP in output, got:\n%s", out) + } + if !strings.Contains(out, stderrMsg) { + t.Errorf("expected stderr in output, got:\n%s", out) + } +} + +// TestReverseEngineeringExecuteWorksFromAnyDir verifies that EXECUTE uses absolute paths +// so it works correctly regardless of the current working directory. +// TestReverseEngineeringReconcileAdvancesToReconcileEval verifies that advancing from RECONCILE +// transitions to RECONCILE_EVAL with no other state mutation. +func TestReverseEngineeringReconcileAdvancesToReconcileEval(t *testing.T) { + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateReconcile, + Config: DefaultForgeConfig(), + ReverseEngineering: NewReverseEngineeringState("concept", []string{"api"}, false), + } + s.ReverseEngineering.ReconcileDomain = 0 + s.ReverseEngineering.Round = 1 + + if err := Advance(s, AdvanceInput{}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateReconcileEval { + t.Errorf("state = %s, want RECONCILE_EVAL", s.State) + } + if s.ReverseEngineering.ReconcileDomain != 0 { + t.Errorf("reconcile_domain mutated, want 0, got %d", s.ReverseEngineering.ReconcileDomain) + } + if s.ReverseEngineering.Round != 1 { + t.Errorf("round mutated, want 1, got %d", s.ReverseEngineering.Round) + } +} + +func TestReverseEngineeringExecuteWorksFromAnyDir(t *testing.T) { + dir := t.TempDir() + specs := []ReverseEngineeringQueueEntry{makeRESpec("spec-a", "api")} + s := setupREExecuteState(t, dir, specs, "single_shot") + + // Capture the executeFilePath passed to the subprocess. + var capturedPath string + old := pyRunner + pyRunner = func(executeFilePath, runDir string) (string, int) { + capturedPath = executeFilePath + // Write success results. + data, _ := os.ReadFile(executeFilePath) + var ef ExecuteJSONFile + json.Unmarshal(data, &ef) + iters := 1 + for i := range ef.Specs { + ef.Specs[i].Result = &ExecuteJSONSpecResult{Status: "success", IterationsCompleted: &iters} + } + updated, _ := json.MarshalIndent(ef, "", " ") + os.WriteFile(executeFilePath, updated, 0644) + return "", 0 + } + defer func() { pyRunner = old }() + + if err := Advance(s, AdvanceInput{}, dir); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if !filepath.IsAbs(capturedPath) { + t.Errorf("execute file path passed to subprocess is not absolute: %q", capturedPath) + } + if s.ReverseEngineering.ExecuteFile != capturedPath { + t.Errorf("state.execute_file = %q, want %q", s.ReverseEngineering.ExecuteFile, capturedPath) + } +} + +// setupREReconcileEvalState returns a state in RECONCILE_EVAL with the given round and config. +func setupREReconcileEvalState(round, minRounds, maxRounds int, colleagueReview bool) *ForgeState { + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateReconcileEval, + Config: DefaultForgeConfig(), + ReverseEngineering: NewReverseEngineeringState("concept", []string{"api"}, false), + } + s.ReverseEngineering.Round = round + s.ReverseEngineering.ReconcileDomain = 0 + s.Config.ReverseEngineering.Reconcile.MinRounds = minRounds + s.Config.ReverseEngineering.Reconcile.MaxRounds = maxRounds + s.Config.ReverseEngineering.Reconcile.ColleagueReview = colleagueReview + return s +} + +// TestREReconcileEvalPassBelowMinLoopsBack verifies PASS before min_rounds loops back to RECONCILE +// and increments round. +func TestREReconcileEvalPassBelowMinLoopsBack(t *testing.T) { + s := setupREReconcileEvalState(1, 2, 3, false) + + if err := Advance(s, AdvanceInput{Verdict: "PASS"}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateReconcile { + t.Errorf("state = %s, want RECONCILE", s.State) + } + if s.ReverseEngineering.Round != 2 { + t.Errorf("round = %d, want 2", s.ReverseEngineering.Round) + } +} + +// TestREReconcileEvalFailBelowMaxLoopsBack verifies FAIL before max_rounds loops back to RECONCILE +// and increments round. +func TestREReconcileEvalFailBelowMaxLoopsBack(t *testing.T) { + s := setupREReconcileEvalState(1, 1, 3, false) + + if err := Advance(s, AdvanceInput{Verdict: "FAIL"}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateReconcile { + t.Errorf("state = %s, want RECONCILE", s.State) + } + if s.ReverseEngineering.Round != 2 { + t.Errorf("round = %d, want 2", s.ReverseEngineering.Round) + } +} + +// TestREReconcileEvalPassAtMinNoColleagueAdvancesToReconcileAdvance verifies PASS at min_rounds +// without colleague_review advances to RECONCILE_ADVANCE. +func TestREReconcileEvalPassAtMinNoColleagueAdvancesToReconcileAdvance(t *testing.T) { + s := setupREReconcileEvalState(1, 1, 3, false) + + if err := Advance(s, AdvanceInput{Verdict: "PASS"}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateReconcileAdvance { + t.Errorf("state = %s, want RECONCILE_ADVANCE", s.State) + } +} + +// TestREReconcileEvalPassAtMinWithColleagueAdvancesToColleagueReview verifies PASS at min_rounds +// with colleague_review enabled advances to COLLEAGUE_REVIEW. +func TestREReconcileEvalPassAtMinWithColleagueAdvancesToColleagueReview(t *testing.T) { + s := setupREReconcileEvalState(1, 1, 3, true) + + if err := Advance(s, AdvanceInput{Verdict: "PASS"}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateColleagueReview { + t.Errorf("state = %s, want COLLEAGUE_REVIEW", s.State) + } +} + +// TestREReconcileEvalFailAtMaxNoColleagueAdvancesToReconcileAdvance verifies FAIL at max_rounds +// without colleague_review advances to RECONCILE_ADVANCE (force-advance). +func TestREReconcileEvalFailAtMaxNoColleagueAdvancesToReconcileAdvance(t *testing.T) { + s := setupREReconcileEvalState(3, 1, 3, false) + + if err := Advance(s, AdvanceInput{Verdict: "FAIL"}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateReconcileAdvance { + t.Errorf("state = %s, want RECONCILE_ADVANCE", s.State) + } +} + +// TestREReconcileEvalMissingVerdictReturnsError verifies that advancing without --verdict +// returns an error and state does not change. +func TestREReconcileEvalMissingVerdictReturnsError(t *testing.T) { + s := setupREReconcileEvalState(1, 1, 3, false) + + err := Advance(s, AdvanceInput{}, "") + if err == nil { + t.Fatal("expected error for missing verdict") + } + if !strings.Contains(err.Error(), "--verdict") { + t.Errorf("expected '--verdict' in error, got: %v", err) + } + if s.State != StateReconcileEval { + t.Errorf("state should stay RECONCILE_EVAL, got %s", s.State) + } +} + +// TestREColleagueReviewAdvancesToReconcileAdvance verifies that advancing from COLLEAGUE_REVIEW +// always transitions to RECONCILE_ADVANCE with no other state mutation. +func TestREColleagueReviewAdvancesToReconcileAdvance(t *testing.T) { + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateColleagueReview, + Config: DefaultForgeConfig(), + ReverseEngineering: NewReverseEngineeringState("concept", []string{"api"}, false), + } + s.ReverseEngineering.Round = 2 + + if err := Advance(s, AdvanceInput{}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateReconcileAdvance { + t.Errorf("state = %s, want RECONCILE_ADVANCE", s.State) + } + if s.ReverseEngineering.Round != 2 { + t.Errorf("round mutated, want 2, got %d", s.ReverseEngineering.Round) + } +} + +// TestREReconcileAdvanceMoreDomainsGoesToReconcile verifies that when more domains remain, +// RECONCILE_ADVANCE increments reconcile_domain, resets round to 1, clears evals, and +// returns to RECONCILE. +func TestREReconcileAdvanceMoreDomainsGoesToReconcile(t *testing.T) { + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateReconcileAdvance, + Config: DefaultForgeConfig(), + ReverseEngineering: NewReverseEngineeringState("concept", []string{"api", "billing"}, false), + } + s.ReverseEngineering.ReconcileDomain = 0 + s.ReverseEngineering.Round = 2 + s.ReverseEngineering.Evals = []EvalRecord{{Round: 1, Verdict: "PASS"}} + + if err := Advance(s, AdvanceInput{}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateReconcile { + t.Errorf("state = %s, want RECONCILE", s.State) + } + if s.ReverseEngineering.ReconcileDomain != 1 { + t.Errorf("reconcile_domain = %d, want 1", s.ReverseEngineering.ReconcileDomain) + } + if s.ReverseEngineering.Round != 1 { + t.Errorf("round = %d, want 1 (reset)", s.ReverseEngineering.Round) + } + if len(s.ReverseEngineering.Evals) != 0 { + t.Errorf("evals not cleared, got %d entries", len(s.ReverseEngineering.Evals)) + } +} + +// TestREReconcileAdvanceLastDomainGoesToDone verifies that when on the last domain, +// RECONCILE_ADVANCE transitions to DONE. +func TestREReconcileAdvanceLastDomainGoesToDone(t *testing.T) { + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateReconcileAdvance, + Config: DefaultForgeConfig(), + ReverseEngineering: NewReverseEngineeringState("concept", []string{"api"}, false), + } + s.ReverseEngineering.ReconcileDomain = 0 + + if err := Advance(s, AdvanceInput{}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateDone { + t.Errorf("state = %s, want DONE", s.State) + } +} + +// TestREReconcileAdvanceSingleDomainGoesToDone verifies the edge case where total_domains == 1: +// RECONCILE_ADVANCE transitions immediately to DONE without any domain increment. +func TestREReconcileAdvanceSingleDomainGoesToDone(t *testing.T) { + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateReconcileAdvance, + Config: DefaultForgeConfig(), + ReverseEngineering: NewReverseEngineeringState("concept", []string{"only-domain"}, false), + } + s.ReverseEngineering.ReconcileDomain = 0 // only domain + + if err := Advance(s, AdvanceInput{}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if s.State != StateDone { + t.Errorf("state = %s, want DONE", s.State) + } + if s.ReverseEngineering.ReconcileDomain != 0 { + t.Errorf("reconcile_domain should not change for single domain, got %d", s.ReverseEngineering.ReconcileDomain) + } +} + +// makeRELogger creates a Logger writing to a temp file and a readEntries helper. +func makeRELogger(t *testing.T) (*Logger, func() []LogEntry) { + t.Helper() + path := filepath.Join(t.TempDir(), "test.jsonl") + logger := &Logger{enabled: true, path: path} + read := func() []LogEntry { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var entries []LogEntry + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if line == "" { + continue + } + var e LogEntry + if json.Unmarshal([]byte(line), &e) == nil { + entries = append(entries, e) + } + } + return entries + } + return logger, read +} + +// TestRELoggingDomainStateContext verifies that advance in reverse_engineering phase produces +// a JSONL log entry containing domain, domain_index, and total_domains. +func TestRELoggingDomainStateContext(t *testing.T) { + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateOrient, + Config: DefaultForgeConfig(), + ReverseEngineering: NewReverseEngineeringState("concept", []string{"api", "billing"}, false), + } + logger, readEntries := makeRELogger(t) + s.Logger = logger + + if err := Advance(s, AdvanceInput{}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + entries := readEntries() + if len(entries) == 0 { + t.Fatal("expected log entry, got none") + } + e := entries[0] + if e.Detail["domain"] != "api" { + t.Errorf("detail.domain = %v, want api", e.Detail["domain"]) + } + if e.Detail["domain_index"] != float64(0) { + t.Errorf("detail.domain_index = %v, want 0", e.Detail["domain_index"]) + } + if e.Detail["total_domains"] != float64(2) { + t.Errorf("detail.total_domains = %v, want 2", e.Detail["total_domains"]) + } +} + +// TestRELoggingExecuteIncludesModeAndSpecCount verifies that an EXECUTE state advance log entry +// includes mode and spec_count in the detail. +func TestRELoggingExecuteIncludesModeAndSpecCount(t *testing.T) { + dir := t.TempDir() + specs := []ReverseEngineeringQueueEntry{ + makeRESpec("spec-a", "api"), + makeRESpec("spec-b", "api"), + makeRESpec("spec-c", "billing"), + } + s := setupREExecuteState(t, dir, specs, "self_refine") + defer withSuccessRunner(t)() + + logger, readEntries := makeRELogger(t) + s.Logger = logger + + if err := Advance(s, AdvanceInput{}, dir); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + entries := readEntries() + if len(entries) == 0 { + t.Fatal("expected log entry, got none") + } + e := entries[0] + if e.Detail["mode"] != "self_refine" { + t.Errorf("detail.mode = %v, want self_refine", e.Detail["mode"]) + } + if e.Detail["spec_count"] != float64(3) { + t.Errorf("detail.spec_count = %v, want 3", e.Detail["spec_count"]) + } +} + +// TestRELoggingReconcileEvalIncludesRoundAndVerdict verifies that a RECONCILE_EVAL advance log +// entry includes round and verdict. +func TestRELoggingReconcileEvalIncludesRoundAndVerdict(t *testing.T) { + s := setupREReconcileEvalState(2, 1, 3, false) + logger, readEntries := makeRELogger(t) + s.Logger = logger + + if err := Advance(s, AdvanceInput{Verdict: "PASS"}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + entries := readEntries() + if len(entries) == 0 { + t.Fatal("expected log entry, got none") + } + e := entries[0] + if e.Detail["round"] != float64(2) { + t.Errorf("detail.round = %v, want 2", e.Detail["round"]) + } + if e.Detail["verdict"] != "PASS" { + t.Errorf("detail.verdict = %v, want PASS", e.Detail["verdict"]) + } +} + +// TestREReconcileEvalRecordsEval verifies that each advance appends an EvalRecord with the correct +// round and verdict. +func TestREReconcileEvalRecordsEval(t *testing.T) { + s := setupREReconcileEvalState(1, 2, 3, false) + + if err := Advance(s, AdvanceInput{Verdict: "FAIL"}, ""); err != nil { + t.Fatalf("Advance failed: %v", err) + } + + if len(s.ReverseEngineering.Evals) != 1 { + t.Fatalf("evals = %d, want 1", len(s.ReverseEngineering.Evals)) + } + if s.ReverseEngineering.Evals[0].Round != 1 { + t.Errorf("eval round = %d, want 1", s.ReverseEngineering.Evals[0].Round) + } + if s.ReverseEngineering.Evals[0].Verdict != "FAIL" { + t.Errorf("eval verdict = %q, want FAIL", s.ReverseEngineering.Evals[0].Verdict) + } +} diff --git a/forgectl/state/config.go b/forgectl/state/config.go index 55b6d06..89a4f04 100644 --- a/forgectl/state/config.go +++ b/forgectl/state/config.go @@ -89,6 +89,57 @@ type tomlImplementingConfig struct { Eval tomlEvalConfig `toml:"eval"` } +// tomlSubAgentConfig mirrors SubAgentConfig for TOML decoding. +type tomlSubAgentConfig struct { + Model string `toml:"model"` + Type string `toml:"type"` + Count int `toml:"count"` +} + +// tomlDrafterConfig mirrors DrafterConfig for TOML decoding. +type tomlDrafterConfig struct { + Model string `toml:"model"` + Subagents tomlSubAgentConfig `toml:"subagents"` +} + +// tomlSelfRefineConfig mirrors SelfRefineConfig for TOML decoding. +type tomlSelfRefineConfig struct { + Rounds int `toml:"rounds"` +} + +// tomlMultiPassConfig mirrors MultiPassConfig for TOML decoding. +type tomlMultiPassConfig struct { + Passes int `toml:"passes"` +} + +// tomlPeerReviewConfig mirrors PeerReviewConfig for TOML decoding. +type tomlPeerReviewConfig struct { + Reviewers int `toml:"reviewers"` + Rounds int `toml:"rounds"` + Subagents tomlSubAgentConfig `toml:"subagents"` +} + +// tomlReconcileConfig mirrors ReconcileConfig for TOML decoding. +type tomlReconcileConfig struct { + MinRounds int `toml:"min_rounds"` + MaxRounds int `toml:"max_rounds"` + ColleagueReview *bool `toml:"colleague_review"` + Eval tomlAgentConfig `toml:"eval"` +} + +// tomlReverseEngineeringConfig mirrors ReverseEngineeringConfig for TOML decoding. +// Mode-specific configs use pointers so absent blocks can be distinguished from zero values. +type tomlReverseEngineeringConfig struct { + Mode string `toml:"mode"` + Drafter tomlDrafterConfig `toml:"drafter"` + SelfRefine *tomlSelfRefineConfig `toml:"self_refine"` + MultiPass *tomlMultiPassConfig `toml:"multi_pass"` + PeerReview *tomlPeerReviewConfig `toml:"peer_review"` + Reconcile tomlReconcileConfig `toml:"reconcile"` + Survey tomlSubAgentConfig `toml:"survey"` + GapAnalysis tomlSubAgentConfig `toml:"gap_analysis"` +} + // tomlDomainConfig mirrors DomainConfig for TOML decoding. type tomlDomainConfig struct { Name string `toml:"name"` @@ -116,13 +167,14 @@ type tomlGeneralConfig struct { // tomlForgeConfig is the intermediate struct for TOML decoding of .forgectl/config. type tomlForgeConfig struct { - General tomlGeneralConfig `toml:"general"` - Domains []tomlDomainConfig `toml:"domains"` - Specifying tomlSpecifyingConfig `toml:"specifying"` - Planning tomlPlanningConfig `toml:"planning"` - Implementing tomlImplementingConfig `toml:"implementing"` - Paths tomlPathsConfig `toml:"paths"` - Logs tomlLogsConfig `toml:"logs"` + General tomlGeneralConfig `toml:"general"` + Domains []tomlDomainConfig `toml:"domains"` + Specifying tomlSpecifyingConfig `toml:"specifying"` + Planning tomlPlanningConfig `toml:"planning"` + Implementing tomlImplementingConfig `toml:"implementing"` + ReverseEngineering tomlReverseEngineeringConfig `toml:"reverse_engineering"` + Paths tomlPathsConfig `toml:"paths"` + Logs tomlLogsConfig `toml:"logs"` } // FindProjectRoot walks up from startDir until it finds a directory containing .forgectl/. @@ -239,6 +291,9 @@ func mergeTomlConfig(cfg *ForgeConfig, raw *tomlForgeConfig) { } mergeEvalConfig(&cfg.Implementing.Eval, &raw.Implementing.Eval) + // ReverseEngineering + mergeReverseEngineeringConfig(&cfg.ReverseEngineering, &raw.ReverseEngineering) + // Paths if raw.Paths.StateDir != "" { cfg.Paths.StateDir = raw.Paths.StateDir @@ -331,6 +386,105 @@ func mergeReconciliationConfig(dst *ReconciliationConfig, src *tomlReconciliatio } } +func mergeSubAgentConfig(dst *SubAgentConfig, src *tomlSubAgentConfig) { + if src.Model != "" { + dst.Model = src.Model + } + if src.Type != "" { + dst.Type = src.Type + } + if src.Count > 0 { + dst.Count = src.Count + } +} + +// mergeReverseEngineeringConfig applies non-zero TOML values onto the default +// ReverseEngineeringConfig. Only the active mode's config block is populated; +// inactive mode blocks are not stored. +func mergeReverseEngineeringConfig(dst *ReverseEngineeringConfig, src *tomlReverseEngineeringConfig) { + if src.Mode != "" { + dst.Mode = src.Mode + } + + // Drafter + if src.Drafter.Model != "" { + dst.Drafter.Model = src.Drafter.Model + } + mergeSubAgentConfig(&dst.Drafter.Subagents, &src.Drafter.Subagents) + + // Only populate the active mode's config block. + activeMode := dst.Mode + if src.SelfRefine != nil && activeMode == "self_refine" { + if dst.SelfRefine == nil { + dst.SelfRefine = &SelfRefineConfig{} + } + if src.SelfRefine.Rounds > 0 { + dst.SelfRefine.Rounds = src.SelfRefine.Rounds + } + } + if src.MultiPass != nil && activeMode == "multi_pass" { + if dst.MultiPass == nil { + dst.MultiPass = &MultiPassConfig{} + } + if src.MultiPass.Passes > 0 { + dst.MultiPass.Passes = src.MultiPass.Passes + } + } + if src.PeerReview != nil && activeMode == "peer_review" { + if dst.PeerReview == nil { + dst.PeerReview = &PeerReviewConfig{} + } + if src.PeerReview.Reviewers > 0 { + dst.PeerReview.Reviewers = src.PeerReview.Reviewers + } + if src.PeerReview.Rounds > 0 { + dst.PeerReview.Rounds = src.PeerReview.Rounds + } + mergeSubAgentConfig(&dst.PeerReview.Subagents, &src.PeerReview.Subagents) + } + + // Reconcile + if src.Reconcile.MinRounds > 0 { + dst.Reconcile.MinRounds = src.Reconcile.MinRounds + } + if src.Reconcile.MaxRounds > 0 { + dst.Reconcile.MaxRounds = src.Reconcile.MaxRounds + } + if src.Reconcile.ColleagueReview != nil { + dst.Reconcile.ColleagueReview = *src.Reconcile.ColleagueReview + } + if src.Reconcile.Eval.Model != "" { + dst.Reconcile.Eval.Model = src.Reconcile.Eval.Model + } + if src.Reconcile.Eval.Type != "" { + dst.Reconcile.Eval.Type = src.Reconcile.Eval.Type + } + if src.Reconcile.Eval.Count > 0 { + dst.Reconcile.Eval.Count = src.Reconcile.Eval.Count + } + + // Survey and GapAnalysis sub-agent configs + mergeSubAgentConfig(&dst.Survey, &src.Survey) + mergeSubAgentConfig(&dst.GapAnalysis, &src.GapAnalysis) + + // Clear inactive mode configs — only the active mode's block is stored. + switch activeMode { + case "single_shot": + dst.SelfRefine = nil + dst.MultiPass = nil + dst.PeerReview = nil + case "self_refine": + dst.MultiPass = nil + dst.PeerReview = nil + case "multi_pass": + dst.SelfRefine = nil + dst.PeerReview = nil + case "peer_review": + dst.SelfRefine = nil + dst.MultiPass = nil + } +} + // GenerateSessionID returns a new UUID v4 string using crypto/rand. func GenerateSessionID() string { var b [16]byte @@ -394,6 +548,30 @@ func ValidateConfig(cfg ForgeConfig) []string { errs = append(errs, "implementing.eval.min_rounds cannot exceed max_rounds") } + // ReverseEngineering validation. + validModes := map[string]bool{ + "single_shot": true, "self_refine": true, + "multi_pass": true, "peer_review": true, + } + if cfg.ReverseEngineering.Mode != "" && !validModes[cfg.ReverseEngineering.Mode] { + errs = append(errs, fmt.Sprintf("reverse_engineering.mode: invalid value %q (must be single_shot, self_refine, multi_pass, or peer_review)", cfg.ReverseEngineering.Mode)) + } + if cfg.ReverseEngineering.Reconcile.MinRounds > cfg.ReverseEngineering.Reconcile.MaxRounds { + errs = append(errs, "reverse_engineering.reconcile.min_rounds cannot exceed max_rounds") + } + if cfg.ReverseEngineering.Drafter.Subagents.Count < 1 { + errs = append(errs, "reverse_engineering.drafter.subagents.count must be >= 1") + } + if cfg.ReverseEngineering.Survey.Count < 1 { + errs = append(errs, "reverse_engineering.survey.count must be >= 1") + } + if cfg.ReverseEngineering.GapAnalysis.Count < 1 { + errs = append(errs, "reverse_engineering.gap_analysis.count must be >= 1") + } + if cfg.ReverseEngineering.PeerReview != nil && cfg.ReverseEngineering.PeerReview.Reviewers < 1 { + errs = append(errs, "reverse_engineering.peer_review.reviewers must be >= 1") + } + // No domain path is a prefix of another domain path. for i, d1 := range cfg.Domains { for j, d2 := range cfg.Domains { diff --git a/forgectl/state/config_test.go b/forgectl/state/config_test.go index 9e65254..bb605cc 100644 --- a/forgectl/state/config_test.go +++ b/forgectl/state/config_test.go @@ -289,3 +289,147 @@ func TestValidateConfigLogsRetentionDaysNegative(t *testing.T) { t.Error("expected violation for logs.retention_days=-1") } } + +// TestDefaultForgeConfigReverseEngineeringDefaults verifies reverse_engineering defaults are valid. +func TestDefaultForgeConfigReverseEngineeringDefaults(t *testing.T) { + cfg := DefaultForgeConfig() + re := cfg.ReverseEngineering + + if re.Mode != "self_refine" { + t.Errorf("reverse_engineering.mode: got %q, want %q", re.Mode, "self_refine") + } + if re.Drafter.Model == "" { + t.Error("reverse_engineering.drafter.model must not be empty") + } + if re.Drafter.Subagents.Count < 1 { + t.Errorf("reverse_engineering.drafter.subagents.count: got %d, must be >= 1", re.Drafter.Subagents.Count) + } + if re.Survey.Count < 1 { + t.Errorf("reverse_engineering.survey.count: got %d, must be >= 1", re.Survey.Count) + } + if re.GapAnalysis.Count < 1 { + t.Errorf("reverse_engineering.gap_analysis.count: got %d, must be >= 1", re.GapAnalysis.Count) + } + if re.Reconcile.MinRounds > re.Reconcile.MaxRounds { + t.Errorf("reverse_engineering.reconcile: min_rounds (%d) > max_rounds (%d)", re.Reconcile.MinRounds, re.Reconcile.MaxRounds) + } + if re.SelfRefine == nil { + t.Error("default mode is self_refine, SelfRefine config must be non-nil") + } + + // No errors from ValidateConfig. + errs := ValidateConfig(cfg) + if len(errs) > 0 { + t.Errorf("ValidateConfig on defaults: unexpected errors: %v", errs) + } +} + +// TestLoadConfigReverseEngineeringToml verifies TOML parsing for reverse_engineering section. +func TestLoadConfigReverseEngineeringToml(t *testing.T) { + dir := t.TempDir() + forgectlDir := filepath.Join(dir, ".forgectl") + if err := os.MkdirAll(forgectlDir, 0755); err != nil { + t.Fatal(err) + } + + tomlContent := ` +[reverse_engineering] +mode = "multi_pass" + +[reverse_engineering.drafter] +model = "haiku" + +[reverse_engineering.drafter.subagents] +model = "sonnet" +type = "explorer" +count = 5 + +[reverse_engineering.multi_pass] +passes = 3 + +[reverse_engineering.reconcile] +min_rounds = 1 +max_rounds = 2 +colleague_review = true + +[reverse_engineering.survey] +model = "haiku" +type = "explorer" +count = 3 +` + if err := os.WriteFile(filepath.Join(forgectlDir, "config"), []byte(tomlContent), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadConfig(dir) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + re := cfg.ReverseEngineering + if re.Mode != "multi_pass" { + t.Errorf("mode: got %q, want multi_pass", re.Mode) + } + if re.Drafter.Model != "haiku" { + t.Errorf("drafter.model: got %q, want haiku", re.Drafter.Model) + } + if re.Drafter.Subagents.Count != 5 { + t.Errorf("drafter.subagents.count: got %d, want 5", re.Drafter.Subagents.Count) + } + if re.MultiPass == nil || re.MultiPass.Passes != 3 { + t.Error("multi_pass.passes: expected 3") + } + // Inactive mode config must not be populated. + if re.SelfRefine != nil { + t.Error("self_refine must be nil when mode=multi_pass") + } + if re.PeerReview != nil { + t.Error("peer_review must be nil when mode=multi_pass") + } + if !re.Reconcile.ColleagueReview { + t.Error("reconcile.colleague_review: want true") + } + if re.Survey.Count != 3 { + t.Errorf("survey.count: got %d, want 3", re.Survey.Count) + } +} + +// TestValidateConfigReverseEngineeringInvalidMode verifies invalid mode is rejected. +func TestValidateConfigReverseEngineeringInvalidMode(t *testing.T) { + cfg := DefaultForgeConfig() + cfg.ReverseEngineering.Mode = "turbo" + errs := ValidateConfig(cfg) + if len(errs) == 0 { + t.Error("expected error for invalid reverse_engineering.mode") + } + found := false + for _, e := range errs { + if strings.Contains(e, "reverse_engineering.mode") { + found = true + } + } + if !found { + t.Errorf("expected reverse_engineering.mode error, got: %v", errs) + } +} + +// TestValidateConfigReverseEngineeringReconcileMinExceedsMax verifies min > max is rejected. +func TestValidateConfigReverseEngineeringReconcileMinExceedsMax(t *testing.T) { + cfg := DefaultForgeConfig() + cfg.ReverseEngineering.Reconcile.MinRounds = 5 + cfg.ReverseEngineering.Reconcile.MaxRounds = 2 + errs := ValidateConfig(cfg) + if len(errs) == 0 { + t.Error("expected error when reconcile.min_rounds > max_rounds") + } +} + +// TestValidateConfigReverseEngineeringSubAgentCountBelowOne verifies count < 1 is rejected. +func TestValidateConfigReverseEngineeringSubAgentCountBelowOne(t *testing.T) { + cfg := DefaultForgeConfig() + cfg.ReverseEngineering.Drafter.Subagents.Count = 0 + errs := ValidateConfig(cfg) + if len(errs) == 0 { + t.Error("expected error for drafter.subagents.count < 1") + } +} diff --git a/forgectl/state/output.go b/forgectl/state/output.go index 71d92c1..f1eadd7 100644 --- a/forgectl/state/output.go +++ b/forgectl/state/output.go @@ -1,6 +1,7 @@ package state import ( + "encoding/json" "fmt" "io" "os" @@ -21,6 +22,8 @@ func PrintAdvanceOutput(w io.Writer, s *ForgeState, dir string) { printPlanningOutput(w, s, dir) case PhaseImplementing: printImplementingOutput(w, s, dir) + case PhaseReverseEngineering: + printReverseEngineeringOutput(w, s, dir) } // Phase shift output is printed regardless of phase. @@ -1031,8 +1034,14 @@ func PrintStatus(w io.Writer, s *ForgeState, dir string, verbose bool) { fmt.Fprintln(w) // Phase-appropriate config values. - batch, minRounds, maxRounds := phaseConfig(s) - fmt.Fprintf(w, "Config: batch=%d, rounds=%d-%d, guided=%v\n", batch, minRounds, maxRounds, s.Config.General.UserGuided) + if s.Phase == PhaseReverseEngineering { + cfg := s.Config.ReverseEngineering + fmt.Fprintf(w, "Config: mode=%s, rounds=%d-%d, guided=%v\n", + cfg.Mode, cfg.Reconcile.MinRounds, cfg.Reconcile.MaxRounds, s.Config.General.UserGuided) + } else { + batch, minRounds, maxRounds := phaseConfig(s) + fmt.Fprintf(w, "Config: batch=%d, rounds=%d-%d, guided=%v\n", batch, minRounds, maxRounds, s.Config.General.UserGuided) + } fmt.Fprintln(w) // Current state + action. @@ -1155,6 +1164,35 @@ func PrintStatus(w io.Writer, s *ForgeState, dir string, verbose bool) { } fmt.Fprintln(w) } + + // Verbose: Reverse Engineering section. + if s.ReverseEngineering != nil && s.Phase == PhaseReverseEngineering { + re := s.ReverseEngineering + fmt.Fprintf(w, "--- Reverse Engineering ---\n\n") + fmt.Fprintf(w, " Concept: %s\n", re.Concept) + fmt.Fprintf(w, " Domains: %d total\n", re.TotalDomains) + for i, d := range re.Domains { + marker := " " + if i == re.CurrentDomain || i == re.ReconcileDomain { + marker = "→ " + } + fmt.Fprintf(w, " %s%s (%d/%d)\n", marker, d, i+1, re.TotalDomains) + } + if re.QueueFile != "" { + fmt.Fprintf(w, "\n Queue: %s\n", re.QueueFile) + } + if len(re.Evals) > 0 { + fmt.Fprintf(w, "\n Evals:\n") + for _, e := range re.Evals { + fmt.Fprintf(w, " Round %d: %s", e.Round, e.Verdict) + if e.EvalReport != "" { + fmt.Fprintf(w, " — %s", e.EvalReport) + } + fmt.Fprintln(w) + } + } + fmt.Fprintln(w) + } } // phaseConfig returns the batch size and round bounds for the current phase. @@ -1164,6 +1202,8 @@ func phaseConfig(s *ForgeState) (batch, minRounds, maxRounds int) { return s.Config.Specifying.Batch, s.Config.Specifying.Eval.MinRounds, s.Config.Specifying.Eval.MaxRounds case PhasePlanning: return s.Config.Planning.Batch, s.Config.Planning.Eval.MinRounds, s.Config.Planning.Eval.MaxRounds + case PhaseReverseEngineering: + return 1, s.Config.ReverseEngineering.Reconcile.MinRounds, s.Config.ReverseEngineering.Reconcile.MaxRounds default: // implementing return s.Config.Implementing.Batch, s.Config.Implementing.Eval.MinRounds, s.Config.Implementing.Eval.MaxRounds } @@ -1204,6 +1244,13 @@ func printProgressLine(w io.Writer, s *ForgeState, dir string) { } total := passed + failed + remaining fmt.Fprintf(w, "Progress: %d/%d passed, %d failed, %d remaining\n", passed, total, failed, remaining) + + case PhaseReverseEngineering: + if s.ReverseEngineering == nil { + return + } + re := s.ReverseEngineering + fmt.Fprintf(w, "Progress: domain %d/%d, concept: %s\n", re.CurrentDomain+1, re.TotalDomains, re.Concept) } } @@ -1461,6 +1508,419 @@ func PrintCrossRefEvalOutput(w io.Writer, s *ForgeState) error { return nil } +// --- Reverse Engineering --- + +func printReverseEngineeringOutput(w io.Writer, s *ForgeState, dir string) { + re := s.ReverseEngineering + if re == nil { + fmt.Fprintf(w, "State: %s\n", s.State) + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "Error: reverse engineering state is nil\n") + return + } + + cfg := s.Config.ReverseEngineering + + domainAt := func(idx int) string { + if idx >= 0 && idx < len(re.Domains) { + return re.Domains[idx] + } + return "unknown" + } + + // loadQueueEntries reads the queue file and returns entries for the given domain. + loadDomainEntries := func(domain string) []ReverseEngineeringQueueEntry { + if re.QueueFile == "" { + return nil + } + data, err := os.ReadFile(re.QueueFile) + if err != nil { + return nil + } + var qi ReverseEngineeringQueueInput + if json.Unmarshal(data, &qi) != nil { + return nil + } + var out []ReverseEngineeringQueueEntry + for _, e := range qi.Specs { + if e.Domain == domain { + out = append(out, e) + } + } + return out + } + + switch s.State { + case StateOrient: + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: ORIENT\n") + fmt.Fprintf(w, "Concept: %q\n", re.Concept) + for i, d := range re.Domains { + if i == 0 { + fmt.Fprintf(w, "Domains: %s (%d/%d)", d, i+1, re.TotalDomains) + } else { + fmt.Fprintf(w, ", %s (%d/%d)", d, i+1, re.TotalDomains) + } + } + fmt.Fprintln(w) + fmt.Fprintln(w) + fmt.Fprintf(w, "Action:\n") + fmt.Fprintf(w, " Prepare for reverse engineering across %d domains.\n", re.TotalDomains) + fmt.Fprintf(w, " Domain order: %s\n", strings.Join(re.Domains, " → ")) + fmt.Fprintln(w) + fmt.Fprintf(w, " Requirements before advancing:\n") + fmt.Fprintf(w, " - Confirm you are familiar with the work concept scope\n") + fmt.Fprintf(w, " - Confirm domain ordering is correct\n") + fmt.Fprintf(w, " (SURVEY → GAP_ANALYSIS → DECOMPOSE → QUEUE runs per domain in this order)\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Advance to begin SURVEY on domain: %s\n", domainAt(0)) + + case StateSurvey: + domain := domainAt(re.CurrentDomain) + specsDir := filepath.Join(dir, domain, "specs") + _, specsErr := os.Stat(specsDir) + + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: SURVEY\n") + fmt.Fprintf(w, "Domain: %s (%d/%d)\n", domain, re.CurrentDomain+1, re.TotalDomains) + fmt.Fprintf(w, "Concept: %q\n", re.Concept) + fmt.Fprintln(w) + fmt.Fprintf(w, "Action:\n") + if os.IsNotExist(specsErr) { + fmt.Fprintf(w, " Note: %s/specs/ does not exist. Proceed with an empty spec inventory.\n", domain) + fmt.Fprintln(w) + } + fmt.Fprintf(w, " Survey existing specifications in %s/specs/.\n", domain) + fmt.Fprintln(w) + fmt.Fprintf(w, " Spawn %d %s %s sub-agents\n", cfg.Survey.Count, cfg.Survey.Model, cfg.Survey.Type) + fmt.Fprintf(w, " scoped to %s/specs/.\n", domain) + fmt.Fprintln(w) + fmt.Fprintf(w, " Read all spec files in the directory to understand what is specified.\n") + fmt.Fprintf(w, " Identify which specs pertain to the concept.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " For each spec, extract:\n") + fmt.Fprintf(w, " - Spec file name\n") + fmt.Fprintf(w, " - Topic of concern\n") + fmt.Fprintf(w, " - Behaviors defined\n") + fmt.Fprintf(w, " - Integration points\n") + fmt.Fprintf(w, " - Dependencies\n") + fmt.Fprintf(w, " - Relevance: whether this spec pertains to the concept\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Disregard specs that do not pertain to the concept.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Advance when complete.\n") + + case StateGapAnalysis: + domain := domainAt(re.CurrentDomain) + + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: GAP_ANALYSIS\n") + fmt.Fprintf(w, "Domain: %s (%d/%d)\n", domain, re.CurrentDomain+1, re.TotalDomains) + fmt.Fprintf(w, "Concept: %q\n", re.Concept) + fmt.Fprintln(w) + fmt.Fprintf(w, "Action:\n") + fmt.Fprintf(w, " Identify unspecified behavior in the %s source code\n", domain) + fmt.Fprintf(w, " that pertains to the concept.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Spawn %d %s %s\n", cfg.GapAnalysis.Count, cfg.GapAnalysis.Model, cfg.GapAnalysis.Type) + fmt.Fprintf(w, " sub-agents scoped to the %s source code.\n", domain) + fmt.Fprintln(w) + fmt.Fprintf(w, " For each behavior found in code that is not covered by an existing spec:\n") + fmt.Fprintf(w, " - Describe what the behavior does\n") + fmt.Fprintf(w, " - Identify a topic of concern for it:\n") + fmt.Fprintf(w, " - Must be a single topic that fits in one sentence\n") + fmt.Fprintf(w, " - Must not contain \"and\" conjoining unrelated capabilities\n") + fmt.Fprintf(w, " - Must describe an activity, not a vague statement\n") + fmt.Fprintf(w, " - Valid: \"The optimizer validates repository URLs before cloning\"\n") + fmt.Fprintf(w, " - Invalid: \"The optimizer handles repos, validation, and caching\"\n") + fmt.Fprintf(w, " - Note where in the code it is implemented\n") + fmt.Fprintf(w, " - Note if an existing spec partially covers it (and what the gap is)\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Advance when complete.\n") + fmt.Fprintf(w, " Next: DECOMPOSE for domain %s\n", domain) + + case StateDecompose: + domain := domainAt(re.CurrentDomain) + + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: DECOMPOSE\n") + fmt.Fprintf(w, "Domain: %s (%d/%d)\n", domain, re.CurrentDomain+1, re.TotalDomains) + fmt.Fprintf(w, "Concept: %q\n", re.Concept) + fmt.Fprintln(w) + fmt.Fprintf(w, "Action:\n") + fmt.Fprintf(w, " Synthesize findings from domain %s.\n", domain) + fmt.Fprintln(w) + fmt.Fprintf(w, " From the SURVEY and GAP_ANALYSIS results for this domain,\n") + fmt.Fprintf(w, " determine which specifications need to be created or updated.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " For each spec, define:\n") + fmt.Fprintf(w, " - Name (display name)\n") + fmt.Fprintf(w, " - Domain: %s\n", domain) + fmt.Fprintf(w, " - Topic of concern:\n") + fmt.Fprintf(w, " - Must be a single topic that fits in one sentence\n") + fmt.Fprintf(w, " - Must not contain \"and\" conjoining unrelated capabilities\n") + fmt.Fprintf(w, " - Must describe an activity, not a vague statement\n") + fmt.Fprintf(w, " - Valid: \"The optimizer validates repository URLs before cloning\"\n") + fmt.Fprintf(w, " - Invalid: \"The optimizer handles repos, validation, and caching\"\n") + fmt.Fprintf(w, " - File: target path relative to domain root (specs/.md)\n") + fmt.Fprintf(w, " - Action: \"create\" for new specs, \"update\" for existing specs with gaps\n") + fmt.Fprintf(w, " - Code search roots (directories relative to domain root)\n") + fmt.Fprintf(w, " - Dependencies on other specs\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Decide:\n") + fmt.Fprintf(w, " - Which gaps warrant new specs vs. updates to existing specs\n") + fmt.Fprintf(w, " - How to group related behaviors into single-topic specs\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Advance when the spec list for this domain is finalized.\n") + + case StateQueue: + domain := domainAt(re.CurrentDomain) + + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: QUEUE\n") + fmt.Fprintf(w, "Domain: %s (%d/%d)\n", domain, re.CurrentDomain+1, re.TotalDomains) + fmt.Fprintf(w, "Concept: %q\n", re.Concept) + if re.QueueFile != "" { + fmt.Fprintf(w, "Queue file: %s\n", re.QueueFile) + } + fmt.Fprintln(w) + fmt.Fprintf(w, "Action:\n") + if re.QueueFile == "" { + fmt.Fprintf(w, " Produce the reverse engineering queue JSON file with entries for domain %s.\n", domain) + fmt.Fprintln(w) + fmt.Fprintf(w, " Requirements:\n") + fmt.Fprintf(w, " - All paths relative to domain root (/%s/)\n", domain) + fmt.Fprintf(w, " - Order entries by dependency: specs with no dependencies first\n") + fmt.Fprintf(w, " - code_search_roots must be non-empty for every entry\n") + fmt.Fprintf(w, " - No circular dependencies\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Advance with the queue file:\n") + fmt.Fprintf(w, " forgectl advance --file \n") + } else { + fmt.Fprintf(w, " Add entries for domain %s to the existing queue file.\n", domain) + fmt.Fprintln(w) + fmt.Fprintf(w, " Update the queue file at: %s\n", re.QueueFile) + fmt.Fprintf(w, " Add new entries for this domain alongside existing entries.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Advance when the file is updated:\n") + fmt.Fprintf(w, " forgectl advance\n") + } + + case StateExecute: + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: EXECUTE\n") + if re.QueueFile != "" { + data, err := os.ReadFile(re.QueueFile) + if err == nil { + var qi ReverseEngineeringQueueInput + if json.Unmarshal(data, &qi) == nil { + fmt.Fprintf(w, "Queue: %d entries\n", len(qi.Specs)) + } + } + } + fmt.Fprintf(w, "Mode: %s\n", cfg.Mode) + fmt.Fprintln(w) + fmt.Fprintf(w, "Action: forgectl invokes the Python subprocess automatically.\n") + fmt.Fprintf(w, " Running: python reverse_engineer.py --execute execute.json\n") + + case StateReconcile: + domain := domainAt(re.ReconcileDomain) + maxRounds := cfg.Reconcile.MaxRounds + + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: RECONCILE\n") + fmt.Fprintf(w, "Domain: %s (%d/%d)\n", domain, re.ReconcileDomain+1, re.TotalDomains) + fmt.Fprintf(w, "Concept: %q\n", re.Concept) + fmt.Fprintf(w, "Round: %d/%d\n", re.Round, maxRounds) + + entries := loadDomainEntries(domain) + if len(entries) > 0 { + fmt.Fprintln(w) + fmt.Fprintf(w, "Specs created or updated for this domain:\n") + for _, e := range entries { + fmt.Fprintf(w, " - %s/%s (%s)\n", domain, e.File, e.Action) + if len(e.DependsOn) > 0 { + fmt.Fprintf(w, " depends_on: [%s]\n", strings.Join(e.DependsOn, ", ")) + } else { + fmt.Fprintf(w, " depends_on: []\n") + } + } + } + + fmt.Fprintln(w) + fmt.Fprintf(w, "Action:\n") + if re.Round > 1 { + fmt.Fprintf(w, " Reconciliation evaluation failed on the previous round.\n") + fmt.Fprintf(w, " Address the findings from the evaluation report and re-reconcile.\n") + fmt.Fprintln(w) + } + fmt.Fprintf(w, " Cross-reference specifications for domain %s.\n", domain) + fmt.Fprintln(w) + fmt.Fprintf(w, " For every spec that was created or updated, use its depends_on\n") + fmt.Fprintf(w, " to add cross-references to the corresponding specs.\n") + fmt.Fprintf(w, " Update both the new/updated spec and the spec it references:\n") + fmt.Fprintf(w, " - Add Depends On entries in the new/updated spec\n") + fmt.Fprintf(w, " - Add Integration Points in both directions\n") + fmt.Fprintf(w, " (if A depends on B, both A and B reference each other)\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Verify consistency:\n") + fmt.Fprintf(w, " - Every Depends On reference points to a spec that exists\n") + fmt.Fprintf(w, " - Every Depends On has a corresponding Integration Points row\n") + fmt.Fprintf(w, " in the referenced spec\n") + fmt.Fprintf(w, " - Integration Points are symmetric (A ↔ B)\n") + fmt.Fprintf(w, " - Spec names are consistent across all references\n") + fmt.Fprintf(w, " - No circular dependencies in the Depends On graph\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Stage all changes:\n") + fmt.Fprintf(w, " git add the modified spec files.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Advance when reconciliation is complete and changes are staged.\n") + + case StateReconcileEval: + domain := domainAt(re.ReconcileDomain) + maxRounds := cfg.Reconcile.MaxRounds + evalFile := filepath.Join(domain, "specs", ".eval", fmt.Sprintf("reconciliation-r%d.md", re.Round)) + + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: RECONCILE_EVAL\n") + fmt.Fprintf(w, "Domain: %s (%d/%d)\n", domain, re.ReconcileDomain+1, re.TotalDomains) + fmt.Fprintf(w, "Concept: %q\n", re.Concept) + fmt.Fprintf(w, "Round: %d/%d\n", re.Round, maxRounds) + fmt.Fprintln(w) + fmt.Fprintf(w, "Action:\n") + fmt.Fprintf(w, " Evaluate cross-spec consistency for domain %s.\n", domain) + fmt.Fprintln(w) + fmt.Fprintf(w, " Spawn %d %s %s\n", cfg.Reconcile.Eval.Count, cfg.Reconcile.Eval.Model, cfg.Reconcile.Eval.Type) + fmt.Fprintf(w, " sub-agents to evaluate the reconciliation.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Instruct your sub-agents to run:\n") + fmt.Fprintf(w, " forgectl eval\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " This outputs the evaluation prompt with the full spec files\n") + fmt.Fprintf(w, " and consistency checklist for the sub-agents to review.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " After the sub-agents complete their evaluation, advance with the verdict:\n") + fmt.Fprintf(w, " forgectl advance --verdict PASS --eval-report \n") + fmt.Fprintf(w, " forgectl advance --verdict FAIL --eval-report \n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Eval reports are written to: %s\n", evalFile) + + case StateColleagueReview: + domain := domainAt(re.ReconcileDomain) + + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: COLLEAGUE_REVIEW\n") + fmt.Fprintf(w, "Domain: %s (%d/%d)\n", domain, re.ReconcileDomain+1, re.TotalDomains) + fmt.Fprintf(w, "Concept: %q\n", re.Concept) + fmt.Fprintln(w) + fmt.Fprintf(w, "Action:\n") + fmt.Fprintf(w, " STOP and review the specifications with your colleague.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Advance when the review is complete:\n") + fmt.Fprintf(w, " forgectl advance\n") + + case StateReconcileAdvance: + domain := domainAt(re.ReconcileDomain) + nextIdx := re.ReconcileDomain + 1 + + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: RECONCILE_ADVANCE\n") + if nextIdx < len(re.Domains) { + nextDomain := re.Domains[nextIdx] + fmt.Fprintf(w, "Domain: %s (%d/%d) → %s (%d/%d)\n", + domain, re.ReconcileDomain+1, re.TotalDomains, + nextDomain, nextIdx+1, re.TotalDomains) + fmt.Fprintln(w) + fmt.Fprintf(w, "Action:\n") + fmt.Fprintf(w, " Domain %s reconciliation complete.\n", domain) + fmt.Fprintln(w) + fmt.Fprintf(w, " Next: RECONCILE for domain %s (%d/%d)\n", nextDomain, nextIdx+1, re.TotalDomains) + fmt.Fprintln(w) + fmt.Fprintf(w, " Advance to proceed.\n") + } else { + fmt.Fprintf(w, "Domain: %s (%d/%d) → DONE\n", domain, re.ReconcileDomain+1, re.TotalDomains) + fmt.Fprintln(w) + fmt.Fprintf(w, "Action:\n") + fmt.Fprintf(w, " Domain %s reconciliation complete.\n", domain) + fmt.Fprintln(w) + fmt.Fprintf(w, " All domains reconciled. Advancing to DONE.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, " Advance to proceed.\n") + } + + case StateDone: + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: DONE\n") + fmt.Fprintf(w, "Concept: %q\n", re.Concept) + fmt.Fprintf(w, "Domains: %d (%s)\n", re.TotalDomains, strings.Join(re.Domains, ", ")) + fmt.Fprintln(w) + fmt.Fprintf(w, "Action: Reverse engineering workflow complete.\n") + fmt.Fprintf(w, " All specifications have been produced, verified, and reconciled.\n") + } +} + +// PrintReverseEngineeringEvalOutput outputs the reconcile evaluation context for the sub-agent. +// Valid in reverse_engineering RECONCILE_EVAL state only. +func PrintReverseEngineeringEvalOutput(w io.Writer, s *ForgeState) error { + if s.Phase != PhaseReverseEngineering || s.State != StateReconcileEval { + return fmt.Errorf("eval is only valid in RECONCILE_EVAL state (current: %s %s)", s.Phase, s.State) + } + + re := s.ReverseEngineering + domain := "" + if re.ReconcileDomain < len(re.Domains) { + domain = re.Domains[re.ReconcileDomain] + } + maxRounds := s.Config.ReverseEngineering.Reconcile.MaxRounds + evalFile := filepath.Join(domain, "specs", ".eval", fmt.Sprintf("reconciliation-r%d.md", re.Round)) + + fmt.Fprintf(w, "=== RECONCILIATION EVALUATION ROUND %d/%d ===\n", re.Round, maxRounds) + fmt.Fprintf(w, "Domain: %s (%d/%d)\n", domain, re.ReconcileDomain+1, re.TotalDomains) + + fmt.Fprintf(w, "\n--- EVALUATOR INSTRUCTIONS ---\n\n") + fmt.Fprintf(w, "%s\n", evaluators.ReverseEngineeringReconcileEval) + + // Spec list for current domain from queue file. + if re.QueueFile != "" { + data, err := os.ReadFile(re.QueueFile) + if err == nil { + var qi ReverseEngineeringQueueInput + if json.Unmarshal(data, &qi) == nil { + fmt.Fprintf(w, "\n--- SPECS ---\n\n") + for _, e := range qi.Specs { + if e.Domain == domain { + fmt.Fprintf(w, " - %s/%s (%s)\n", domain, e.File, e.Action) + if len(e.DependsOn) > 0 { + fmt.Fprintf(w, " depends_on: [%s]\n", strings.Join(e.DependsOn, ", ")) + } else { + fmt.Fprintf(w, " depends_on: []\n") + } + } + } + } + } + } + + fmt.Fprintf(w, "\n--- REPORT OUTPUT ---\n\n") + fmt.Fprintf(w, "Write your evaluation report to:\n") + fmt.Fprintf(w, " %s\n", evalFile) + + return nil +} + +// PrintExecuteFailureOutput prints the STOP template for subprocess failures in EXECUTE state. +// Called by advance.go when the Python subprocess exits non-zero and execute.json is unreadable. +func PrintExecuteFailureOutput(w io.Writer, stderr string) { + fmt.Fprintf(w, "Phase: reverse_engineering\n") + fmt.Fprintf(w, "State: EXECUTE\n") + fmt.Fprintln(w) + fmt.Fprintf(w, "STOP there was an issue with the subprocess for reverse engineering.\n") + fmt.Fprintf(w, "Please consult with your user and inform them that there was an issue\n") + fmt.Fprintf(w, "with the Python subprocess running Claude Agent SDK.\n") + fmt.Fprintln(w) + fmt.Fprintf(w, "%s\n", stderr) +} + // --- Helpers --- // currentPlanDir returns the directory containing the active plan file. diff --git a/forgectl/state/output_test.go b/forgectl/state/output_test.go index bd84840..410a642 100644 --- a/forgectl/state/output_test.go +++ b/forgectl/state/output_test.go @@ -390,6 +390,360 @@ func TestEvalOutputOutsideValidStatesReturnsError(t *testing.T) { // TestOutputDoneDomainVariantWhenPlansRemain verifies that the DONE output shows // "Domain complete. Advance to continue to next domain." when plans remain. +// TestREEvalOutputContainsPromptAndSpecs verifies that PrintReverseEngineeringEvalOutput +// outputs the evaluator prompt, spec list with depends_on, and eval report path. +func TestREEvalOutputContainsPromptAndSpecs(t *testing.T) { + dir := t.TempDir() + + queueFile := filepath.Join(dir, "re-queue.json") + qi := ReverseEngineeringQueueInput{ + Specs: []ReverseEngineeringQueueEntry{ + {Name: "Auth Init", Domain: "api", File: "specs/auth-init.md", Action: "create", DependsOn: []string{}}, + {Name: "Auth Tokens", Domain: "api", File: "specs/auth-tokens.md", Action: "create", DependsOn: []string{"Auth Init"}}, + }, + } + data, _ := json.Marshal(qi) + os.WriteFile(queueFile, data, 0644) + + s := newREState([]string{"api"}) + s.State = StateReconcileEval + s.ReverseEngineering.ReconcileDomain = 0 + s.ReverseEngineering.Round = 1 + s.ReverseEngineering.QueueFile = queueFile + s.Config.ReverseEngineering.Reconcile.MaxRounds = 3 + + var buf bytes.Buffer + if err := PrintReverseEngineeringEvalOutput(&buf, s); err != nil { + t.Fatalf("PrintReverseEngineeringEvalOutput: %v", err) + } + out := buf.String() + + if !strings.Contains(out, "RECONCILIATION EVALUATION ROUND 1/3") { + t.Errorf("expected round header in eval output, got:\n%s", out) + } + if !strings.Contains(out, "--- EVALUATOR INSTRUCTIONS ---") { + t.Errorf("expected evaluator instructions section, got:\n%s", out) + } + if !strings.Contains(out, "Reconciliation Evaluation Prompt") { + t.Errorf("expected embedded prompt in eval output, got:\n%s", out) + } + if !strings.Contains(out, "api/specs/auth-init.md") { + t.Errorf("expected spec file in eval output, got:\n%s", out) + } + if !strings.Contains(out, "Auth Init") { + t.Errorf("expected depends_on reference in eval output, got:\n%s", out) + } + if !strings.Contains(out, "reconciliation-r1.md") { + t.Errorf("expected eval report path in eval output, got:\n%s", out) + } +} + +// TestREEvalOutputRejectsNonReconcileEvalState verifies that PrintReverseEngineeringEvalOutput +// returns an error when called outside RECONCILE_EVAL state. +func TestREEvalOutputRejectsNonReconcileEvalState(t *testing.T) { + s := newREState([]string{"api"}) + s.State = StateReconcile // wrong state + + var buf bytes.Buffer + err := PrintReverseEngineeringEvalOutput(&buf, s) + if err == nil { + t.Fatal("expected error when calling PrintReverseEngineeringEvalOutput outside RECONCILE_EVAL") + } + if !strings.Contains(err.Error(), "RECONCILE_EVAL") { + t.Errorf("expected error to mention RECONCILE_EVAL, got: %v", err) + } +} + +// --- Reverse Engineering Output Tests --- + +func newREState(domains []string) *ForgeState { + cfg := DefaultForgeConfig() + return &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateOrient, + Config: cfg, + ReverseEngineering: &ReverseEngineeringState{ + Concept: "understand the auth system", + Domains: domains, + TotalDomains: len(domains), + CurrentDomain: 0, + Round: 1, + }, + } +} + +func TestStatusREShowsModeAndRounds(t *testing.T) { + s := newREState([]string{"api", "billing"}) + s.Config.ReverseEngineering.Mode = "self_refine" + s.Config.ReverseEngineering.Reconcile.MinRounds = 1 + s.Config.ReverseEngineering.Reconcile.MaxRounds = 3 + + var buf bytes.Buffer + PrintStatus(&buf, s, t.TempDir(), false) + out := buf.String() + + if !strings.Contains(out, "mode=self_refine") { + t.Errorf("expected mode in RE status config line, got:\n%s", out) + } + if !strings.Contains(out, "rounds=1-3") { + t.Errorf("expected reconcile rounds in RE status config line, got:\n%s", out) + } + // Should NOT show batch= for RE phase. + if strings.Contains(out, "batch=") { + t.Errorf("unexpected batch= in RE status config line, got:\n%s", out) + } +} + +func TestOutputREOrientShowsConceptAndDomains(t *testing.T) { + s := newREState([]string{"api", "billing"}) + s.State = StateOrient + + out := outputOf(s, t.TempDir()) + if !strings.Contains(out, "understand the auth system") { + t.Errorf("expected concept in ORIENT output, got:\n%s", out) + } + if !strings.Contains(out, "api (1/2)") { + t.Errorf("expected domain index in ORIENT output, got:\n%s", out) + } + if !strings.Contains(out, "billing (2/2)") { + t.Errorf("expected second domain in ORIENT output, got:\n%s", out) + } + if !strings.Contains(out, "Advance to begin SURVEY on domain: api") { + t.Errorf("expected advance action in ORIENT output, got:\n%s", out) + } +} + +func TestOutputRESurveyShowsDomainAndSubagentConfig(t *testing.T) { + s := newREState([]string{"api"}) + s.State = StateSurvey + s.Config.ReverseEngineering.Survey = SubAgentConfig{Model: "haiku", Type: "explorer", Count: 3} + + out := outputOf(s, t.TempDir()) + if !strings.Contains(out, "api (1/1)") { + t.Errorf("expected domain in SURVEY output, got:\n%s", out) + } + if !strings.Contains(out, "3 haiku explorer") { + t.Errorf("expected sub-agent config in SURVEY output, got:\n%s", out) + } + if !strings.Contains(out, "api/specs/") { + t.Errorf("expected specs dir in SURVEY output, got:\n%s", out) + } +} + +func TestOutputRESurveyNotesMissingSpecsDir(t *testing.T) { + dir := t.TempDir() + s := newREState([]string{"no-specs-domain"}) + s.State = StateSurvey + // dir has no no-specs-domain/specs/ subdirectory + + out := outputOf(s, dir) + if !strings.Contains(out, "does not exist") { + t.Errorf("expected missing specs dir note in SURVEY output, got:\n%s", out) + } +} + +func TestOutputREGapAnalysisShowsDomainAndSubagentConfig(t *testing.T) { + s := newREState([]string{"api"}) + s.State = StateGapAnalysis + s.Config.ReverseEngineering.GapAnalysis = SubAgentConfig{Model: "sonnet", Type: "explore", Count: 5} + + out := outputOf(s, t.TempDir()) + if !strings.Contains(out, "GAP_ANALYSIS") { + t.Errorf("expected state in GAP_ANALYSIS output, got:\n%s", out) + } + if !strings.Contains(out, "5 sonnet explore") { + t.Errorf("expected sub-agent config in GAP_ANALYSIS output, got:\n%s", out) + } + if !strings.Contains(out, "Next: DECOMPOSE for domain api") { + t.Errorf("expected next step in GAP_ANALYSIS output, got:\n%s", out) + } +} + +func TestOutputREDecomposeShowsDomainAndAction(t *testing.T) { + s := newREState([]string{"payments"}) + s.State = StateDecompose + + out := outputOf(s, t.TempDir()) + if !strings.Contains(out, "DECOMPOSE") { + t.Errorf("expected state in DECOMPOSE output, got:\n%s", out) + } + if !strings.Contains(out, "Synthesize findings from domain payments") { + t.Errorf("expected action in DECOMPOSE output, got:\n%s", out) + } + if !strings.Contains(out, "payments") { + t.Errorf("expected domain name in DECOMPOSE output, got:\n%s", out) + } +} + +func TestOutputREQueueFirstTimeShowsFileFlag(t *testing.T) { + s := newREState([]string{"api"}) + s.State = StateQueue + // QueueFile is empty — first time + + out := outputOf(s, t.TempDir()) + if !strings.Contains(out, "--file ") { + t.Errorf("expected --file flag instruction in first QUEUE output, got:\n%s", out) + } +} + +func TestOutputREQueueSubsequentShowsStoredPath(t *testing.T) { + s := newREState([]string{"api", "billing"}) + s.State = StateQueue + s.ReverseEngineering.CurrentDomain = 1 + s.ReverseEngineering.QueueFile = "/tmp/re-queue.json" + + out := outputOf(s, t.TempDir()) + if !strings.Contains(out, "/tmp/re-queue.json") { + t.Errorf("expected stored queue path in subsequent QUEUE output, got:\n%s", out) + } + if strings.Contains(out, "--file ") { + t.Errorf("unexpected --file flag in subsequent QUEUE output (file already set), got:\n%s", out) + } +} + +func TestOutputREReconcileShowsSpecsAndDependsOn(t *testing.T) { + dir := t.TempDir() + + // Write a queue file with entries. + queueFile := filepath.Join(dir, "re-queue.json") + qi := ReverseEngineeringQueueInput{ + Specs: []ReverseEngineeringQueueEntry{ + {Name: "Auth Init", Domain: "api", Topic: "auth init", File: "specs/auth-init.md", Action: "create", DependsOn: []string{}}, + {Name: "Auth Tokens", Domain: "api", Topic: "auth tokens", File: "specs/auth-tokens.md", Action: "create", DependsOn: []string{"Auth Init"}}, + }, + } + data, _ := json.Marshal(qi) + os.WriteFile(queueFile, data, 0644) + + s := newREState([]string{"api"}) + s.State = StateReconcile + s.ReverseEngineering.ReconcileDomain = 0 + s.ReverseEngineering.QueueFile = queueFile + + out := outputOf(s, dir) + if !strings.Contains(out, "RECONCILE") { + t.Errorf("expected state in RECONCILE output, got:\n%s", out) + } + if !strings.Contains(out, "api/specs/auth-init.md") { + t.Errorf("expected spec file in RECONCILE output, got:\n%s", out) + } + if !strings.Contains(out, "Auth Init") { + t.Errorf("expected depends_on entry in RECONCILE output, got:\n%s", out) + } +} + +func TestOutputREReconcileEvalShowsEvalInstructions(t *testing.T) { + s := newREState([]string{"api"}) + s.State = StateReconcileEval + s.ReverseEngineering.ReconcileDomain = 0 + s.ReverseEngineering.Round = 1 + s.Config.ReverseEngineering.Reconcile.MaxRounds = 3 + s.Config.ReverseEngineering.Reconcile.Eval = AgentConfig{Model: "opus", Type: "general-purpose", Count: 1} + + out := outputOf(s, t.TempDir()) + if !strings.Contains(out, "RECONCILE_EVAL") { + t.Errorf("expected state in RECONCILE_EVAL output, got:\n%s", out) + } + if !strings.Contains(out, "forgectl eval") { + t.Errorf("expected forgectl eval instruction in RECONCILE_EVAL output, got:\n%s", out) + } + if !strings.Contains(out, "reconciliation-r1.md") { + t.Errorf("expected eval report path in RECONCILE_EVAL output, got:\n%s", out) + } +} + +func TestOutputREExecuteShowsModeAndQueueCount(t *testing.T) { + dir := t.TempDir() + + // Write a queue file. + queueFile := filepath.Join(dir, "re-queue.json") + qi := ReverseEngineeringQueueInput{ + Specs: []ReverseEngineeringQueueEntry{ + {Name: "Spec A", Domain: "api", File: "specs/a.md", Action: "create", CodeSearchRoots: []string{"src/"}}, + {Name: "Spec B", Domain: "api", File: "specs/b.md", Action: "create", CodeSearchRoots: []string{"src/"}}, + {Name: "Spec C", Domain: "api", File: "specs/c.md", Action: "update", CodeSearchRoots: []string{"src/"}}, + }, + } + data, _ := json.Marshal(qi) + os.WriteFile(queueFile, data, 0644) + + s := newREState([]string{"api"}) + s.State = StateExecute + s.ReverseEngineering.QueueFile = queueFile + s.Config.ReverseEngineering.Mode = "self_refine" + + out := outputOf(s, dir) + if !strings.Contains(out, "EXECUTE") { + t.Errorf("expected state in EXECUTE output, got:\n%s", out) + } + if !strings.Contains(out, "3 entries") { + t.Errorf("expected queue entry count in EXECUTE output, got:\n%s", out) + } + if !strings.Contains(out, "self_refine") { + t.Errorf("expected mode in EXECUTE output, got:\n%s", out) + } +} + +func TestOutputREReconcileSubsequentRoundShowsFailureContext(t *testing.T) { + dir := t.TempDir() + + queueFile := filepath.Join(dir, "re-queue.json") + qi := ReverseEngineeringQueueInput{ + Specs: []ReverseEngineeringQueueEntry{ + {Name: "Spec A", Domain: "api", File: "specs/a.md", Action: "create", DependsOn: []string{}}, + }, + } + data, _ := json.Marshal(qi) + os.WriteFile(queueFile, data, 0644) + + s := newREState([]string{"api"}) + s.State = StateReconcile + s.ReverseEngineering.ReconcileDomain = 0 + s.ReverseEngineering.Round = 2 // subsequent round + s.ReverseEngineering.QueueFile = queueFile + + out := outputOf(s, dir) + if !strings.Contains(out, "evaluation failed on the previous round") { + t.Errorf("expected failure context in subsequent RECONCILE output, got:\n%s", out) + } + if !strings.Contains(out, "api/specs/a.md") { + t.Errorf("expected spec file in subsequent RECONCILE output, got:\n%s", out) + } +} + +func TestOutputREExecuteFailureRendersStopTemplate(t *testing.T) { + var buf bytes.Buffer + stderrText := "Traceback (most recent call last):\n File \"reverse_engineer.py\"\nKeyError: 'model'" + PrintExecuteFailureOutput(&buf, stderrText) + out := buf.String() + + if !strings.Contains(out, "STOP") { + t.Errorf("expected STOP in failure output, got:\n%s", out) + } + if !strings.Contains(out, "Python subprocess") { + t.Errorf("expected Python subprocess mention in failure output, got:\n%s", out) + } + if !strings.Contains(out, stderrText) { + t.Errorf("expected full stderr in failure output, got:\n%s", out) + } +} + +func TestOutputREDoneShowsSummary(t *testing.T) { + s := newREState([]string{"api", "billing"}) + s.State = StateDone + + out := outputOf(s, t.TempDir()) + if !strings.Contains(out, "DONE") { + t.Errorf("expected state in DONE output, got:\n%s", out) + } + if !strings.Contains(out, "Reverse engineering workflow complete") { + t.Errorf("expected completion message in DONE output, got:\n%s", out) + } + if !strings.Contains(out, "understand the auth system") { + t.Errorf("expected concept in DONE output, got:\n%s", out) + } +} + func TestOutputDoneDomainVariantWhenPlansRemain(t *testing.T) { dir := t.TempDir() s := newImplementingState(dir, 1, 1) diff --git a/forgectl/state/state.go b/forgectl/state/state.go index 119c60f..7e90e09 100644 --- a/forgectl/state/state.go +++ b/forgectl/state/state.go @@ -199,3 +199,19 @@ func NewImplementingState() *ImplementingState { LayerHistory: []LayerHistory{}, } } + +// NewReverseEngineeringState creates initial reverse engineering state from init input. +func NewReverseEngineeringState(concept string, domains []string, colleagueReview bool) *ReverseEngineeringState { + d := make([]string, len(domains)) + copy(d, domains) + return &ReverseEngineeringState{ + Concept: concept, + Domains: d, + CurrentDomain: 0, + TotalDomains: len(domains), + Round: 0, + ColleagueReview: colleagueReview, + ReconcileDomain: 0, + Evals: []EvalRecord{}, + } +} diff --git a/forgectl/state/state_test.go b/forgectl/state/state_test.go index 240c631..7727769 100644 --- a/forgectl/state/state_test.go +++ b/forgectl/state/state_test.go @@ -195,6 +195,140 @@ func TestArchiveSessionContainsValidJSON(t *testing.T) { } } +func TestSaveAndLoadReverseEngineeringState(t *testing.T) { + dir := t.TempDir() + re := NewReverseEngineeringState("understand the auth module", []string{"domain-a", "domain-b"}, true) + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateOrient, + ReverseEngineering: re, + } + + if err := Save(dir, s); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if loaded.ReverseEngineering == nil { + t.Fatal("ReverseEngineering should not be nil after load") + } + re2 := loaded.ReverseEngineering + if re2.Concept != "understand the auth module" { + t.Errorf("Concept = %q, want %q", re2.Concept, "understand the auth module") + } + if re2.TotalDomains != 2 { + t.Errorf("TotalDomains = %d, want 2", re2.TotalDomains) + } + if re2.Domains[0] != "domain-a" || re2.Domains[1] != "domain-b" { + t.Errorf("Domains = %v, want [domain-a domain-b]", re2.Domains) + } + if !re2.ColleagueReview { + t.Error("ColleagueReview should be true") + } + if re2.CurrentDomain != 0 || re2.ReconcileDomain != 0 || re2.Round != 0 { + t.Errorf("unexpected initial counters: current=%d reconcile=%d round=%d", + re2.CurrentDomain, re2.ReconcileDomain, re2.Round) + } +} + +func TestSaveAndLoadReverseEngineeringStateAllFields(t *testing.T) { + dir := t.TempDir() + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateExecute, + ReverseEngineering: &ReverseEngineeringState{ + Concept: "map the billing system", + Domains: []string{"billing"}, + CurrentDomain: 0, + TotalDomains: 1, + QueueFile: "/tmp/queue.json", + QueueHash: "abc123", + ExecuteFile: "/tmp/execute.json", + Round: 2, + ColleagueReview: false, + ReconcileDomain: 0, + Evals: []EvalRecord{ + {Round: 1, Verdict: "FAIL", EvalReport: "needs more detail"}, + {Round: 2, Verdict: "PASS"}, + }, + }, + } + + if err := Save(dir, s); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + + re := loaded.ReverseEngineering + if re == nil { + t.Fatal("ReverseEngineering should not be nil") + } + if re.QueueFile != "/tmp/queue.json" { + t.Errorf("QueueFile = %q, want /tmp/queue.json", re.QueueFile) + } + if re.QueueHash != "abc123" { + t.Errorf("QueueHash = %q, want abc123", re.QueueHash) + } + if re.ExecuteFile != "/tmp/execute.json" { + t.Errorf("ExecuteFile = %q, want /tmp/execute.json", re.ExecuteFile) + } + if re.Round != 2 { + t.Errorf("Round = %d, want 2", re.Round) + } + if len(re.Evals) != 2 { + t.Fatalf("Evals len = %d, want 2", len(re.Evals)) + } + if re.Evals[0].Verdict != "FAIL" || re.Evals[1].Verdict != "PASS" { + t.Errorf("Evals verdicts = %v/%v, want FAIL/PASS", re.Evals[0].Verdict, re.Evals[1].Verdict) + } +} + +func TestRecoverReverseEngineeringStateFromBackup(t *testing.T) { + dir := t.TempDir() + s := &ForgeState{ + Phase: PhaseReverseEngineering, + State: StateGapAnalysis, + ReverseEngineering: &ReverseEngineeringState{ + Concept: "trace payment flow", + Domains: []string{"payments"}, + TotalDomains: 1, + CurrentDomain: 0, + Round: 1, + }, + } + + // Save to put a valid state in place, then put it in the backup path. + data, _ := json.MarshalIndent(s, "", " ") + os.WriteFile(filepath.Join(dir, stateBackup), data, 0644) + + if err := Recover(dir); err != nil { + t.Fatalf("Recover: %v", err) + } + + loaded, err := Load(dir) + if err != nil { + t.Fatalf("Load after recovery: %v", err) + } + + if loaded.ReverseEngineering == nil { + t.Fatal("ReverseEngineering should not be nil after recovery") + } + if loaded.ReverseEngineering.Concept != "trace payment flow" { + t.Errorf("Concept = %q, want %q", loaded.ReverseEngineering.Concept, "trace payment flow") + } + if loaded.State != StateGapAnalysis { + t.Errorf("State = %s, want GAP_ANALYSIS", loaded.State) + } +} + func TestArchiveSessionCreatesSessionsDir(t *testing.T) { dir := t.TempDir() s := &ForgeState{Phase: PhaseImplementing, State: StateDone} diff --git a/forgectl/state/types.go b/forgectl/state/types.go index 395ec53..6c72247 100644 --- a/forgectl/state/types.go +++ b/forgectl/state/types.go @@ -8,6 +8,7 @@ const ( PhasePlanning PhaseName = "planning" PhaseGeneratePlanningQueue PhaseName = "generate_planning_queue" PhaseImplementing PhaseName = "implementing" + PhaseReverseEngineering PhaseName = "reverse_engineering" ) // StateName represents the current state within a phase. @@ -37,6 +38,14 @@ const ( StateImplement StateName = "IMPLEMENT" StateCommit StateName = "COMMIT" StateSelfReview StateName = "SELF_REVIEW" + // Reverse engineering phase states + StateSurvey StateName = "SURVEY" + StateGapAnalysis StateName = "GAP_ANALYSIS" + StateDecompose StateName = "DECOMPOSE" + StateQueue StateName = "QUEUE" + StateExecute StateName = "EXECUTE" + StateColleagueReview StateName = "COLLEAGUE_REVIEW" + StateReconcileAdvance StateName = "RECONCILE_ADVANCE" ) // --- Configuration structs --- @@ -112,6 +121,56 @@ type ImplementingConfig struct { Eval EvalConfig `json:"eval"` } +// SubAgentConfig specifies a sub-agent used within a reverse engineering task. +type SubAgentConfig struct { + Model string `json:"model" toml:"model"` + Type string `json:"type" toml:"type"` + Count int `json:"count" toml:"count"` +} + +// DrafterConfig configures the primary drafting agent and its exploration sub-agents. +type DrafterConfig struct { + Model string `json:"model" toml:"model"` + Subagents SubAgentConfig `json:"subagents" toml:"subagents"` +} + +// SelfRefineConfig configures the self-refine execution mode. +type SelfRefineConfig struct { + Rounds int `json:"rounds" toml:"rounds"` +} + +// MultiPassConfig configures the multi-pass execution mode. +type MultiPassConfig struct { + Passes int `json:"passes" toml:"passes"` +} + +// PeerReviewConfig configures the peer-review execution mode. +type PeerReviewConfig struct { + Reviewers int `json:"reviewers" toml:"reviewers"` + Rounds int `json:"rounds" toml:"rounds"` + Subagents SubAgentConfig `json:"subagents" toml:"subagents"` +} + +// ReconcileConfig configures the reconciliation loop after EXECUTE. +type ReconcileConfig struct { + MinRounds int `json:"min_rounds" toml:"min_rounds"` + MaxRounds int `json:"max_rounds" toml:"max_rounds"` + ColleagueReview bool `json:"colleague_review" toml:"colleague_review"` + Eval AgentConfig `json:"eval" toml:"eval"` +} + +// ReverseEngineeringConfig configures the reverse engineering phase. +type ReverseEngineeringConfig struct { + Mode string `json:"mode" toml:"mode"` + Drafter DrafterConfig `json:"drafter" toml:"drafter"` + SelfRefine *SelfRefineConfig `json:"self_refine,omitempty" toml:"self_refine"` + MultiPass *MultiPassConfig `json:"multi_pass,omitempty" toml:"multi_pass"` + PeerReview *PeerReviewConfig `json:"peer_review,omitempty" toml:"peer_review"` + Reconcile ReconcileConfig `json:"reconcile" toml:"reconcile"` + Survey SubAgentConfig `json:"survey" toml:"survey"` + GapAnalysis SubAgentConfig `json:"gap_analysis" toml:"gap_analysis"` +} + // DomainConfig identifies a domain within the project. type DomainConfig struct { Name string `json:"name"` @@ -141,13 +200,14 @@ type GeneralConfig struct { // ForgeConfig is the full project configuration loaded from .forgectl/config. // It is locked into ForgeState at init time so all commands use consistent settings. type ForgeConfig struct { - General GeneralConfig `json:"general"` - Domains []DomainConfig `json:"domains,omitempty"` - Specifying SpecifyingConfig `json:"specifying"` - Planning PlanningConfig `json:"planning"` - Implementing ImplementingConfig `json:"implementing"` - Paths PathsConfig `json:"paths"` - Logs LogsConfig `json:"logs"` + General GeneralConfig `json:"general"` + Domains []DomainConfig `json:"domains,omitempty"` + Specifying SpecifyingConfig `json:"specifying"` + Planning PlanningConfig `json:"planning"` + Implementing ImplementingConfig `json:"implementing"` + ReverseEngineering ReverseEngineeringConfig `json:"reverse_engineering"` + Paths PathsConfig `json:"paths"` + Logs LogsConfig `json:"logs"` } // DefaultForgeConfig returns a ForgeConfig with all spec-defined default values applied. @@ -196,6 +256,40 @@ func DefaultForgeConfig() ForgeConfig { }, }, }, + ReverseEngineering: ReverseEngineeringConfig{ + Mode: "self_refine", + Drafter: DrafterConfig{ + Model: "opus", + Subagents: SubAgentConfig{ + Model: "opus", + Type: "explorer", + Count: 3, + }, + }, + SelfRefine: &SelfRefineConfig{ + Rounds: 2, + }, + Reconcile: ReconcileConfig{ + MinRounds: 1, + MaxRounds: 3, + ColleagueReview: false, + Eval: AgentConfig{ + Model: "opus", + Type: "general-purpose", + Count: 1, + }, + }, + Survey: SubAgentConfig{ + Model: "haiku", + Type: "explorer", + Count: 2, + }, + GapAnalysis: SubAgentConfig{ + Model: "sonnet", + Type: "explorer", + Count: 5, + }, + }, Paths: PathsConfig{ StateDir: ".forgectl/state", WorkspaceDir: ".forge_workspace", @@ -240,6 +334,64 @@ type PlanQueueInput struct { Plans []PlanQueueEntry `json:"plans"` } +// ReverseEngineeringInitInput is the schema for --from with --phase reverse_engineering. +type ReverseEngineeringInitInput struct { + Concept string `json:"concept"` + Domains []string `json:"domains"` +} + +// ReverseEngineeringQueueEntry is a spec entry in the reverse engineering queue file. +type ReverseEngineeringQueueEntry struct { + Name string `json:"name"` + Domain string `json:"domain"` + Topic string `json:"topic"` + File string `json:"file"` + Action string `json:"action"` + CodeSearchRoots []string `json:"code_search_roots"` + DependsOn []string `json:"depends_on"` +} + +// ReverseEngineeringQueueInput is the schema for the reverse engineering queue file. +type ReverseEngineeringQueueInput struct { + Specs []ReverseEngineeringQueueEntry `json:"specs"` +} + +// ExecuteJSONConfig is the config section of execute.json. +// Only the active mode's config block is included. +type ExecuteJSONConfig struct { + Mode string `json:"mode"` + Drafter DrafterConfig `json:"drafter"` + SelfRefine *SelfRefineConfig `json:"self_refine,omitempty"` + MultiPass *MultiPassConfig `json:"multi_pass,omitempty"` + PeerReview *PeerReviewConfig `json:"peer_review,omitempty"` +} + +// ExecuteJSONSpecResult is the result field of a spec entry in execute.json. +type ExecuteJSONSpecResult struct { + Status string `json:"status"` + IterationsCompleted *int `json:"iterations_completed,omitempty"` + Error *string `json:"error,omitempty"` +} + +// ExecuteJSONSpec is a spec entry in execute.json. +type ExecuteJSONSpec struct { + Name string `json:"name"` + Domain string `json:"domain"` + Topic string `json:"topic"` + File string `json:"file"` + Action string `json:"action"` + CodeSearchRoots []string `json:"code_search_roots"` + DependsOn []string `json:"depends_on"` + Result *ExecuteJSONSpecResult `json:"result"` +} + +// ExecuteJSONFile is the complete execute.json structure written by forgectl for the Python subprocess. +type ExecuteJSONFile struct { + ProjectRoot string `json:"project_root"` + Config ExecuteJSONConfig `json:"config"` + Specs []ExecuteJSONSpec `json:"specs"` +} + // --- Plan.json schema (for implementing phase) --- // PlanContext is the context section of plan.json. @@ -438,6 +590,23 @@ type ImplementingState struct { PlanQueue []PlanQueueEntry `json:"plan_queue,omitempty"` } +// --- Reverse engineering phase state --- + +// ReverseEngineeringState holds reverse engineering phase data. +type ReverseEngineeringState struct { + Concept string `json:"concept"` + Domains []string `json:"domains"` + CurrentDomain int `json:"current_domain"` + TotalDomains int `json:"total_domains"` + QueueFile string `json:"queue_file,omitempty"` + QueueHash string `json:"queue_hash,omitempty"` + ExecuteFile string `json:"execute_file,omitempty"` + Round int `json:"round"` + ColleagueReview bool `json:"colleague_review"` + ReconcileDomain int `json:"reconcile_domain"` + Evals []EvalRecord `json:"evals,omitempty"` +} + // --- Phase shift info --- // PhaseShiftInfo records the from→to of a phase shift. @@ -460,6 +629,10 @@ type ForgeState struct { GeneratePlanningQueue *GeneratePlanningQueueState `json:"generate_planning_queue,omitempty"` Planning *PlanningState `json:"planning"` Implementing *ImplementingState `json:"implementing"` + ReverseEngineering *ReverseEngineeringState `json:"reverse_engineering,omitempty"` + // Logger is a transient, non-serialized activity logger attached at the cmd layer. + // When nil, all log writes are no-ops. + Logger *Logger `json:"-"` } // AdvanceInput carries flags from the advance command. diff --git a/forgectl/state/validate.go b/forgectl/state/validate.go index e16a16d..21f9e1e 100644 --- a/forgectl/state/validate.go +++ b/forgectl/state/validate.go @@ -361,3 +361,234 @@ func PlanQueueSchema() string { ] }` } + +// ValidateReverseEngineeringInit validates the reverse engineering init input JSON. +// Only "concept" and "domains" are allowed; both are required. +func ValidateReverseEngineeringInit(data []byte) []string { + var errs []string + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return []string{fmt.Sprintf("invalid JSON: %s", err)} + } + + // Check for unexpected top-level keys. + for k := range raw { + if k != "concept" && k != "domains" { + errs = append(errs, fmt.Sprintf("unexpected field %q", k)) + } + } + + // Validate "concept". + conceptRaw, ok := raw["concept"] + if !ok { + errs = append(errs, "missing required field \"concept\"") + } else { + var concept string + if err := json.Unmarshal(conceptRaw, &concept); err != nil { + errs = append(errs, fmt.Sprintf("\"concept\" must be a string: %s", err)) + } else if strings.TrimSpace(concept) == "" { + errs = append(errs, "\"concept\" must not be empty") + } + } + + // Validate "domains". + domainsRaw, ok := raw["domains"] + if !ok { + errs = append(errs, "missing required field \"domains\"") + } else { + var domains []json.RawMessage + if err := json.Unmarshal(domainsRaw, &domains); err != nil { + errs = append(errs, fmt.Sprintf("\"domains\" must be an array: %s", err)) + } else if len(domains) == 0 { + errs = append(errs, "\"domains\" must not be empty") + } else { + seen := map[string]bool{} + for i, d := range domains { + var s string + if err := json.Unmarshal(d, &s); err != nil { + errs = append(errs, fmt.Sprintf("domains[%d]: must be a string", i)) + continue + } + if seen[s] { + errs = append(errs, fmt.Sprintf("duplicate domain %q", s)) + } + seen[s] = true + } + } + } + + return errs +} + +// ReverseEngineeringInitSchema returns the expected schema for the reverse engineering init input. +func ReverseEngineeringInitSchema() string { + return `{ + "concept": "", + "domains": ["", ...] +}` +} + +// ValidateReverseEngineeringQueue validates the reverse engineering queue JSON. +// projectRoot and domains are optional: pass "" and nil to skip path-existence and +// domain-membership checks respectively. +func ValidateReverseEngineeringQueue(data []byte, projectRoot string, domains []string) []string { + var errs []string + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return []string{fmt.Sprintf("invalid JSON: %s", err)} + } + + // Only "specs" allowed at top level. + for k := range raw { + if k != "specs" { + errs = append(errs, fmt.Sprintf("unexpected field %q", k)) + } + } + + specsRaw, ok := raw["specs"] + if !ok { + return append(errs, "missing required field \"specs\"") + } + + var specs []json.RawMessage + if err := json.Unmarshal(specsRaw, &specs); err != nil { + return append(errs, fmt.Sprintf("\"specs\" must be an array: %s", err)) + } + if len(specs) == 0 { + return append(errs, "\"specs\" array must not be empty") + } + + validDomains := map[string]bool{} + for _, d := range domains { + validDomains[d] = true + } + + requiredFields := []string{"name", "domain", "topic", "file", "action", "code_search_roots", "depends_on"} + allowedFields := map[string]bool{ + "name": true, "domain": true, "topic": true, "file": true, + "action": true, "code_search_roots": true, "depends_on": true, + } + + // First pass: build full name index for ordering checks. + nameIndex := map[string]int{} + for i, specRaw := range specs { + var entry map[string]json.RawMessage + if err := json.Unmarshal(specRaw, &entry); err != nil { + continue + } + if nameRaw, ok := entry["name"]; ok { + var name string + json.Unmarshal(nameRaw, &name) + if name != "" { + nameIndex[name] = i + } + } + } + + for i, specRaw := range specs { + var entry map[string]json.RawMessage + if err := json.Unmarshal(specRaw, &entry); err != nil { + errs = append(errs, fmt.Sprintf("specs[%d]: invalid object: %s", i, err)) + continue + } + + // Check required fields present. + for _, field := range requiredFields { + if _, ok := entry[field]; !ok { + errs = append(errs, fmt.Sprintf("specs[%d]: missing required field %q", i, field)) + } + } + + // Check no extra fields. + for k := range entry { + if !allowedFields[k] { + errs = append(errs, fmt.Sprintf("specs[%d]: unexpected field %q", i, k)) + } + } + + // Extract name for error messages. + var entryName string + if nameRaw, ok := entry["name"]; ok { + json.Unmarshal(nameRaw, &entryName) + } + + // Validate action. + if actionRaw, ok := entry["action"]; ok { + var action string + if err := json.Unmarshal(actionRaw, &action); err != nil { + errs = append(errs, fmt.Sprintf("specs[%d]: \"action\" must be a string", i)) + } else if action != "create" && action != "update" { + errs = append(errs, fmt.Sprintf("specs[%d]: \"action\" must be \"create\" or \"update\", got %q", i, action)) + } + } + + // Validate code_search_roots non-empty. + var entryDomain string + if domainRaw, ok := entry["domain"]; ok { + json.Unmarshal(domainRaw, &entryDomain) + } + + if rootsRaw, ok := entry["code_search_roots"]; ok { + var roots []json.RawMessage + if err := json.Unmarshal(rootsRaw, &roots); err != nil { + errs = append(errs, fmt.Sprintf("specs[%d]: \"code_search_roots\" must be an array", i)) + } else if len(roots) == 0 { + errs = append(errs, fmt.Sprintf("specs[%d]: \"code_search_roots\" must not be empty", i)) + } else if projectRoot != "" && entryDomain != "" { + // Validate each directory exists on disk. + domainRoot := filepath.Join(projectRoot, entryDomain) + for j, rootRaw := range roots { + var root string + if err := json.Unmarshal(rootRaw, &root); err != nil { + errs = append(errs, fmt.Sprintf("specs[%d]: code_search_roots[%d]: must be a string", i, j)) + continue + } + dirPath := filepath.Join(domainRoot, root) + if info, err := os.Stat(dirPath); err != nil || !info.IsDir() { + errs = append(errs, fmt.Sprintf("specs[%d]: code_search_roots[%d]: directory %q does not exist", i, j, root)) + } + } + } + } + + // Domain membership check. + if len(validDomains) > 0 && entryDomain != "" { + if !validDomains[entryDomain] { + errs = append(errs, fmt.Sprintf("specs[%d]: domain %q is not in the initialized domain list", i, entryDomain)) + } + } + + // Dependency ordering: depends_on entries must appear before this entry. + if depsRaw, ok := entry["depends_on"]; ok { + var deps []string + if err := json.Unmarshal(depsRaw, &deps); err == nil { + for _, dep := range deps { + if depIdx, found := nameIndex[dep]; found && depIdx >= i { + errs = append(errs, fmt.Sprintf("specs[%d] (%s): depends_on %q which appears at or after this entry", i, entryName, dep)) + } + } + } + } + } + + return errs +} + +// ReverseEngineeringQueueSchema returns the expected schema for the reverse engineering queue file. +func ReverseEngineeringQueueSchema() string { + return `{ + "specs": [ + { + "name": "", + "domain": "", + "topic": "", + "file": "", + "action": "create|update", + "code_search_roots": ["", ...], + "depends_on": ["", ...] + } + ] +}` +} diff --git a/forgectl/state/validate_test.go b/forgectl/state/validate_test.go index beddb24..8177f27 100644 --- a/forgectl/state/validate_test.go +++ b/forgectl/state/validate_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -218,3 +219,119 @@ func TestValidatePlanJSON_LayerOrderViolation(t *testing.T) { t.Error("expected error for layer order violation") } } + +func TestValidateReverseEngineeringInit_Valid(t *testing.T) { + data := []byte(`{"concept": "auth middleware refactor", "domains": ["optimizer", "api", "portal"]}`) + errs := ValidateReverseEngineeringInit(data) + if len(errs) > 0 { + t.Errorf("expected no errors, got %v", errs) + } +} + +func TestValidateReverseEngineeringInit_MissingConcept(t *testing.T) { + data := []byte(`{"domains": ["optimizer"]}`) + errs := ValidateReverseEngineeringInit(data) + if len(errs) == 0 { + t.Error("expected error for missing concept") + } +} + +func TestValidateReverseEngineeringInit_EmptyDomains(t *testing.T) { + data := []byte(`{"concept": "auth refactor", "domains": []}`) + errs := ValidateReverseEngineeringInit(data) + if len(errs) == 0 { + t.Error("expected error for empty domains") + } +} + +func TestValidateReverseEngineeringInit_DuplicateDomain(t *testing.T) { + data := []byte(`{"concept": "auth refactor", "domains": ["optimizer", "optimizer"]}`) + errs := ValidateReverseEngineeringInit(data) + if len(errs) == 0 { + t.Error("expected error for duplicate domain") + } +} + +func TestValidateReverseEngineeringInit_ExtraField(t *testing.T) { + data := []byte(`{"concept": "auth refactor", "domains": ["optimizer"], "extra": true}`) + errs := ValidateReverseEngineeringInit(data) + if len(errs) == 0 { + t.Error("expected error for extra field") + } +} + +func validQueueEntry() string { + return `{"name":"Auth Spec","domain":"optimizer","topic":"auth","file":"specs/auth.md","action":"create","code_search_roots":["src/"],"depends_on":[]}` +} + +func TestValidateReverseEngineeringQueue_Valid(t *testing.T) { + data := []byte(`{"specs": [` + validQueueEntry() + `]}`) + errs := ValidateReverseEngineeringQueue(data, "", nil) + if len(errs) > 0 { + t.Errorf("expected no errors, got %v", errs) + } +} + +func TestValidateReverseEngineeringQueue_MissingField(t *testing.T) { + data := []byte(`{"specs": [{"name":"A","domain":"optimizer","topic":"t","file":"f","action":"create","code_search_roots":["src/"]}]}`) + errs := ValidateReverseEngineeringQueue(data, "", nil) + if len(errs) == 0 { + t.Error("expected error for missing depends_on") + } +} + +func TestValidateReverseEngineeringQueue_InvalidAction(t *testing.T) { + data := []byte(`{"specs": [{"name":"A","domain":"optimizer","topic":"t","file":"f","action":"delete","code_search_roots":["src/"],"depends_on":[]}]}`) + errs := ValidateReverseEngineeringQueue(data, "", nil) + if len(errs) == 0 { + t.Error("expected error for invalid action") + } +} + +func TestValidateReverseEngineeringQueue_EmptyCodeSearchRoots(t *testing.T) { + data := []byte(`{"specs": [{"name":"A","domain":"optimizer","topic":"t","file":"f","action":"create","code_search_roots":[],"depends_on":[]}]}`) + errs := ValidateReverseEngineeringQueue(data, "", nil) + if len(errs) == 0 { + t.Error("expected error for empty code_search_roots") + } +} + +func TestValidateReverseEngineeringQueue_DomainMembership(t *testing.T) { + data := []byte(`{"specs": [{"name":"A","domain":"unknown","topic":"t","file":"f","action":"create","code_search_roots":["src/"],"depends_on":[]}]}`) + errs := ValidateReverseEngineeringQueue(data, "", []string{"optimizer"}) + if len(errs) == 0 { + t.Error("expected error for domain not in initialized list") + } +} + +func TestValidateReverseEngineeringQueue_ForwardDependency(t *testing.T) { + // B depends on A but A appears after B — forward dependency violation. + data := []byte(`{"specs": [ + {"name":"B","domain":"optimizer","topic":"t","file":"specs/b.md","action":"create","code_search_roots":["src/"],"depends_on":["A"]}, + {"name":"A","domain":"optimizer","topic":"t","file":"specs/a.md","action":"create","code_search_roots":["src/"],"depends_on":[]} + ]}`) + errs := ValidateReverseEngineeringQueue(data, "", nil) + if len(errs) == 0 { + t.Error("expected error for dependency on entry that appears later") + } +} + +func TestValidateReverseEngineeringQueue_CodeSearchRootsDirExists(t *testing.T) { + // Create a temp dir structure to simulate project root. + dir := t.TempDir() + domainDir := filepath.Join(dir, "optimizer") + os.MkdirAll(filepath.Join(domainDir, "src"), 0o755) + + data := []byte(`{"specs": [{"name":"A","domain":"optimizer","topic":"t","file":"f","action":"create","code_search_roots":["src/","nonexistent/"],"depends_on":[]}]}`) + errs := ValidateReverseEngineeringQueue(data, dir, []string{"optimizer"}) + // Should have an error for nonexistent/ but not for src/ + found := false + for _, e := range errs { + if strings.Contains(e, "nonexistent") { + found = true + } + } + if !found { + t.Errorf("expected error for nonexistent directory, got %v", errs) + } +} diff --git a/reverse_engineer/.python-version b/reverse_engineer/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/reverse_engineer/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/reverse_engineer/README.md b/reverse_engineer/README.md new file mode 100644 index 0000000..e69de29 diff --git a/reverse_engineer/pyproject.toml b/reverse_engineer/pyproject.toml new file mode 100644 index 0000000..8ed5934 --- /dev/null +++ b/reverse_engineer/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "reverse-engineer" +version = "0.1.0" +description = "Constructs and runs Claude Agent SDK sessions for reverse-engineering specs from code" +readme = "README.md" +authors = [ + { name = "Jake Cukjati", email = "jakecukjati@gmail.com" } +] +requires-python = ">=3.13" +dependencies = [ + "claude-agent-sdk>=0.1.56", + "pydantic>=2.12.5", +] + +[dependency-groups] +dev = [ + "pytest>=8.3", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[build-system] +requires = ["uv_build>=0.8.13,<0.9.0"] +build-backend = "uv_build" diff --git a/reverse_engineer/src/reverse_engineer/__init__.py b/reverse_engineer/src/reverse_engineer/__init__.py new file mode 100644 index 0000000..9d5fbb2 --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/__init__.py @@ -0,0 +1,2 @@ +def hello() -> str: + return "Hello from reverse-engineer!" diff --git a/reverse_engineer/src/reverse_engineer/__pycache__/__init__.cpython-313.pyc b/reverse_engineer/src/reverse_engineer/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5e686d9 Binary files /dev/null and b/reverse_engineer/src/reverse_engineer/__pycache__/__init__.cpython-313.pyc differ diff --git a/reverse_engineer/src/reverse_engineer/__pycache__/constants.cpython-313.pyc b/reverse_engineer/src/reverse_engineer/__pycache__/constants.cpython-313.pyc new file mode 100644 index 0000000..27d9418 Binary files /dev/null and b/reverse_engineer/src/reverse_engineer/__pycache__/constants.cpython-313.pyc differ diff --git a/reverse_engineer/src/reverse_engineer/__pycache__/schemas.cpython-313.pyc b/reverse_engineer/src/reverse_engineer/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000..4e4c3d0 Binary files /dev/null and b/reverse_engineer/src/reverse_engineer/__pycache__/schemas.cpython-313.pyc differ diff --git a/reverse_engineer/src/reverse_engineer/cli.py b/reverse_engineer/src/reverse_engineer/cli.py new file mode 100644 index 0000000..6e8c220 --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/cli.py @@ -0,0 +1,37 @@ +"""CLI entry point for reverse_engineer.""" + +from __future__ import annotations + +import argparse +import asyncio +import sys + +from pydantic import ValidationError + +from .runner import execute + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="reverse-engineer", + description="Run reverse-engineering agent sessions from an execute.json file.", + ) + parser.add_argument( + "--execute", + required=True, + metavar="PATH", + help="Path to the execute.json execution file.", + ) + args = parser.parse_args() + + try: + asyncio.run(execute(args.execute)) + except FileNotFoundError as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) + except ValidationError as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) + except Exception as exc: # noqa: BLE001 + print(f"Unexpected error: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/reverse_engineer/src/reverse_engineer/constants.py b/reverse_engineer/src/reverse_engineer/constants.py new file mode 100644 index 0000000..be28445 --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/constants.py @@ -0,0 +1,10 @@ +"""Package constants for the reverse engineer agent. + +These values are hardcoded in the package and never supplied externally. +""" + +ALLOWED_TOOLS: list[str] = ["Read", "Glob", "Grep", "Edit", "Write", "Agent"] + +PERMISSION_MODE: str = "acceptEdits" + +READ_CLAUDE_MD: bool = False diff --git a/reverse_engineer/src/reverse_engineer/factory.py b/reverse_engineer/src/reverse_engineer/factory.py new file mode 100644 index 0000000..c85ea84 --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/factory.py @@ -0,0 +1,95 @@ +"""Agent session factory. + +Builds ClaudeAgentOptions and an assembled prompt from an execution file entry. +""" + +import re +from pathlib import Path + +from claude_agent_sdk import ClaudeAgentOptions + +from .constants import ALLOWED_TOOLS, PERMISSION_MODE, READ_CLAUDE_MD +from .prompts_loader import load_prompt +from .schemas import ExecutionConfig, SpecEntry + + +def build_session( + entry: SpecEntry, + config: ExecutionConfig, + project_root: str, +) -> tuple[ClaudeAgentOptions, str]: + """Construct a ClaudeAgentOptions and assembled prompt for a spec entry. + + Args: + entry: The spec entry from the execution file. + config: The execution configuration. + project_root: The project root directory. + + Returns: + A tuple of (ClaudeAgentOptions, assembled_prompt). + + Raises: + FileNotFoundError: If the domain root does not exist or the existing + spec file is missing for an update action. + ValueError: If the prompt template contains unresolvable placeholders. + """ + # Resolve domain root. + domain_root = Path(project_root) / entry.domain + if not domain_root.is_dir(): + raise FileNotFoundError(f"Domain root not found: {domain_root}") + + # Load and concatenate bundled prompts. + initial_prompt = load_prompt("reverse-engineering-prompt.md") + format_ref = load_prompt("spec-format-reference.md") + prompt = initial_prompt + "\n\n" + format_ref + + # For update action: read existing spec file. + existing_spec_content = "" + if entry.action == "update": + spec_path = domain_root / entry.file + if not spec_path.exists(): + raise FileNotFoundError( + f"Existing spec file not found for update: {spec_path}" + ) + existing_spec_content = spec_path.read_text(encoding="utf-8") + + # Build substitution map. + subagents = config.drafter.subagents + code_search_roots_str = ", ".join(entry.code_search_roots) + substitutions = { + "name": entry.name, + "topic": entry.topic, + "file": entry.file, + "action": entry.action, + "code_search_roots": code_search_roots_str, + "existing_spec_content": existing_spec_content, + "subagent_type": subagents.type, + "subagent_model": subagents.model, + "subagent_count": str(subagents.count), + } + + # Replace all known placeholders. + for key, value in substitutions.items(): + prompt = prompt.replace("{" + key + "}", value) + + # Check for any remaining unresolved placeholders. + remaining = re.findall(r"\{([a-z_]+)\}", prompt) + if remaining: + raise ValueError( + f"Unresolvable interpolation placeholders in prompt: {remaining}" + ) + + # Build ClaudeAgentOptions. + # READ_CLAUDE_MD=False is enforced via setting_sources=["user"], which prevents + # Claude Code from loading project-level config (CLAUDE.md files). The + # claude-agent-sdk does not yet expose read_claude_md directly on ClaudeAgentOptions. + setting_sources: list[str] | None = None if READ_CLAUDE_MD else ["user"] + options = ClaudeAgentOptions( + model=config.drafter.model, + cwd=str(domain_root), + allowed_tools=list(ALLOWED_TOOLS), + permission_mode=PERMISSION_MODE, + setting_sources=setting_sources, + ) + + return options, prompt diff --git a/reverse_engineer/src/reverse_engineer/prompts/__init__.py b/reverse_engineer/src/reverse_engineer/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reverse_engineer/src/reverse_engineer/prompts/peer-review-prompt.md b/reverse_engineer/src/reverse_engineer/prompts/peer-review-prompt.md new file mode 100644 index 0000000..9077b3b --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/prompts/peer-review-prompt.md @@ -0,0 +1,43 @@ +# Peer Review Prompt + +## Purpose +Sent as a follow-up to the primary agent after the initial draft. Instructs the agent to spawn reviewer sub-agents in parallel to evaluate the spec and then synthesize their feedback into the spec file. + +## Used By +EXECUTE state — primary agent follow-up, when `peer_review` mode is enabled. Sent `peer_review.rounds` times after the initial draft. + +## Interpolation Fields +- `{peer_review.reviewers}` — number of reviewer sub-agents to spawn +- `{peer_review.subagents.model}` — model for reviewer sub-agents +- `{peer_review.subagents.type}` — type for reviewer sub-agents +- `{file}` — the spec file path to review +- `{code_search_roots}` — directories to verify spec accuracy against + +## Prompt + +TODO: Write the full peer review prompt content. The prompt must cover: + +- You have drafted a specification at `{file}` +- Spawn `{peer_review.reviewers}` `{peer_review.subagents.model}` `{peer_review.subagents.type}` sub-agents in parallel to review your work +- Each sub-agent receives: + - The spec file to review: `{file}` + - The source code to verify against: `{code_search_roots}` + - The spec format reference (included below) +- Each reviewer evaluates the spec against: + - Topic of concern: single sentence, no "and", describes an activity + - Declarative voice: no "should", "could", "might" + - Every behavior has testing criteria (Given/When/Then) + - Error handling is exhaustive: every failure mode named + - Edge cases have scenario, expected behavior, and rationale + - Invariants are always-true, testable properties + - Observability: INFO for success, ERROR for failures, DEBUG for diagnostics + - No open questions or TBDs + - Code accuracy: does the spec match what the code actually does? +- Each reviewer reports back: + - Issues found (with specific section references) + - Missing behaviors found in code but not in spec + - Suggested corrections +- After all reviewers report back, synthesize their feedback and update the spec file at `{file}` +- Resolve conflicting feedback using your judgment +- Do not add reviewer notes to the spec — incorporate the fixes directly +- The spec format reference is appended below for reviewer context diff --git a/reverse_engineer/src/reverse_engineer/prompts/reverse-engineering-prompt.md b/reverse_engineer/src/reverse_engineer/prompts/reverse-engineering-prompt.md new file mode 100644 index 0000000..303a385 --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/prompts/reverse-engineering-prompt.md @@ -0,0 +1,35 @@ +# Reverse Engineering Prompt + +## Purpose +Instructs the primary Claude Agent SDK session to read code within its assigned code search roots and produce or update a single spec file for the given topic of concern. + +## Used By +EXECUTE state — primary agent. Concatenated with `spec-format-reference.md` to form the complete agent prompt. + +## Interpolation Fields +- `{name}` — display name of the spec +- `{topic}` — one-sentence topic of concern +- `{file}` — target spec file path (relative to domain root) +- `{action}` — "create" or "update" +- `{code_search_roots}` — directories to examine (relative to domain root) +- `{existing_spec_content}` — current spec content (populated for updates, empty for creates) +- `{subagent_type}` — role for sub-agents (e.g., "explorer") +- `{subagent_model}` — model for sub-agents +- `{subagent_count}` — number of sub-agents to use + +## Prompt + +TODO: Write the full prompt content. The prompt must cover: + +- The agent's role: reverse-engineer a specification from existing code +- The assigned topic of concern: `{topic}` +- The target output file: `{file}` +- Whether this is a create or update: `{action}` +- Where to look in the codebase: `{code_search_roots}` +- For updates: the existing spec content to revise: `{existing_spec_content}` +- Constraint: write or edit only the single file at `{file}` — no other files +- Constraint: read-only codebase — do not modify source code +- Constraint: capture what the code *does*, not what it *should* do +- Constraint: the Implements section references the reverse-engineered topic, not a planning document +- Sub-agent usage: spawn `{subagent_count}` `{subagent_type}` sub-agents at `{subagent_model}` to assist with code exploration +- The spec format is provided in a separate concatenated file — follow it exactly diff --git a/reverse_engineer/src/reverse_engineer/prompts/review-work-prompt.md b/reverse_engineer/src/reverse_engineer/prompts/review-work-prompt.md new file mode 100644 index 0000000..4fdb392 --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/prompts/review-work-prompt.md @@ -0,0 +1,34 @@ +# Review Work Prompt + +## Purpose +Sent as a follow-up to the primary agent after the initial draft. Instructs the agent to re-read its spec file and critique it against the spec format, topic-of-concern rules, completeness, and common evaluation findings. The agent refines the spec in place. + +## Used By +EXECUTE state — primary agent follow-up, when `review_work` is enabled. Sent `review_work.number_of_times` times after the initial draft. + +## Interpolation Fields +None. This prompt is sent as-is after the initial drafting prompt completes. + +## Prompt + +TODO: Write the full review prompt content. The prompt must cover: + +- Re-read the spec file you just wrote or updated +- Critique the spec against these checklists: + - Topic of concern: single sentence, no "and", describes an activity + - Declarative voice throughout: no "should", "could", "might" + - Every behavior has testing criteria (Given/When/Then) + - Error handling is exhaustive: every failure mode named with a specific response + - Edge cases capture judgment calls with scenario, expected behavior, rationale + - Invariants are always-true properties, not postconditions — each has a Given/When/Then test + - No references to planning file paths + - No open questions or TBDs + - Observability section present: INFO for success, ERROR for failures, DEBUG for diagnostics +- Check for common evaluation findings: + - Phantom observability entries: log entry references a behavior not defined in any Behavior section + - Unverifiable invariants: invariant describes intent rather than a testable property + - Untested invariants: every invariant needs a Given/When/Then test + - Internal architecture as invariants: don't prescribe concurrency or data structures — reformulate as externally observable properties + - Silent omissions: every identified behavior must be covered, explicitly excluded, or marked out of scope +- Fix any issues found by editing the spec file in place +- If no issues found, confirm the spec passes review diff --git a/reverse_engineer/src/reverse_engineer/prompts/spec-format-reference.md b/reverse_engineer/src/reverse_engineer/prompts/spec-format-reference.md new file mode 100644 index 0000000..9fac553 --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/prompts/spec-format-reference.md @@ -0,0 +1,48 @@ +# Spec Format Reference + +## Purpose +Provides the full specification format structure so the agent knows exactly how to structure its output. Sections, ordering, principles, topic-of-concern rules, and common evaluation findings to avoid. + +## Used By +EXECUTE state — primary agent. Concatenated with `reverse-engineering-prompt.md` to form the complete agent prompt. + +## Interpolation Fields +None. This is static content. + +## Content + +TODO: Write the full spec format reference. The content must cover: + +- What a spec is: a permanent, authoritative contract for a single topic of concern +- What a spec is not: not a plan, not code documentation, not a tutorial +- The complete spec structure in order: + - Title (activity-oriented) + - Topic of Concern (one sentence, no "and", describes an activity) + - Context (why the spec exists) + - Depends On (upstream spec dependencies) + - Integration Points (relationships with other specs) + - Interface (inputs, outputs, rejection) + - Behavior (preconditions, steps, postconditions, error handling) + - Configuration (parameters, types, defaults) + - Observability (logging levels, metrics) + - Invariants (always-true properties) + - Edge Cases (scenario, expected behavior, rationale) + - Testing Criteria (Given/When/Then) + - Implements (what this spec covers) +- Principles: + - One topic of concern per spec + - No codebase references (file paths, module names) + - Declarative voice ("the system does", not "the system should") + - No open questions or TBDs + - Technology-aware, not technology-coupled + - Error handling is exhaustive + - Invariants are always true, not postconditions + - Every behavior has testing criteria + - Edge cases capture judgment calls +- Common evaluation findings to avoid: + - Phantom observability entries (log entry with no corresponding behavior) + - Unverifiable invariants (intent, not testable property) + - Missing observability section + - Untested invariants + - Internal architecture prescribed as invariants + - Silent omissions (behavior not covered, excluded, or marked out of scope) diff --git a/reverse_engineer/src/reverse_engineer/prompts_loader.py b/reverse_engineer/src/reverse_engineer/prompts_loader.py new file mode 100644 index 0000000..59f3a0d --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/prompts_loader.py @@ -0,0 +1,29 @@ +"""Bundled prompt file loader. + +Resolves prompt markdown files from the package's bundled prompts/ sub-package +using importlib.resources. CWD-independent — works from any working directory. +""" + +from importlib.resources import files + + +def load_prompt(filename: str) -> str: + """Load a bundled prompt file by filename. + + Args: + filename: The filename of the prompt (e.g., "reverse-engineering-prompt.md"). + + Returns: + The full text content of the prompt file. + + Raises: + FileNotFoundError: If the bundled file is not found (corrupt installation). + """ + try: + resource = files("reverse_engineer.prompts").joinpath(filename) + return resource.read_text(encoding="utf-8") + except (FileNotFoundError, ModuleNotFoundError): + raise FileNotFoundError( + f"Bundled prompt file not found: {filename!r}. " + "The package installation may be corrupt." + ) diff --git a/reverse_engineer/src/reverse_engineer/py.typed b/reverse_engineer/src/reverse_engineer/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/reverse_engineer/src/reverse_engineer/runner.py b/reverse_engineer/src/reverse_engineer/runner.py new file mode 100644 index 0000000..e6b9b7e --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/runner.py @@ -0,0 +1,212 @@ +"""Mode-specific async execution runner for reverse engineering agent sessions.""" + +import asyncio +import logging +from pathlib import Path + +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + +from .factory import build_session +from .prompts_loader import load_prompt +from .schemas import ExecutionConfig, ExecutionFile, SpecEntry, SpecResult + +logger = logging.getLogger(__name__) + + +async def _run_session( + options: ClaudeAgentOptions, + initial_prompt: str, + follow_up_prompts: list[str], +) -> int: + """Run a single agent session with optional follow-up prompts. + + Args: + options: ClaudeAgentOptions for the session. + initial_prompt: The initial prompt to send. + follow_up_prompts: Follow-up prompts sent sequentially after the initial. + + Returns: + Number of iterations completed (1 + len(follow_up_prompts)). + + Raises: + Any exception raised by the SDK during the session. + """ + async with ClaudeSDKClient(options) as client: + await client.query(initial_prompt) + async for _ in client.receive_response(): + pass + + for follow_up in follow_up_prompts: + await client.query(follow_up) + async for _ in client.receive_response(): + pass + + return 1 + len(follow_up_prompts) + + +async def _run_entry( + entry: SpecEntry, + config: ExecutionConfig, + project_root: str, + follow_up_prompts: list[str], +) -> SpecResult: + """Build and run a session for one spec entry, returning a SpecResult. + + Errors are caught and converted to failure results so other entries can continue. + """ + try: + options, initial_prompt = build_session(entry, config, project_root) + logger.info("Agent session constructed for spec '%s'", entry.name) + iterations = await _run_session(options, initial_prompt, follow_up_prompts) + logger.info("Agent completed for spec '%s': success", entry.name) + return SpecResult(status="success", iterations_completed=iterations) + except Exception as exc: # noqa: BLE001 + logger.error("Agent session failure for '%s': %s", entry.name, exc) + return SpecResult(status="failure", error=str(exc)) + + +async def run_single_shot( + entries: list[SpecEntry], + config: ExecutionConfig, + project_root: str, +) -> list[SpecResult]: + """Run all entries in parallel with a single prompt each.""" + logger.info("single_shot: running %d sessions in parallel", len(entries)) + return list( + await asyncio.gather( + *[_run_entry(entry, config, project_root, []) for entry in entries] + ) + ) + + +async def run_self_refine( + entries: list[SpecEntry], + config: ExecutionConfig, + project_root: str, +) -> list[SpecResult]: + """Run all entries with initial prompt + N self-review follow-ups in parallel.""" + rounds = config.self_refine.rounds # type: ignore[union-attr] + review_template = load_prompt("review-work-prompt.md") + + follow_ups = [ + f"{review_template}\n\nThis is review round {n} of {rounds}." + for n in range(1, rounds + 1) + ] + + logger.info( + "self_refine: running %d sessions with %d review round(s) each", + len(entries), + rounds, + ) + return list( + await asyncio.gather( + *[_run_entry(entry, config, project_root, follow_ups) for entry in entries] + ) + ) + + +async def run_multi_pass( + entries: list[SpecEntry], + config: ExecutionConfig, + project_root: str, +) -> list[SpecResult]: + """Run all entries P times; after pass 1 all 'create' actions become 'update'.""" + passes = config.multi_pass.passes # type: ignore[union-attr] + logger.info("multi_pass: running %d pass(es) over %d entries", passes, len(entries)) + + results: list[SpecResult] = [] + current_entries = list(entries) + + for pass_num in range(passes): + if pass_num > 0: + current_entries = [ + entry.model_copy(update={"action": "update"}) + if entry.action == "create" + else entry + for entry in current_entries + ] + + results = list( + await asyncio.gather( + *[_run_entry(entry, config, project_root, []) for entry in current_entries] + ) + ) + logger.info("multi_pass: pass %d/%d complete", pass_num + 1, passes) + + return results + + +async def run_peer_review( + entries: list[SpecEntry], + config: ExecutionConfig, + project_root: str, +) -> list[SpecResult]: + """Run all entries with initial prompt + N peer review follow-ups in parallel.""" + peer_cfg = config.peer_review # type: ignore[union-attr] + review_template = load_prompt("peer-review-prompt.md") + + follow_up_base = ( + review_template + .replace("{peer_review.reviewers}", str(peer_cfg.reviewers)) + .replace("{peer_review.subagents.model}", peer_cfg.subagents.model) + .replace("{peer_review.subagents.type}", peer_cfg.subagents.type) + ) + follow_ups = [follow_up_base] * peer_cfg.rounds + + logger.info( + "peer_review: running %d sessions with %d review round(s), %d reviewer(s) each", + len(entries), + peer_cfg.rounds, + peer_cfg.reviewers, + ) + return list( + await asyncio.gather( + *[_run_entry(entry, config, project_root, follow_ups) for entry in entries] + ) + ) + + +async def execute(execute_file_path: str) -> None: + """Read execute.json, run agent sessions per mode, write results back. + + Args: + execute_file_path: Absolute or relative path to the execute.json file. + + Raises: + FileNotFoundError: If the execute file does not exist. + pydantic.ValidationError: If the execute file fails schema validation. + OSError: If the execute file cannot be written after execution. + """ + path = Path(execute_file_path) + if not path.exists(): + raise FileNotFoundError(f"Execution file not found: {path}") + + execution = ExecutionFile.model_validate_json(path.read_text(encoding="utf-8")) + logger.info( + "Execution file loaded: %d spec(s), mode=%s", + len(execution.specs), + execution.config.mode, + ) + + # Build the dispatch table inside execute() so that module-level patches in + # tests replace the correct function references at call time. + mode_runners = { + "single_shot": run_single_shot, + "self_refine": run_self_refine, + "multi_pass": run_multi_pass, + "peer_review": run_peer_review, + } + runner = mode_runners[execution.config.mode] + results = await runner(execution.specs, execution.config, execution.project_root) + + for entry, result in zip(execution.specs, results, strict=True): + entry.result = result + + try: + path.write_text(execution.model_dump_json(indent=2) + "\n", encoding="utf-8") + logger.info("Execution file written with all results: %s", path) + except OSError as exc: + logger.error("Failed to write execution file '%s': %s", path, exc) + raise + + logger.info("All sessions complete.") diff --git a/reverse_engineer/src/reverse_engineer/schemas.py b/reverse_engineer/src/reverse_engineer/schemas.py new file mode 100644 index 0000000..0521817 --- /dev/null +++ b/reverse_engineer/src/reverse_engineer/schemas.py @@ -0,0 +1,80 @@ +"""Pydantic models for validating execute.json.""" + +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel, Field, model_validator + + +class SubAgentConfig(BaseModel): + model: str + type: str + count: int = Field(ge=1) + + +class DrafterConfig(BaseModel): + model: str + subagents: SubAgentConfig + + +class SelfRefineConfig(BaseModel): + rounds: int = Field(ge=1) + + +class MultiPassConfig(BaseModel): + passes: int = Field(ge=1) + + +class PeerReviewConfig(BaseModel): + reviewers: int = Field(ge=1) + rounds: int = Field(ge=1) + subagents: SubAgentConfig + + +VALID_MODES = frozenset({"single_shot", "self_refine", "multi_pass", "peer_review"}) + + +class ExecutionConfig(BaseModel): + mode: str + drafter: DrafterConfig + self_refine: Optional[SelfRefineConfig] = None + multi_pass: Optional[MultiPassConfig] = None + peer_review: Optional[PeerReviewConfig] = None + + @model_validator(mode="after") + def validate_mode_and_config(self) -> "ExecutionConfig": + if self.mode not in VALID_MODES: + raise ValueError( + f"Unknown mode {self.mode!r}. Valid modes: {sorted(VALID_MODES)}" + ) + if self.mode == "self_refine" and self.self_refine is None: + raise ValueError("self_refine config block is required when mode is 'self_refine'") + if self.mode == "multi_pass" and self.multi_pass is None: + raise ValueError("multi_pass config block is required when mode is 'multi_pass'") + if self.mode == "peer_review" and self.peer_review is None: + raise ValueError("peer_review config block is required when mode is 'peer_review'") + return self + + +class SpecResult(BaseModel): + status: Literal["success", "failure"] + iterations_completed: Optional[int] = None + error: Optional[str] = None + + +class SpecEntry(BaseModel): + name: str + domain: str + topic: str + file: str + action: Literal["create", "update"] + code_search_roots: list[str] + depends_on: list[str] + result: Optional[SpecResult] = None + + +class ExecutionFile(BaseModel): + project_root: str + config: ExecutionConfig + specs: list[SpecEntry] diff --git a/reverse_engineer/tests/__init__.py b/reverse_engineer/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reverse_engineer/tests/__pycache__/__init__.cpython-312.pyc b/reverse_engineer/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..669575b Binary files /dev/null and b/reverse_engineer/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/reverse_engineer/tests/__pycache__/__init__.cpython-313.pyc b/reverse_engineer/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..424ec18 Binary files /dev/null and b/reverse_engineer/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/reverse_engineer/tests/__pycache__/test_constants.cpython-313-pytest-9.0.2.pyc b/reverse_engineer/tests/__pycache__/test_constants.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..897da58 Binary files /dev/null and b/reverse_engineer/tests/__pycache__/test_constants.cpython-313-pytest-9.0.2.pyc differ diff --git a/reverse_engineer/tests/__pycache__/test_schemas.cpython-312-pytest-8.3.2.pyc b/reverse_engineer/tests/__pycache__/test_schemas.cpython-312-pytest-8.3.2.pyc new file mode 100644 index 0000000..ac45db9 Binary files /dev/null and b/reverse_engineer/tests/__pycache__/test_schemas.cpython-312-pytest-8.3.2.pyc differ diff --git a/reverse_engineer/tests/__pycache__/test_schemas.cpython-313-pytest-9.0.2.pyc b/reverse_engineer/tests/__pycache__/test_schemas.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..ec1dd1b Binary files /dev/null and b/reverse_engineer/tests/__pycache__/test_schemas.cpython-313-pytest-9.0.2.pyc differ diff --git a/reverse_engineer/tests/test_cli.py b/reverse_engineer/tests/test_cli.py new file mode 100644 index 0000000..e6d5b58 --- /dev/null +++ b/reverse_engineer/tests/test_cli.py @@ -0,0 +1,116 @@ +"""Tests for the CLI entry point (cli.py).""" + +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import ValidationError + +from reverse_engineer.cli import main +from reverse_engineer.schemas import ExecutionFile + + +# --- Helpers --- + +def _make_validation_error(data: dict) -> ValidationError: + """Produce a real ValidationError from bad ExecutionFile data.""" + try: + ExecutionFile.model_validate(data) + except ValidationError as exc: + return exc + raise AssertionError("Expected ValidationError was not raised") # pragma: no cover + + +_INVALID_CONFIG_DATA = { + "project_root": "/project", + "config": { + "mode": "single_shot", + "drafter": { + "model": "opus", + "subagents": {"model": "sonnet", "type": "explorer", "count": 0}, # count < 1 + }, + }, + "specs": [], +} + +_UNKNOWN_MODE_DATA = { + "project_root": "/project", + "config": { + "mode": "turbo_mode", + "drafter": { + "model": "opus", + "subagents": {"model": "sonnet", "type": "explorer", "count": 2}, + }, + }, + "specs": [], +} + + +# --- Functional test --- + +def test_cli_reads_execute_json_and_invokes_runner(capsys) -> None: + """CLI passes the --execute path to the runner and exits successfully.""" + with patch("reverse_engineer.cli.execute", new_callable=AsyncMock) as mock_execute: + with patch("sys.argv", ["reverse-engineer", "--execute", "/path/to/execute.json"]): + main() + + mock_execute.assert_called_once_with("/path/to/execute.json") + assert capsys.readouterr().err == "" + + +# --- Rejection tests --- + +def test_missing_execute_flag_prints_usage_and_exits_nonzero(capsys) -> None: + """Missing --execute flag causes argparse to print usage to stderr and exit non-zero.""" + with patch("sys.argv", ["reverse-engineer"]): + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code != 0 + # argparse writes the error message to stderr + assert len(capsys.readouterr().err) > 0 + + +def test_invalid_execute_json_exits_nonzero_with_validation_errors(capsys) -> None: + """Invalid execute.json (schema violation) exits non-zero with validation errors on stderr.""" + validation_error = _make_validation_error(_INVALID_CONFIG_DATA) + + with patch("reverse_engineer.cli.execute", new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = validation_error + with patch("sys.argv", ["reverse-engineer", "--execute", "execute.json"]): + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code != 0 + assert len(capsys.readouterr().err) > 0 + + +def test_nonexistent_execute_json_exits_nonzero_with_file_not_found(capsys) -> None: + """Non-existent execute.json path exits non-zero with file-not-found message on stderr.""" + with patch("reverse_engineer.cli.execute", new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = FileNotFoundError("Execution file not found: /nonexistent.json") + with patch("sys.argv", ["reverse-engineer", "--execute", "/nonexistent.json"]): + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code != 0 + stderr = capsys.readouterr().err + assert "/nonexistent.json" in stderr + + +def test_unknown_mode_exits_nonzero_with_valid_modes_listed(capsys) -> None: + """execute.json with unknown mode exits non-zero with valid modes listed on stderr.""" + validation_error = _make_validation_error(_UNKNOWN_MODE_DATA) + + with patch("reverse_engineer.cli.execute", new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = validation_error + with patch("sys.argv", ["reverse-engineer", "--execute", "execute.json"]): + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code != 0 + stderr = capsys.readouterr().err + # The ValidationError from schemas.py lists valid modes + assert any( + mode in stderr + for mode in ("single_shot", "multi_pass", "self_refine", "peer_review") + ) diff --git a/reverse_engineer/tests/test_constants.py b/reverse_engineer/tests/test_constants.py new file mode 100644 index 0000000..d9eb355 --- /dev/null +++ b/reverse_engineer/tests/test_constants.py @@ -0,0 +1,15 @@ +"""Tests for package constants.""" + +from reverse_engineer.constants import ALLOWED_TOOLS, PERMISSION_MODE, READ_CLAUDE_MD + + +def test_allowed_tools(): + assert ALLOWED_TOOLS == ["Read", "Glob", "Grep", "Edit", "Write", "Agent"] + + +def test_permission_mode(): + assert PERMISSION_MODE == "acceptEdits" + + +def test_read_claude_md_is_false(): + assert READ_CLAUDE_MD is False diff --git a/reverse_engineer/tests/test_factory.py b/reverse_engineer/tests/test_factory.py new file mode 100644 index 0000000..19f3fd6 --- /dev/null +++ b/reverse_engineer/tests/test_factory.py @@ -0,0 +1,156 @@ +"""Tests for the agent session factory.""" + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest +from claude_agent_sdk import ClaudeAgentOptions + +from reverse_engineer.constants import ALLOWED_TOOLS, PERMISSION_MODE +from reverse_engineer.factory import build_session +from reverse_engineer.schemas import ( + DrafterConfig, + ExecutionConfig, + SelfRefineConfig, + SpecEntry, + SubAgentConfig, +) + + +# --- Fixtures --- + +def make_entry( + action: str = "create", + domain: str = "myservice", + code_search_roots: list[str] | None = None, +) -> SpecEntry: + return SpecEntry( + name="Auth Handler", + domain=domain, + topic="handles user authentication requests", + file="specs/auth-handler.md", + action=action, + code_search_roots=code_search_roots or ["src/auth/"], + depends_on=[], + ) + + +def make_config(model: str = "opus") -> ExecutionConfig: + return ExecutionConfig( + mode="self_refine", + drafter=DrafterConfig( + model=model, + subagents=SubAgentConfig(model="sonnet", type="explorer", count=2), + ), + self_refine=SelfRefineConfig(rounds=1), + ) + + +def make_domain_root(tmp_path: Path, domain: str = "myservice") -> Path: + domain_root = tmp_path / domain + domain_root.mkdir() + return domain_root + + +# --- Functional tests --- + +def test_build_session_options_have_correct_constants(tmp_path: Path) -> None: + """build_session sets model, constants (tools, permission_mode), and disables CLAUDE.md.""" + make_domain_root(tmp_path) + config = make_config(model="sonnet") + entry = make_entry() + + options, _ = build_session(entry, config, str(tmp_path)) + + assert options.model == "sonnet" + assert options.allowed_tools == list(ALLOWED_TOOLS) + assert options.permission_mode == PERMISSION_MODE + # READ_CLAUDE_MD=False is enforced by restricting setting_sources to user-level only. + assert options.setting_sources == ["user"] + + +def test_build_session_sets_cwd_to_domain_root(tmp_path: Path) -> None: + """build_session sets cwd to the domain root directory.""" + make_domain_root(tmp_path) + entry = make_entry() + + options, _ = build_session(entry, make_config(), str(tmp_path)) + + assert options.cwd == str(tmp_path / "myservice") + + +def test_build_session_prompt_contains_interpolated_fields(tmp_path: Path) -> None: + """build_session interpolates entry fields into the assembled prompt.""" + make_domain_root(tmp_path) + entry = make_entry() + + _, prompt = build_session(entry, make_config(), str(tmp_path)) + + assert "Auth Handler" in prompt + assert "handles user authentication requests" in prompt + assert "specs/auth-handler.md" in prompt + assert "create" in prompt + assert "src/auth/" in prompt + + +def test_build_session_update_appends_existing_spec(tmp_path: Path) -> None: + """For update action, existing spec content is appended to the prompt.""" + domain_root = make_domain_root(tmp_path) + spec_dir = domain_root / "specs" + spec_dir.mkdir() + existing = "# Existing spec content\nSome details." + (spec_dir / "auth-handler.md").write_text(existing, encoding="utf-8") + + entry = make_entry(action="update") + _, prompt = build_session(entry, make_config(), str(tmp_path)) + + assert existing in prompt + + +def test_build_session_embeds_subagent_config(tmp_path: Path) -> None: + """build_session embeds sub-agent model, type, and count into the prompt.""" + make_domain_root(tmp_path) + entry = make_entry() + config = make_config() # subagents: model=sonnet, type=explorer, count=2 + + _, prompt = build_session(entry, config, str(tmp_path)) + + assert "sonnet" in prompt + assert "explorer" in prompt + assert "2" in prompt + + +# --- Rejection tests --- + +def test_build_session_domain_root_missing(tmp_path: Path) -> None: + """build_session raises FileNotFoundError if domain root does not exist.""" + entry = make_entry(domain="nonexistent") # directory not created + + with pytest.raises(FileNotFoundError, match="Domain root not found"): + build_session(entry, make_config(), str(tmp_path)) + + +def test_build_session_update_missing_spec_raises(tmp_path: Path) -> None: + """build_session raises FileNotFoundError if the existing spec is missing for update.""" + make_domain_root(tmp_path) + entry = make_entry(action="update") # spec file not created + + with pytest.raises(FileNotFoundError, match="Existing spec file not found"): + build_session(entry, make_config(), str(tmp_path)) + + +def test_build_session_unresolvable_placeholder_raises(tmp_path: Path) -> None: + """build_session raises ValueError if the prompt template has unresolvable placeholders.""" + make_domain_root(tmp_path) + entry = make_entry() + + # Mock load_prompt to return a template with an unknown placeholder. + def mock_load_prompt(filename: str) -> str: + if filename == "reverse-engineering-prompt.md": + return "Placeholder: {unknown_field}" + return "" + + with patch("reverse_engineer.factory.load_prompt", side_effect=mock_load_prompt): + with pytest.raises(ValueError, match="unknown_field"): + build_session(entry, make_config(), str(tmp_path)) diff --git a/reverse_engineer/tests/test_prompts_loader.py b/reverse_engineer/tests/test_prompts_loader.py new file mode 100644 index 0000000..b848c3b --- /dev/null +++ b/reverse_engineer/tests/test_prompts_loader.py @@ -0,0 +1,31 @@ +"""Tests for the bundled prompt file loader.""" + +import pytest + +from reverse_engineer.prompts_loader import load_prompt + + +def test_load_prompt_returns_content(): + """load_prompt returns a non-empty string for a known bundled file.""" + content = load_prompt("reverse-engineering-prompt.md") + assert isinstance(content, str) + assert len(content) > 0 + + +def test_load_prompt_all_bundled_files(): + """All four bundled prompt files are loadable.""" + files = [ + "reverse-engineering-prompt.md", + "spec-format-reference.md", + "review-work-prompt.md", + "peer-review-prompt.md", + ] + for filename in files: + content = load_prompt(filename) + assert len(content) > 0, f"{filename} must not be empty" + + +def test_load_prompt_missing_file_raises_file_not_found(): + """load_prompt raises FileNotFoundError for a nonexistent bundled file.""" + with pytest.raises(FileNotFoundError, match="nonexistent-prompt.md"): + load_prompt("nonexistent-prompt.md") diff --git a/reverse_engineer/tests/test_runner.py b/reverse_engineer/tests/test_runner.py new file mode 100644 index 0000000..4d266fb --- /dev/null +++ b/reverse_engineer/tests/test_runner.py @@ -0,0 +1,236 @@ +"""Tests for the mode-specific execution runner.""" + +import json +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from reverse_engineer.runner import ( + execute, + run_multi_pass, + run_peer_review, + run_self_refine, + run_single_shot, +) +from reverse_engineer.schemas import ( + DrafterConfig, + ExecutionConfig, + ExecutionFile, + MultiPassConfig, + PeerReviewConfig, + SelfRefineConfig, + SpecEntry, + SpecResult, + SubAgentConfig, +) + + +# --- Fixtures --- + +def make_entry( + name: str = "Auth Handler", + action: str = "create", + domain: str = "myservice", +) -> SpecEntry: + return SpecEntry( + name=name, + domain=domain, + topic="handles user authentication requests", + file="specs/auth-handler.md", + action=action, + code_search_roots=["src/auth/"], + depends_on=[], + ) + + +def make_config( + mode: str = "single_shot", + rounds: int = 2, + passes: int = 2, + reviewers: int = 2, + review_rounds: int = 1, +) -> ExecutionConfig: + base = dict( + mode=mode, + drafter=DrafterConfig( + model="opus", + subagents=SubAgentConfig(model="sonnet", type="explorer", count=2), + ), + ) + if mode == "self_refine": + base["self_refine"] = SelfRefineConfig(rounds=rounds) + elif mode == "multi_pass": + base["multi_pass"] = MultiPassConfig(passes=passes) + elif mode == "peer_review": + base["peer_review"] = PeerReviewConfig( + reviewers=reviewers, + rounds=review_rounds, + subagents=SubAgentConfig(model="haiku", type="reviewer", count=1), + ) + return ExecutionConfig(**base) + + +_SUCCESS = SpecResult(status="success", iterations_completed=1) + + +# --- Functional tests --- + +@pytest.mark.anyio +async def test_single_shot_all_entries_get_success_results() -> None: + """single_shot runs _run_entry once per entry and returns a result for each.""" + entries = [make_entry("Entry A"), make_entry("Entry B")] + config = make_config("single_shot") + + with patch("reverse_engineer.runner._run_entry", new_callable=AsyncMock) as mock: + mock.return_value = _SUCCESS + results = await run_single_shot(entries, config, "/project") + + assert len(results) == 2 + assert all(r.status == "success" for r in results) + assert mock.call_count == 2 + + +@pytest.mark.anyio +async def test_self_refine_follow_ups_contain_round_context() -> None: + """self_refine sends one follow-up per round, each annotated with round number.""" + entries = [make_entry()] + config = make_config("self_refine", rounds=2) + captured: list[list[str]] = [] + + async def capture(_entry, _cfg, _root, follow_ups): + captured.append(follow_ups) + return _SUCCESS + + with patch("reverse_engineer.runner._run_entry", side_effect=capture): + await run_self_refine(entries, config, "/project") + + assert len(captured) == 1 + follow_ups = captured[0] + assert len(follow_ups) == 2 + assert "review round 1 of 2" in follow_ups[0].lower() + assert "review round 2 of 2" in follow_ups[1].lower() + + +@pytest.mark.anyio +async def test_multi_pass_flips_create_to_update_after_first_pass() -> None: + """multi_pass overrides 'create' to 'update' for passes after the first.""" + entries = [make_entry(action="create")] + config = make_config("multi_pass", passes=2) + seen_actions: list[str] = [] + + async def capture(entry, _cfg, _root, _follow_ups): + seen_actions.append(entry.action) + return _SUCCESS + + with patch("reverse_engineer.runner._run_entry", side_effect=capture): + await run_multi_pass(entries, config, "/project") + + assert seen_actions == ["create", "update"] + + +@pytest.mark.anyio +async def test_peer_review_follow_up_contains_reviewer_config() -> None: + """peer_review embeds reviewers, subagent_model, and subagent_type in follow-up.""" + entries = [make_entry()] + config = make_config("peer_review", reviewers=3, review_rounds=1) + captured: list[list[str]] = [] + + async def capture(_entry, _cfg, _root, follow_ups): + captured.append(follow_ups) + return _SUCCESS + + with patch("reverse_engineer.runner._run_entry", side_effect=capture): + await run_peer_review(entries, config, "/project") + + assert len(captured) == 1 + follow_ups = captured[0] + assert len(follow_ups) == 1 + # Template uses {peer_review.reviewers}, {peer_review.subagents.model}, etc. + assert "3" in follow_ups[0] # reviewers count + assert "haiku" in follow_ups[0] # subagents.model + assert "reviewer" in follow_ups[0] # subagents.type + + +@pytest.mark.anyio +async def test_execute_reads_routes_mode_and_writes_results(tmp_path: Path) -> None: + """execute() reads execute.json, routes to the correct mode runner, writes results.""" + domain_root = tmp_path / "myservice" + domain_root.mkdir() + + payload = { + "project_root": str(tmp_path), + "config": { + "mode": "single_shot", + "drafter": { + "model": "opus", + "subagents": {"model": "sonnet", "type": "explorer", "count": 2}, + }, + }, + "specs": [ + { + "name": "Auth Handler", + "domain": "myservice", + "topic": "handles auth", + "file": "specs/auth.md", + "action": "create", + "code_search_roots": ["src/"], + "depends_on": [], + "result": None, + } + ], + } + exec_file = tmp_path / "execute.json" + exec_file.write_text(json.dumps(payload), encoding="utf-8") + + with patch("reverse_engineer.runner.run_single_shot", new_callable=AsyncMock) as mock: + mock.return_value = [SpecResult(status="success", iterations_completed=1)] + await execute(str(exec_file)) + + assert mock.call_count == 1 + written = ExecutionFile.model_validate_json(exec_file.read_text(encoding="utf-8")) + assert written.specs[0].result is not None + assert written.specs[0].result.status == "success" + + +# --- Edge case tests --- + +@pytest.mark.anyio +async def test_session_failure_writes_failure_result_and_continues() -> None: + """_run_entry catches SDK exceptions and returns a failure result; others continue.""" + entries = [make_entry("Failing"), make_entry("Succeeding")] + config = make_config("single_shot") + _FAILURE = SpecResult(status="failure", error="session timed out") + + async def per_entry(entry, _cfg, _root, _follow_ups): + # _run_entry itself catches exceptions — return the failure result directly. + if entry.name == "Failing": + return _FAILURE + return _SUCCESS + + with patch("reverse_engineer.runner._run_entry", side_effect=per_entry): + results = await run_single_shot(entries, config, "/project") + + assert len(results) == 2 + failing_result = next(r for r, e in zip(results, entries) if e.name == "Failing") + succeeding_result = next(r for r, e in zip(results, entries) if e.name == "Succeeding") + assert failing_result.status == "failure" + assert "session timed out" in (failing_result.error or "") + assert succeeding_result.status == "success" + + +@pytest.mark.anyio +async def test_multi_pass_single_pass_does_not_flip_action() -> None: + """multi_pass with passes=1 does not flip 'create' entries to 'update'.""" + entries = [make_entry(action="create")] + config = make_config("multi_pass", passes=1) + seen_actions: list[str] = [] + + async def capture(entry, _cfg, _root, _follow_ups): + seen_actions.append(entry.action) + return _SUCCESS + + with patch("reverse_engineer.runner._run_entry", side_effect=capture): + await run_multi_pass(entries, config, "/project") + + assert seen_actions == ["create"] diff --git a/reverse_engineer/tests/test_schemas.py b/reverse_engineer/tests/test_schemas.py new file mode 100644 index 0000000..764e16f --- /dev/null +++ b/reverse_engineer/tests/test_schemas.py @@ -0,0 +1,106 @@ +"""Tests for Pydantic schemas in schemas.py.""" + +import pytest +from pydantic import ValidationError + +from reverse_engineer.schemas import ( + ExecutionConfig, + ExecutionFile, + SpecEntry, + SpecResult, + SubAgentConfig, +) + + +VALID_EXECUTION_FILE = { + "project_root": "/project/", + "config": { + "mode": "self_refine", + "drafter": { + "model": "opus", + "subagents": { + "model": "opus", + "type": "explorer", + "count": 3, + }, + }, + "self_refine": {"rounds": 2}, + }, + "specs": [ + { + "name": "Auth Middleware Validation", + "domain": "optimizer", + "topic": "The optimizer validates authentication tokens", + "file": "specs/auth-middleware-validation.md", + "action": "create", + "code_search_roots": ["src/middleware/", "src/auth/"], + "depends_on": [], + "result": None, + } + ], +} + + +def test_valid_execution_file_parses(): + """Functional: A valid execute.json round-trips through the model.""" + ef = ExecutionFile.model_validate(VALID_EXECUTION_FILE) + assert ef.project_root == "/project/" + assert ef.config.mode == "self_refine" + assert ef.config.drafter.model == "opus" + assert ef.config.drafter.subagents.count == 3 + assert ef.config.self_refine is not None + assert ef.config.self_refine.rounds == 2 + assert len(ef.specs) == 1 + spec = ef.specs[0] + assert spec.name == "Auth Middleware Validation" + assert spec.action == "create" + assert spec.result is None + + +def test_unknown_mode_rejected(): + """Rejection: unknown mode value produces a validation error.""" + bad = { + **VALID_EXECUTION_FILE, + "config": { + "mode": "turbo", + "drafter": { + "model": "opus", + "subagents": {"model": "opus", "type": "explorer", "count": 1}, + }, + }, + } + with pytest.raises(ValidationError) as exc_info: + ExecutionFile.model_validate(bad) + assert "Unknown mode" in str(exc_info.value) + + +def test_subagent_count_below_minimum_rejected(): + """Rejection: drafter.subagents.count < 1 produces a validation error.""" + bad = { + **VALID_EXECUTION_FILE, + "config": { + "mode": "single_shot", + "drafter": { + "model": "opus", + "subagents": {"model": "opus", "type": "explorer", "count": 0}, + }, + }, + } + with pytest.raises(ValidationError): + ExecutionFile.model_validate(bad) + + +def test_missing_required_field_rejected(): + """Rejection: missing required field (project_root) produces a validation error.""" + bad = { + "config": { + "mode": "single_shot", + "drafter": { + "model": "opus", + "subagents": {"model": "opus", "type": "explorer", "count": 1}, + }, + }, + "specs": [], + } + with pytest.raises(ValidationError): + ExecutionFile.model_validate(bad) diff --git a/reverse_engineer/uv.lock b/reverse_engineer/uv.lock new file mode 100644 index 0000000..1788561 --- /dev/null +++ b/reverse_engineer/uv.lock @@ -0,0 +1,634 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.1.56" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/c1/c7afb1a08cecef0644693ccd9975651e45cf23a50272b94b6eca2c1a7dc8/claude_agent_sdk-0.1.56.tar.gz", hash = "sha256:a95bc14e59f9d6c8e7fa2e6581008a3f24f10e1b57302719823f62cfb5beccdc", size = 121659, upload-time = "2026-04-04T00:56:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/73/4e3d13c4d43614de35a113c87ec96b3db605baa23f9f5c4a38536837e18e/claude_agent_sdk-0.1.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9f5a7c87617101e6bb0f23408104ac6f40f9b5adec91dcfe5b8de5f65a7df73a", size = 58585662, upload-time = "2026-04-04T00:56:34.935Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6d/78347c2efa1526f1f6e7edecabe636575f622bcaa7921965457f95dd12dc/claude_agent_sdk-0.1.56-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:824f4a10340f46dd26fee8e74aeed4fc64fec95084e327ab1ebb6058b349e1c3", size = 60419564, upload-time = "2026-04-04T00:56:39.64Z" }, + { url = "https://files.pythonhosted.org/packages/87/c1/708262318926c8393d494a5dcaafd9bc7d6ba547c0a5fad4eff5f9aa0ecd/claude_agent_sdk-0.1.56-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:ff60dedc06b62b52e5937a9a2c4b0ec4ad0dd6764c20be656d01aeb8b11fba1d", size = 71893844, upload-time = "2026-04-04T00:56:44.402Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4f/24918a596b0d61c3a691af2a9ee52b8c54f1769ce2c5fef1d64350056e53/claude_agent_sdk-0.1.56-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:fe866b2119f69e99d9637acc27b588670c610fed1c4a096287874db5744d029b", size = 72030943, upload-time = "2026-04-04T00:56:49.892Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d8/5ded242e55f0b5f295d4ee2cbe5ae3bca914eb0a2a291f81e38b68d3ef58/claude_agent_sdk-0.1.56-py3-none-win_amd64.whl", hash = "sha256:5934e082e1ccf975d65cd7412f2eaf2c5ffa6b9019a2ca2a9fb228310df7ddc8", size = 74141451, upload-time = "2026-04-04T00:56:57.683Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "reverse-engineer" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "claude-agent-sdk" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "claude-agent-sdk", specifier = ">=0.1.56" }, + { name = "pydantic", specifier = ">=2.12.5" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.3" }] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/f2/368268300fb8af33743508d738ef7bb4d56afdb46c6d9c0fa3dd515df171/uvicorn-0.43.0.tar.gz", hash = "sha256:ab1652d2fb23abf124f36ccc399828558880def222c3cb3d98d24021520dc6e8", size = 85686, upload-time = "2026-04-03T18:37:48.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl", hash = "sha256:46fac64f487fd968cd999e5e49efbbe64bd231b5bd8b4a0b482a23ebce499620", size = 68591, upload-time = "2026-04-03T18:37:47.64Z" }, +] diff --git a/skills/implementation/SKILL.md b/skills/implementation/SKILL.md index 82fbe29..ba5390b 100644 --- a/skills/implementation/SKILL.md +++ b/skills/implementation/SKILL.md @@ -15,7 +15,7 @@ The forgectl scaffold drives your work — it tells you what to implement, when The implementation plan (`plan.json`) is a required input for this phase. It is **not** generated during implementation — it is produced during the **planning phase** (see: `skills/implementation_planning/`). -Before starting, confirm that `{domain}/.workspace/implementation_plan/plan.json` exists along with its companion `notes/` directory. If these do not exist, the planning phase must be completed first. +Before starting, confirm that `{domain}/.forge_workspace/implementation_plan/plan.json` exists along with its companion `notes/` directory. If these do not exist, the planning phase must be completed first. @@ -30,14 +30,13 @@ You need to have a domain — the user MUST supply this to you. All paths below 1. Read `{domain}/CLAUDE.md` for project-specific operational notes. 2. Run `forgectl status` to check for an active session. -3. **If no state file exists** — initialize. Ask the user for `batch-size` and `max-rounds` if not provided: +3. **If no state file exists** — initialize: ```bash forgectl init \ - --from {domain}/.workspace/implementation_plan/plan.json \ - --phase implementing \ - --batch-size \ - --max-rounds + --from {domain}/.forge_workspace/implementation_plan/plan.json \ + --phase implementing ``` + All batch sizes, round limits, and guided settings are configured in `.forgectl/config` (TOML) and locked into the state file at init time. 4. **If a state file exists** — read the output to understand the current state. 5. Enter the state loop. @@ -71,12 +70,12 @@ The scaffold is the **single source of truth** for what to implement next. Do NO See: [references/forgectl-workflow.md](references/forgectl-workflow.md) ### Implementation Plan -The plan lives at `{domain}/.workspace/implementation_plan/plan.json` with notes in `{domain}/.workspace/implementation_plan/notes/`. **Read the relevant notes file before implementing an item** — it contains specific guidance on approach, data structures, and library usage. +The plan lives at `{domain}/.forge_workspace/implementation_plan/plan.json` with notes in `{domain}/.forge_workspace/implementation_plan/notes/`. **Read the relevant notes file before implementing an item** — it contains specific guidance on approach, data structures, and library usage. The plan is produced by the planning phase (`skills/implementation_planning/`). It is a prerequisite for this phase and must exist before initialization. ### Implementation Log -Log progress in `{domain}/.workspace/implementation/IMPLEMENTATION_LOG.md` after each batch is committed. +Log progress in `{domain}/.forge_workspace/implementation/IMPLEMENTATION_LOG.md` after each batch is committed. See: [references/implementation_log_format.md](references/implementation_log_format.md) ### CLAUDE.md @@ -90,7 +89,7 @@ See: [references/subagent-usage.md](references/subagent-usage.md) 99999. Forgectl is the driver. Run `forgectl status` when unsure what to do. Read and follow the `Action:` line in every output. -999999. Study the item's `ref` (notes file) before implementing — it contains specific guidance on approach, data structures, and library usage. +999999. Study the item's `refs` (notes files) before implementing — they contain specific guidance on approach, data structures, and library usage. 9999999. Implement functionality completely. Placeholders and stubs waste effort and time redoing the same work. 99999999. Single sources of truth, no migrations/adapters. If tests unrelated to your work fail, resolve them as part of the increment. 999999999. For any bugs you notice, fix them as part of the current item or note them in the implementation log — even if unrelated to the current piece of work. diff --git a/skills/implementation/references/forgectl-workflow.md b/skills/implementation/references/forgectl-workflow.md index 485ad8a..d0225de 100644 --- a/skills/implementation/references/forgectl-workflow.md +++ b/skills/implementation/references/forgectl-workflow.md @@ -8,22 +8,16 @@ When no `forgectl-state.json` exists, initialize from the plan: ```bash forgectl init \ - --from {domain}/.workspace/implementation_plan/plan.json \ - --phase implementing \ - --batch-size \ - --max-rounds + --from {domain}/.forge_workspace/implementation_plan/plan.json \ + --phase implementing ``` | Flag | Required | Default | Description | |------|----------|---------|-------------| | `--from` | yes | — | Path to plan.json | | `--phase` | no | specifying | Set to `implementing` to start at implementation | -| `--batch-size` | yes | — | Max items per evaluation batch | -| `--max-rounds` | yes | — | Maximum evaluation rounds per batch before force-accept | -| `--min-rounds` | no | 1 | Minimum rounds before a PASS verdict is accepted | -| `--guided` / `--no-guided` | no | guided | Whether ORIENT states pause for user discussion | -Ask the user for `batch-size` and `max-rounds` if not provided. +All batch sizes, round limits, and guided settings are configured in `.forgectl/config` (TOML) and locked into the state file at init time. ## Key Commands @@ -83,10 +77,10 @@ The scaffold has selected a batch or is transitioning between layers. ### IMPLEMENT (round 1 — first time seeing items) -You have been assigned an item. The forgectl output shows: item ID, name, description, steps, files, spec, ref, and test count. +You have been assigned an item. The forgectl output shows: item ID, name, description, steps, files, specs, refs, and test count. -1. Read the item's `spec` (specification section) to understand the contract. -2. Read the item's `ref` (notes file section at `{domain}/.workspace/implementation_plan/notes/`) for implementation guidance. +1. Read the item's `specs` (specification references) to understand the contract. +2. Read the item's `refs` (notes file paths at `{domain}/.forge_workspace/implementation_plan/notes/`) for implementation guidance. 3. Search the codebase using subagents to confirm the feature doesn't already exist. 4. Implement the functionality completely. No placeholders, no stubs. 5. Run the tests for the code you changed or added. @@ -103,7 +97,7 @@ You have been assigned an item. The forgectl output shows: item ID, name, descri The batch has been evaluated and returned for another round. The forgectl output shows the eval report path. 1. **Study the eval report** — it contains specific deficiencies to address. -2. Read the item's spec and ref again if needed. +2. Read the item's specs and refs again if needed. 3. Fix the deficiencies identified in the eval report. 4. If the eval was PASS but minimum rounds weren't met, verify the implementation and look for improvements. 5. Run the tests. @@ -138,7 +132,7 @@ The batch is terminal (passed or force-accepted). Commit your work. ```bash git add -A && git commit -m "" ``` -2. Add a log entry to `{domain}/.workspace/implementation/IMPLEMENTATION_LOG.md`. +2. Add a log entry to `{domain}/.forge_workspace/implementation/IMPLEMENTATION_LOG.md`. 3. Advance: ```bash forgectl advance --message "" @@ -149,7 +143,7 @@ The batch is terminal (passed or force-accepted). Commit your work. All layers and items are complete. -1. Add a final summary log entry to `{domain}/.workspace/implementation/IMPLEMENTATION_LOG.md`. +1. Add a final summary log entry to `{domain}/.forge_workspace/implementation/IMPLEMENTATION_LOG.md`. 2. The session is finished. --- @@ -158,7 +152,7 @@ All layers and items are complete. - **Round 1 IMPLEMENT** requires `--message` — forgectl auto-commits per item. - **Round 2+ IMPLEMENT** does not need `--message` — no auto-commit, just fixing deficiencies. -- **ORIENT** is a guided pause when `--guided` is set. Stop and discuss with the user. +- **ORIENT** is a guided pause when `general.user_guided` is true in the config. Stop and discuss with the user. - **EVALUATE round 2+** output includes a `--- PREVIOUS EVALUATIONS ---` section listing prior round reports. - Forgectl tracks `passes` and `rounds` in plan.json automatically. Do not modify these fields manually. -- The `--guided` / `--no-guided` flags can be passed on any `advance` call to toggle guided mode. +- The `--guided` / `--no-guided` flags can be passed on `advance` calls to override the config's guided setting for that transition. diff --git a/skills/implementation/references/git-commit-guidelines.md b/skills/implementation/references/git-commit-guidelines.md index c04d35b..e1859c4 100644 --- a/skills/implementation/references/git-commit-guidelines.md +++ b/skills/implementation/references/git-commit-guidelines.md @@ -22,7 +22,7 @@ There are two commit points in the forgectl workflow: ## At COMMIT State 1. Ensure tests pass for all code in the batch. -2. Add a log entry to `{domain}/.workspace/implementation/IMPLEMENTATION_LOG.md`. +2. Add a log entry to `{domain}/.forge_workspace/implementation/IMPLEMENTATION_LOG.md`. 3. Stage all relevant files: `git add -A` 4. Commit with a descriptive message: `git commit -m ""` 5. Advance the scaffold: `forgectl advance --message ""` diff --git a/skills/implementation/references/subagent-usage.md b/skills/implementation/references/subagent-usage.md index 5f65fe1..d2686bf 100644 --- a/skills/implementation/references/subagent-usage.md +++ b/skills/implementation/references/subagent-usage.md @@ -5,7 +5,7 @@ Guidelines for using subagents effectively during implementation work. ## When to Use Subagents - **Codebase search:** Before implementing anything, use a subagent to search the codebase and confirm the feature doesn't already exist. -- **Updating documents:** Use a subagent to update `{domain}/.workspace/implementation/references/IMPLEMENTATION_LOG.md` or `{domain}/CLAUDE.md` so the main agent stays focused on implementation. +- **Updating documents:** Use a subagent to update `{domain}/.forge_workspace/implementation/references/IMPLEMENTATION_LOG.md` or `{domain}/CLAUDE.md` so the main agent stays focused on implementation. - **Build and test:** Use only one subagent for build/test operations to avoid conflicts. - **Evaluation:** Spawn a subagent for `forgectl eval` during EVALUATE state. The subagent reads the eval context, reviews implementation files, and writes the eval report. diff --git a/skills/implementation_planning/SKILL.md b/skills/implementation_planning/SKILL.md index fdcf859..22888d9 100644 --- a/skills/implementation_planning/SKILL.md +++ b/skills/implementation_planning/SKILL.md @@ -19,20 +19,64 @@ The implementation plan should answer: "What is needed to fully implement THESE -**Init — Start the Forgectl Planning Session** +**Entry Mode — Ask the User** + +Before starting, ask the user which entry mode they want: + +1. **`generate_planning_queue`** — Auto-generate the plan queue from completed specs. **This is only available as a transition from a completed specifying phase** — it cannot be initialized directly. It requires `forgectl-state.json` with completed specifying data. If no specifying session was run (e.g., specs were committed outside forgectl), use `planning` instead. + +2. **`planning`** — Start planning directly with a pre-built plan queue. Use this when you already have a `plan-queue.json` file (manually constructed or from a previous session), or when specs were created outside of a forgectl specifying session. You build the queue yourself and pass it via `--from`. + +Ask: **"Would you like to start with `generate_planning_queue` (auto-generate from completed specs) or `planning` (provide your own plan queue)?"** + +- If **`generate_planning_queue`**: proceed to step_0a. +- If **`planning`**: proceed to step_0b. + + + +**Generate Planning Queue — Auto-Generate from Completed Specs** + +This path uses forgectl's `generate_planning_queue` phase to auto-generate the plan queue from completed specs. + +See: [references/generate-planning-queue.md](references/generate-planning-queue.md) + +The generate_planning_queue phase has 3 states: + +1. **ORIENT** — Forgectl groups completed specs by domain and writes `/plan-queue.json`. Advance to continue. +2. **REFINE** — Review the generated `/plan-queue.json`. Reorder domains, adjust entries, or leave unchanged. Advance when satisfied (forgectl validates before transitioning). +3. **PHASE_SHIFT** — Validates the queue and transitions to the planning phase. Advance to begin planning. + +```bash +# Advance through each state +forgectl advance +``` + +At the PHASE_SHIFT (generate_planning_queue → planning), you may optionally override with a different file: + +```bash +forgectl advance --from +``` + +After the phase shift, planning begins at ORIENT. Proceed to step_1. + + + +**Init — Start the Forgectl Planning Session Directly** Prepare a plan queue JSON file listing every implementation plan to produce in this session. -See: [references/plan-queue-format.md](references/plan-queue-format.md) +See: [references/creating-plan-queue.md](references/creating-plan-queue.md) and [references/plan-queue-format.md](references/plan-queue-format.md) ```bash -forgectl init --phase planning --from --batch-size 1 --max-rounds 3 --min-rounds 1 --guided +forgectl init --phase planning --from ``` +All batch sizes, round limits, and guided settings are configured in `.forgectl/config` (TOML). + This creates `forgectl-state.json` and sets the state to ORIENT. Run `forgectl status` to see the full session overview. - + **Loop — Follow the Planning State Machine** @@ -47,7 +91,7 @@ For each plan in the queue, follow the forgectl state machine: 1f. **DRAFT** — Generate the implementation plan as `plan.json` + `notes/` at the target path. Follow the schema in [references/plan-format.json](references/plan-format.json) exactly. Forgectl validates automatically on advance. See [Schema Gotchas](#schema-gotchas) below. 1g. **EVALUATE** — Use `forgectl eval` to get evaluation context. Spawn an Opus sub-agent to assess the plan against all 11 dimensions. Record the verdict with `forgectl advance --verdict PASS|FAIL --eval-report `. 1h. **REFINE** — If evaluation failed or min rounds not met, spawn a sub-agent to update the plan and notes. Advance to re-evaluate. -1i. **ACCEPT** — Plan finalized. `forgectl advance --message `. +1i. **ACCEPT** — Plan finalized. When `enable_commits` is true in `.forgectl/config`, advance with `forgectl advance --message ` to auto-commit. When `enable_commits` is false, just `forgectl advance`. Use `forgectl status` at any point to see current state and what action is needed. @@ -59,7 +103,7 @@ The plan output format is defined in [references/plan-format.json](references/pl **Phase Transition** -After acceptance, forgectl transitions to PHASE_SHIFT (planning → implementing). This validates `plan.json`, adds tracking fields (`passes: "pending"`, `rounds: 0`) to every item, and writes the updated plan back to disk. +After acceptance, forgectl transitions to either ORIENT (if more plans remain in the queue) or DONE (if all plans are complete). From DONE, advancing triggers PHASE_SHIFT (planning → implementing). The phase shift validates `plan.json`, adds tracking fields (`passes: "pending"`, `rounds: 0`) to every item, and writes the updated plan back to disk. ```bash forgectl advance @@ -87,7 +131,7 @@ forgectl advance Plans are written to the path specified in the plan queue's `file` field: ``` -/.workspace/implementation_plan/ +/.forge_workspace/implementation_plan/ ├── plan.json # The implementation plan manifest └── notes/ # Reference notes per package ├── .md @@ -102,8 +146,8 @@ These are common validation failures. Read [references/plan-format.json](referen 1. **`refs` must be objects, not strings.** Each entry needs `{"id": "...", "path": "..."}`. Plain strings like `"specs/foo.md"` will fail parsing. 2. **All paths are relative to the project root** (the directory containing `.forgectl/`). If plan.json is at `api/.forge_workspace/implementation_plan/plan.json`, then spec paths look like `api/specs/foo.md` and notes paths look like `api/.forge_workspace/implementation_plan/notes/bar.md`. -3. **No `#anchor` fragments in paths.** `ref: "notes/foo.md#section"` will fail — forgectl runs `os.Stat()` on the raw string. Use `ref: "notes/foo.md"` instead. -4. **`spec` is a single string, not an array.** To reference multiple spec sections, use the description or notes file. +3. **No `#anchor` fragments in `refs` paths.** `refs: ["notes/foo.md#section"]` will fail — forgectl runs `os.Stat()` on the raw string. Use `refs: ["notes/foo.md"]` instead. (`specs` paths allow `#anchors` since they are display-only.) +4. **`specs` is a string array, not a single string.** Use `"specs": ["spec1.md", "spec2.md"]`. The field is `specs` (plural). Display-only, `#anchor` fragments are OK. There is also `refs` (plural, string array) for notes file paths validated on disk. 5. **`context` only has `domain` and `module`.** Extra fields like `go_version` or `binary` are silently ignored but add no value. 6. **`tests` must be an array, never null.** Use `[]` for items with no tests. `null` fails validation. 7. **`depends_on` must be an array, never null.** Use `[]` for items with no dependencies. @@ -124,9 +168,9 @@ The planning evaluator assesses 11 dimensions against the referenced specs: 10. Testing Criteria 11. Dependencies & Format -Full evaluator instructions: `~/.local/bin/evaluators/plan-eval.md` (read by `forgectl eval` automatically) +Full evaluator instructions: `forgectl/evaluators/plan-eval.md` (embedded in the binary, output by `forgectl eval` automatically) -Eval reports are written to: `/.workspace/implementation_plan/evals/round-N.md` +Eval reports are written to: `/.forge_workspace/implementation_plan/evals/round-N.md` ### Evaluation Sub-Agent Context diff --git a/skills/implementation_planning/references/creating-plan-queue.md b/skills/implementation_planning/references/creating-plan-queue.md index 3e12973..4854d23 100644 --- a/skills/implementation_planning/references/creating-plan-queue.md +++ b/skills/implementation_planning/references/creating-plan-queue.md @@ -48,9 +48,9 @@ For each domain, fill in the 6 required fields: |---|---| | `name` | ` ` (e.g., "API Centralized Logging") | | `domain` | The domain directory name (e.g., `api`) | -| `topic` | One sentence summarizing the implementation scope, derived from the spec research | -| `file` | `/.workspace/implementation_plan/plan.json` | +| `file` | `/.forge_workspace/implementation_plan/plan.json` | | `specs` | All staged spec paths for this domain | +| `spec_commits` | Git commit hashes associated with the specs (for viewing diffs); may be empty | | `code_search_roots` | The domain's source directory (e.g., `["api/"]`); add cross-domain roots if specs reference shared code | --- @@ -70,21 +70,15 @@ Forgectl also validates strictly on `init` — if any field is missing or extra ## Step 6 — Initialize Forgectl ```bash -forgectl init --phase planning \ - --from .workspace/plan-queue.json \ - --batch-size 1 \ - --max-rounds 3 \ - --min-rounds 1 \ - --guided +forgectl init --phase planning --from .workspace/plan-queue.json ``` | Flag | Purpose | |---|---| | `--from` | Path to the plan queue file | -| `--batch-size` | How many plans to process concurrently (1 = sequential) | -| `--max-rounds` | Maximum evaluate/refine cycles per plan | -| `--min-rounds` | Minimum evaluate/refine cycles before a plan can be accepted | -| `--guided` | Pause at REVIEW for user discussion before drafting | +| `--phase` | Starting phase (use `planning`) | + +All other settings (batch size, round limits, guided mode) are configured in `.forgectl/config` (TOML). See `docs/configurations.md` for the full reference. After init, use `forgectl status` to see the session overview and `forgectl advance` to begin processing. @@ -92,7 +86,6 @@ After init, use `forgectl status` to see the session overview and `forgectl adva ## Tips -- **New vs modified specs**: Identify which specs are new (core initiative work) vs modified (integration changes). This helps write a better `topic`. -- **Topic quality matters**: The topic orients the entire planning phase. It should capture the "what and why" in one sentence. +- **New vs modified specs**: Identify which specs are new (core initiative work) vs modified (integration changes). This helps write a better `name`. - **Code search roots**: Typically just the domain directory. Add additional roots only if specs explicitly reference cross-domain code (e.g., a shared `lib/` directory). - **Review before init**: Present each plan entry to the user for approval before writing. The plan queue is hard to change after `forgectl init`. diff --git a/skills/implementation_planning/references/generate-planning-queue.md b/skills/implementation_planning/references/generate-planning-queue.md new file mode 100644 index 0000000..ce4da85 --- /dev/null +++ b/skills/implementation_planning/references/generate-planning-queue.md @@ -0,0 +1,111 @@ +# Generate Planning Queue + +> How forgectl auto-generates a plan queue from completed specs. + +--- + +## When to Use + +Use the `generate_planning_queue` phase when: +- You have completed a specifying phase (all specs accepted/reconciled) +- `forgectl-state.json` exists with completed specifying data +- You want forgectl to auto-group specs by domain rather than building the queue manually + +This phase sits between specifying and planning. It cannot be initialized directly — it requires a completed specifying phase or a PHASE_SHIFT from specifying's COMPLETE state. + +--- + +## How It Works + +### ORIENT + +On entry, forgectl: + +1. Groups completed specs by domain (order follows the spec queue — domains appear in the order they were first seen). +2. For each domain, produces a plan entry: + - `name`: `" Implementation Plan"` (domain name capitalized) + - `domain`: the domain name + - `file`: `/.forge_workspace/implementation_plan/plan.json` + - `specs`: all completed spec file paths for this domain + - `spec_commits`: deduplicated commit hashes from the domain's completed specs + - `code_search_roots`: from `specifying.domains[].code_search_roots` if set via `set-roots`, otherwise `["/"]` +3. Writes the plan queue to `/plan-queue.json`. + +```bash +forgectl advance +``` + +### REFINE + +The architect reviews `/plan-queue.json`. You can: +- Reorder domains (change plan processing order) +- Adjust plan names +- Modify `code_search_roots` to include cross-domain directories +- Add or remove specs from a plan entry +- Leave it unchanged + +Advancing validates the file. If validation fails, errors are printed and state stays at REFINE. Fix and re-advance. + +```bash +forgectl advance +``` + +### PHASE_SHIFT (generate_planning_queue -> planning) + +Advancing consumes the validated plan queue and transitions to planning ORIENT. + +You may optionally override with a different file at this point: + +```bash +# Use the auto-generated queue: +forgectl advance + +# Or override with a custom queue: +forgectl advance --from +``` + +--- + +## Skipping This Phase + +If you already have a plan queue file, you can skip `generate_planning_queue` entirely at the specifying PHASE_SHIFT: + +```bash +forgectl advance --from +``` + +This jumps directly from specifying to planning ORIENT. + +Alternatively, initialize a fresh session at planning: + +```bash +forgectl init --phase planning --from +``` + +See [creating-plan-queue.md](creating-plan-queue.md) for how to build the file manually. + +--- + +## Plan Queue Schema + +The auto-generated file follows the same schema as manually constructed queues: + +```json +{ + "plans": [ + { + "name": "Optimizer Implementation Plan", + "domain": "optimizer", + "file": "optimizer/.forge_workspace/implementation_plan/plan.json", + "specs": [ + "optimizer/specs/cost-function.md", + "optimizer/specs/constraint-solver.md" + ], + "spec_commits": ["7cede10", "8743b1d"], + "code_search_roots": ["optimizer/", "lib/shared/"] + } + ] +} +``` + +See [plan-queue-format.md](plan-queue-format.md) for the full schema reference. diff --git a/skills/implementation_planning/references/plan-format.json b/skills/implementation_planning/references/plan-format.json index 8d6d2df..c01c0f5 100644 --- a/skills/implementation_planning/references/plan-format.json +++ b/skills/implementation_planning/references/plan-format.json @@ -4,13 +4,14 @@ "Derived from forgectl/state/types.go and forgectl/state/validate.go.", "", "PATH RESOLUTION:", - " All paths in 'refs[].path' and 'items[].ref' are resolved relative to", + " All paths in 'refs[].path' and 'items[].refs' are resolved relative to", " the directory containing plan.json (filepath.Dir(plan.json)).", - " Example: if plan.json is at api/.workspace/implementation_plan/plan.json,", + " Example: if plan.json is at api/.forge_workspace/implementation_plan/plan.json,", " then ref path '../../specs/foo.md' resolves to api/specs/foo.md.", "", - " Paths MUST NOT contain '#anchor' fragments — forgectl runs os.Stat()", + " Paths in refs (top-level and items) MUST NOT contain '#anchor' fragments — forgectl runs os.Stat()", " on the raw path string. 'notes/foo.md#section' will fail validation.", + " Paths in items[].specs are display-only and DO allow '#anchor' fragments.", "", "VALIDATION RULES:", " - Top-level: only 'context', 'refs', 'layers', 'items' are allowed.", @@ -19,7 +20,7 @@ " - No dependency cycles.", " - All depends_on IDs must exist in the items array.", " - All refs paths must exist on disk.", - " - All item ref paths must exist on disk.", + " - All item refs paths must exist on disk.", " - Test categories must be: 'functional', 'rejection', or 'edge_case'.", "", "IMPLEMENTING PHASE:", @@ -58,8 +59,8 @@ "depends_on": [" Item IDs that must complete first. Use empty array [] for no deps."], "steps": [" Ordered implementation instructions"], "files": [" File paths to create or modify, relative to domain root"], - "spec": " Spec filename reference. Single string only, not array. No #anchors.", - "ref": " Notes file path relative to plan.json directory. Must exist on disk. No #anchors.", + "specs": [" Spec filename references. Display-only, #anchors OK, not validated on disk."], + "refs": [" Notes file paths relative to plan.json directory. Validated on disk. No #anchors."], "tests": [ { "$required": true, @@ -79,8 +80,8 @@ "depends_on": [], "files": ["internal/config/types.go"], "steps": ["Define ServiceEndpoint struct", "Define ServicesConfig struct"], - "spec": "service-configuration.md", - "ref": "notes/config.md", + "specs": ["service-configuration.md"], + "refs": ["notes/config.md"], "tests": [ { "category": "functional", "description": "Three named fields, not a map" } ] diff --git a/skills/implementation_planning/references/plan-queue-format.json b/skills/implementation_planning/references/plan-queue-format.json index 9d5382f..0e8a0ae 100644 --- a/skills/implementation_planning/references/plan-queue-format.json +++ b/skills/implementation_planning/references/plan-queue-format.json @@ -3,12 +3,15 @@ { "name": "", "domain": "", - "topic": "", "file": "", "specs": [ "", "<...>" ], + "spec_commits": [ + "", + "<...>" + ], "code_search_roots": [ "", "<...>" diff --git a/skills/implementation_planning/references/plan-queue-format.md b/skills/implementation_planning/references/plan-queue-format.md index 65cfe92..f524ed2 100644 --- a/skills/implementation_planning/references/plan-queue-format.md +++ b/skills/implementation_planning/references/plan-queue-format.md @@ -19,9 +19,9 @@ The plan queue tells forgectl which implementation plans to produce in this sess { "name": "", "domain": "", - "topic": "", "file": "", "specs": ["", ...], + "spec_commits": ["", ...], "code_search_roots": ["", ...] } ] @@ -32,9 +32,9 @@ The plan queue tells forgectl which implementation plans to produce in this sess |-------|------|----------|-------------| | `name` | string | yes | Display name for the plan (shown in `forgectl status`) | | `domain` | string | yes | Domain this plan covers (e.g., `launcher`, `api`) | -| `topic` | string | yes | One-sentence description of what this plan addresses | | `file` | string | yes | Target path for the output `plan.json`, relative to project root | | `specs` | string[] | yes | Spec file paths to study during STUDY_SPECS. May be empty. | +| `spec_commits` | string[] | yes | Git commit hashes associated with specs for viewing diffs. May be empty. | | `code_search_roots` | string[] | yes | Directory roots for codebase exploration during STUDY_CODE. May be empty. | --- @@ -47,6 +47,7 @@ Forgectl validates the queue strictly on `init`. If validation fails, it prints - `plans` must be a non-empty array - Each entry must have all 6 fields listed above - No extra fields permitted beyond the 6 listed +- `spec_commits` is an array and may be empty (`[]`) - `specs` and `code_search_roots` are arrays and may be empty (`[]`) --- @@ -59,12 +60,12 @@ Forgectl validates the queue strictly on `init`. If validation fails, it prints { "name": "Service Configuration", "domain": "launcher", - "topic": "Implementation plan for service configuration loading and validation", - "file": "launcher/.workspace/implementation_plan/plan.json", + "file": "launcher/.forge_workspace/implementation_plan/plan.json", "specs": [ "launcher/specs/service-configuration.md", "launcher/specs/launching-system-processes.md" ], + "spec_commits": ["7cede10", "8743b1d"], "code_search_roots": ["launcher/", "api/"] } ] @@ -78,7 +79,7 @@ Forgectl validates the queue strictly on `init`. If validation fails, it prints The planning phase produces files at the path specified in `file`: ``` -/.workspace/implementation_plan/ +/.forge_workspace/implementation_plan/ ├── plan.json # The implementation plan manifest └── notes/ # Reference notes per package ├── .md @@ -92,12 +93,9 @@ The `plan.json` format is defined in the forgectl docs at `forgectl/docs/PLAN_FO ## How to Start ```bash -forgectl init --phase planning \ - --from plan-queue.json \ - --batch-size 1 \ - --max-rounds 3 \ - --min-rounds 1 \ - --guided +forgectl init --phase planning --from plan-queue.json ``` +All batch sizes, round limits, and guided settings are configured in `.forgectl/config` (TOML). + After init, run `forgectl status` to see the session overview and `forgectl advance` to begin. diff --git a/skills/implementation_planning/references/planning-navigation.md b/skills/implementation_planning/references/planning-navigation.md index 84d57b9..921b577 100644 --- a/skills/implementation_planning/references/planning-navigation.md +++ b/skills/implementation_planning/references/planning-navigation.md @@ -79,7 +79,7 @@ forgectl advance Generate the implementation plan: - Write `plan.json` following the schema in `references/plan-format.json` (the authoritative schema derived from forgectl's Go types) - Write `notes/.md` files for implementation guidance -- Output location: `/.workspace/implementation_plan/` +- Output location: `/.forge_workspace/implementation_plan/` When you advance, forgectl automatically validates `plan.json`. If valid, you go straight to EVALUATE. If invalid, you enter VALIDATE. See VALIDATE section below for common failures. @@ -94,7 +94,7 @@ Only entered when `plan.json` fails structural validation. Forgectl prints speci **Common validation failures:** - `cannot unmarshal string into Go struct field PlanJSON.refs of type state.PlanRef` — `refs` entries must be objects `{"id": "...", "path": "..."}`, not plain strings. - `refs: path "..." does not exist` — Paths are resolved relative to the project root (the directory containing `.forgectl/`). Use full paths from root like `api/specs/foo.md`. -- `items[N]: ref "notes/foo.md#section" does not exist` — Remove `#anchor` fragments from `ref` paths. Forgectl runs `os.Stat()` on the raw string including the fragment. +- `items[N]: refs path "notes/foo.md#section" does not exist` — Remove `#anchor` fragments from `refs` paths. Forgectl runs `os.Stat()` on the raw string including the fragment. - `missing required field "tests"` or `"depends_on"` — These fields must be arrays, never null. Use `[]` for empty. ```bash @@ -118,7 +118,7 @@ forgectl advance --verdict PASS --eval-report forgectl advance --verdict FAIL --eval-report ``` -Eval reports are written to: `/.workspace/implementation_plan/evals/round-N.md` +Eval reports are written to: `/.forge_workspace/implementation_plan/evals/round-N.md` ### REFINE @@ -130,13 +130,21 @@ forgectl advance ### ACCEPT -Plan is accepted (either by passing evaluation or forced at max rounds). Commit and advance with a message. +Plan is accepted (either by passing evaluation or forced at max rounds). + +When `enable_commits` is true in `.forgectl/config`, advance with a commit message for auto-commit: ```bash forgectl advance --message "Accept implementation plan for " ``` -This transitions to PHASE_SHIFT (planning → implementing). +When `enable_commits` is false, just advance: + +```bash +forgectl advance +``` + +After ACCEPT, forgectl transitions to ORIENT (if more plans remain in the queue) or DONE (if all plans are complete). From DONE, advancing triggers PHASE_SHIFT (planning → implementing). --- @@ -144,7 +152,7 @@ This transitions to PHASE_SHIFT (planning → implementing). ```bash # Initialize planning session -forgectl init --phase planning --from plan-queue.json --batch-size 1 --max-rounds 3 --min-rounds 1 --guided +forgectl init --phase planning --from plan-queue.json # See current state and what to do next forgectl status @@ -159,8 +167,11 @@ forgectl eval forgectl advance --verdict PASS --eval-report forgectl advance --verdict FAIL --eval-report -# Accept plan (ACCEPT only) +# Accept plan (ACCEPT only, when enable_commits is true) forgectl advance --message "" + +# Accept plan (ACCEPT only, when enable_commits is false) +forgectl advance ``` ### Flag Reference @@ -168,9 +179,8 @@ forgectl advance --message "" | Flag | Used in | Description | |------|---------|-------------| | `--verdict PASS\|FAIL` | EVALUATE | Evaluation result (required) | -| `--eval-report ` | EVALUATE | Path to evaluation report file (required, must exist) | -| `--message ` | ACCEPT | Commit message for accepted plan (required) | -| `--guided` / `--no-guided` | any `advance` | Toggle guided mode (pauses for user discussion at REVIEW) | +| `--eval-report ` | EVALUATE | Path to evaluation report file (required when `enable_eval_output` is true, must exist) | +| `--message ` | ACCEPT | Commit message for accepted plan (required when `enable_commits` is true) | --- @@ -184,7 +194,7 @@ forgectl advance --message "" ## Phase Transition -After ACCEPT, forgectl enters PHASE_SHIFT (planning → implementing): +After the last plan is accepted (queue empty), forgectl enters DONE. Advancing from DONE triggers PHASE_SHIFT (planning → implementing): 1. Reads `plan.json` from `current_plan.file` 2. Validates the plan structure diff --git a/skills/specs/SKILL.md b/skills/specs/SKILL.md index 108ebc7..15e2e3e 100644 --- a/skills/specs/SKILL.md +++ b/skills/specs/SKILL.md @@ -37,10 +37,10 @@ See: [references/search-specs-for-specifying.md](references/search-specs-for-spe Feed the spec queue JSON to forgectl: ```bash -forgectl init --phase specifying --from --batch-size 1 --max-rounds 3 --min-rounds 1 --guided +forgectl init --phase specifying --from ``` -This creates `forgectl-state.json` and sets the state to ORIENT. +This creates `forgectl-state.json` and sets the state to ORIENT. All batch sizes, round limits, and guided settings are configured in `.forgectl/config` (TOML). Run `forgectl status` to see the full session overview. @@ -56,7 +56,7 @@ For each spec in the queue, follow the forgectl state machine: 2a. **ORIENT** — Read the planning sources and any existing specs. Understand what exists before writing. 2b. **SELECT** — Pull the next spec from the queue. If guided, discuss scope with the user. 2c. **DRAFT** — Write the spec file following the format in `references/spec-format.md`. Advance with `forgectl advance` (optionally `--file ` to override the output path). -2d. **EVALUATE** — Spawn an Opus sub-agent to adversarially review the draft. Record the verdict with `forgectl advance --verdict PASS|FAIL --eval-report `. PASS requires `--message`. +2d. **EVALUATE** — Spawn an Opus sub-agent to adversarially review the draft. Record the verdict with `forgectl advance --verdict PASS|FAIL --eval-report `. 2e. **REFINE** — If evaluation failed, fix the deficiencies and advance back to EVALUATE. 2f. **ACCEPT** — Spec finalized. Forgectl loops to ORIENT for the next spec, or moves to DONE when the queue is empty. @@ -68,11 +68,14 @@ See: [references/spec-format.md](references/spec-format.md) **Reconcile — Cross-Spec Consistency** -After all individual specs are accepted, forgectl enters reconciliation: +After all individual specs in a domain are accepted, forgectl enters per-domain cross-referencing. After all domains are cross-referenced, it enters cross-domain reconciliation: -3a. **RECONCILE** — Fix cross-references across all specs. Verify dependency symmetry, naming consistency, and scope boundaries. Stage the changes. -3b. **RECONCILE_EVAL** — Spawn a sub-agent to evaluate cross-spec consistency from `git diff --staged`. -3c. **RECONCILE_REVIEW** — Human reviews the reconciliation eval. Accept or grant another pass. +3a. **CROSS_REFERENCE** — After the last batch for a domain is accepted, cross-reference ALL specs in that domain (session specs and existing specs). Spawn sub-agents to review. +3b. **CROSS_REFERENCE_EVAL** — Sub-agent evaluates intra-domain cross-reference consistency. +3c. **CROSS_REFERENCE_REVIEW** — Review cross-reference eval. Add specs or set code search roots for the domain. Then proceed to the next domain or DONE. +3d. **RECONCILE** — After all domains pass cross-referencing, fix cross-references across all specs and all domains. Stage the changes. +3e. **RECONCILE_EVAL** — Spawn a sub-agent to evaluate cross-domain consistency from `git diff --staged`. +3f. **RECONCILE_REVIEW** — Human reviews the reconciliation eval. Accept or grant another pass. Reconciliation checklist: - Every `Depends On` reference points to a spec that exists diff --git a/skills/specs/references/forgectl-state-example.json b/skills/specs/references/forgectl-state-example.json index c0a14cf..89415e9 100644 --- a/skills/specs/references/forgectl-state-example.json +++ b/skills/specs/references/forgectl-state-example.json @@ -1,43 +1,102 @@ { "phase": "specifying", "state": "EVALUATE", - "batch_size": 1, - "min_rounds": 1, - "max_rounds": 3, - "user_guided": true, + "config": { + "general": { + "enable_commits": false, + "enable_eval_output": false, + "user_guided": true + }, + "specifying": { + "batch": 1, + "commit_strategy": "all-specs", + "eval": { + "min_rounds": 1, + "max_rounds": 3, + "model": "opus", + "type": "eval", + "count": 1, + "enable_eval_output": false + }, + "cross_reference": { + "min_rounds": 0, + "max_rounds": 0, + "model": "", + "type": "", + "count": 0, + "user_review": false, + "eval": { "model": "", "type": "", "count": 0 } + }, + "reconciliation": { + "min_rounds": 0, + "max_rounds": 0, + "model": "", + "type": "", + "count": 0, + "user_review": false + } + }, + "planning": { + "batch": 1, + "commit_strategy": "strict", + "self_review": false, + "plan_all_before_implementing": false, + "study_code": { "model": "haiku", "type": "explore", "count": 3 }, + "eval": { "min_rounds": 1, "max_rounds": 3, "model": "opus", "type": "eval", "count": 1, "enable_eval_output": false }, + "refine": { "model": "opus", "type": "refine", "count": 1 } + }, + "implementing": { + "batch": 2, + "commit_strategy": "scoped", + "eval": { "min_rounds": 1, "max_rounds": 3, "model": "opus", "type": "eval", "count": 1, "enable_eval_output": false } + }, + "paths": { + "state_dir": ".forgectl/state", + "workspace_dir": ".forge_workspace" + }, + "logs": { + "enabled": true, + "retention_days": 90, + "max_files": 50 + } + }, + "session_id": "a3f1b2c4-7e2d-4f01-b4c8-e312d9f01234", "started_at_phase": "specifying", "phase_shift": null, "specifying": { - "current_spec": { - "id": 1, - "name": "Reservation Booking", - "domain": "accounting", - "topic": "The accounting service records reservation bookings and tracks ledger entries", - "file": "accounting/specs/reservation-booking.md", - "planning_sources": [".workspace/planning/accounting/reservation-lifecycle.md"], - "depends_on": [], - "round": 2, - "evals": [ - { - "round": 1, - "verdict": "FAIL", - "eval_report": "accounting/specs/.eval/reservation-booking-r1.md" - } - ] - }, + "current_specs": [ + { + "id": 1, + "name": "Reservation Booking", + "domain": "accounting", + "topic": "The accounting service records reservation bookings and tracks ledger entries", + "file": "accounting/specs/reservation-booking.md", + "planning_sources": [".forge_workspace/planning/accounting/reservation-lifecycle.md"], + "depends_on": [], + "round": 2, + "evals": [ + { + "round": 1, + "verdict": "FAIL", + "eval_report": "accounting/specs/.eval/reservation-booking-r1.md" + } + ] + } + ], "queue": [ { "name": "Ledger Reconciliation", "domain": "accounting", "topic": "The accounting service reconciles ledger entries against completed bookings", "file": "accounting/specs/ledger-reconciliation.md", - "planning_sources": [".workspace/planning/accounting/ledger-management.md"], + "planning_sources": [".forge_workspace/planning/accounting/ledger-management.md"], "depends_on": ["Reservation Booking"] } ], "completed": [], "reconcile": null }, + "generate_planning_queue": null, "planning": null, "implementing": null } diff --git a/skills/specs/references/forgectl-state-schema.md b/skills/specs/references/forgectl-state-schema.md index 0ac085d..426fbc6 100644 --- a/skills/specs/references/forgectl-state-schema.md +++ b/skills/specs/references/forgectl-state-schema.md @@ -1,6 +1,6 @@ # forgectl State File Schema -> Defines the JSON structure of `forgectl-state.json` — the persistent state file that drives the spec generation workflow. +> Defines the JSON structure of `forgectl-state.json` --- the persistent state file that drives the spec generation workflow. > See `forgectl-state-example.json` for a concrete example. --- @@ -8,58 +8,103 @@ ## File Location - **File name:** `forgectl-state.json` +- **Location:** `//forgectl-state.json` (default: `.forgectl/state/forgectl-state.json`) - **Created by:** `forgectl init` - **Backup:** `forgectl-state.json.bak` (previous state, atomic write with crash recovery) --- +## Configuration + +All configuration comes from `.forgectl/config` (TOML) and is locked into the state file's `config` object at init time. The config is not re-read from the TOML file after init --- the state file's `config` is the single source of truth for the session. + +The only mutable config value is `config.general.user_guided`, which can be toggled via `--guided` / `--no-guided` on any `advance` call. + +See the nested `config` structure in the ForgeState table below. + +--- + ## Top-Level: ForgeState | Field | Type | Description | |-------|------|-------------| -| `phase` | `string` | Active phase: `"specifying"`, `"planning"`, or `"implementing"` | +| `phase` | `string` | Active phase: `"specifying"`, `"generate_planning_queue"`, `"planning"`, or `"implementing"` | | `state` | `string` | Current state within the phase (see State Names below) | -| `batch_size` | `int` | Max items per batch | -| `min_rounds` | `int` | Minimum evaluation rounds per spec (default 1) | -| `max_rounds` | `int` | Maximum evaluation rounds per spec | -| `user_guided` | `bool` | Whether the session pauses for user input at key states | +| `config` | `ForgeConfig` | Full project configuration locked at init. See Config section. | +| `session_id` | `string` | UUID v4 generated at init, stable for session lifetime | | `started_at_phase` | `string` | The phase selected at `init` time | | `phase_shift` | `object \| null` | Present only during a phase transition | | `specifying` | `object \| null` | Specifying phase state (populated when `phase` = `"specifying"`) | +| `generate_planning_queue` | `object \| null` | Generate planning queue phase state. Null when skipped or not yet reached. | | `planning` | `object \| null` | Planning phase state | | `implementing` | `object \| null` | Implementing phase state | --- +## Config: ForgeConfig + +| Field | Type | Description | +|-------|------|-------------| +| `general.enable_commits` | `bool` | Whether scaffold auto-commits at commit points (default: `false`) | +| `general.enable_eval_output` | `bool` | Whether eval sub-agents write report files (default: `false`) | +| `general.user_guided` | `bool` | Whether guided pauses are active. Mutable via `--guided`/`--no-guided`. | +| `domains` | `DomainConfig[]` | Optional. Configured domains with `name` and `path`. | +| `specifying.batch` | `int` | Specs per specifying cycle, domain-grouped (default: 1) | +| `specifying.commit_strategy` | `string` | Git staging strategy: `strict`, `all-specs`, `scoped`, `tracked`, `all` (default: `all-specs`) | +| `specifying.eval.*` | `EvalConfig` | Eval round limits and agent config for specifying | +| `specifying.cross_reference.*` | `CrossRefConfig` | Cross-reference round limits, agent config, and user_review flag | +| `specifying.reconciliation.*` | `ReconciliationConfig` | Reconciliation round limits and agent config | +| `planning.batch` | `int` | Plans per planning cycle (default: 1) | +| `planning.commit_strategy` | `string` | Git staging strategy (default: `strict`) | +| `planning.self_review` | `bool` | Whether SELF_REVIEW state is entered (default: `false`) | +| `planning.plan_all_before_implementing` | `bool` | When `true`: all planning then all implementing (default: `false`) | +| `planning.study_code.*` | `AgentConfig` | Agent config for codebase exploration | +| `planning.eval.*` | `EvalConfig` | Eval round limits and agent config for planning | +| `planning.refine.*` | `AgentConfig` | Agent config for plan refinement | +| `implementing.batch` | `int` | Plan items per implementing batch (default: 1) | +| `implementing.commit_strategy` | `string` | Git staging strategy (default: `scoped`) | +| `implementing.eval.*` | `EvalConfig` | Eval round limits and agent config for implementing | +| `paths.state_dir` | `string` | State file directory (default: `.forgectl/state`) | +| `paths.workspace_dir` | `string` | Domain artifact directory name (default: `.forge_workspace`) | +| `logs.enabled` | `bool` | Whether activity logging is active (default: `true`) | +| `logs.retention_days` | `int` | Log file age limit for pruning (default: `90`) | +| `logs.max_files` | `int` | Maximum log file count for pruning (default: `50`) | + +--- + ## State Names ### Specifying Phase ``` -ORIENT → SELECT → DRAFT → EVALUATE ⇄ REFINE → ACCEPT → (next spec or DONE) -DONE → RECONCILE → RECONCILE_EVAL → RECONCILE_REVIEW → COMPLETE -COMPLETE → PHASE_SHIFT +ORIENT -> SELECT -> DRAFT -> EVALUATE <-> REFINE -> ACCEPT -> (next batch or domain cross-reference) +ACCEPT (domain complete) -> CROSS_REFERENCE -> CROSS_REFERENCE_EVAL <-> CROSS_REFERENCE -> CROSS_REFERENCE_REVIEW -> (next domain or DONE) +DONE -> RECONCILE -> RECONCILE_EVAL <-> RECONCILE -> RECONCILE_REVIEW -> COMPLETE +COMPLETE -> PHASE_SHIFT ``` | State | Description | |-------|-------------| | `ORIENT` | Read plans and existing specs. Build mental model. | -| `SELECT` | Pull next spec from queue. If guided, discuss with user. | -| `DRAFT` | Write the spec file. | +| `SELECT` | Pull next batch from queue (domain-grouped). If guided, discuss with user. | +| `DRAFT` | Write the spec files for the batch. | | `EVALUATE` | Spawn evaluator sub-agent. Record verdict and eval report. | | `REFINE` | Fix deficiencies found during evaluation. | -| `ACCEPT` | Spec finalized. Next spec → ORIENT. Empty queue → DONE. | -| `DONE` | All individual specs complete. Advance to begin reconciliation. | -| `RECONCILE` | Fix cross-references across all specs. Stage files. | -| `RECONCILE_EVAL` | Sub-agent evaluates cross-spec consistency. | +| `ACCEPT` | Batch finalized. Next batch -> ORIENT. Domain complete -> CROSS_REFERENCE. | +| `CROSS_REFERENCE` | Cross-reference all specs within the completed domain. | +| `CROSS_REFERENCE_EVAL` | Sub-agent evaluates intra-domain cross-reference consistency. | +| `CROSS_REFERENCE_REVIEW` | Review cross-reference eval. Add specs or set code search roots. | +| `DONE` | All individual specs and domain cross-references complete. Advance to begin reconciliation. | +| `RECONCILE` | Fix cross-references across all specs and domains. Stage files. | +| `RECONCILE_EVAL` | Sub-agent evaluates cross-domain consistency. | | `RECONCILE_REVIEW` | Human reviews reconciliation eval. Accept or grant another pass. | -| `COMPLETE` | Session fully done. | -| `PHASE_SHIFT` | Transitioning to the next phase (specifying → generate_planning_queue). | +| `COMPLETE` | Specifying phase fully done. Auto-commits when `enable_commits` is true. | +| `PHASE_SHIFT` | Transitioning to the next phase (specifying -> generate_planning_queue). | ### Generate Planning Queue Phase ``` -ORIENT → REFINE → PHASE_SHIFT +ORIENT -> REFINE -> PHASE_SHIFT ``` | State | Description | @@ -71,27 +116,56 @@ ORIENT → REFINE → PHASE_SHIFT ### Planning Phase ``` -ORIENT → STUDY_SPECS → STUDY_CODE → STUDY_PACKAGES → REVIEW → DRAFT → VALIDATE → SELF_REVIEW* → EVALUATE ⇄ REFINE → ACCEPT → PHASE_SHIFT +ORIENT -> STUDY_SPECS -> STUDY_CODE -> STUDY_PACKAGES -> REVIEW -> DRAFT -> VALIDATE -> SELF_REVIEW* -> EVALUATE <-> REFINE -> ACCEPT -> (next or DONE) +DONE -> PHASE_SHIFT * SELF_REVIEW only entered when planning.self_review is true. ``` +| State | Description | +|-------|-------------| +| `ORIENT` | Begin studying the plan. | +| `STUDY_SPECS` | Study spec files and git diffs. | +| `STUDY_CODE` | Explore codebase with sub-agents. | +| `STUDY_PACKAGES` | Study technical stack. | +| `REVIEW` | Review findings. Guided pause. | +| `DRAFT` | Write the plan. | +| `VALIDATE` | Validate plan.json structure. | +| `SELF_REVIEW` | Self-review checkpoint (only when `planning.self_review` is true). | +| `EVALUATE` | Spawn evaluator sub-agent. Record verdict and eval report. | +| `REFINE` | Fix deficiencies found during evaluation. | +| `ACCEPT` | Plan accepted. Auto-commits when `enable_commits` is true. | +| `DONE` | All plans complete. | +| `PHASE_SHIFT` | Transitioning to implementing. | + ### Implementing Phase ``` -ORIENT → IMPLEMENT → EVALUATE ⇄ IMPLEMENT → COMMIT → ORIENT | DONE +ORIENT -> IMPLEMENT -> EVALUATE <-> IMPLEMENT -> COMMIT -> ORIENT | DONE ``` +| State | Description | +|-------|-------------| +| `ORIENT` | Selects batch of unblocked items. Guided pause. | +| `IMPLEMENT` | Implement the current item. | +| `EVALUATE` | Spawn evaluator sub-agent for the batch. | +| `COMMIT` | Commit implemented items. | +| `DONE` | All layers complete. Terminal state. | + --- ## Specifying Phase State: `specifying` | Field | Type | Description | |-------|------|-------------| -| `current_spec` | `ActiveSpec \| null` | Spec currently being drafted/evaluated. Null between specs. | +| `current_specs` | `ActiveSpec[] \| null` | The spec batch being worked on. Array of specs, not a single object. Null between batches. | +| `current_domain` | `string` | The domain currently being processed. | +| `batch_number` | `int` | Current batch number within the domain. | +| `domains` | `object` | Per-domain metadata (e.g., `code_search_roots` from set-roots). | +| `cross_reference` | `object` | Per-domain cross-reference round and eval history. | | `queue` | `SpecQueueEntry[]` | Remaining specs to process. | | `completed` | `CompletedSpec[]` | Specs that have been accepted. | -| `reconcile` | `ReconcileState \| null` | Reconciliation state. Populated after all specs reach DONE. | +| `reconcile` | `ReconcileState \| null` | Reconciliation state. Populated after all specs and cross-references reach DONE. | --- @@ -104,6 +178,7 @@ ORIENT → IMPLEMENT → EVALUATE ⇄ IMPLEMENT → COMMIT → ORIENT | DONE | `domain` | `string` | Domain this spec belongs to. | | `topic` | `string` | One-sentence topic of concern. | | `file` | `string` | Path where the spec file is written. | +| `domain_path` | `string` | Filesystem path to the domain directory. Optional. | | `planning_sources` | `string[]` | Paths to planning documents this spec is derived from. | | `depends_on` | `string[]` | Names of other specs this one depends on. | | `round` | `int` | Current evaluation round. Increments on REFINE. | @@ -132,9 +207,10 @@ ORIENT → IMPLEMENT → EVALUATE ⇄ IMPLEMENT → COMMIT → ORIENT | DONE | `name` | `string` | Spec name. | | `domain` | `string` | Domain. | | `file` | `string` | Path to the spec file. | +| `domain_path` | `string` | Filesystem path to the domain directory. Optional. | +| `batch_number` | `int` | Which batch this spec was part of. Optional. | | `rounds_taken` | `int` | Total rounds before acceptance. | -| `commit_hash` | `string` | Single commit hash. Optional. | -| `commit_hashes` | `string[]` | Multiple commit hashes. Optional. | +| `commit_hashes` | `string[]` | Git commit hashes registered via auto-commit at COMPLETE when `enable_commits: true`. Optional. | | `evals` | `EvalRecord[]` | Complete evaluation history. Optional. | --- @@ -145,7 +221,7 @@ ORIENT → IMPLEMENT → EVALUATE ⇄ IMPLEMENT → COMMIT → ORIENT | DONE |-------|------|-------------| | `round` | `int` | Round number. | | `verdict` | `string` | `"PASS"` or `"FAIL"`. | -| `eval_report` | `string` | Path to evaluation report file. Optional. | +| `eval_report` | `string` | Path to evaluation report file. Optional (omitted when `enable_eval_output: false`). | --- @@ -158,6 +234,24 @@ ORIENT → IMPLEMENT → EVALUATE ⇄ IMPLEMENT → COMMIT → ORIENT | DONE --- +### CrossReferenceState + +| Field | Type | Description | +|-------|------|-------------| +| `domain` | `string` | Domain being cross-referenced. | +| `round` | `int` | Cross-reference round counter. | +| `evals` | `EvalRecord[]` | Cross-reference evaluation history. Optional. | + +--- + +### GeneratePlanningQueueState + +| Field | Type | Description | +|-------|------|-------------| +| `plan_queue_file` | `string` | Path to the auto-generated plan-queue.json. | + +--- + ## PhaseShiftInfo | Field | Type | Description | @@ -169,7 +263,7 @@ ORIENT → IMPLEMENT → EVALUATE ⇄ IMPLEMENT → COMMIT → ORIENT | DONE ## Input File: Spec Queue -The `--from` file for `forgectl init --phase specifying`. This is the same schema defined in `spec-queue-from-plan.md`. +The `--from` file for `forgectl init --phase specifying`. ### Validation Rules @@ -184,11 +278,14 @@ The `--from` file for `forgectl init --phase specifying`. This is the same schem | Command | What it does | |---------|-------------| -| `forgectl init --from --batch-size --max-rounds ` | Creates the state file from a spec queue. | +| `forgectl init --from ` | Creates the state file from an input file and `.forgectl/config`. Optional `--phase` (default: specifying). | | `forgectl advance` | Transitions to the next state. Flags vary by current state. | -| `forgectl advance --verdict PASS --eval-report --message "msg"` | Accept an evaluation (EVALUATE state). | -| `forgectl advance --verdict FAIL --eval-report ` | Fail an evaluation → REFINE. | +| `forgectl advance --verdict PASS\|FAIL --eval-report ` | Record an evaluation verdict (EVALUATE states). | +| `forgectl advance --verdict FAIL --eval-report ` | Fail an evaluation -> REFINE. | | `forgectl advance --file ` | Override spec file path (DRAFT state only). | +| `forgectl advance --message "msg"` | Commit message (COMPLETE and ACCEPT commit points when `enable_commits: true`). | +| `forgectl advance --guided` / `--no-guided` | Toggle guided mode (accepted on any advance). | +| `forgectl advance --from ` | Plan queue input file (PHASE_SHIFT from specifying or generate_planning_queue). | | `forgectl add-queue-item --name --topic --file ` | Append a spec to the queue (DRAFT, CROSS_REFERENCE_REVIEW, DONE, RECONCILE_REVIEW). | | `forgectl set-roots [...]` | Set code search roots for a domain (CROSS_REFERENCE_REVIEW, DONE). | -| `forgectl status` | Read-only. Print current state and session overview. | +| `forgectl status` | Read-only. Print current state, action guidance, and progress summary. | diff --git a/skills/specs/references/spec-generation-skill.md b/skills/specs/references/spec-generation-skill.md index d3cf1b6..c4d3ba5 100644 --- a/skills/specs/references/spec-generation-skill.md +++ b/skills/specs/references/spec-generation-skill.md @@ -13,7 +13,7 @@ You are a **Systems Architect**. You write contracts that define what "correct" ## Inputs -- **Planning documents** — found in `.workspace/planning/`. These are read-only. You consume them but never modify or delete them. +- **Planning documents** — found in `.forge_workspace/planning/`. These are read-only. You consume them but never modify or delete them. - **Architecture documents** — a standalone document describing a system's components, behaviors, and boundaries. When the input is an architecture document rather than a planning document, the architect decomposes it using the Topic of Concern test (Phase 2 of the standard workflow) before generating specs. The same rules apply: identify nouns (components), verbs (behaviors), and boundaries (where one responsibility ends and another begins), then extract one spec per topic. - **Existing specs** — found in `/specs/` directories. These tell you what is already covered. - **Spec queue** — a JSON file listing the specs to generate with topics of concern, domains, file paths, planning source references, and dependency ordering. This is the input to `scaffold init`. @@ -35,8 +35,8 @@ The spec generation process is managed by the `forgectl` CLI tool that tracks st ### Quick Reference ```bash -# Initialize session — validate queue, set rounds -forgectl init --min-rounds 1 --max-rounds 3 --batch-size 1 --from queue.json --guided +# Initialize session — configuration is read from .forgectl/config (TOML) +forgectl init --from queue.json # Full session overview forgectl status @@ -47,6 +47,8 @@ forgectl advance --file /specs/.md # DRAFT only (o forgectl advance --verdict PASS --eval-report # EVALUATE: accept forgectl advance --verdict FAIL --eval-report # EVALUATE: fail → REFINE forgectl advance --message "Complete specifying phase" # COMPLETE: auto-commit (when enable_commits: true) +forgectl advance --guided # Toggle guided mode on +forgectl advance --no-guided # Toggle guided mode off # Add a spec to the queue during drafting or review forgectl add-queue-item --name --topic --file [--source ...] @@ -59,7 +61,7 @@ forgectl set-roots [...] **Global:** `--dir ` — directory containing state file (default `.`) -**`init`:** `--from` (required), `--phase` (default `specifying`) +**`init`:** `--from` (required), `--phase` (default `specifying`). All other settings (batch, rounds, guided, etc.) come from `.forgectl/config` TOML. **`advance`:** `--verdict PASS|FAIL`, `--eval-report `, `--file `, `--message ` / `-m`, `--from `, `--guided` / `--no-guided` @@ -70,24 +72,27 @@ forgectl set-roots [...] ### State Flow ``` -INIT → ORIENT → SELECT → DRAFT → EVALUATE ⇄ REFINE → REVIEW → ACCEPT → (next spec or DONE) - ↑ │ - └─────────┘ (grant extra round) +INIT → ORIENT → SELECT → DRAFT → EVALUATE ⇄ REFINE → ACCEPT → (next batch or domain cross-reference) +ACCEPT (domain complete) → CROSS_REFERENCE → CROSS_REFERENCE_EVAL ⇄ CROSS_REFERENCE → CROSS_REFERENCE_REVIEW → (next domain or DONE) +DONE → RECONCILE → RECONCILE_EVAL ⇄ RECONCILE → RECONCILE_REVIEW → COMPLETE → PHASE_SHIFT ``` -- **INIT**: Create state file from validated queue. Set `--min-rounds`, `--max-rounds`, `--batch-size`, `--guided`. +- **INIT**: Create state file from validated queue. Configuration is read from `.forgectl/config` TOML. - **ORIENT**: Architect reads plans and existing specs. Builds mental model. -- **SELECT**: Pull next topic from queue. If `--guided`, discuss with user. -- **DRAFT**: Write the spec file. Optionally override path with `--file`. -- **EVALUATE**: Spawn Opus sub-agent evaluator. Record `--verdict` and `--eval-report`. PASS requires `--message`. +- **SELECT**: Pull next batch from queue (domain-grouped). If guided, discuss with user. +- **DRAFT**: Write the spec files for the batch. Optionally override path with `--file`. +- **EVALUATE**: Spawn Opus sub-agent evaluator. Record `--verdict` and `--eval-report`. - **REFINE**: Fix deficiencies. Advance back to EVALUATE when done (no special flags needed). -- **REVIEW**: Max rounds reached (or past min rounds). Human decides: accept or `--verdict FAIL` for extra round. -- **ACCEPT**: Spec finalized. Next spec loops to ORIENT. Empty queue → DONE. -- **DONE**: All individual specs complete. Advance to begin reconciliation. -- **RECONCILE**: Fix cross-references across all specs. Stage files. Advance. -- **RECONCILE_EVAL**: Sub-agent evaluates `git diff --staged` for cross-spec consistency. +- **ACCEPT**: Batch finalized. Next batch -> ORIENT. Domain complete -> CROSS_REFERENCE. Queue empty -> DONE. +- **CROSS_REFERENCE**: Cross-reference all specs within the completed domain. Spawn sub-agents to review. +- **CROSS_REFERENCE_EVAL**: Sub-agent evaluates intra-domain cross-reference consistency. +- **CROSS_REFERENCE_REVIEW**: Review cross-reference eval. Add specs or set code search roots for the domain. +- **DONE**: All individual specs and domain cross-references complete. Advance to begin reconciliation. +- **RECONCILE**: Fix cross-references across all specs and domains. Stage files. Advance. +- **RECONCILE_EVAL**: Sub-agent evaluates `git diff --staged` for cross-domain consistency. - **RECONCILE_REVIEW**: Human reviews eval. Accept or grant another pass. -- **COMPLETE**: Session fully done. +- **COMPLETE**: Specifying phase fully done. Auto-commits when `enable_commits` is true. +- **PHASE_SHIFT**: Transitioning to the next phase (specifying -> generate_planning_queue). ### Queue Input File @@ -101,7 +106,7 @@ The architect generates this JSON before `init`. The scaffold validates it stric "domain": "accounting", "topic": "The accounting service processes reservation bookings and records settlement outcomes", "file": "accounting/specs/reservation-booking.md", - "planning_sources": [".workspace/planning/accounting/transaction-processing.md"], + "planning_sources": [".forge_workspace/planning/accounting/transaction-processing.md"], "depends_on": [] } ]