From 07e23ecf8ca2749af4169a04649e2534fb166509 Mon Sep 17 00:00:00 2001 From: k-atana-agent Date: Sun, 25 Oct 2026 22:59:13 +0800 Subject: [PATCH] fix: native mode now works end to end with a real agent First-run path in a repo with no origin and no Docker, verified with claude on a 2-task repo (docs/reports/e2e-smoke-2026-10-25.md). Fixes, each found by a failed real run: - CLI: native modes do not need origin; errors print one line (stack only with FSC_DEBUG); one-line config summary instead of a JSON dump; missing config files are silent - native analyzer (analyzeCodebaseNative): runs claude in a throwaway detached worktree, no Docker; .fsc/ is excluded via .git/info/exclude - native run uses UnifiedScheduler (worktree per task -> ordered merge), not the Docker-era solver + CodeCommitter which never merged worktree results - scheduler: drop the unused pool from execute (it created fsc/ and LocalExecution then failed to create it again); clean up task worktrees and merged branches - agent names normalized (claude-code -> claude); unknown agents error instead of falling back to opencode; claude builder drops the nonexistent --cwd, adds --permission-mode - an agent that exits 0 without a commit is a failure; spawn errors are reported; exit 1 when anything is not merged (the old report said 100% success for a run that did nothing) - sequencer (TS + Rust, goldens regenerated): only tracked changes make the tree dirty - worktree create: remove empty stale fsc/ branches, refuse ones holding commits Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016FSVfwwP6TVxGLUWatWM53 --- crates/fsc-merge/src/sequencer.rs | 12 +- crates/fsc-merge/tests/golden/merge.json | 108 ++- crates/fsc-merge/tests/scenarios.json | 857 ++++++++++++++++--- docs/reports/e2e-smoke-2026-10-25.md | 51 ++ packages/cli/src/index.ts | 68 +- packages/core/src/analyzerNative.ts | 135 +++ packages/core/src/config.ts | 4 + packages/core/src/configReader.ts | 6 +- packages/core/src/execution/index.ts | 2 + packages/core/src/execution/local.ts | 16 +- packages/core/src/execution/mapping.ts | 43 + packages/core/src/execution/scheduler.ts | 38 +- packages/core/src/execution/sequencer.ts | 5 +- packages/core/src/execution/types.ts | 2 +- packages/core/src/execution/worktree.ts | 15 + packages/core/src/index.ts | 1 + packages/core/src/prompts/analyzerPrompt.ts | 5 +- packages/core/test/execution/mapping.test.ts | 23 + 18 files changed, 1223 insertions(+), 168 deletions(-) create mode 100644 docs/reports/e2e-smoke-2026-10-25.md create mode 100644 packages/core/src/analyzerNative.ts diff --git a/crates/fsc-merge/src/sequencer.rs b/crates/fsc-merge/src/sequencer.rs index 2a13735..a37cebe 100644 --- a/crates/fsc-merge/src/sequencer.rs +++ b/crates/fsc-merge/src/sequencer.rs @@ -247,9 +247,17 @@ impl CommitSequencer { } fn pre_merge_check(&self, hash: &str) -> PreMerge { - // `.fsc/` is our own runtime state (merge-history.json); it must not make the tree dirty. + // Only tracked changes make the tree dirty: untracked files (logs, `.fsc/` state) do not + // block a cherry-pick, and git itself refuses if one would be overwritten. let status = git( - &["status", "--porcelain", "--", ".", ":(exclude).fsc"], + &[ + "status", + "--porcelain", + "--untracked-files=no", + "--", + ".", + ":(exclude).fsc", + ], &self.repo, ); if !status.stdout.trim().is_empty() { diff --git a/crates/fsc-merge/tests/golden/merge.json b/crates/fsc-merge/tests/golden/merge.json index e1f4810..67cdb27 100644 --- a/crates/fsc-merge/tests/golden/merge.json +++ b/crates/fsc-merge/tests/golden/merge.json @@ -322,8 +322,8 @@ }, { "write": [ - "dirty.txt", - "untracked" + "a.txt", + "locally modified" ] } ], @@ -335,7 +335,7 @@ } ], "inspect": [ - "dirty.txt" + "a.txt" ], "expected": { "results": [ @@ -348,7 +348,107 @@ } ], "files": { - "dirty.txt": "untracked" + "a.txt": "locally modified" + }, + "history": {}, + "startTiers": {}, + "porcelainClean": false, + "cherryPickInProgress": false + } + }, + { + "name": "untracked_files_do_not_block", + "target": "main", + "steps": [ + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent" + ] + }, + { + "write": [ + "new-file.txt", + "x" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "task" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + }, + { + "write": [ + "run.log", + "untracked noise" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "new-file.txt", + "run.log" + ], + "expected": { + "results": [ + { + "taskId": "t1", + "success": true, + "resolvedTier": 1, + "error": null, + "hasCommitHash": true + } + ], + "files": { + "new-file.txt": "x", + "run.log": "untracked noise" }, "history": {}, "startTiers": {}, diff --git a/crates/fsc-merge/tests/scenarios.json b/crates/fsc-merge/tests/scenarios.json index 876b544..bef99fb 100644 --- a/crates/fsc-merge/tests/scenarios.json +++ b/crates/fsc-merge/tests/scenarios.json @@ -3,169 +3,798 @@ "name": "tier1_clean", "target": "main", "steps": [ - { "write": ["shared.txt", "line1\nline2\nline3\n"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "initial"] }, - { "git": ["checkout", "-q", "-b", "agent"] }, - { "write": ["new-file.txt", "no conflict"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "task"] }, - { "rev": "h" }, - { "git": ["checkout", "-q", "main"] } - ], - "applies": [{ "id": "t1", "hash": "${h}", "branch": "agent" }], - "inspect": ["new-file.txt", "shared.txt"] + { + "write": [ + "shared.txt", + "line1\nline2\nline3\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent" + ] + }, + { + "write": [ + "new-file.txt", + "no conflict" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "task" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "new-file.txt", + "shared.txt" + ] }, { "name": "no_commit_hash", "target": "main", "steps": [ - { "write": ["a.txt", "a"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "initial"] } + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": null, + "branch": "agent" + } ], - "applies": [{ "id": "t1", "hash": null, "branch": "agent" }], - "inspect": ["a.txt"] + "inspect": [ + "a.txt" + ] }, { "name": "already_merged", "target": "main", "steps": [ - { "write": ["a.txt", "a"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "initial"] }, - { "rev": "h" } + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + }, + { + "rev": "h" + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } ], - "applies": [{ "id": "t1", "hash": "${h}", "branch": "agent" }], - "inspect": ["a.txt"] + "inspect": [ + "a.txt" + ] }, { "name": "missing_commit", "target": "main", "steps": [ - { "write": ["a.txt", "a"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "initial"] } + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + } ], - "applies": [{ "id": "t1", "hash": "0123456789abcdef0123456789abcdef01234567", "branch": "agent" }], - "inspect": ["a.txt"] + "applies": [ + { + "id": "t1", + "hash": "0123456789abcdef0123456789abcdef01234567", + "branch": "agent" + } + ], + "inspect": [ + "a.txt" + ] }, { "name": "dirty_tree", "target": "main", "steps": [ - { "write": ["a.txt", "a"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "initial"] }, - { "git": ["checkout", "-q", "-b", "agent"] }, - { "write": ["new-file.txt", "x"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "task"] }, - { "rev": "h" }, - { "git": ["checkout", "-q", "main"] }, - { "write": ["dirty.txt", "untracked"] } - ], - "applies": [{ "id": "t1", "hash": "${h}", "branch": "agent" }], - "inspect": ["dirty.txt"] + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent" + ] + }, + { + "write": [ + "new-file.txt", + "x" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "task" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + }, + { + "write": [ + "a.txt", + "locally modified" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "a.txt" + ] + }, + { + "name": "untracked_files_do_not_block", + "target": "main", + "steps": [ + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent" + ] + }, + { + "write": [ + "new-file.txt", + "x" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "task" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + }, + { + "write": [ + "run.log", + "untracked noise" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "new-file.txt", + "run.log" + ] }, { "name": "tier2_blocked_contentful_canonical", "target": "main", "steps": [ - { "write": ["conflict.txt", "original content\n"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "base"] }, - { "rev": "base" }, - { "write": ["conflict.txt", "main's important change\n"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "main change"] }, - { "git": ["checkout", "-q", "-b", "agent", "${base}"] }, - { "write": ["conflict.txt", "agent's different change\n"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "agent change"] }, - { "rev": "h" }, - { "git": ["checkout", "-q", "main"] } - ], - "applies": [{ "id": "t1", "hash": "${h}", "branch": "agent" }], - "inspect": ["conflict.txt"], - "history_files": ["conflict.txt"], - "start_tier_files": ["conflict.txt"] + { + "write": [ + "conflict.txt", + "original content\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "base" + ] + }, + { + "rev": "base" + }, + { + "write": [ + "conflict.txt", + "main's important change\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "main change" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent", + "${base}" + ] + }, + { + "write": [ + "conflict.txt", + "agent's different change\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "agent change" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "conflict.txt" + ], + "history_files": [ + "conflict.txt" + ], + "start_tier_files": [ + "conflict.txt" + ] }, { "name": "tier2_resolved_empty_canonical", "target": "main", "steps": [ - { "write": ["f.txt", "a\nb\nc\n"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "base"] }, - { "git": ["checkout", "-q", "-b", "agent"] }, - { "write": ["f.txt", "a\nB2\nc\n"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "agent modifies b"] }, - { "rev": "h" }, - { "git": ["checkout", "-q", "main"] }, - { "write": ["f.txt", "a\nc\n"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "main deletes b"] } - ], - "applies": [{ "id": "t1", "hash": "${h}", "branch": "agent" }], - "inspect": ["f.txt"], - "history_files": ["f.txt"] + { + "write": [ + "f.txt", + "a\nb\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "base" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent" + ] + }, + { + "write": [ + "f.txt", + "a\nB2\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "agent modifies b" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + }, + { + "write": [ + "f.txt", + "a\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "main deletes b" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "f.txt" + ], + "history_files": [ + "f.txt" + ] }, { "name": "mixed_conflicts_blocked_twice", "target": "main", "steps": [ - { "write": ["f1.txt", "a\nb\nc\n"] }, - { "write": ["f2.txt", "a\nb\nc\n"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "base"] }, - { "git": ["checkout", "-q", "-b", "agent"] }, - { "write": ["f1.txt", "a\nB2\nc\n"] }, - { "write": ["f2.txt", "a\nX2\nc\n"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "agent"] }, - { "rev": "h" }, - { "git": ["checkout", "-q", "main"] }, - { "write": ["f1.txt", "a\nc\n"] }, - { "write": ["f2.txt", "a\nb-main\nc\n"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "main"] } + { + "write": [ + "f1.txt", + "a\nb\nc\n" + ] + }, + { + "write": [ + "f2.txt", + "a\nb\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "base" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent" + ] + }, + { + "write": [ + "f1.txt", + "a\nB2\nc\n" + ] + }, + { + "write": [ + "f2.txt", + "a\nX2\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "agent" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + }, + { + "write": [ + "f1.txt", + "a\nc\n" + ] + }, + { + "write": [ + "f2.txt", + "a\nb-main\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "main" + ] + } ], "applies": [ - { "id": "t1", "hash": "${h}", "branch": "agent" }, - { "id": "t2", "hash": "${h}", "branch": "agent" } + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + }, + { + "id": "t2", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "f1.txt", + "f2.txt" + ], + "history_files": [ + "f1.txt", + "f2.txt" ], - "inspect": ["f1.txt", "f2.txt"], - "history_files": ["f1.txt", "f2.txt"], - "start_tier_files": ["f1.txt", "f2.txt"] + "start_tier_files": [ + "f1.txt", + "f2.txt" + ] }, { "name": "apply_all_sorts_and_filters", "target": "main", "mode": "all", "steps": [ - { "write": ["base.txt", "base"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "base"] }, - { "git": ["checkout", "-q", "-b", "agent-b"] }, - { "write": ["b.txt", "b"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "b"] }, - { "rev": "hb" }, - { "git": ["checkout", "-q", "main"] }, - { "git": ["checkout", "-q", "-b", "agent-a"] }, - { "write": ["a.txt", "a"] }, - { "git": ["add", "-A"] }, - { "git": ["commit", "-q", "-m", "a"] }, - { "rev": "ha" }, - { "git": ["checkout", "-q", "main"] } + { + "write": [ + "base.txt", + "base" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "base" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent-b" + ] + }, + { + "write": [ + "b.txt", + "b" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "b" + ] + }, + { + "rev": "hb" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent-a" + ] + }, + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "a" + ] + }, + { + "rev": "ha" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + } ], "applies": [ - { "id": "b", "hash": "${hb}", "branch": "agent-b" }, - { "id": "z-no-commit", "hash": null, "branch": "none" }, - { "id": "a", "hash": "${ha}", "branch": "agent-a" } + { + "id": "b", + "hash": "${hb}", + "branch": "agent-b" + }, + { + "id": "z-no-commit", + "hash": null, + "branch": "none" + }, + { + "id": "a", + "hash": "${ha}", + "branch": "agent-a" + } ], - "inspect": ["a.txt", "b.txt"] + "inspect": [ + "a.txt", + "b.txt" + ] } ] diff --git a/docs/reports/e2e-smoke-2026-10-25.md b/docs/reports/e2e-smoke-2026-10-25.md new file mode 100644 index 0000000..4739e3c --- /dev/null +++ b/docs/reports/e2e-smoke-2026-10-25.md @@ -0,0 +1,51 @@ +# End-to-end smoke test: native worktree mode with a real agent + +Date: 2026-10-25. Machine: Windows Server 2025, `claude` CLI logged in, no Docker involved. + +## Setup + +A scratch repository with one commit and no `origin` remote: + +``` +math_ops.py def add(a, b): # TODO: return the sum of a and b / pass +text_ops.py def shout(s): # TODO: return s upper-cased with "!" appended / pass +``` + +Command (from inside the repository): + +``` +FSC_MAX_TASKS=2 FSC_MIN_TASKS=2 bun packages/cli/src/index.ts run +``` + +## Result of the last run + +- Analysis found 2 tasks, one per file. +- Two `claude` agents ran in parallel, each in its own git worktree (exec phase: 77 s). +- Both commits were cherry-picked onto `main` at tier 1 (no conflicts), exit code 0. +- `git log`: `feat: implement add(a, b) in math_ops`, `feat: implement shout() in text_ops` on top of `init`. +- `python -c "assert add(2,3)==5 and add(-1,1)==0 and shout('hi')=='HI!'"` passes. +- Worktrees and task branches were removed after the merge; only `__pycache__/` (from the check above) was left untracked. + +Not measured: the first, analysis phase of the final run. Total wall clock of that run was 16 min 10 s, against 38 s to 2 min for earlier runs with the same prompt. Cause not investigated (could be API queueing); exec phase was 77 s. + +## What it took to get here (all fixed in the same PR) + +Four earlier runs failed. Each failure was a real defect, not a flaky environment: + +1. **No `origin` crashed the CLI** with a source-code stack trace, although native mode never uses a remote. +2. **The analysis step was Docker-only** (container, `apt-get`, copying `~/.ssh` into it). Added a native analyzer that runs `claude` in a throwaway detached worktree. +3. **A failed analysis cleanup hid the result** (`EBUSY` on Windows). Cleanup is best effort now. +4. **Agent name `claude-code` fell back silently to `opencode`** (not installed). Names are normalized; unknown names are an error. +5. **The `claude` builder passed `--cwd`**, a flag that does not exist, and no permission mode, so unattended edits could not work. +6. **False success.** With no changes made, the old reporter printed "ALL TASKS COMPLETED SUCCESSFULLY, 100%". A run that changes nothing is now a failure, spawn errors are reported, and the exit code is 1 when anything is not merged. +7. **The native path never merged anything.** The CLI used the Docker-era solver plus `CodeCommitter`. It now uses `UnifiedScheduler` (worktree per task, then ordered merge). +8. **The scheduler collided with itself.** It leased a pool slot and ran `checkout -b fsc/`; `LocalExecution` then ran `worktree add -b fsc/` and failed with "branch already exists" on every task. The unused pool is gone from the execute path. +9. **Untracked files blocked merges** ("working tree is dirty") although they never affect a cherry-pick. Only tracked changes count now (TS and Rust sequencers, with golden scenarios). +10. Stale `fsc/` branches from earlier runs: removed when they hold no unmerged commits; refused with a clear message when they do. + +## Limits of this test + +- One repository, two independent tasks, one agent type (`claude-code`). No conflicting tasks, no failing agent, no more than 2 agents at once. +- Other agents (`gemini`, `codex`) are mapped but untested; `--cwd` is still present in their builders and may be wrong. +- Worktrees are created next to the repository (`-fsc-`), not inside it. +- Scale: see issue #10. Nothing here says anything about 100 or 1000 agents. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5c651a7..b8cc5c4 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,8 @@ import { Command } from 'commander'; import { analyzeCodebase, + analyzeCodebaseNative, + UnifiedScheduler, TaskSolverManager, DistributedScheduler, createConfig, @@ -44,7 +46,6 @@ export async function runFullAnalysis(): Promise { // Use ConfigReader to load from standard location with env var support try { config = readConfigWithEnv(); - console.log('Loaded configuration from ~/.config/full-self-coding/config.json'); } catch (error) { console.error('Error loading configuration:', error); process.exit(1); @@ -54,29 +55,65 @@ export async function runFullAnalysis(): Promise { // Store configuration globally for later use appConfig = config; - // Log key configuration details - console.log('Configuration loaded:'); - console.log(JSON.stringify(config, null, 2)); + const mode = config.executionMode ?? 'worktree'; + const useDistributed = process.argv.includes('--distributed') || process.env.FSC_DISTRIBUTED === '1'; + console.log(`FSC: agent=${config.agentType} mode=${mode} repo=${process.cwd()}`); + if (process.env.FSC_DEBUG) console.log(JSON.stringify(config, null, 2)); - let gitRemoteUrl: string; - try { + // Only docker and distributed runs fetch code from a remote; native runs use the local repo. + let gitRemoteUrl = ''; + if (mode === 'docker' || useDistributed) { const { fetchUrl } = await getGitRemoteUrls(config.useGithubSSH); - gitRemoteUrl = fetchUrl || ''; // Use fetchUrl, or empty string if not found + gitRemoteUrl = fetchUrl || ''; if (!gitRemoteUrl) { - throw new Error("Could not determine git remote URL."); + throw new Error( + `${useDistributed ? 'Distributed' : 'Docker'} mode clones the repository from its "origin" remote, ` + + `but this repository has none. Add one (git remote add origin ), or use the default native mode.`, + ); } - console.log(`Detected Git remote URL: ${gitRemoteUrl}`); - } catch (error) { - console.error("Error getting git remote URL:", error); - process.exit(1); + console.log(`Git remote: ${gitRemoteUrl}`); } // Step 1: analyze the codebase and get tasks - const tasks: Task[] = await analyzeCodebase(config, gitRemoteUrl); + console.log('Analyzing the codebase...'); + const tasks: Task[] = + mode === 'docker' + ? await analyzeCodebase(config, gitRemoteUrl) + : await analyzeCodebaseNative(config, process.cwd()); + console.log(`Found ${tasks.length} task(s):`); + for (const t of tasks) console.log(` - [P${t.priority}] ${t.title}`); + + // Native path: worktree per task -> agent -> ordered merge onto the current branch. + if (mode !== 'docker' && !useDistributed) { + const concurrency = Math.max(1, Math.min(config.maxParallelWorktrees ?? 5, tasks.length)); + console.log(`Running ${tasks.length} task(s), up to ${concurrency} at a time...`); + const scheduler = new UnifiedScheduler({ + localPoolSize: concurrency, + maxConcurrency: concurrency, + agentType: config.agentType, + }); + const report = await scheduler.execute(tasks, process.cwd()); + + console.log(''); + for (const t of report.tasks) { + const merged = report.mergeResults.find((m) => m.taskId === t.taskId); + const mergeNote = merged ? (merged.success ? ` (merged${merged.resolvedTier ? `, tier ${merged.resolvedTier}` : ''})` : ` (NOT merged: ${merged.error})`) : ''; + console.log(` ${t.status === 'completed' && merged?.success ? 'ok ' : 'FAIL'} ${t.taskId}${mergeNote}${t.error ? ` - ${t.error}` : ''}`); + } + const merged = report.mergeResults.filter((m) => m.success && m.resolvedTier).length; + console.log(` +${report.completed}/${report.total} task(s) completed, ${merged} merged, ${report.failed} failed (${Math.round(report.durationMs / 1000)}s)`); + + const logDir = `${process.cwd()}/.fsc`; + fs.mkdirSync(logDir, { recursive: true }); + fs.writeFileSync(`${logDir}/last-report.json`, JSON.stringify(report, null, 2)); + console.log(`Report: ${logDir}/last-report.json`); + if (report.failed > 0 || merged < report.completed) process.exit(1); + return; + } // Step 2: execute tasks — distributed (Redis Streams) or local let allTaskReports; - const useDistributed = process.argv.includes('--distributed') || process.env.FSC_DISTRIBUTED === '1'; if (useDistributed) { console.log('[FSC] Distributed mode — submitting tasks to Redis Streams'); @@ -154,7 +191,8 @@ export function createCLI(): Command { } await runFullAnalysis(); } catch (error) { - console.error('Error running full analysis:', error); + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + if (process.env.FSC_DEBUG && error instanceof Error && error.stack) console.error(error.stack); process.exit(1); } }); diff --git a/packages/core/src/analyzerNative.ts b/packages/core/src/analyzerNative.ts new file mode 100644 index 0000000..e280a7a --- /dev/null +++ b/packages/core/src/analyzerNative.ts @@ -0,0 +1,135 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'fs'; +import { join, resolve } from 'path'; +import type { Task } from './task'; +import { type Config, SWEAgentType } from './config'; +import { analyzerPrompt } from './prompts/analyzerPrompt'; +import { getCodingStyle } from './codingStyle'; +import { getWorkStyleDescription, WorkStyle } from './workStyle'; +import { trimJSONObjectArray } from './utils/trimJSON'; + +const ALLOWED_TOOLS = 'Bash,ReadEdit,Glob,Grep,WebFetch,WebSearch,Write,TodoWrite,SlashCommand'; +const DEFAULT_TIMEOUT_SECONDS = 1200; + +function git(args: string[], cwd: string): { ok: boolean; out: string } { + const r = Bun.spawnSync({ cmd: ['git', ...args], cwd }); + return { ok: r.exitCode === 0, out: new TextDecoder().decode(r.stdout) + new TextDecoder().decode(r.stderr) }; +} + +/** Keep our `.fsc/` runtime state out of `git status` without touching the user's .gitignore. */ +function ignoreStateDir(repo: string): void { + const p = git(['rev-parse', '--git-path', 'info/exclude'], repo); + if (!p.ok) return; + const file = resolve(repo, p.out.trim()); + try { + const cur = existsSync(file) ? readFileSync(file, 'utf-8') : ''; + if (!/^\/?\.fsc\/?$/m.test(cur)) { + mkdirSync(join(file, '..'), { recursive: true }); + const sep = cur && !cur.endsWith('\n') ? '\n' : ''; + writeFileSync(file, `${cur}${sep}.fsc/\n`, 'utf-8'); + } + } catch { + // not fatal: worst case `.fsc/` shows up as untracked + } +} + +/** Environment for the local agent process. Never put keys on a command line. */ +function agentEnv(config: Config): Record { + const env: Record = { ...(process.env as Record) }; + if (config.anthropicAPIKey) env.ANTHROPIC_AUTH_TOKEN = config.anthropicAPIKey; + if (config.anthropicAPIBaseUrl) env.ANTHROPIC_BASE_URL = config.anthropicAPIBaseUrl; + return env; +} + +/** + * Analyze the codebase with a locally installed agent CLI and return the task list. + * No Docker and no git remote needed. The agent runs in a throwaway detached worktree of HEAD + * so a misbehaving analyzer cannot touch the user's working tree. + */ +export async function analyzeCodebaseNative(config: Config, repoPath: string): Promise { + if (config.agentType !== SWEAgentType.CLAUDE_CODE) { + throw new Error( + `Native analysis currently supports agentType "claude-code" (got "${config.agentType}"). ` + + `Use --mode docker for other agents.`, + ); + } + const repo = resolve(repoPath); + if (!git(['rev-parse', '--git-dir'], repo).ok) { + throw new Error(`${repo} is not a git repository. Run FSC from inside one (git init, and make at least one commit).`); + } + if (!git(['rev-parse', '--verify', 'HEAD'], repo).ok) { + throw new Error('This repository has no commits yet. Make an initial commit first.'); + } + if (!Bun.which('claude')) { + throw new Error('The "claude" CLI was not found on PATH. Install Claude Code, or change agentType.'); + } + + ignoreStateDir(repo); + const stateDir = join(repo, '.fsc', 'analysis'); + mkdirSync(stateDir, { recursive: true }); + const tasksPath = join(stateDir, 'tasks.json'); + const promptPath = join(stateDir, 'codeAnalyzerPrompt.txt'); + rmSync(tasksPath, { force: true }); + + const workStyle = await getWorkStyleDescription(config.workStyle || WorkStyle.DEFAULT, { + customLabel: config.customizedWorkStyle, + }); + const codingStyle = getCodingStyle(config.codingStyleLevel || 0); + writeFileSync(promptPath, analyzerPrompt(workStyle, codingStyle, config, tasksPath), 'utf-8'); + + const scratch = join(stateDir, `wt-${Date.now()}`); + const add = git(['worktree', 'add', '--detach', scratch, 'HEAD'], repo); + if (!add.ok) throw new Error(`Could not create the analysis worktree: ${add.out.trim()}`); + + try { + const seconds = config.analysisTimeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; + const proc = Bun.spawn({ + cmd: [ + 'claude', + '-p', + `All the task descriptions are in ${promptPath}. Read it and do what it says.`, + '--allowedTools', + ALLOWED_TOOLS, + '--permission-mode', + 'bypassPermissions', + ], + cwd: scratch, + env: { ...agentEnv(config), IS_SANDBOX: '1' }, + stdout: 'pipe', + stderr: 'pipe', + }); + const timer = setTimeout(() => proc.kill(), seconds * 1000); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + clearTimeout(timer); + + if (!existsSync(tasksPath)) { + const why = code === 0 ? 'the agent finished without writing the task file' : `the agent exited with code ${code}`; + const tail = (stderr || stdout).trim().split('\n').slice(-5).join('\n'); + throw new Error(`Analysis produced no tasks: ${why}.${tail ? `\n${tail}` : ''}`); + } + const json = trimJSONObjectArray(readFileSync(tasksPath, 'utf-8')); + let tasks: Task[]; + try { + tasks = JSON.parse(json); + } catch (e) { + throw new Error(`The agent wrote a task file that is not valid JSON (${tasksPath}): ${e instanceof Error ? e.message : e}`); + } + if (!Array.isArray(tasks) || tasks.length === 0) { + throw new Error(`The agent found no tasks (${tasksPath}).`); + } + return tasks; + } finally { + // Cleanup is best effort: on Windows the agent can still hold the directory for a moment, + // and a failed cleanup must never hide the analysis result (or its real error). + git(['worktree', 'remove', '--force', scratch], repo); + try { + rmSync(scratch, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch { + console.warn(`Could not remove ${scratch}; delete it manually.`); + } + git(['worktree', 'prune'], repo); + } +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7c3783b..6cd7e44 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -46,6 +46,8 @@ export interface Config { */ anthropicAPIKey?: string; anthropicAPIKeyExportNeeded?: boolean; + /** Native analysis time limit in seconds (default 1200). */ + analysisTimeoutSeconds?: number; anthropicAPIBaseUrl?: string; /** @@ -186,6 +188,8 @@ export interface Config { * @default 1 */ maxParallelAgents?: number; + /** Native (worktree) mode: how many agents run at once. Default 5. */ + maxParallelWorktrees?: number; /** * Directory where progress.txt and agent lock files are stored. diff --git a/packages/core/src/configReader.ts b/packages/core/src/configReader.ts index 47c9417..b890b1c 100644 --- a/packages/core/src/configReader.ts +++ b/packages/core/src/configReader.ts @@ -102,7 +102,7 @@ export class ConfigReader { if (this.throwOnMissing) { throw new Error(`Configuration file not found at: ${this.configPath}`); } - console.warn(`Configuration file not found at: ${this.configPath}. Using default configuration.`); + // No user config file is the normal first-run state; defaults apply silently. } // Read supplementary project-level config if it exists and is enabled @@ -257,12 +257,10 @@ export class ConfigReader { const supplementaryConfigPath = path.resolve(process.cwd(), '.fsc', 'config.json'); if (!fs.existsSync(supplementaryConfigPath)) { - console.log(`Supplementary configuration file not found at: ${supplementaryConfigPath}`); return {}; } - console.log(`Reading supplementary configuration from: ${supplementaryConfigPath}`); - + // Read and parse the supplementary config file const fileContent = fs.readFileSync(supplementaryConfigPath, 'utf8'); const parsedConfig = JSON.parse(fileContent); diff --git a/packages/core/src/execution/index.ts b/packages/core/src/execution/index.ts index ca7cafe..2deee2d 100644 --- a/packages/core/src/execution/index.ts +++ b/packages/core/src/execution/index.ts @@ -10,3 +10,5 @@ export * from "./collector"; export * from "./mapping"; export * from "./pool"; export * from "./sequencer"; +export * from "./scheduler"; +export * from "./state"; diff --git a/packages/core/src/execution/local.ts b/packages/core/src/execution/local.ts index 2267b4a..ee43a68 100644 --- a/packages/core/src/execution/local.ts +++ b/packages/core/src/execution/local.ts @@ -40,6 +40,7 @@ export class LocalExecution implements ExecutionStrategy { // 4. Spawn agent process let timedOut = false; + let spawnError: string | null = null; let exitCode: number | null = null; let stdoutChunks: string[] = []; let stderrChunks: string[] = []; @@ -89,7 +90,8 @@ export class LocalExecution implements ExecutionStrategy { } } catch (e: any) { if (!timedOut) { - stderrChunks.push(e?.message || String(e)); + spawnError = e?.message || String(e); + stderrChunks.push(spawnError!); } } finally { this.running.delete(unit.id); @@ -124,9 +126,11 @@ export class LocalExecution implements ExecutionStrategy { .filter(Boolean) .slice(-100); // Cap at 100 lines + // An agent that exits 0 but leaves no commit did nothing; do not report that as success. + const noChanges = !timedOut && exitCode === 0 && commitHash === null; const status = timedOut ? "timeout" as const - : (exitCode === 0 ? "success" as const : "failure" as const); + : (exitCode === 0 && !noChanges ? "success" as const : "failure" as const); const result: ExecutionResult = { workUnitId: unit.id, @@ -142,8 +146,12 @@ export class LocalExecution implements ExecutionStrategy { error: status !== "success" ? { message: timedOut ? `Timeout after ${unit.constraints.timeoutMs}ms` - : `Exit code ${exitCode}`, - category: timedOut ? "TRANSIENT" : "UNKNOWN", + : spawnError + ? `Could not start "${unit.agentConfig.command}": ${spawnError}` + : noChanges + ? "The agent finished but made no changes" + : `Exit code ${exitCode}`, + category: timedOut ? "TRANSIENT" : noChanges ? "QUALITY" : "UNKNOWN", } : undefined, }; diff --git a/packages/core/src/execution/mapping.ts b/packages/core/src/execution/mapping.ts index 56013e1..7e9bf05 100644 --- a/packages/core/src/execution/mapping.ts +++ b/packages/core/src/execution/mapping.ts @@ -38,6 +38,49 @@ export function buildTaskPrompt(task: Task): string { return prompt; } +/** Map config / CLI agent names to AGENT_CATALOG keys. Unknown names are an error, not a fallback. */ +export function normalizeAgentType(name: string): string { + const aliases: Record = { + "claude-code": "claude", + "gemini-cli": "gemini", + }; + const key = aliases[name] ?? name; + if (!(key in AGENT_CATALOG) || !(key in AGENT_BUILDERS)) { + const supported = Object.keys(AGENT_CATALOG).filter(k => k in AGENT_BUILDERS).join(", "); + throw new Error(`Unsupported agent "${name}" for worktree execution. Supported: ${supported}.`); + } + return key; +} + +/** Work unit that runs a specific agent (no cost-tier routing). */ +export function toWorkUnitForAgent( + task: Task, + agentName: string, + repoPath: string, + salaciaPlugin?: SalaciaPlugin, +): WorkUnit { + const type = normalizeAgentType(agentName); + const catalog = AGENT_CATALOG[type]; + const unit: WorkUnit = { + id: task.ID, + repoPath, + baseBranch: getCurrentBranch(repoPath), + taskPrompt: buildTaskPrompt(task), + agentConfig: { + type: type as AgentType, + command: catalog.command, + buildArgs: AGENT_BUILDERS[type], + costTier: catalog.costTier as CostTier, + memoryMB: catalog.memoryMB, + }, + constraints: { + timeoutMs: task.maxExecutionTimeMs ?? 900_000, + maxTokens: task.estimatedTokens, + }, + }; + return salaciaPlugin ? salaciaPlugin.enrichWorkUnit(unit, task) : unit; +} + export function toWorkUnit( task: Task, config: Config, diff --git a/packages/core/src/execution/scheduler.ts b/packages/core/src/execution/scheduler.ts index feed45d..83cb559 100644 --- a/packages/core/src/execution/scheduler.ts +++ b/packages/core/src/execution/scheduler.ts @@ -4,7 +4,7 @@ import { ConcurrencyPool } from "./concurrency"; import { LocalExecution } from "./local"; import { WorktreeManager } from "./worktree"; import { CommitSequencer, type SequencerResult } from "./sequencer"; -import { toWorkUnitByTier, toTaskResult } from "./mapping"; +import { toWorkUnitByTier, toWorkUnitForAgent, toTaskResult } from "./mapping"; import { gitExec } from "./gitExec"; import type { Task, TaskResult } from "../task"; import type { CostTierStrategy } from "../modelRouter"; @@ -16,6 +16,8 @@ export interface SchedulerConfig { localPoolSize: number; // default 24 maxConcurrency: number; // default 5 costStrategy: CostTierStrategy; // default "minimize-cost" + /** Run this agent for every task instead of cost-tier routing (e.g. "claude-code"). */ + agentType?: string; freeclawBase?: string; salacia?: SalaciaConfig & { experiment?: { enabled: boolean; splitRatio: number }; @@ -68,9 +70,9 @@ export class UnifiedScheduler { this.repoPath = repoPath; this.baseBranch = gitExec(["rev-parse", "--abbrev-ref", "HEAD"], repoPath).stdout.trim() || "main"; - // 1. Initialize pool - this.pool = new WorktreePool(repoPath, this.baseBranch, this.config.localPoolSize); - await this.pool.init(); + // 1. (no pool) LocalExecution creates one worktree + branch per task itself. The pool used to + // lease a slot and `checkout -b fsc/` in it, which made LocalExecution's own + // `worktree add -b fsc/` fail with "branch already exists" on every task. // 2. Salacia A/B experiment setup const salaciaConfig = this.config.salacia; @@ -98,26 +100,20 @@ export class UnifiedScheduler { journalPath: `${repoPath}/.fsc/journal`, }); - // Dispatch: lease worktree this.stateStore.transition(task.ID, "dispatched"); - const wt = await this.pool!.lease(task.ID); - this.stateStore.transition(task.ID, "dispatched", { worktreePath: wt.worktreePath }); - - // Prepare task branch - await this.pool!.prepareForTask(task.ID); // Build work unit (with optional salacia enrichment) - const unit = toWorkUnitByTier( - task, repoPath, this.config.costStrategy, this.config.freeclawBase, plugin - ); - // Override worktree path from pool + const unit = this.config.agentType + ? toWorkUnitForAgent(task, this.config.agentType, repoPath, plugin) + : toWorkUnitByTier(task, repoPath, this.config.costStrategy, this.config.freeclawBase, plugin); + // LocalExecution creates the worktree under repoPath unit.repoPath = repoPath; // Running — inject salacia plugin into execution this.stateStore.transition(task.ID, "running", { agentType: unit.agentConfig.type }); this.execution.salaciaPlugin = plugin.enabled ? plugin : undefined; - // Execute — use the pool's worktree, not create a new one + // Execute (creates its own worktree and branch) const result = await this.execution.execute({ ...unit, id: task.ID, @@ -153,9 +149,6 @@ export class UnifiedScheduler { } } catch (e: any) { this.stateStore.transition(task.ID, "failed", { error: e.message }); - } finally { - // Release worktree back to pool - try { await this.pool!.release(task.ID); } catch {} } }) ); @@ -202,8 +195,13 @@ export class UnifiedScheduler { experiment.save(`${repoPath}/.fsc`); } - // 5. Cleanup pool - await this.pool.destroy(); + // 5. Cleanup: task worktrees always; task branches only once their commit is on the base branch + for (const er of executionResults) { + gitExec(["worktree", "remove", "--force", er.worktreePath], repoPath); + if (mergeResults.some(m => m.taskId === er.workUnitId && m.success)) { + gitExec(["branch", "-D", er.branch], repoPath); + } + } const durationMs = Date.now() - startTime; const summary = this.stateStore.summary(); diff --git a/packages/core/src/execution/sequencer.ts b/packages/core/src/execution/sequencer.ts index 02d4c1e..64b294a 100644 --- a/packages/core/src/execution/sequencer.ts +++ b/packages/core/src/execution/sequencer.ts @@ -239,8 +239,9 @@ export class CommitSequencer { // ── Safety Checks ────────────────────────────────────────────── private preMergeChecks(commitHash: string): string | null { - // .fsc/ is our own runtime state (merge-history.json); it must not make the tree "dirty" - const status = gitExec(["status", "--porcelain", "--", ".", ":(exclude).fsc"], this.repoPath); + // Only tracked changes make the tree dirty: untracked files (logs, .fsc/ state) do not block a + // cherry-pick, and git itself refuses if one would be overwritten. + const status = gitExec(["status", "--porcelain", "--untracked-files=no", "--", ".", ":(exclude).fsc"], this.repoPath); if (status.stdout.trim()) return "working tree is dirty"; const exists = gitExec(["cat-file", "-t", commitHash], this.repoPath); diff --git a/packages/core/src/execution/types.ts b/packages/core/src/execution/types.ts index a51e5b0..312e1e7 100644 --- a/packages/core/src/execution/types.ts +++ b/packages/core/src/execution/types.ts @@ -65,7 +65,7 @@ export interface WorktreeInfo { // Pre-built agent arg builders export const AGENT_BUILDERS: Record = { claude: (prompt: string, cwd: string) => - ["-p", prompt, "--cwd", cwd, "--allowedTools", "Edit,Write,Bash,Read,Glob,Grep"], + ["-p", prompt, "--allowedTools", "Edit,Write,Bash,Read,Glob,Grep", "--permission-mode", "bypassPermissions"], codex: (prompt: string, cwd: string) => ["exec", "--approval-mode", "full-auto", "-w", cwd, prompt], gemini: (prompt: string, cwd: string) => diff --git a/packages/core/src/execution/worktree.ts b/packages/core/src/execution/worktree.ts index 61383c5..0c1165d 100644 --- a/packages/core/src/execution/worktree.ts +++ b/packages/core/src/execution/worktree.ts @@ -59,6 +59,21 @@ export class WorktreeManager { const worktreePath = path.resolve(parentDir, `${repoName}-fsc-${taskId}`); const branch = `fsc/${taskId}`; + // Leftovers from an earlier run: drop dangling worktree records, and a branch that has + // no unmerged work. A branch that still holds commits is the user's data: refuse. + gitExec(["worktree", "prune"], resolvedRepo); + if (gitExec(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], resolvedRepo).exitCode === 0) { + const ahead = gitExec(["rev-list", "--count", `${baseBranch}..${branch}`], resolvedRepo).stdout.trim(); + if (ahead !== "0") { + throw new Error( + `branch ${branch} already exists with ${ahead} commit(s) not in ${baseBranch} (an earlier run?). ` + + `Merge or delete it first: git branch -D ${branch}`, + ); + } + gitExec(["worktree", "remove", "--force", worktreePath], resolvedRepo); + gitExec(["branch", "-D", branch], resolvedRepo); + } + const result = gitExec( ["worktree", "add", "-b", branch, worktreePath, baseBranch], resolvedRepo, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9bebfda..3b6089c 100755 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ // Main engines export { default as analyzeCodebase } from './analyzer'; +export { analyzeCodebaseNative } from './analyzerNative'; export { TaskSolver } from './taskSolver'; export { TaskSolverManager } from './taskSolverManager'; export { CodeCommitter, type QualityGateHook, type TrustUpdateHook } from './codeCommitter'; diff --git a/packages/core/src/prompts/analyzerPrompt.ts b/packages/core/src/prompts/analyzerPrompt.ts index aa3a5d4..2c05598 100644 --- a/packages/core/src/prompts/analyzerPrompt.ts +++ b/packages/core/src/prompts/analyzerPrompt.ts @@ -12,7 +12,8 @@ import type {Config} from '../config'; export function analyzerPrompt( strWorkStyleDesription: string, strCodingStyleDesription: string, - config: Config + config: Config, + tasksPath: string = '/app/tasks.json' ) { return ` @@ -21,7 +22,7 @@ Now your task is to analyze the whole codebase and extract tasks that need to be Here are the rules: 1. DO NOT DO ANY CODE CHANGES OR MODIFICATIONS. 2. After generating the tasks, please make sure that the number of tasks is between ${config.minTasks ?? 1} and ${config.maxTasks ?? 10}. -3. After output the tasks in JSON format, please make sure that the JSON is valid and can be parsed by TypeScript. Then save the JSON file in it "/app/tasks.json". +3. After output the tasks in JSON format, please make sure that the JSON is valid and can be parsed by TypeScript. Then save the JSON file in it "${tasksPath}". 4. When creating the task, please make sure that the description is rich, instruction and actionable. Make sure that the description is specific and can be followed by a human, providing clear instructions on what needs to be done. Also please provide as many tasks as possible. Your supervisor will make the final decision on which tasks to include. 5. The final output should be a JSON array of tasks. Even if there is only one task, it should be wrapped in an array. 6. After JSON task array output, please double check the JSON format and syntax, and make sure that it can be parsed by TypeScript. diff --git a/packages/core/test/execution/mapping.test.ts b/packages/core/test/execution/mapping.test.ts index ae7a5f6..a36ea7f 100644 --- a/packages/core/test/execution/mapping.test.ts +++ b/packages/core/test/execution/mapping.test.ts @@ -60,3 +60,26 @@ describe("buildTaskPrompt", () => { expect(prompt).toContain("Commit your changes"); }); }); + +describe("normalizeAgentType", () => { + const { normalizeAgentType, toWorkUnitForAgent } = require("../../src/execution/mapping"); + + test("maps config names to catalog keys", () => { + expect(normalizeAgentType("claude-code")).toBe("claude"); + expect(normalizeAgentType("gemini-cli")).toBe("gemini"); + expect(normalizeAgentType("codex")).toBe("codex"); + }); + + test("unknown agents are an error, not a silent fallback to another agent", () => { + expect(() => normalizeAgentType("cursor")).toThrow(/Unsupported agent/); + expect(() => normalizeAgentType("nope")).toThrow(/Supported:/); + }); + + test("claude is launched unattended and without the nonexistent --cwd flag", () => { + const unit = toWorkUnitForAgent({ ID: "t", title: "x", description: "y", priority: 1 }, "claude-code", "."); + expect(unit.agentConfig.command).toBe("claude"); + const args = unit.agentConfig.buildArgs("do it", "/wt"); + expect(args).not.toContain("--cwd"); + expect(args).toContain("--permission-mode"); + }); +});