From 14ddddd1d18dd8285e67536747eeb611ea6d0aca Mon Sep 17 00:00:00 2001 From: "tbhb.dev Agent" Date: Sun, 1 Nov 2026 07:12:55 -0500 Subject: [PATCH] feat(agentrepo): add retro preflight A shell preflight produces the profile that opens a retrospective. Before any caller can switch, the Go migration needs a command that matches it. Git Bash and Go name one Windows location differently: the shell prints a mounted path and Go a native one with backslashes and a short user directory. Printing the transcript and artifacts paths with forward slashes aligns their separators with the spelling the shell already uses for the retro directory. The drive and directory spelling of the transcript can still differ, so the parity test resolves that one path before comparing it. No shell entry point calls the command yet, which leaves consumers unchanged. Related to #331. Assisted-by: codex:gpt-6.1-sol Assisted-by: claude-code:claude-sonnet-5-5 Signed-off-by: Tony Burns --- internal/agentrepo/retro.go | 7 ++ internal/retro/preflight.go | 198 +++++++++++++++++++++++++++++++ internal/retro/preflight_test.go | 143 ++++++++++++++++++++++ internal/retro/retro_test.go | 2 +- 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 internal/retro/preflight.go create mode 100644 internal/retro/preflight_test.go diff --git a/internal/agentrepo/retro.go b/internal/agentrepo/retro.go index 3daa50d1..c120acae 100644 --- a/internal/agentrepo/retro.go +++ b/internal/agentrepo/retro.go @@ -22,6 +22,13 @@ func retroCommand(code *int) *cobra.Command { *code = retro.New(cmd.Context(), skill, args, cmd.OutOrStdout(), cmd.ErrOrStderr()) }, }, + &cobra.Command{ + Use: "preflight [path-or-id]", + Short: "Resolve a transcript, scaffold its retro, and print its profile", + Run: func(cmd *cobra.Command, args []string) { + *code = retro.Preflight(cmd.Context(), skill, args, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + }, ) return group } diff --git a/internal/retro/preflight.go b/internal/retro/preflight.go new file mode 100644 index 00000000..46c06f27 --- /dev/null +++ b/internal/retro/preflight.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package retro + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// Preflight resolves a transcript and prints the seven-section session profile. +func Preflight(ctx context.Context, skill string, args []string, out, stderr io.Writer) int { + if _, err := exec.LookPath("uv"); err != nil { + _, _ = fmt.Fprint( + out, + "\n== uv ==\nMISSING — session.py selects its interpreter through a uv script\nshebang, so every report here needs uv on PATH.\n\nInstall it, then run this preflight again:\n\n brew install uv\n", + ) + return 0 + } + arg := "" + if len(args) > 0 { + arg = args[0] + } + transcript, reason := resolve(arg) + if reason != "" { + unresolved(out, skill, reason) + return 0 + } + transcript, err := filepath.Abs(transcript) + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + file, err := os.Open(transcript) + if err != nil { + unresolved(out, skill, "cannot read: "+transcript) + return 0 + } + first := make([]byte, 1) + _, err = file.Read(first) + _ = file.Close() + if err != nil && !errors.Is(err, io.EOF) { + unresolved(out, skill, "cannot read: "+transcript) + return 0 + } + if first[0] != '{' { + unresolved(out, skill, "not JSON lines: "+transcript) + return 0 + } + _, _ = fmt.Fprintf(out, "\n== transcript ==\n%s\n\n== retro ==\n", filepath.ToSlash(transcript)) + var summary bytes.Buffer + if New(ctx, skill, []string{transcript}, &summary, stderr) != 0 { + _, _ = fmt.Fprintln(out, "scaffolding failed — tell the operator") + return 0 + } + _, _ = fmt.Fprint(out, summary.String()) + printProfile(out, stderr, summary.String()) + return 0 +} + +func printProfile(out, stderr io.Writer, summary string) { + const indexRows, lintRows = 24, 14 + first, _, _ := strings.Cut(summary, "\n") + dir := strings.TrimPrefix(first, "retro dir: ") + artifacts := filepath.Join(dir, "artifacts") + rows := 12 + if value := os.Getenv("ANALYZE_PREFLIGHT_LOOPS"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + rows = parsed + } else { + rows = 0 + } + } + profile(out, artifacts, "index", indexRows, "") + profile(out, artifacts, "loops", rows, "Ranked by rounds. Each round beyond the first is a wasted trip.\n\n") + profile(out, artifacts, "lint", lintRows, "") + _, _ = fmt.Fprintf(out, "\n== artifacts ==\n%s\n", filepath.ToSlash(artifacts)) + entries, err := os.ReadDir(artifacts) // ReadDir sorts names. List any readable entries as find does. + if err != nil { + _, _ = fmt.Fprintf(stderr, "preflight: list artifacts: %v\n", err) + } + for _, entry := range entries { + if entry.Type().IsRegular() { + _, _ = fmt.Fprintf(out, " %s\n", entry.Name()) + } + } + _, _ = fmt.Fprint( + out, + "\n== next ==\nThe transcript is resolved, the retro is open, and the reports are on\ndisk. Start at step 1 of the skill body, reading only what these line\nnumbers point at.\n", + ) +} + +func resolve(arg string) (string, string) { + if arg == "" { + return "", "no path given" + } + if info, err := os.Stat(arg); err == nil && info.Mode().IsRegular() { + return arg, "" + } + id := strings.TrimSuffix(arg, ".jsonl") + var matches []string + for _, store := range []string{".claude/projects", ".codex/sessions"} { + matches = append( + matches, + findTranscripts(filepath.Join(os.Getenv("HOME"), store), id, store == ".claude/projects")..., + ) + } + if len(matches) == 1 { + return matches[0], "" + } + if len(matches) > 1 { + return "", "the id matches more than one transcript; pass a full path" + } + return "", "not a readable file: " + arg +} + +func profile(out io.Writer, artifacts, name string, limit int, intro string) { + _, _ = fmt.Fprintf(out, "\n== %s ==\n%s", name, intro) + data, err := os.ReadFile(filepath.Join(artifacts, name+".txt")) + if err != nil { + _, _ = fmt.Fprintln(out, "(missing)") + return + } + for line := range strings.SplitAfterSeq(string(data), "\n") { + if limit <= 0 { + break + } + _, _ = fmt.Fprint(out, line) + limit-- + } +} + +func unresolved(out io.Writer, skill, reason string) { + _, _ = fmt.Fprintf(out, ` +== transcript == +UNRESOLVED — %s + +== what to do == +Do not open transcripts to search them. Reading transcripts to find +one costs more context than the analysis that follows. + +Dispatch a cheap delegated agent to locate it, following the template in +%s/references/LOCATE_SESSION.md. Give it the fingerprint the operator +described: a filename the session created, an identifier, or a rule name +out of its output. + +Then run this preflight again with the path it returns: + +%s%s/scripts/preflight.sh + +Claude Code keeps transcripts under ~/.claude/projects//. A session +running inside a git worktree writes under the MAIN repository's project +directory, and the worktree's own directory may exist while holding no +.jsonl at all. Codex keeps rollouts under date directories in +~/.codex/sessions. Do not infer either path from the repository or +worktree path. +`, reason, skill, " bash ", skill) +} + +func findTranscripts(root, id string, claude bool) []string { + var matches []string + store, err := os.OpenRoot(root) + if err != nil { + // An absent or unreadable store contributes no hits. + return matches + } + defer store.Close() + err = fs.WalkDir(store.FS(), ".", func(rel string, entry fs.DirEntry, err error) error { + if err != nil { + return fs.SkipDir + } + if claude && strings.Count(rel, "/") > 1 { + return fs.SkipDir + } + match := strings.HasSuffix(entry.Name(), id+".jsonl") + if claude { + match = entry.Name() == id+".jsonl" + } + if match && entry.Type().IsRegular() { + matches = append(matches, filepath.Join(root, filepath.FromSlash(rel))) + } + return nil + }) + if err != nil { + // The callback only skips entries. After a walk error, the hits are unreliable. + return nil + } + return matches +} diff --git a/internal/retro/preflight_test.go b/internal/retro/preflight_test.go new file mode 100644 index 00000000..d5f98885 --- /dev/null +++ b/internal/retro/preflight_test.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package retro_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tbhb-dev/devtools/internal/agentrepo" +) + +func runPreflight(t *testing.T, skill string, args ...string) (int, string, string) { + t.Helper() + var out, stderr bytes.Buffer + code := agentrepo.Run(append([]string{"retro", "preflight", "--skill-dir", skill}, args...), nil, &out, &stderr) + return code, out.String(), stderr.String() +} + +// Covers Bats bare-ID resolution and compares all seven sections byte for byte. +func TestRetroPreflightShellParity(t *testing.T) { + skill, c, x := fixture(t) + t.Setenv("DEVTOOLS_HARNESS", "claude") + for _, transcript := range []string{c, x} { + code, _, stderr := run(t, skill, transcript) + require.Zero(t, code, stderr) + } + for _, arg := range []string{c, "session-claude-1", "cafe", x} { + for _, loops := range []string{"", "2"} { + t.Run(filepath.Base(arg)+"/loops="+loops, func(t *testing.T) { + t.Setenv("ANALYZE_PREFLIGHT_LOOPS", loops) + code, out, stderr := runPreflight(t, skill, arg) + require.Zero(t, code, stderr) + require.Empty(t, stderr) + command := []string{filepath.Join(skill, "scripts/preflight.sh"), arg} + script := exec.CommandContext(t.Context(), "bash", command...) + shell, err := script.CombinedOutput() + require.NoError(t, err, "%s", shell) + require.Equal(t, transcriptLine(t, string(shell)), transcriptLine(t, out)) + require.Equal(t, withoutTranscript(string(shell)), withoutTranscript(out)) + }) + } + } +} + +// The shell prints the transcript in the spelling Git Bash uses on Windows. This test +// compares that line as one real location and every other byte exactly. +func transcriptLine(t *testing.T, output string) string { + t.Helper() + _, rest, _ := strings.Cut(output, "== transcript ==\n") + line, _, _ := strings.Cut(rest, "\n") + if runtime.GOOS != "windows" { + return line + } + native, err := exec.CommandContext(t.Context(), "cygpath", "-w", line).Output() + if err == nil { + line = strings.TrimSpace(string(native)) + } + resolved, err := filepath.EvalSymlinks(line) + require.NoError(t, err) + return filepath.ToSlash(resolved) +} + +func withoutTranscript(output string) string { + head, rest, _ := strings.Cut(output, "== transcript ==\n") + _, rest, _ = strings.Cut(rest, "\n") + return head + rest +} + +// Covers Bats unresolved guidance. +func TestRetroPreflightRefusals(t *testing.T) { + skill, c, _ := fixture(t) + t.Setenv("DEVTOOLS_HARNESS", "claude") + write(t, "not-json", "plain text") + write(t, filepath.Join(os.Getenv("HOME"), ".claude/projects/other/session-claude-1.jsonl"), read(t, c)) + for _, tc := range []struct { + args []string + message string + }{ + {nil, "no path given"}, + {[]string{"absent"}, "not a readable file"}, + {[]string{"session-claude-1"}, "matches more than one"}, + {[]string{"not-json"}, "not JSON lines"}, + } { + code, out, _ := runPreflight(t, skill, tc.args...) + require.Zero(t, code) + require.Contains(t, out, tc.message) + require.Contains(t, out, "~/.claude/projects") + require.Contains(t, out, "~/.codex/sessions") + } + if runtime.GOOS != "windows" { + require.NoError(t, os.Chmod(c, 0)) + code, out, _ := runPreflight(t, skill, c) + require.Zero(t, code) + require.Contains(t, out, "cannot read:") + require.NoError(t, os.Chmod(c, 0o600)) + } +} + +func TestRetroMissingUV(t *testing.T) { + skill, c, _ := fixture(t) + t.Setenv("PATH", t.TempDir()) + code, out, _ := runPreflight(t, skill, c) + require.Zero(t, code) + require.Contains(t, out, "MISSING") +} + +func TestRetroPreflightFailuresAndFormat(t *testing.T) { + _, c, _ := fixture(t) + skill := stub(t) + code, out, stderr := runPreflight(t, skill, c) + require.Zero(t, code, stderr) + require.Contains(t, out, strings.Join(numberLines(12), "\n")+"\n\n== lint ==") + t.Setenv("ANALYZE_PREFLIGHT_LOOPS", "3") + code, out, stderr = runPreflight(t, skill, c) + require.Zero(t, code, stderr) + require.Contains(t, out, "== index ==\n"+strings.Join(numberLines(24), "\n")+"\n\n== loops ==") + require.Contains(t, out, "0\n1\n2\n\n== lint ==\n"+strings.Join(numberLines(14), "\n")+"\n\n== artifacts ==") + for _, report := range []string{"meta", "index", "errors", "loops", "lint"} { + t.Setenv("FAIL_REPORT", report) + code, out, stderr = runPreflight(t, skill, c) + require.Zero(t, code) + require.Empty(t, stderr) + require.Contains(t, out, "scaffolding failed — tell the operator") + require.NotContains(t, out, "partial output") + } +} + +func numberLines(n int) []string { + lines := make([]string, n) + for i := range n { + lines[i] = strconv.Itoa(i) + } + return lines +} diff --git a/internal/retro/retro_test.go b/internal/retro/retro_test.go index 5830f501..df598e81 100644 --- a/internal/retro/retro_test.go +++ b/internal/retro/retro_test.go @@ -51,7 +51,7 @@ func fixture(t *testing.T) (string, string, string) { 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"} { + for _, key := range []string{"GIT_DIR", "GIT_WORK_TREE", "CLAUDE_CODE_SESSION_ID", "CLAUDECODE", "CODEX_THREAD_ID", "ANALYZE_SESSION_FORMAT", "ANALYZE_PREFLIGHT_LOOPS"} { t.Setenv(key, "") require.NoError(t, os.Unsetenv(key)) }