11
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// rolloutUUIDRegex matches the UUID at the tail of rollout-<ISO>-<UUID>.jsonl.
|
||||
// Matches v7 UUIDs (still 8-4-4-4-12 hex groups).
|
||||
var rolloutUUIDRegex = regexp.MustCompile(`-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$`)
|
||||
|
||||
// extractIDFromRolloutFilename tries to extract the thread_id UUID from a
|
||||
// rollout filename. Returns "" when the name doesn't match the expected pattern.
|
||||
func extractIDFromRolloutFilename(filename string) string {
|
||||
m := rolloutUUIDRegex.FindStringSubmatch(strings.ToLower(filename))
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// CodexSessionsDir returns the codex sessions directory for a given date:
|
||||
// <homeDir>/.codex/sessions/YYYY/MM/DD. The time.Time parameter makes this
|
||||
// testable without calling time.Now() inside the library.
|
||||
func CodexSessionsDir(homeDir string, t time.Time) string {
|
||||
return filepath.Join(homeDir, ".codex", "sessions",
|
||||
fmt.Sprintf("%04d", t.Year()),
|
||||
fmt.Sprintf("%02d", t.Month()),
|
||||
fmt.Sprintf("%02d", t.Day()),
|
||||
)
|
||||
}
|
||||
|
||||
// CodexSessionWatchOpts configures WatchForNewCodexSession.
|
||||
type CodexSessionWatchOpts struct {
|
||||
SessionsDir string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// WatchForNewCodexSession watches SessionsDir for the next new rollout-*.jsonl
|
||||
// file. It prefers the UUID embedded in the filename; falls back to parsing
|
||||
// payload.id from the first JSON line. Returns ("", nil) on timeout or context
|
||||
// cancellation. MkdirAll ensures the leaf dir exists so the first-of-day
|
||||
// session (when codex creates the YYYY/MM/DD dir from scratch) is captured.
|
||||
//
|
||||
// Known limitation: a session file landing in a different day-dir than the one
|
||||
// watched (watcher started just before midnight) will not be caught.
|
||||
func WatchForNewCodexSession(ctx context.Context, opts CodexSessionWatchOpts) (string, error) {
|
||||
// Ensure the target dir exists so fsnotify can watch it even before codex
|
||||
// creates it for the first time today.
|
||||
if err := os.MkdirAll(opts.SessionsDir, 0o755); err != nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
if err := watcher.Add(opts.SessionsDir); err != nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Deduplicate: fsnotify may fire multiple events for the same file.
|
||||
seen := make(map[string]bool)
|
||||
|
||||
timer := time.NewTimer(opts.Timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", nil
|
||||
case <-timer.C:
|
||||
return "", nil
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
if event.Op&(fsnotify.Create|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
name := filepath.Base(event.Name)
|
||||
if !strings.HasPrefix(name, "rollout-") || !strings.HasSuffix(name, ".jsonl") {
|
||||
continue
|
||||
}
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
// Confirm the file exists (rename fires for deletes too).
|
||||
if _, err := os.Stat(event.Name); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefer UUID from filename; fall back to first-line JSON payload.id.
|
||||
if id := extractIDFromRolloutFilename(name); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
if id := extractIDFromFirstLine(event.Name); id != "" {
|
||||
return id, nil
|
||||
}
|
||||
// Could not extract an id — keep watching.
|
||||
|
||||
case _, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sessionMetaLine is the shape of the first JSON line in a codex rollout file.
|
||||
type sessionMetaLine struct {
|
||||
Payload *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"payload"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// extractIDFromFirstLine reads the first JSON line of filePath and returns the
|
||||
// thread id found in payload.id, thread_id, session_id, or id (in that order).
|
||||
func extractIDFromFirstLine(filePath string) string {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
if !scanner.Scan() {
|
||||
return ""
|
||||
}
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var meta sessionMetaLine
|
||||
if err := json.Unmarshal([]byte(line), &meta); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if meta.Payload != nil && meta.Payload.ID != "" {
|
||||
return meta.Payload.ID
|
||||
}
|
||||
if meta.ThreadID != "" {
|
||||
return meta.ThreadID
|
||||
}
|
||||
if meta.SessionID != "" {
|
||||
return meta.SessionID
|
||||
}
|
||||
return meta.ID
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- pure UUID extraction tests ---
|
||||
|
||||
func TestExtractIDFromRolloutFilename(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"rollout-2026-04-27T02-57-26-019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a.jsonl",
|
||||
"019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a",
|
||||
},
|
||||
{
|
||||
"rollout-2026-01-01T00-00-00-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl",
|
||||
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
},
|
||||
{
|
||||
// No UUID — should return empty string.
|
||||
"rollout-2026-04-27T02-57-26.jsonl",
|
||||
"",
|
||||
},
|
||||
{
|
||||
// Not a rollout file.
|
||||
"session.jsonl",
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := extractIDFromRolloutFilename(tc.name)
|
||||
if got != tc.want {
|
||||
t.Errorf("extractIDFromRolloutFilename(%q) = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexSessionsDir(t *testing.T) {
|
||||
ts := time.Date(2026, 4, 27, 2, 57, 26, 0, time.UTC)
|
||||
got := CodexSessionsDir("/home/alice", ts)
|
||||
want := "/home/alice/.codex/sessions/2026/04/27"
|
||||
if got != want {
|
||||
t.Errorf("CodexSessionsDir = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- watcher tests ---
|
||||
|
||||
func TestWatchForNewCodexSession_DetectsRolloutByFilename(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
ch := make(chan struct {
|
||||
id string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
|
||||
SessionsDir: dir,
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
ch <- struct {
|
||||
id string
|
||||
err error
|
||||
}{id, err}
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
uuid := "019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a"
|
||||
fname := "rollout-2026-04-27T02-57-26-" + uuid + ".jsonl"
|
||||
f, err := os.Create(filepath.Join(dir, fname))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
r := <-ch
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
if r.id != uuid {
|
||||
t.Errorf("got id %q, want %q", r.id, uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchForNewCodexSession_FallbackToFirstLine(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
ch := make(chan struct {
|
||||
id string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
|
||||
SessionsDir: dir,
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
ch <- struct {
|
||||
id string
|
||||
err error
|
||||
}{id, err}
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// File name without a UUID — triggers first-line fallback.
|
||||
wantID := "fallback-thread-id-0001"
|
||||
fname := "rollout-2026-04-27T02-57-26-no-uuid-here.jsonl"
|
||||
content := `{"timestamp":"2026-04-27T02:57:26Z","type":"session_meta","payload":{"id":"` + wantID + `","cwd":"/tmp"}}` + "\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, fname), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := <-ch
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
if r.id != wantID {
|
||||
t.Errorf("got id %q, want %q", r.id, wantID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchForNewCodexSession_TimeoutReturnsEmptyNil(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
|
||||
SessionsDir: dir,
|
||||
Timeout: 200 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != "" {
|
||||
t.Errorf("expected empty string on timeout, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWatchForNewCodexSession_FirstOfDay proves the primary fix: when
|
||||
// SessionsDir does not exist at call time (first codex session of the day),
|
||||
// MkdirAll creates it and the watcher still captures the rollout file.
|
||||
func TestWatchForNewCodexSession_FirstOfDay(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
// SessionsDir does NOT exist yet — simulates first-of-day scenario.
|
||||
sessDir := filepath.Join(base, "2099", "12", "31")
|
||||
|
||||
ctx := context.Background()
|
||||
ch := make(chan struct {
|
||||
id string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
|
||||
SessionsDir: sessDir,
|
||||
Timeout: 300 * time.Millisecond,
|
||||
})
|
||||
ch <- struct {
|
||||
id string
|
||||
err error
|
||||
}{id, err}
|
||||
}()
|
||||
|
||||
// Wait for watcher to start and MkdirAll to create the dir.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
uuid := "019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a"
|
||||
fname := "rollout-2099-12-31T00-00-00-" + uuid + ".jsonl"
|
||||
f, err := os.Create(filepath.Join(sessDir, fname))
|
||||
if err != nil {
|
||||
t.Fatalf("create rollout file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
r := <-ch
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
if r.id != uuid {
|
||||
t.Errorf("got id %q, want %q (first-of-day session not captured)", r.id, uuid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWatchForNewCodexSession_MkdirAllFailReturnsEmptyNil verifies that when
|
||||
// MkdirAll itself cannot create the dir (e.g. path under a file), the watcher
|
||||
// returns ("", nil) without panicking.
|
||||
func TestWatchForNewCodexSession_MkdirAllFailReturnsEmptyNil(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
// Create a plain file so MkdirAll on a sub-path will fail.
|
||||
blocker := filepath.Join(base, "blocker")
|
||||
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
|
||||
SessionsDir: filepath.Join(blocker, "subdir"),
|
||||
Timeout: 300 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != "" {
|
||||
t.Errorf("expected empty string when MkdirAll fails, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchForNewCodexSession_ContextCancel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ch := make(chan struct {
|
||||
id string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
id, err := WatchForNewCodexSession(ctx, CodexSessionWatchOpts{
|
||||
SessionsDir: dir,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
ch <- struct {
|
||||
id string
|
||||
err error
|
||||
}{id, err}
|
||||
}()
|
||||
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
r := <-ch
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error on cancel: %v", r.err)
|
||||
}
|
||||
if r.id != "" {
|
||||
t.Errorf("expected empty id on cancel, got %q", r.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClaudeTimeoutErrorUnwrapsToClaudeError(t *testing.T) {
|
||||
err := error(&ClaudeTimeoutError{ClaudeError{Msg: "timeout", Stderr: "boom"}})
|
||||
|
||||
var ce *ClaudeError
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatal("errors.As(*ClaudeError) should match a *ClaudeTimeoutError")
|
||||
}
|
||||
if ce.Msg != "timeout" {
|
||||
t.Errorf("unwrapped Msg = %q, want %q", ce.Msg, "timeout")
|
||||
}
|
||||
|
||||
var te *ClaudeTimeoutError
|
||||
if !errors.As(err, &te) {
|
||||
t.Fatal("errors.As(*ClaudeTimeoutError) should still match")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClaudeProfile represents a Claude settings profile discovered on disk.
|
||||
type ClaudeProfile struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
// ListClaudeProfiles scans dir for *.json files and returns them sorted by Name.
|
||||
// A missing or unreadable dir returns an empty slice without error.
|
||||
func ListClaudeProfiles(dir string) ([]ClaudeProfile, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return []ClaudeProfile{}, nil
|
||||
}
|
||||
var profiles []ClaudeProfile
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(e.Name(), ".json")
|
||||
profiles = append(profiles, ClaudeProfile{
|
||||
Name: name,
|
||||
Path: filepath.Join(dir, e.Name()),
|
||||
})
|
||||
}
|
||||
sort.Slice(profiles, func(i, j int) bool {
|
||||
return profiles[i].Name < profiles[j].Name
|
||||
})
|
||||
return profiles, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListClaudeProfiles_basic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"beta.json", "alpha.json", "gamma.json"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// non-.json files should be ignored
|
||||
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// subdirs should be ignored
|
||||
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
profiles, err := ListClaudeProfiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(profiles) != 3 {
|
||||
t.Fatalf("want 3 profiles, got %d", len(profiles))
|
||||
}
|
||||
wantNames := []string{"alpha", "beta", "gamma"}
|
||||
for i, p := range profiles {
|
||||
if p.Name != wantNames[i] {
|
||||
t.Errorf("profiles[%d].Name = %q, want %q", i, p.Name, wantNames[i])
|
||||
}
|
||||
wantPath := filepath.Join(dir, wantNames[i]+".json")
|
||||
if p.Path != wantPath {
|
||||
t.Errorf("profiles[%d].Path = %q, want %q", i, p.Path, wantPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListClaudeProfiles_emptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
profiles, err := ListClaudeProfiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(profiles) != 0 {
|
||||
t.Fatalf("want 0 profiles, got %d", len(profiles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListClaudeProfiles_missingDir(t *testing.T) {
|
||||
profiles, err := ListClaudeProfiles("/nonexistent/path/does/not/exist")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(profiles) != 0 {
|
||||
t.Fatalf("want 0 profiles, got %d", len(profiles))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
|
||||
"superwork-tui/internal/config"
|
||||
)
|
||||
|
||||
//go:embed prompts/brainstorm.md
|
||||
var defaultBrainstormPrompt string
|
||||
|
||||
//go:embed prompts/brainstorm-continue.md
|
||||
var defaultBrainstormContinuePrompt string
|
||||
|
||||
//go:embed prompts/implement-plan.md
|
||||
var defaultImplementPlanPrompt string
|
||||
|
||||
//go:embed prompts/review.md
|
||||
var defaultReviewPrompt string
|
||||
|
||||
func BrainstormPrompt(s *config.Settings, vars struct{ UserRequest, Nonce string }) string {
|
||||
tpl := s.BrainstormPrompt
|
||||
if tpl == "" {
|
||||
tpl = defaultBrainstormPrompt
|
||||
}
|
||||
return strings.NewReplacer("{userRequest}", vars.UserRequest, "{nonce}", vars.Nonce).Replace(tpl)
|
||||
}
|
||||
|
||||
func BrainstormContinuePrompt(s *config.Settings, vars struct{ IssueNumber string }) string {
|
||||
tpl := s.BrainstormContinuePrompt
|
||||
if tpl == "" {
|
||||
tpl = defaultBrainstormContinuePrompt
|
||||
}
|
||||
return strings.NewReplacer("{issueNumber}", vars.IssueNumber).Replace(tpl)
|
||||
}
|
||||
|
||||
func ImplementPlanPrompt(s *config.Settings, vars struct{ PlanFile, IssueNumber string }) string {
|
||||
tpl := s.ImplementPlanPrompt
|
||||
if tpl == "" {
|
||||
tpl = defaultImplementPlanPrompt
|
||||
}
|
||||
return strings.NewReplacer("{planFile}", vars.PlanFile, "{issueNumber}", vars.IssueNumber).Replace(tpl)
|
||||
}
|
||||
|
||||
func ReviewPrompt(s *config.Settings, vars struct{ PrNumber string }) string {
|
||||
tpl := s.ReviewPrompt
|
||||
if tpl == "" {
|
||||
tpl = defaultReviewPrompt
|
||||
}
|
||||
return strings.NewReplacer("{prNumber}", vars.PrNumber).Replace(tpl)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/superpowers:brainstorming 讨论下 {issueNumber} 号工单
|
||||
|
||||
用tea命令找工单
|
||||
|
||||
## 后续 marker 维护(本会话有效)
|
||||
|
||||
spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支。创建后追加对应 marker:
|
||||
|
||||
```
|
||||
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
|
||||
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
|
||||
```
|
||||
|
||||
## 严禁擅自继续
|
||||
|
||||
如果你创建/更新了 spec/plan 文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
|
||||
|
||||
特别地:
|
||||
|
||||
- 不要创建分支
|
||||
- 不要切换分支,包括 `git checkout` / `git switch`
|
||||
- 不要修改当前主 worktree 所在分支
|
||||
- spec/plan 一律在 main 分支(当前主 worktree)创建
|
||||
- 不要修改任何代码文件
|
||||
- 不要创建 PR
|
||||
- 不要调用 gitea 其他写操作(除上面的 marker 同步)
|
||||
|
||||
只讨论需求与 spec/plan,必要时用 spx issue marker 更新 marker。
|
||||
@@ -0,0 +1,48 @@
|
||||
接下来我要实现下面这样的功能: {userRequest}
|
||||
|
||||
你的任务:用 spx CLI 创建一个 Gitea 工单。spx 用法参考 `using-spx-cli` skill。
|
||||
|
||||
## 工单格式
|
||||
|
||||
先检查仓库的 `.gitea/ISSUE_TEMPLATE/` 目录:
|
||||
|
||||
- 有模板(`.md` 或 `.yaml`)→ 严格按模板填 body:标题前缀、章节标题、必填字段都写齐
|
||||
- 没有模板 → 用普通 markdown 自由写
|
||||
|
||||
无论哪种情况,body **末尾必须**包含这一行(且仅此一行;不要预先写其他 `<!-- spx:* -->` marker):
|
||||
|
||||
```
|
||||
<!-- spx:nonce={nonce} -->
|
||||
```
|
||||
|
||||
## 创建
|
||||
|
||||
把 body 写到 `/tmp/issue-body.md`,调 spx:
|
||||
|
||||
```
|
||||
opencli spx issue create --title "<标题>" --body-file /tmp/issue-body.md
|
||||
```
|
||||
|
||||
spx 返回工单号 + html_url。记下工单号,后续命令用。
|
||||
|
||||
## 后续 marker 维护(本会话有效)
|
||||
|
||||
**只有**当你真正创建了 spec 或 plan 文件后才追加对应 marker。路径形如 `docs/superpowers/specs/<slug>/spec.md` 或 `docs/superpowers/plans/<slug>/plan.md`。spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支:
|
||||
|
||||
```
|
||||
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
|
||||
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
|
||||
```
|
||||
|
||||
spx 自动找到对应行替换或追加,保留所有其他 marker。**不要自己手写 `<!-- spx:* -->` 行**。
|
||||
|
||||
## 严禁擅自继续
|
||||
|
||||
成功创建工单后**立即停下**汇报:输出工单号 + html_url 即可。
|
||||
|
||||
特别地:
|
||||
|
||||
- 不要创建分支
|
||||
- 不要切换分支
|
||||
- 不要修改当前主 worktree 所在分支
|
||||
- spec/plan 一律在 main 分支(当前主 worktree)创建
|
||||
@@ -0,0 +1,13 @@
|
||||
/goal 使用子代理全程绿灯实施 @{planFile},发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
|
||||
|
||||
**严禁合并 PR**:你的职责只到发起 PR 为止,后续审查反馈到了请继续修复并 push,永远不要执行 `tea pulls merge` 或任何合并操作。
|
||||
|
||||
## 数据库迁移(alembic)
|
||||
|
||||
多个 feature worktree 共用同一台 dev DB(`192.168.1.4:5433`)。任何 worktree 直接在共享库上跑迁移,都会让别的分支 `alembic upgrade` 崩(DB 里记着的 revision 在对方代码里不存在)。所以本会话:
|
||||
|
||||
- 只用 `uv run alembic revision --autogenerate -m "..."` 生成迁移文件,**绝不手写 revision 文件**。
|
||||
- 生成后立即 `git add` 提交迁移文件,纳入 PR。
|
||||
- **绝不**对共享 dev DB(`192.168.1.4:5433`)或 prod 执行 `alembic upgrade` / `downgrade` 等任何改库命令。
|
||||
- 需要运行时验证迁移,就起一个一次性独立库(本地 docker postgres 或唯一命名的 scratch 库),在它上面 upgrade,验证完即丢弃,不要留痕。
|
||||
- 共享 dev DB 与 prod 的迁移合并后由用户统一执行,时机由用户决定。
|
||||
@@ -0,0 +1,18 @@
|
||||
/review 审查这个仓库的 PR #{prNumber}。
|
||||
|
||||
用 `tea pulls {prNumber}` 看 PR 元信息(标题/描述/分支)。看代码差异用 git(当前目录就是 PR 分支的 worktree):先 `git fetch origin main`,再 `git diff origin/main...HEAD`(概览可加 --stat)。注意 tea 没有 `diff` 子命令,不要尝试 `tea pulls diff`。
|
||||
|
||||
## 提交审查意见
|
||||
|
||||
把审查意见(markdown 格式)写到 `/tmp/review-{prNumber}.md`,调 spx:
|
||||
|
||||
```
|
||||
opencli spx pr review-comment --pr {prNumber} --body-file /tmp/review-{prNumber}.md
|
||||
|
||||
## 严禁
|
||||
|
||||
- 不要在审查意见里建议"合并 PR"或"merge"
|
||||
- 不要执行 `tea pulls merge` 或任何合并命令
|
||||
- 不要 push 到 main / dev 分支
|
||||
|
||||
合并权完全在用户手上,你的工作只是指出问题或确认通过。
|
||||
@@ -0,0 +1,75 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"superwork-tui/internal/config"
|
||||
)
|
||||
|
||||
func TestBrainstormPrompt_SubstitutesVars(t *testing.T) {
|
||||
s := config.DefaultSettings()
|
||||
out := BrainstormPrompt(s, struct{ UserRequest, Nonce string }{"add dark mode", "deadbeef"})
|
||||
if !strings.Contains(out, "add dark mode") {
|
||||
t.Error("output should contain userRequest")
|
||||
}
|
||||
if !strings.Contains(out, "deadbeef") {
|
||||
t.Error("output should contain nonce")
|
||||
}
|
||||
if strings.Contains(out, "{userRequest}") {
|
||||
t.Error("placeholder {userRequest} should be replaced")
|
||||
}
|
||||
if strings.Contains(out, "{nonce}") {
|
||||
t.Error("placeholder {nonce} should be replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrainstormPrompt_SettingsOverride(t *testing.T) {
|
||||
s := config.DefaultSettings()
|
||||
s.BrainstormPrompt = "custom {userRequest}"
|
||||
out := BrainstormPrompt(s, struct{ UserRequest, Nonce string }{"fix login", "xx"})
|
||||
if out != "custom fix login" {
|
||||
t.Errorf("expected 'custom fix login', got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrainstormPrompt_DefaultFallback(t *testing.T) {
|
||||
s := config.DefaultSettings()
|
||||
out := BrainstormPrompt(s, struct{ UserRequest, Nonce string }{"req", "n"})
|
||||
if out == "" {
|
||||
t.Error("expected non-empty output from embedded default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrainstormContinuePrompt_SubstitutesVars(t *testing.T) {
|
||||
s := config.DefaultSettings()
|
||||
out := BrainstormContinuePrompt(s, struct{ IssueNumber string }{"42"})
|
||||
if !strings.Contains(out, "42") {
|
||||
t.Error("output should contain issue number")
|
||||
}
|
||||
if strings.Contains(out, "{issueNumber}") {
|
||||
t.Error("placeholder {issueNumber} should be replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImplementPlanPrompt_SubstitutesVars(t *testing.T) {
|
||||
s := config.DefaultSettings()
|
||||
out := ImplementPlanPrompt(s, struct{ PlanFile, IssueNumber string }{"docs/plan.md", "7"})
|
||||
if !strings.Contains(out, "docs/plan.md") {
|
||||
t.Error("output should contain planFile")
|
||||
}
|
||||
if !strings.Contains(out, "7") {
|
||||
t.Error("output should contain issueNumber")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewPrompt_SubstitutesVars(t *testing.T) {
|
||||
s := config.DefaultSettings()
|
||||
out := ReviewPrompt(s, struct{ PrNumber string }{"13"})
|
||||
if !strings.Contains(out, "13") {
|
||||
t.Error("output should contain prNumber")
|
||||
}
|
||||
if strings.Contains(out, "{prNumber}") {
|
||||
t.Error("placeholder {prNumber} should be replaced")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const reviewDefaultTimeoutMs = 300_000
|
||||
|
||||
// ReviewOpts configures a RunReview call.
|
||||
type ReviewOpts struct {
|
||||
WorkspaceRoot string
|
||||
Prompt string
|
||||
TimeoutMs int // 0 → reviewDefaultTimeoutMs
|
||||
OnThreadID func(string)
|
||||
}
|
||||
|
||||
// RunCodex is the injectable runner. Tests replace this to avoid needing a real codex binary.
|
||||
var RunCodex func(ctx context.Context, cwd string, args []string) (stdout, stderr []byte, err error) = defaultRunCodex
|
||||
|
||||
func defaultRunCodex(ctx context.Context, cwd string, args []string) ([]byte, []byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "codex", args...)
|
||||
if cwd != "" {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
return stdout.Bytes(), stderr.Bytes(), err
|
||||
}
|
||||
|
||||
// RunReview runs `codex exec review --json` and calls opts.OnThreadID with the
|
||||
// first thread_id/session_id found in the NDJSON output.
|
||||
// Errors are logged, not returned (fire-and-forget semantics).
|
||||
func RunReview(ctx context.Context, opts ReviewOpts) error {
|
||||
timeoutMs := opts.TimeoutMs
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = reviewDefaultTimeoutMs
|
||||
}
|
||||
tCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"exec",
|
||||
"-c", "model_reasoning_effort=xhigh",
|
||||
"review",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--json",
|
||||
opts.Prompt,
|
||||
}
|
||||
|
||||
stdout, stderr, err := RunCodex(tCtx, opts.WorkspaceRoot, args)
|
||||
if err != nil {
|
||||
slog.Warn("codex exec review failed", "err", err, "stderr", string(stderr))
|
||||
return nil // fire-and-forget: never propagate
|
||||
}
|
||||
|
||||
if opts.OnThreadID != nil {
|
||||
scanForThreadID(stdout, opts.OnThreadID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type threadEvent struct {
|
||||
ThreadID string `json:"thread_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
var uuidRe = regexp.MustCompile(`(?i)^[0-9a-f-]{16,}$`)
|
||||
|
||||
func scanForThreadID(ndjson []byte, onThreadID func(string)) {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(ndjson))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(bytes.TrimSpace(line)) == 0 {
|
||||
continue
|
||||
}
|
||||
var ev threadEvent
|
||||
if err := json.Unmarshal(line, &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
id := ""
|
||||
switch {
|
||||
case ev.ThreadID != "":
|
||||
id = ev.ThreadID
|
||||
case ev.SessionID != "":
|
||||
id = ev.SessionID
|
||||
case uuidRe.MatchString(ev.ID):
|
||||
id = ev.ID
|
||||
}
|
||||
if id != "" {
|
||||
onThreadID(id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package cc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"superwork-tui/internal/cc"
|
||||
)
|
||||
|
||||
func TestRunReview_CallsOnThreadID(t *testing.T) {
|
||||
ndjson := strings.Join([]string{
|
||||
`{"type":"thread.started","thread_id":"tid-abc-123"}`,
|
||||
`{"type":"message","content":"hello"}`,
|
||||
}, "\n") + "\n"
|
||||
|
||||
fakeRunner := func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
|
||||
return []byte(ndjson), nil, nil
|
||||
}
|
||||
|
||||
var gotID string
|
||||
opts := cc.ReviewOpts{
|
||||
WorkspaceRoot: "/tmp",
|
||||
Prompt: "review this",
|
||||
OnThreadID: func(id string) { gotID = id },
|
||||
}
|
||||
|
||||
oldRunner := cc.RunCodex
|
||||
cc.RunCodex = fakeRunner
|
||||
defer func() { cc.RunCodex = oldRunner }()
|
||||
|
||||
if err := cc.RunReview(context.Background(), opts); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotID != "tid-abc-123" {
|
||||
t.Errorf("want tid-abc-123, got %q", gotID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReview_NoThreadStarted_NotCalled(t *testing.T) {
|
||||
ndjson := `{"type":"message","content":"hello"}` + "\n"
|
||||
fakeRunner := func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
|
||||
return []byte(ndjson), nil, nil
|
||||
}
|
||||
|
||||
called := false
|
||||
opts := cc.ReviewOpts{
|
||||
WorkspaceRoot: "/tmp",
|
||||
Prompt: "review this",
|
||||
OnThreadID: func(id string) { called = true },
|
||||
}
|
||||
|
||||
oldRunner := cc.RunCodex
|
||||
cc.RunCodex = fakeRunner
|
||||
defer func() { cc.RunCodex = oldRunner }()
|
||||
|
||||
if err := cc.RunReview(context.Background(), opts); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("OnThreadID should not be called when no thread.started event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReview_SessionIDFallback(t *testing.T) {
|
||||
// older codex uses session_id
|
||||
ndjson := `{"type":"session.created","session_id":"sess-xyz"}` + "\n"
|
||||
fakeRunner := func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
|
||||
return []byte(ndjson), nil, nil
|
||||
}
|
||||
|
||||
var gotID string
|
||||
opts := cc.ReviewOpts{
|
||||
WorkspaceRoot: "/tmp",
|
||||
Prompt: "review",
|
||||
OnThreadID: func(id string) { gotID = id },
|
||||
}
|
||||
|
||||
oldRunner := cc.RunCodex
|
||||
cc.RunCodex = fakeRunner
|
||||
defer func() { cc.RunCodex = oldRunner }()
|
||||
|
||||
cc.RunReview(context.Background(), opts) //nolint
|
||||
if gotID != "sess-xyz" {
|
||||
t.Errorf("want sess-xyz, got %q", gotID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReview_RunnerError_NoError(t *testing.T) {
|
||||
// fire-and-forget: runner error should NOT propagate
|
||||
fakeRunner := func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
|
||||
return nil, []byte("exec error"), fmt.Errorf("exec failed")
|
||||
}
|
||||
|
||||
opts := cc.ReviewOpts{WorkspaceRoot: "/tmp", Prompt: "x"}
|
||||
oldRunner := cc.RunCodex
|
||||
cc.RunCodex = fakeRunner
|
||||
defer func() { cc.RunCodex = oldRunner }()
|
||||
|
||||
// Should not panic or return error — fire-and-forget
|
||||
cc.RunReview(context.Background(), opts)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// EncodeCwdForProjectsDir converts an absolute path to claude's projects-dir
|
||||
// encoding: both '/' and '.' are replaced with '-'.
|
||||
func EncodeCwdForProjectsDir(absPath string) string {
|
||||
return strings.NewReplacer("/", "-", ".", "-").Replace(absPath)
|
||||
}
|
||||
|
||||
// ClaudeProjectsDir returns the claude transcript directory for a given cwd:
|
||||
// <homeDir>/.claude/projects/<encoded-cwd>.
|
||||
func ClaudeProjectsDir(homeDir, cwd string) string {
|
||||
return filepath.Join(homeDir, ".claude", "projects", EncodeCwdForProjectsDir(cwd))
|
||||
}
|
||||
|
||||
// SessionWatchOpts configures WatchForNewSession.
|
||||
type SessionWatchOpts struct {
|
||||
ProjectsDir string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// WatchForNewSession watches ProjectsDir for the first new .jsonl file that
|
||||
// appears after the call, returning its basename without the .jsonl suffix
|
||||
// (the claude session id). Returns ("", nil) on timeout or context cancellation,
|
||||
// mirroring the TypeScript null-on-timeout semantics. MkdirAll ensures the
|
||||
// target dir exists so a missing claude projects dir is handled symmetrically
|
||||
// with the codex watcher — the first session is captured even if the dir was
|
||||
// absent at call time.
|
||||
func WatchForNewSession(ctx context.Context, opts SessionWatchOpts) (string, error) {
|
||||
// Ensure the target dir exists before snapshotting and watching.
|
||||
if err := os.MkdirAll(opts.ProjectsDir, 0o755); err != nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Snapshot existing .jsonl files so we only react to truly new ones.
|
||||
snapshot, err := snapshotJSONL(opts.ProjectsDir)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
if err := watcher.Add(opts.ProjectsDir); err != nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(opts.Timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", nil
|
||||
case <-timer.C:
|
||||
return "", nil
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
if event.Op&(fsnotify.Create|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
name := filepath.Base(event.Name)
|
||||
if !strings.HasSuffix(name, ".jsonl") {
|
||||
continue
|
||||
}
|
||||
if snapshot[name] {
|
||||
continue
|
||||
}
|
||||
// Confirm the file actually exists (rename fires for deletes too).
|
||||
if _, err := os.Stat(event.Name); err != nil {
|
||||
continue
|
||||
}
|
||||
return strings.TrimSuffix(name, ".jsonl"), nil
|
||||
case _, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
// Non-fatal watcher error; keep watching.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotJSONL returns the set of .jsonl filenames currently in dir.
|
||||
func snapshotJSONL(dir string) (map[string]bool, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap := make(map[string]bool, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") {
|
||||
snap[e.Name()] = true
|
||||
}
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- pure encoding tests ---
|
||||
|
||||
func TestEncodeCwdForProjectsDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
cwd string
|
||||
want string
|
||||
}{
|
||||
{"/home/user/project", "-home-user-project"},
|
||||
{"/tmp/my.project", "-tmp-my-project"},
|
||||
{"/a/b/c", "-a-b-c"},
|
||||
{"/single", "-single"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := EncodeCwdForProjectsDir(tc.cwd)
|
||||
if got != tc.want {
|
||||
t.Errorf("EncodeCwdForProjectsDir(%q) = %q, want %q", tc.cwd, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeProjectsDir(t *testing.T) {
|
||||
got := ClaudeProjectsDir("/home/alice", "/home/alice/myrepo")
|
||||
want := "/home/alice/.claude/projects/-home-alice-myrepo"
|
||||
if got != want {
|
||||
t.Errorf("ClaudeProjectsDir = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- watcher tests ---
|
||||
|
||||
func TestWatchForNewSession_DetectsNewFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
type result struct {
|
||||
id string
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
id, err := WatchForNewSession(ctx, SessionWatchOpts{
|
||||
ProjectsDir: dir,
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
ch <- result{id, err}
|
||||
}()
|
||||
|
||||
// Give the watcher time to start.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Create a new .jsonl file — this should resolve the watcher.
|
||||
sessionID := "abc123sessionid"
|
||||
f, err := os.Create(filepath.Join(dir, sessionID+".jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
r := <-ch
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
if r.id != sessionID {
|
||||
t.Errorf("got id %q, want %q", r.id, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchForNewSession_IgnoresExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Pre-existing file must be ignored.
|
||||
existing := "existing-session"
|
||||
f, _ := os.Create(filepath.Join(dir, existing+".jsonl"))
|
||||
f.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
ch := make(chan struct {
|
||||
id string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
id, err := WatchForNewSession(ctx, SessionWatchOpts{
|
||||
ProjectsDir: dir,
|
||||
Timeout: 200 * time.Millisecond,
|
||||
})
|
||||
ch <- struct {
|
||||
id string
|
||||
err error
|
||||
}{id, err}
|
||||
}()
|
||||
|
||||
// No new file created — should timeout returning ("", nil).
|
||||
r := <-ch
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
if r.id != "" {
|
||||
t.Errorf("expected empty id on timeout, got %q", r.id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchForNewSession_TimeoutReturnsEmptyNil(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := WatchForNewSession(ctx, SessionWatchOpts{
|
||||
ProjectsDir: dir,
|
||||
Timeout: 200 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id != "" {
|
||||
t.Errorf("expected empty string on timeout, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWatchForNewSession_MissingDirThenCreated proves the symmetric fix for the
|
||||
// claude watcher: ProjectsDir absent at call time is created by MkdirAll, and a
|
||||
// new session file written afterwards is still captured.
|
||||
func TestWatchForNewSession_MissingDirThenCreated(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
// ProjectsDir does NOT exist yet.
|
||||
projDir := filepath.Join(base, "projects", "-home-user-myrepo")
|
||||
|
||||
ctx := context.Background()
|
||||
ch := make(chan struct {
|
||||
id string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
id, err := WatchForNewSession(ctx, SessionWatchOpts{
|
||||
ProjectsDir: projDir,
|
||||
Timeout: 300 * time.Millisecond,
|
||||
})
|
||||
ch <- struct {
|
||||
id string
|
||||
err error
|
||||
}{id, err}
|
||||
}()
|
||||
|
||||
// Allow MkdirAll + watcher.Add to complete.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
sessionID := "newclaudesessionabc"
|
||||
f, err := os.Create(filepath.Join(projDir, sessionID+".jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("create session file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
r := <-ch
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
if r.id != sessionID {
|
||||
t.Errorf("got id %q, want %q (missing-dir session not captured)", r.id, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchForNewSession_ContextCancel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ch := make(chan struct {
|
||||
id string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
id, err := WatchForNewSession(ctx, SessionWatchOpts{
|
||||
ProjectsDir: dir,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
ch <- struct {
|
||||
id string
|
||||
err error
|
||||
}{id, err}
|
||||
}()
|
||||
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
r := <-ch
|
||||
// Context cancellation returns ("", nil) matching timeout semantics.
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error on cancel: %v", r.err)
|
||||
}
|
||||
if r.id != "" {
|
||||
t.Errorf("expected empty id on cancel, got %q", r.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Image input (stream-json path) is not implemented; see spawnClaudeStreamed in TypeScript source for the extension point.
|
||||
|
||||
const (
|
||||
defaultTimeoutMs = 300_000
|
||||
maxStdoutBytes = 10 * 1024 * 1024
|
||||
)
|
||||
|
||||
// Opts configures a SpawnClaude call.
|
||||
type Opts struct {
|
||||
Prompt string
|
||||
TimeoutMs int // 0 → defaultTimeoutMs
|
||||
Cwd string // working directory for the claude process
|
||||
ProfilePath string // optional --settings profile applied to the run
|
||||
}
|
||||
|
||||
// Result holds a successful SpawnClaude response.
|
||||
type Result struct {
|
||||
SessionID string
|
||||
ResultText string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
// ClaudeError is returned when the claude process exits with a non-zero status.
|
||||
type ClaudeError struct {
|
||||
Msg string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
func (e *ClaudeError) Error() string {
|
||||
if e.Stderr != "" {
|
||||
return fmt.Sprintf("%s: %s", e.Msg, e.Stderr)
|
||||
}
|
||||
return e.Msg
|
||||
}
|
||||
|
||||
// ClaudeTimeoutError wraps ClaudeError for timeout-specific failures.
|
||||
type ClaudeTimeoutError struct {
|
||||
ClaudeError
|
||||
}
|
||||
|
||||
// Unwrap lets errors.As(err, *ClaudeError) also match a timeout, mirroring the TS IS-A relationship.
|
||||
func (e *ClaudeTimeoutError) Unwrap() error { return &e.ClaudeError }
|
||||
|
||||
// RunClaude is the injectable command runner. Tests replace this to avoid needing
|
||||
// a real claude binary. The real implementation uses exec.CommandContext.
|
||||
var RunClaude func(ctx context.Context, cwd string, args []string) (stdout, stderr []byte, err error) = defaultRunClaude
|
||||
|
||||
func defaultRunClaude(ctx context.Context, cwd string, args []string) ([]byte, []byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "claude", args...)
|
||||
cmd.Env = filteredEnv()
|
||||
if cwd != "" {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
|
||||
stdout, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return stdout, exitErr.Stderr, err
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
return stdout, nil, nil
|
||||
}
|
||||
|
||||
// filteredEnv returns os.Environ() minus ANTHROPIC_* and nested Claude guard vars.
|
||||
func filteredEnv() []string {
|
||||
blocked := map[string]bool{
|
||||
"CLAUDECODE": true,
|
||||
"CLAUDE_CODE_ENTRYPOINT": true,
|
||||
"CLAUDE_CODE_SESSION_ID": true,
|
||||
"CLAUDE_CODE_SESSION": true,
|
||||
}
|
||||
env := os.Environ()
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
key := kv
|
||||
if idx := strings.IndexByte(kv, '='); idx >= 0 {
|
||||
key = kv[:idx]
|
||||
}
|
||||
if strings.HasPrefix(key, "ANTHROPIC_") || blocked[key] {
|
||||
continue
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parsedPayload is the shape claude emits for --output-format json.
|
||||
type parsedPayload struct {
|
||||
Result string `json:"result"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// extractClaudePayload parses the stdout from claude.
|
||||
// It first tries a direct JSON parse; if that fails it scans lines from the end
|
||||
// looking for the last object that has both result and session_id (NDJSON tail).
|
||||
func extractClaudePayload(stdout string) (*parsedPayload, bool) {
|
||||
stdout = strings.TrimSpace(stdout)
|
||||
|
||||
var p parsedPayload
|
||||
if err := json.Unmarshal([]byte(stdout), &p); err == nil && p.SessionID != "" {
|
||||
return &p, true
|
||||
}
|
||||
|
||||
lines := strings.Split(stdout, "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var candidate parsedPayload
|
||||
if err := json.Unmarshal([]byte(line), &candidate); err == nil && candidate.SessionID != "" {
|
||||
return &candidate, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// SpawnClaude runs claude with --output-format json and returns the parsed result.
|
||||
func SpawnClaude(ctx context.Context, opts Opts) (*Result, error) {
|
||||
timeoutMs := opts.TimeoutMs
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = defaultTimeoutMs
|
||||
}
|
||||
|
||||
tCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"--dangerously-skip-permissions",
|
||||
"-p", opts.Prompt,
|
||||
"--output-format", "json",
|
||||
}
|
||||
if opts.ProfilePath != "" {
|
||||
args = append(args, "--settings", opts.ProfilePath)
|
||||
}
|
||||
|
||||
stdout, stderr, err := RunClaude(tCtx, opts.Cwd, args)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(tCtx.Err(), context.DeadlineExceeded) {
|
||||
return nil, &ClaudeTimeoutError{ClaudeError{
|
||||
Msg: "claude timed out",
|
||||
Stderr: string(stderr),
|
||||
}}
|
||||
}
|
||||
return nil, &ClaudeError{
|
||||
Msg: fmt.Sprintf("claude exited with error: %v", err),
|
||||
Stderr: string(stderr),
|
||||
}
|
||||
}
|
||||
|
||||
if len(stdout) > maxStdoutBytes {
|
||||
return nil, &ClaudeError{Msg: fmt.Sprintf("claude stdout exceeded %d bytes", maxStdoutBytes)}
|
||||
}
|
||||
|
||||
payload, ok := extractClaudePayload(string(stdout))
|
||||
if !ok {
|
||||
return nil, &ClaudeError{Msg: "could not parse claude output as JSON"}
|
||||
}
|
||||
|
||||
return &Result{
|
||||
SessionID: payload.SessionID,
|
||||
ResultText: payload.Result,
|
||||
RawJSON: string(stdout),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package cc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func withRunner(fn func(ctx context.Context, cwd string, args []string) ([]byte, []byte, error)) func() {
|
||||
orig := RunClaude
|
||||
RunClaude = fn
|
||||
return func() { RunClaude = orig }
|
||||
}
|
||||
|
||||
func TestSpawnClaude_CleanJSON(t *testing.T) {
|
||||
restore := withRunner(func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
|
||||
return []byte(`{"result":"ok","session_id":"abc123"}`), nil, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res, err := SpawnClaude(context.Background(), Opts{Prompt: "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.SessionID != "abc123" {
|
||||
t.Errorf("SessionID = %q, want abc123", res.SessionID)
|
||||
}
|
||||
if res.ResultText != "ok" {
|
||||
t.Errorf("ResultText = %q, want ok", res.ResultText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnClaude_NDJSONTail(t *testing.T) {
|
||||
ndjson := `{"type":"progress","data":"thinking"}
|
||||
{"type":"progress","data":"still thinking"}
|
||||
{"result":"done","session_id":"sess99"}`
|
||||
|
||||
restore := withRunner(func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
|
||||
return []byte(ndjson), nil, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res, err := SpawnClaude(context.Background(), Opts{Prompt: "go"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.SessionID != "sess99" {
|
||||
t.Errorf("SessionID = %q, want sess99", res.SessionID)
|
||||
}
|
||||
if res.ResultText != "done" {
|
||||
t.Errorf("ResultText = %q, want done", res.ResultText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnClaude_NonZeroExit(t *testing.T) {
|
||||
restore := withRunner(func(_ context.Context, _ string, _ []string) ([]byte, []byte, error) {
|
||||
return nil, []byte("something went wrong"), errors.New("exit status 1")
|
||||
})
|
||||
defer restore()
|
||||
|
||||
_, err := SpawnClaude(context.Background(), Opts{Prompt: "hi"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ce *ClaudeError
|
||||
if !errors.As(err, &ce) {
|
||||
t.Errorf("expected ClaudeError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnClaude_Timeout(t *testing.T) {
|
||||
restore := withRunner(func(ctx context.Context, _ string, _ []string) ([]byte, []byte, error) {
|
||||
// Respect context cancellation — as the real runner would.
|
||||
<-ctx.Done()
|
||||
return nil, nil, ctx.Err()
|
||||
})
|
||||
defer restore()
|
||||
|
||||
_, err := SpawnClaude(context.Background(), Opts{Prompt: "slow", TimeoutMs: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
var te *ClaudeTimeoutError
|
||||
if !errors.As(err, &te) {
|
||||
t.Errorf("expected ClaudeTimeoutError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user