Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/agentrepo/retro.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
198 changes: 198 additions & 0 deletions internal/retro/preflight.go
Original file line number Diff line number Diff line change
@@ -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 <path>

Claude Code keeps transcripts under ~/.claude/projects/<project>/. 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
}
143 changes: 143 additions & 0 deletions internal/retro/preflight_test.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion internal/retro/retro_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Loading