diff --git a/internal/agentrepo/command.go b/internal/agentrepo/command.go index d01ff749..92fbf7ee 100644 --- a/internal/agentrepo/command.go +++ b/internal/agentrepo/command.go @@ -94,6 +94,7 @@ func NewCommand() (*cobra.Command, *int) { skillTokensCommand(code), postCommand(code), proseCommand(code), + retroCommand(code), ) return root, code } diff --git a/internal/agentrepo/retro.go b/internal/agentrepo/retro.go new file mode 100644 index 00000000..3daa50d1 --- /dev/null +++ b/internal/agentrepo/retro.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package agentrepo + +import ( + "github.com/spf13/cobra" + + "github.com/tbhb-dev/devtools/internal/retro" +) + +func retroCommand(code *int) *cobra.Command { + group := &cobra.Command{Use: "retro", Short: "Scaffold and profile session retrospectives"} + var skill string + group.PersistentFlags(). + StringVar(&skill, "skill-dir", ".", "Deployed analyze-session directory containing scripts/ and templates/") + group.AddCommand( + &cobra.Command{ + Use: "new [extra-artifact ...]", + Short: "Preserve session evidence under the main checkout", + Run: func(cmd *cobra.Command, args []string) { + *code = retro.New(cmd.Context(), skill, args, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + }, + ) + return group +} diff --git a/internal/agentrepo/retro_test.go b/internal/agentrepo/retro_test.go new file mode 100644 index 00000000..b1646640 --- /dev/null +++ b/internal/agentrepo/retro_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package agentrepo_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tbhb-dev/devtools/internal/agentrepo" +) + +func TestRetroCommand(t *testing.T) { + t.Parallel() + var out, stderr bytes.Buffer + require.Equal(t, 2, agentrepo.Run([]string{"retro", "new"}, nil, &out, &stderr)) + require.Empty(t, out.String()) + require.Contains(t, stderr.String(), "usage:") + out.Reset() + stderr.Reset() + require.Zero(t, agentrepo.Run([]string{"retro", "new", "--help"}, nil, &out, &stderr)) + require.Contains(t, out.String(), "--skill-dir") +} diff --git a/internal/retro/fake_uv_test.go b/internal/retro/fake_uv_test.go new file mode 100644 index 00000000..a4fecac6 --- /dev/null +++ b/internal/retro/fake_uv_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package retro_test + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + fakeUVEnv = "RETRO_FAKE_UV" + fakeGitEnv = "RETRO_FAKE_GIT" +) + +// TestMain runs the test binary as uv or as a failing git when a fixture links it to +// that name and sets the matching fake variable. The command under test still starts a child process. +func TestMain(m *testing.M) { + if os.Getenv(fakeGitEnv) == "1" { + _, _ = fmt.Fprintln(os.Stdout, "partial path") + os.Exit(9) + } + if os.Getenv(fakeUVEnv) == "1" { + os.Exit(fakeUV(os.Args[1:], os.Stdout)) + } + os.Exit(m.Run()) +} + +// fakeUV replaces the report script on hosts without uv. +// It verifies the environment and arguments the command passes and prints canned reports. +func fakeUV(args []string, stdout io.Writer) int { + if len(args) != 7 || args[0] != "run" || args[1] != "--script" || args[4] != "--format" { + return 2 + } + format := os.Getenv("ANALYZE_SESSION_FORMAT") + if format == "" { + format = "auto" + } + if os.Getenv("LC_ALL") != "C" || os.Getenv("PYTHONUTF8") != "1" || args[5] != format { + return 3 + } + report, transcript := args[3], args[6] + if report == os.Getenv("FAIL_REPORT") { + _, _ = fmt.Fprintln(stdout, "partial output") + return 9 + } + if override := os.Getenv("RETRO_FAKE_META"); report == "meta" && override != "" { + _, _ = fmt.Fprintln(stdout, override) + return 0 + } + if report == "meta" { + id := fakeSessionID(transcript) + _, _ = fmt.Fprintf(stdout, "SESSION_ID=%s\nSESSION_DATE=20260102\nRECORDS=40\nSIZE_MB=1\n", id) + return 0 + } + for i := range 40 { + _, _ = fmt.Fprintln(stdout, i) + } + return 0 +} + +// fakeSessionID names a session the way the report script does: a Codex +// transcript carries its identifier in the first record and any other file uses its stem. +func fakeSessionID(transcript string) string { + if root, err := os.OpenRoot(filepath.Dir(transcript)); err == nil { + defer root.Close() + if file, openErr := root.Open(filepath.Base(transcript)); openErr == nil { + defer file.Close() + var first struct { + Type string `json:"type"` + Payload struct { + ID string `json:"id"` + } `json:"payload"` + } + scanner := bufio.NewScanner(file) + if scanner.Scan() && json.Unmarshal(scanner.Bytes(), &first) == nil && first.Type == "session_meta" { + return first.Payload.ID + } + } + } + return strings.TrimSuffix(filepath.Base(transcript), ".jsonl") +} + +func linkFakeUV(t *testing.T) { + t.Helper() + linkFake(t, "uv") + t.Setenv(fakeUVEnv, "1") +} + +// linkFake puts a link to the test binary first on PATH under the program name. +func linkFake(t *testing.T, program string) { + t.Helper() + self, err := os.Executable() + require.NoError(t, err) + bin := t.TempDir() + if runtime.GOOS == "windows" { + program += ".exe" + } + require.NoError(t, os.Link(self, filepath.Join(bin, program))) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) +} diff --git a/internal/retro/new.go b/internal/retro/new.go new file mode 100644 index 00000000..62338e2b --- /dev/null +++ b/internal/retro/new.go @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +// Package retro preserves session evidence and profiles transcripts through session.py. +package retro + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + claudeHarness = "claude" + codexHarness = "codex" +) + +var ( + errNoTranscript = errors.New("new-retro: no such transcript") + errNoTemplate = errors.New("new-retro: missing template") + errNoRepository = errors.New("new-retro: not inside a git repository") + errNoMetadata = errors.New("new-retro: could not read session metadata") + errBadFormat = errors.New("new-retro: unsupported ANALYZE_SESSION_FORMAT") +) + +// New runs the new-retro contract. Skill names the deployed analyze-session directory. +func New(ctx context.Context, skill string, args []string, out, stderr io.Writer) int { + if len(args) == 0 { + _, _ = fmt.Fprint( + stderr, + `usage: agentrepo retro new --skill-dir [extra-artifact ...] + +Creates
/.claude/retros/YYYYMMDD_/ (or .codex/retros +under Codex) holding RETRO.md plus artifacts/ (the transcript, the canned +session.py reports, and any extra files named on the command line). + +The transcript may come from either harness. ANALYZE_SESSION_FORMAT=claude +or =codex overrides the format session.py would detect. +`, + ) + const usageStatus = 2 + return usageStatus + } + summary, err := create(ctx, skill, args, stderr) + if err != nil { + if exit, ok := errors.AsType[*exec.ExitError](err); ok { + return exit.ExitCode() + } + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + _, _ = fmt.Fprint(out, summary) + return 0 +} + +func create(ctx context.Context, skill string, args []string, stderr io.Writer) (string, error) { + skill, err := filepath.Abs(skill) + if err != nil { + return "", fmt.Errorf("new-retro: resolve skill directory: %w", err) + } + transcript := args[0] + info, err := os.Stat(transcript) + if err != nil || !info.Mode().IsRegular() { + return "", fmt.Errorf("%w: %s", errNoTranscript, transcript) + } + template := filepath.Join(skill, "templates/RETRO.md") + body, err := os.ReadFile(template) + if err != nil { + return "", fmt.Errorf("%w: %s", errNoTemplate, template) + } + git := exec.CommandContext(ctx, "git", "rev-parse", "--path-format=absolute", "--git-common-dir") + git.Env = environment() + common, err := git.Output() + if err != nil { + return "", errNoRepository + } + commonDir := strings.TrimSpace(string(common)) + metadata, err := report(ctx, skill, "meta", transcript, stderr) + if err != nil { + return "", err + } + values := make(map[string]string) + for line := range strings.SplitSeq(string(metadata), "\n") { + key, value, _ := strings.Cut(line, "=") + values[key] = value + } + if values["SESSION_ID"] == "" || values["SESSION_DATE"] == "" { + return "", errNoMetadata + } + base := ".claude/retros" + if harness(skill) == codexHarness { + base = ".codex/retros" + } + name := values["SESSION_DATE"] + "_" + values["SESSION_ID"] + dir := filepath.ToSlash(filepath.Join(filepath.Dir(commonDir), base, name)) + artifacts := filepath.Join(dir, "artifacts") + copied, err := preserve(ctx, skill, transcript, artifacts, args[1:], stderr) + if err != nil { + return "", err + } + state, err := scaffold(filepath.Join(dir, "RETRO.md"), string(body), values) + if err != nil { + return "", err + } + ignored, err := exclude(commonDir, base+"/") + if err != nil { + return "", err + } + return fmt.Sprintf( + "retro dir: %s\nRETRO.md: %s\nartifacts: transcript.jsonl, 4 canned reports, %d extra\ngitignore: %s\n", + dir, + state, + copied, + ignored, + ), nil +} + +func report(ctx context.Context, skill, name, transcript string, stderr io.Writer) ([]byte, error) { + format := "auto" + switch value := os.Getenv("ANALYZE_SESSION_FORMAT"); value { + case "", "auto": + case claudeHarness: + format = claudeHarness + case codexHarness: + format = codexHarness + default: + return nil, fmt.Errorf("%w: %q", errBadFormat, value) + } + script := filepath.Join(skill, "scripts/session.py") + cmd := exec.CommandContext(ctx, "uv", "run", "--script", script, name, "--format", format, transcript) + cmd.Env, cmd.Stderr = environment(), stderr + data, err := cmd.Output() + if err != nil { + return data, fmt.Errorf("new-retro: run %s report: %w", name, err) + } + return data, nil +} + +func environment() []string { + env := make([]string, 0, len(os.Environ())) + for _, item := range os.Environ() { + key, _, _ := strings.Cut(item, "=") + switch key { + case "GIT_DIR", "GIT_WORK_TREE", "CDPATH", "GREP_OPTIONS", "LC_ALL", "PYTHONUTF8": + continue + } + env = append(env, item) + } + return append(env, "LC_ALL=C", "PYTHONUTF8=1") +} + +func harness(skill string) string { + if value := os.Getenv("DEVTOOLS_HARNESS"); value != "" { + return value + } + if os.Getenv("CLAUDE_CODE_SESSION_ID") != "" || os.Getenv("CLAUDECODE") != "" { + return "claude" + } + if os.Getenv("CODEX_THREAD_ID") != "" { + return codexHarness + } + path := "/" + filepath.ToSlash(skill) + "/" + if strings.Contains(path, "/.agents/skills/") || strings.Contains(path, "/packages/agents/codex/") { + return codexHarness + } + return "unknown" +} + +func copyArtifact(src, dst string, follow bool) error { + info, err := os.Lstat(src) + if follow { + info, err = os.Stat(src) + } + if err != nil { + return fmt.Errorf("new-retro: read artifact: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return copySymlink(src, dst) + } + if info.IsDir() { + return copyDirectory(src, dst) + } + data, err := os.ReadFile(src) + if err != nil { + return fmt.Errorf("new-retro: read artifact: %w", err) + } + if err = writeFile(dst, data, info.Mode().Perm()); err != nil { + return fmt.Errorf("new-retro: write artifact: %w", err) + } + return nil +} + +const artifactPerm = 0o600 + +func writeFile(path string, data []byte, perm os.FileMode) error { + dir, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return fmt.Errorf("open directory: %w", err) + } + defer dir.Close() + if err = dir.WriteFile(filepath.Base(path), data, perm); err != nil { + return fmt.Errorf("write file: %w", err) + } + return nil +} + +func copyDirectory(src, dst string) error { + if err := os.MkdirAll(dst, 0o700); err != nil { + return fmt.Errorf("new-retro: copy directory: %w", err) + } + entries, err := os.ReadDir(src) + if err != nil { + return fmt.Errorf("new-retro: read directory: %w", err) + } + for _, entry := range entries { + if err = copyArtifact(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name()), false); err != nil { + return err + } + } + return nil +} + +func copySymlink(src, dst string) error { + target, err := os.Readlink(src) + if err != nil { + return fmt.Errorf("new-retro: read artifact link: %w", err) + } + if err = os.Remove(dst); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("new-retro: remove artifact link: %w", err) + } + if err = os.Symlink(target, dst); err != nil { + return fmt.Errorf("new-retro: copy artifact link: %w", err) + } + return nil +} + +func scaffold(path, body string, values map[string]string) (string, error) { + if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() { + return "left alone (already exists)", nil + } + body = strings.NewReplacer("{{SESSION_ID}}", values["SESSION_ID"], "{{RECORDS}}", values["RECORDS"], "{{SIZE_MB}}", values["SIZE_MB"], "{{ANALYZED}}", time.Now().Format("2006-01-02")). + Replace(strings.TrimRight(body, "\n")) + + "\n" + if err := writeFile(path, []byte(body), artifactPerm); err != nil { + return "", fmt.Errorf("new-retro: write RETRO.md: %w", err) + } + return "scaffolded from template", nil +} + +func exclude(commonDir, entry string) (string, error) { + path := filepath.Join(commonDir, "info/exclude") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("new-retro: create exclude directory: %w", err) + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0o600) + if err != nil { + return "", fmt.Errorf("new-retro: open exclude: %w", err) + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil { + return "", fmt.Errorf("new-retro: read exclude: %w", err) + } + for line := range bytes.SplitSeq(data, []byte{'\n'}) { + if string(line) == entry || string(line) == strings.TrimSuffix(entry, "/") { + return "already ignored", nil + } + } + if _, err = fmt.Fprintf( + file, + "\n# Session retrospectives. Local working notes, never committed.\n%s\n", + entry, + ); err != nil { + return "", fmt.Errorf("new-retro: write exclude: %w", err) + } + return "added to .git/info/exclude", nil +} + +func preserve( + ctx context.Context, + skill, transcript, artifacts string, + extras []string, + stderr io.Writer, +) (int, error) { + if err := os.MkdirAll(artifacts, 0o700); err != nil { + return 0, fmt.Errorf("new-retro: create artifacts: %w", err) + } + if err := copyArtifact(transcript, filepath.Join(artifacts, "transcript.jsonl"), true); err != nil { + return 0, err + } + for _, name := range []string{"index", "errors", "loops", "lint"} { + data, reportErr := report(ctx, skill, name, transcript, stderr) + if reportErr != nil { + return 0, reportErr + } + if err := os.WriteFile(filepath.Join(artifacts, name+".txt"), data, 0o600); err != nil { + return 0, fmt.Errorf("new-retro: write report: %w", err) + } + } + copied := 0 + for _, extra := range extras { + if _, statErr := os.Lstat(extra); statErr != nil { + _, _ = fmt.Fprintf(stderr, "new-retro: skipping missing artifact: %s\n", extra) + continue + } + if err := copyArtifact( + extra, + filepath.Join(artifacts, filepath.Base(filepath.Clean(extra))), + false, + ); err != nil { + return 0, err + } + copied++ + } + + return copied, nil +} diff --git a/internal/retro/retro_test.go b/internal/retro/retro_test.go new file mode 100644 index 00000000..5830f501 --- /dev/null +++ b/internal/retro/retro_test.go @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package retro_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tbhb-dev/devtools/internal/agentrepo" +) + +func write(t *testing.T, path, body string) { + t.Helper() + parent := filepath.Dir(path) + base := filepath.VolumeName(parent) + string(filepath.Separator) + if !filepath.IsAbs(parent) { + base = "." + } + tree, err := os.OpenRoot(base) + require.NoError(t, err) + defer tree.Close() + rel, err := filepath.Rel(base, parent) + require.NoError(t, err) + require.NoError(t, tree.MkdirAll(rel, 0o700)) + require.NoError(t, tree.WriteFile(filepath.Join(rel, filepath.Base(path)), []byte(body), 0o600)) +} + +func read(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + return string(b) +} + +func git(t *testing.T, args ...string) { + t.Helper() + b, err := exec.CommandContext(t.Context(), "git", args...).CombinedOutput() + require.NoError(t, err, "%s", b) +} + +func fixture(t *testing.T) (string, string, string) { + t.Helper() + linkFakeUV(t) + source, err := filepath.Abs("../../packages/agents/claude/.apm/skills/analyze-session") + require.NoError(t, err) + claude, codex := read(t, "testdata/claude.jsonl"), read(t, "testdata/codex.jsonl") + for _, key := range []string{"GIT_DIR", "GIT_WORK_TREE", "CLAUDE_CODE_SESSION_ID", "CLAUDECODE", "CODEX_THREAD_ID", "ANALYZE_SESSION_FORMAT"} { + t.Setenv(key, "") + require.NoError(t, os.Unsetenv(key)) + } + t.Setenv("HOME", t.TempDir()) + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("DEVTOOLS_HARNESS", "claude") + t.Chdir(t.TempDir()) + git(t, "init", "--quiet", "--initial-branch=main") + c := filepath.Join(os.Getenv("HOME"), ".claude/projects/proj/session-claude-1.jsonl") + x := filepath.Join(os.Getenv("HOME"), ".codex/sessions/2026/01/02/rollout-cafe.jsonl") + write(t, c, claude) + write(t, x, codex) + return source, c, x +} + +func run(t *testing.T, skill string, args ...string) (int, string, string) { + t.Helper() + var out, stderr bytes.Buffer + code := agentrepo.Run(append([]string{"retro", "new", "--skill-dir", skill}, args...), nil, &out, &stderr) + return code, out.String(), stderr.String() +} + +// The first three cases preserve the corresponding new-retro Bats scenarios. +func TestRetroNew(t *testing.T) { + skill, c, x := fixture(t) + t.Setenv("DEVTOOLS_HARNESS", "claude") + for _, tc := range []struct{ harness, transcript, dir string }{ + {"claude", c, ".claude/retros/20260102_session-claude-1"}, + {"codex", x, ".codex/retros/20260102_019abc00-feed"}, + } { + t.Setenv("DEVTOOLS_HARNESS", tc.harness) + write(t, "extra.txt", "evidence") + write(t, "extra-dir/nested", "nested") + require.NoError(t, os.Symlink("..", "extra-dir/ancestor")) + code, out, stderr := run(t, skill, tc.transcript, "extra.txt", "extra-dir", "absent") + require.Zero(t, code, stderr) + require.Contains(t, stderr, "skipping missing artifact: absent") + lines := strings.Split(strings.TrimSpace(out), "\n") + require.Len(t, lines, 4) + for i, label := range []string{"retro dir:", "RETRO.md:", "artifacts:", "gitignore:"} { + require.True(t, strings.HasPrefix(lines[i], label)) + } + require.Contains(t, out, "4 canned reports, 2 extra") + require.NoFileExists(t, ".gitignore") + require.Equal(t, "evidence", read(t, tc.dir+"/artifacts/extra.txt")) + require.Equal(t, "nested", read(t, tc.dir+"/artifacts/extra-dir/nested")) + target, err := os.Readlink(tc.dir + "/artifacts/extra-dir/ancestor") + require.NoError(t, err) + require.Equal(t, "..", target) + require.Equal(t, read(t, tc.transcript), read(t, tc.dir+"/artifacts/transcript.jsonl")) + for _, report := range []string{"index", "errors", "loops", "lint"} { + require.FileExists(t, tc.dir+"/artifacts/"+report+".txt") + } + body := read(t, tc.dir+"/RETRO.md") + require.NotContains(t, body, "{{SESSION_ID}}") + write(t, tc.dir+"/RETRO.md", "mine\x00\n") + code, out, stderr = run(t, skill, tc.transcript, "extra.txt", "extra-dir") + require.Zero(t, code, stderr) + require.Contains(t, out, "already ignored") + require.Contains(t, out, "left alone") + require.Equal(t, "mine\x00\n", read(t, tc.dir+"/RETRO.md")) + require.Equal(t, 1, strings.Count(read(t, ".git/info/exclude"), "."+tc.harness+"/retros/")) + require.NoError(t, os.Remove("extra-dir/ancestor")) + } +} + +func TestRetroWorktreeIsolation(t *testing.T) { + skill, c, _ := fixture(t) + cwd, err := os.Getwd() + require.NoError(t, err) + logical, err := filepath.EvalSymlinks(cwd) + require.NoError(t, err) + root := filepath.ToSlash(logical) + git( + t, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "fixture", + ) + linked := filepath.Join(t.TempDir(), "linked") + git(t, "worktree", "add", "--quiet", "-b", "linked", linked) + t.Chdir(linked) + t.Setenv("GIT_DIR", t.TempDir()) + t.Setenv("GIT_WORK_TREE", t.TempDir()) + t.Setenv("CDPATH", t.TempDir()) + write(t, filepath.Join(root, ".gitignore"), "unchanged\n") + code, out, stderr := run(t, skill, c) + require.Zero(t, code, stderr) + require.Contains(t, out, root+"/.claude/retros/") + require.Equal(t, "unchanged\n", read(t, filepath.Join(root, ".gitignore"))) + require.NoDirExists(t, ".claude/retros") +} + +func stub(t *testing.T) string { + t.Helper() + dir := t.TempDir() + write(t, filepath.Join(dir, "templates/RETRO.md"), "{{SESSION_ID}} {{RECORDS}} {{SIZE_MB}} {{ANALYZED}}\n") + return dir +} + +func TestRetroSymlinkedTranscript(t *testing.T) { + skill, c, _ := fixture(t) + t.Setenv("DEVTOOLS_HARNESS", "claude") + write(t, "originals/transcript.jsonl", read(t, c)) + require.NoError(t, os.Symlink("originals/transcript.jsonl", "link.jsonl")) + code, _, stderr := run(t, skill, "link.jsonl") + require.Zero(t, code, stderr) + require.Equal(t, read(t, c), read(t, ".claude/retros/20260102_link/artifacts/transcript.jsonl")) +} + +func TestRetroFailuresAndFormat(t *testing.T) { + _, c, _ := fixture(t) + t.Setenv("LC_ALL", "hostile") + t.Setenv("PYTHONUTF8", "0") + skill := stub(t) + code, out, stderr := run(t, skill) + require.Equal(t, 2, code) + require.Empty(t, out) + require.Contains(t, stderr, "usage:") + code, _, stderr = run(t, skill, "absent") + require.Equal(t, 1, code) + require.Contains(t, stderr, "new-retro: no such transcript:") + code, _, stderr = run(t, t.TempDir(), c) + require.Equal(t, 1, code) + require.Contains(t, stderr, "new-retro: missing template:") + for _, format := range []string{"", "auto", "claude", "codex"} { + t.Setenv("ANALYZE_SESSION_FORMAT", format) + code, _, stderr = run(t, skill, c) + require.Zero(t, code, stderr) + } + for _, report := range []string{"meta", "index", "errors", "loops", "lint"} { + t.Setenv("FAIL_REPORT", report) + code, out, stderr = run(t, skill, c) + require.Equal(t, 9, code) + require.Empty(t, out) + require.Empty(t, stderr) + } + t.Setenv("FAIL_REPORT", "") + for _, meta := range []string{"SESSION_ID=stub", "SESSION_DATE=20260102"} { + t.Setenv("RETRO_FAKE_META", meta) + code, _, stderr = run(t, skill, c) + require.Equal(t, 1, code) + require.Contains(t, stderr, "could not read session metadata") + } + t.Chdir(t.TempDir()) + code, _, stderr = run(t, skill, c) + require.Equal(t, 1, code) + require.Contains(t, stderr, "new-retro: not inside a git repository") +} + +func TestRetroPathFailure(t *testing.T) { + skill, c, _ := fixture(t) + linkFake(t, "git") + t.Setenv(fakeGitEnv, "1") + code, out, _ := run(t, skill, c) + require.NotZero(t, code) + require.Empty(t, out) + require.NoDirExists(t, ".claude/retros") + require.NoDirExists(t, "partial path") +} + +func TestRetroHarnessPrecedence(t *testing.T) { + skill, c, _ := fixture(t) + t.Setenv("DEVTOOLS_HARNESS", "") + for _, tc := range []struct{ claude, codex, dir string }{ + {"", "thread", ".codex"}, {"session", "thread", ".claude"}, {"", "", ".claude"}, + } { + t.Setenv("CLAUDE_CODE_SESSION_ID", tc.claude) + t.Setenv("CODEX_THREAD_ID", tc.codex) + code, out, stderr := run(t, skill, c) + require.Zero(t, code, stderr) + require.Contains(t, out, tc.dir+"/retros/") + } + code, out, stderr := run(t, strings.Replace(filepath.ToSlash(skill), "/claude/", "/codex/", 1), c) + require.Zero(t, code, stderr) + require.Contains(t, out, ".codex/retros/") + t.Setenv("CLAUDECODE", "1") + t.Setenv("CODEX_THREAD_ID", "thread") + code, out, stderr = run(t, skill, c) + require.Zero(t, code, stderr) + require.Contains(t, out, ".claude/retros/") +} + +func TestRetroDefaultSkillDirectory(t *testing.T) { + skill, c, _ := fixture(t) + t.Setenv("DEVTOOLS_HARNESS", "") + deployed := filepath.Join(".agents", "skills", "analyze-session") + require.NoError(t, os.CopyFS(deployed, os.DirFS(skill))) + t.Chdir(deployed) + var out, stderr bytes.Buffer + require.Zero(t, agentrepo.Run([]string{"retro", "new", c}, nil, &out, &stderr), stderr.String()) + require.Contains(t, out.String(), ".codex/retros/") +} diff --git a/internal/retro/testdata/claude.jsonl b/internal/retro/testdata/claude.jsonl new file mode 100644 index 00000000..01edeba7 --- /dev/null +++ b/internal/retro/testdata/claude.jsonl @@ -0,0 +1,7 @@ +{"type":"user","timestamp":"2026-01-02T12:00:00Z","message":{"role":"user","content":"lint it"}} +{"type":"assistant","timestamp":"2026-01-02T12:00:01Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"try the linter"},{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"mise run lint-prose"}}]}} +{"type":"user","timestamp":"2026-01-02T12:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"Exit code 1\nfile.md:1 [error] AI.Hedge match=\"just\""}]}} +{"type":"assistant","timestamp":"2026-01-02T12:00:03Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t2","name":"Bash","input":{"command":"mise run lint-prose"}}]}} +{"type":"user","timestamp":"2026-01-02T12:00:04Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t2","content":"PreToolUse hook error: blocked"}]}} +{"type":"assistant","timestamp":"2026-01-02T12:00:05Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t3","name":"Bash","input":{"command":"mise run lint-prose"}}]}} +{"type":"user","timestamp":"2026-01-02T12:00:06Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t3","is_error":true,"content":"The user has been denied this command"}]}} diff --git a/internal/retro/testdata/codex.jsonl b/internal/retro/testdata/codex.jsonl new file mode 100644 index 00000000..ba247ae8 --- /dev/null +++ b/internal/retro/testdata/codex.jsonl @@ -0,0 +1,8 @@ +{"type":"session_meta","timestamp":"2026-01-02T12:00:00Z","payload":{"id":"019abc00-feed","cwd":"/work/proj"}} +{"type":"response_item","timestamp":"2026-01-02T12:00:01Z","payload":{"type":"reasoning","summary":[{"text":"try the linter"}]}} +{"type":"response_item","timestamp":"2026-01-02T12:00:02Z","payload":{"type":"custom_tool_call","name":"exec","input":"await tools.exec_command({cmd: \"mise run lint-prose\"})"}} +{"type":"response_item","timestamp":"2026-01-02T12:00:03Z","payload":{"type":"custom_tool_call_output","output":"Script failed with code 1\nfile.md:1 [error] AI.Hedge match=\"just\""}} +{"type":"response_item","timestamp":"2026-01-02T12:00:04Z","payload":{"type":"custom_tool_call","name":"exec","input":"await tools.exec_command({cmd: \"mise run lint-prose\"})"}} +{"type":"response_item","timestamp":"2026-01-02T12:00:05Z","payload":{"type":"custom_tool_call_output","output":"Script failed with code 1\nPermission denied"}} +{"type":"response_item","timestamp":"2026-01-02T12:00:06Z","payload":{"type":"custom_tool_call","name":"exec","input":"await tools.exec_command({cmd: \"mise run lint-prose\"})"}} +{"type":"response_item","timestamp":"2026-01-02T12:00:07Z","payload":{"type":"custom_tool_call_output","output":"ok"}}