Initial patterm project
This commit is contained in:
79
cmd/patterm/main.go
Normal file
79
cmd/patterm/main.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// patterm is a terminal-based agent orchestration shell. SPEC §2: one
|
||||
// foreground process owns the TUI, every PTY, and the in-process MCP
|
||||
// server. Closing the terminal window ends the session.
|
||||
//
|
||||
// patterm run in $PWD
|
||||
// patterm --project <dir> run in <dir>
|
||||
// patterm mcp-stdio --socket S --identity I
|
||||
// internal: stdio MCP proxy spawned for
|
||||
// children, forwards JSON-RPC over S
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/app"
|
||||
"github.com/harrybrwn/patterm/internal/mcp"
|
||||
"github.com/harrybrwn/patterm/internal/projectkey"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// The mcp-stdio subcommand is a separate top-level mode: when an
|
||||
// agent CLI launches `patterm mcp-stdio --socket ...`, the same
|
||||
// binary forwards JSON-RPC to the running process over the per-PID
|
||||
// socket. SPEC §10.
|
||||
if len(os.Args) >= 2 && os.Args[1] == "mcp-stdio" {
|
||||
os.Args = append(os.Args[:1], os.Args[2:]...)
|
||||
runMCPProxy()
|
||||
return
|
||||
}
|
||||
|
||||
var projectDir = flag.String("project", "", "project directory (default $PWD)")
|
||||
flag.Parse()
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
die("getwd: %v", err)
|
||||
}
|
||||
if *projectDir != "" {
|
||||
cwd = *projectDir
|
||||
}
|
||||
key, err := projectkey.Key(cwd)
|
||||
if err != nil {
|
||||
die("project key: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chdir(cwd); err != nil {
|
||||
die("chdir %s: %v", cwd, err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := app.Run(ctx, app.Options{
|
||||
ProjectDir: cwd,
|
||||
ProjectKey: key,
|
||||
}); err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runMCPProxy() {
|
||||
var (
|
||||
socket = flag.String("socket", "", "path to the running patterm process's MCP socket")
|
||||
identity = flag.String("identity", "", "per-child identity token")
|
||||
)
|
||||
flag.Parse()
|
||||
if *socket == "" || *identity == "" {
|
||||
die("mcp-stdio: --socket and --identity are required")
|
||||
}
|
||||
if err := mcp.RunStdioProxy(*socket, *identity); err != nil {
|
||||
die("mcp-stdio: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "patterm: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
390
cmd/spike/main.go
Normal file
390
cmd/spike/main.go
Normal file
@@ -0,0 +1,390 @@
|
||||
// cmd/spike is the milestone-1 throwaway: spawn a child in a PTY, pump bytes
|
||||
// through a libghostty-vt-backed emulator, and dump the rendered grid as
|
||||
// plain text on idle or hotkey.
|
||||
//
|
||||
// Stdin from the host terminal is forwarded raw to the child PTY, so vim,
|
||||
// htop, claude, codex and friends behave as if you ran them directly. We are
|
||||
// explicitly NOT encoding keys ourselves yet — that's a daemon-era concern.
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/pty"
|
||||
"github.com/harrybrwn/patterm/internal/vt"
|
||||
|
||||
cpty "github.com/creack/pty"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCols = 120
|
||||
defaultRows = 40
|
||||
defaultIdleMS = 1000
|
||||
readBufferBytes = 64 * 1024
|
||||
)
|
||||
|
||||
// Known hotkey aliases mapped to their raw control bytes.
|
||||
var hotkeyAliases = map[string]byte{
|
||||
"ctrl-]": 0x1d, // GS — default, but some layouts/terminals swallow it
|
||||
"ctrl-\\": 0x1c, // FS — sends SIGQUIT in cooked mode but raw passes through
|
||||
"ctrl-^": 0x1e, // RS
|
||||
"ctrl-_": 0x1f, // US
|
||||
"ctrl-t": 0x14,
|
||||
"ctrl-o": 0x0f,
|
||||
"ctrl-space": 0x00, // NUL
|
||||
}
|
||||
|
||||
type spikeFlags struct {
|
||||
cols, rows int
|
||||
idleMS int
|
||||
followHost bool
|
||||
noPassthrough bool
|
||||
bytesPath string
|
||||
gridPath string
|
||||
gridToStderr bool
|
||||
hotkey string
|
||||
debugStdin bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
var f spikeFlags
|
||||
flag.IntVar(&f.cols, "cols", defaultCols, "PTY columns (overridden by host size if -follow-host)")
|
||||
flag.IntVar(&f.rows, "rows", defaultRows, "PTY rows (overridden by host size if -follow-host)")
|
||||
flag.IntVar(&f.idleMS, "dump-after-idle", defaultIdleMS, "dump grid to stderr after this many ms of PTY silence (0 disables)")
|
||||
flag.BoolVar(&f.followHost, "follow-host", true, "use the host terminal's size and follow SIGWINCH")
|
||||
flag.BoolVar(&f.noPassthrough, "no-stdin", false, "don't forward host stdin to the child PTY")
|
||||
flag.StringVar(&f.bytesPath, "bytes-out", "", "tee raw PTY bytes to this file (default: spike-<pid>.bytes when child starts)")
|
||||
flag.StringVar(&f.gridPath, "grid-out", "", "write grid dumps to this file (default: spike-<pid>.grid.log). Use - for stderr (will visually corrupt alt-screen TUIs).")
|
||||
flag.BoolVar(&f.gridToStderr, "grid-stderr", false, "also echo each grid dump to stderr. Convenient for non-TUI children (echo, bash); avoid with vim/htop/agent CLIs.")
|
||||
flag.StringVar(&f.hotkey, "hotkey", "ctrl-]", "key chord that triggers a grid dump: ctrl-], ctrl-\\, ctrl-^, ctrl-_, ctrl-t, ctrl-o, ctrl-space")
|
||||
flag.BoolVar(&f.debugStdin, "debug-stdin", false, "log every stdin byte to stderr as we read it (for working out what your terminal sends)")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "usage: spike [flags] -- <argv>\n\nflags:\n")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nWhile running, press the configured -hotkey to dump the grid.\nDefault sink is spike-<pid>.grid.log; tail -f it in another terminal.\n")
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
argv := flag.Args()
|
||||
if len(argv) == 0 {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
hotkey, ok := hotkeyAliases[strings.ToLower(f.hotkey)]
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "spike: unknown -hotkey %q (see -h for options)\n", f.hotkey)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
startCols, startRows := uint16(f.cols), uint16(f.rows)
|
||||
if f.followHost {
|
||||
if c, r, ok := hostSize(); ok {
|
||||
startCols, startRows = c, r
|
||||
}
|
||||
}
|
||||
|
||||
if err := run(argv, startCols, startRows, f.idleMS, f.followHost, !f.noPassthrough, f.bytesPath, f.gridPath, f.gridToStderr, hotkey, f.debugStdin); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "spike: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(argv []string, cols, rows uint16, idleMS int, followHost, stdinPassthrough bool, bytesPath, gridPath string, gridToStderr bool, hotkey byte, debugStdin bool) error {
|
||||
em, err := vt.NewGhosttyEmulator(cols, rows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("emulator: %w", err)
|
||||
}
|
||||
defer em.Close()
|
||||
|
||||
child, err := pty.Start(argv, nil, cols, rows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pty: %w", err)
|
||||
}
|
||||
defer child.Close()
|
||||
|
||||
// Wire WRITE_PTY back to the child's stdin so DA/DSR query responses
|
||||
// reach the program asking.
|
||||
em.OnWritePTY(func(b []byte) {
|
||||
if _, werr := child.Write(b); werr != nil {
|
||||
fmt.Fprintf(os.Stderr, "\r\nspike: write_pty back to child failed: %v\r\n", werr)
|
||||
}
|
||||
})
|
||||
|
||||
// Set up the bytes tee.
|
||||
if bytesPath == "" {
|
||||
bytesPath = fmt.Sprintf("spike-%d.bytes", child.Pid())
|
||||
}
|
||||
bytesFile, err := os.Create(bytesPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bytes tee: %w", err)
|
||||
}
|
||||
defer bytesFile.Close()
|
||||
|
||||
// Set up the grid sink. By default this is a file, not stderr, because
|
||||
// writing a multi-line dump to the host terminal while an alt-screen TUI
|
||||
// owns it visually corrupts the host display (the TUI inside the PTY is
|
||||
// fine; libghostty-vt's grid is fine; only the host's render breaks).
|
||||
var gridSink *os.File
|
||||
gridIsStderr := false
|
||||
switch gridPath {
|
||||
case "-":
|
||||
gridSink = os.Stderr
|
||||
gridIsStderr = true
|
||||
case "":
|
||||
gridPath = fmt.Sprintf("spike-%d.grid.log", child.Pid())
|
||||
fallthrough
|
||||
default:
|
||||
gridSink, err = os.Create(gridPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grid log: %w", err)
|
||||
}
|
||||
defer gridSink.Close()
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "spike: child pid=%d, bytes=%s, grid=%s (%dx%d)\r\n",
|
||||
child.Pid(), bytesPath, gridPath, cols, rows)
|
||||
if !gridIsStderr {
|
||||
fmt.Fprintf(os.Stderr, "spike: tail -f %s in another terminal to watch dumps live\r\n", gridPath)
|
||||
}
|
||||
|
||||
// Set host stdin to raw mode so key sequences (arrows, Ctrl-C, etc.)
|
||||
// reach the child intact. Save the state for restore.
|
||||
var restoreState *term.State
|
||||
if stdinPassthrough && term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
st, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin raw: %w", err)
|
||||
}
|
||||
restoreState = st
|
||||
defer term.Restore(int(os.Stdin.Fd()), restoreState)
|
||||
}
|
||||
|
||||
// Idle detection: PTY reader updates lastWrite; ticker checks if we
|
||||
// crossed the threshold without writes and prints a grid dump.
|
||||
var lastWriteNS atomic.Int64
|
||||
lastWriteNS.Store(time.Now().UnixNano())
|
||||
var lastDumpNS atomic.Int64
|
||||
var dumpRequest = make(chan string, 4)
|
||||
|
||||
// Coordinated shutdown.
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
closeDone := sync.OnceFunc(func() { close(done) })
|
||||
|
||||
// Reader: PTY -> stdout passthrough + emulator + bytes tee.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer closeDone()
|
||||
buf := make([]byte, readBufferBytes)
|
||||
for {
|
||||
n, rerr := child.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := buf[:n]
|
||||
// Tee to host stdout so the user can see the TUI normally.
|
||||
_, _ = os.Stdout.Write(chunk)
|
||||
// Tee to bytes file for golden replay.
|
||||
_, _ = bytesFile.Write(chunk)
|
||||
// Feed the emulator.
|
||||
if _, werr := em.Write(chunk); werr != nil {
|
||||
fmt.Fprintf(os.Stderr, "\r\nspike: emulator.Write error: %v\r\n", werr)
|
||||
}
|
||||
lastWriteNS.Store(time.Now().UnixNano())
|
||||
}
|
||||
if rerr != nil {
|
||||
// EIO from the PTY master is the normal "child closed its
|
||||
// side" signal on Linux; treat it like EOF.
|
||||
if rerr != io.EOF && !errors.Is(rerr, syscall.EIO) {
|
||||
fmt.Fprintf(os.Stderr, "\r\nspike: pty read: %v\r\n", rerr)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Writer: stdin -> PTY, watching for the dump hotkey.
|
||||
if stdinPassthrough {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
n, rerr := os.Stdin.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := buf[:n]
|
||||
if debugStdin {
|
||||
fmt.Fprintf(os.Stderr, "\r\nspike[debug-stdin]: %d bytes:", n)
|
||||
for _, b := range chunk {
|
||||
fmt.Fprintf(os.Stderr, " %02x", b)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\r\n")
|
||||
}
|
||||
out := make([]byte, 0, len(chunk))
|
||||
for _, b := range chunk {
|
||||
if b == hotkey {
|
||||
select {
|
||||
case dumpRequest <- "hotkey":
|
||||
default:
|
||||
// channel full; user is mashing the hotkey,
|
||||
// dumps are still coming
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
if len(out) > 0 {
|
||||
if _, werr := child.Write(out); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SIGWINCH propagation.
|
||||
if followHost {
|
||||
winch := make(chan os.Signal, 1)
|
||||
signal.Notify(winch, syscall.SIGWINCH)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer signal.Stop(winch)
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-winch:
|
||||
if c, r, ok := hostSize(); ok {
|
||||
_ = child.Resize(c, r)
|
||||
_ = em.Resize(c, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Idle ticker: enqueue an "idle" dump request when crossing the threshold.
|
||||
if idleMS > 0 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tick := time.NewTicker(time.Duration(idleMS) * time.Millisecond / 4)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-tick.C:
|
||||
now := time.Now().UnixNano()
|
||||
lw := lastWriteNS.Load()
|
||||
ld := lastDumpNS.Load()
|
||||
if now-lw >= int64(idleMS)*int64(time.Millisecond) && lw > ld {
|
||||
lastDumpNS.Store(now)
|
||||
dumpRequest <- "idle"
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Dump worker: serialises grid reads. PlainText is not cheap and we
|
||||
// don't want overlapping calls.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case reason := <-dumpRequest:
|
||||
dumpGrid(em, reason, gridSink, gridIsStderr || gridToStderr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for the child to exit, then close everything down.
|
||||
exitErr := child.Wait()
|
||||
closeDone()
|
||||
wg.Wait()
|
||||
|
||||
// Final dump for the record.
|
||||
dumpGrid(em, "final", gridSink, gridIsStderr || gridToStderr)
|
||||
|
||||
if restoreState != nil {
|
||||
_ = term.Restore(int(os.Stdin.Fd()), restoreState)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "spike: child exited (%v); bytes=%s grid=%s\r\n", exitErr, bytesPath, gridPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dumpGrid renders the emulator's active screen and writes it to sink.
|
||||
//
|
||||
// When sinkIsTTY is true the lines are terminated with CRLF so they render
|
||||
// correctly even when stdin is in raw mode. Otherwise we use plain LF —
|
||||
// log files don't want CRs.
|
||||
func dumpGrid(em *vt.GhosttyEmulator, reason string, sink *os.File, sinkIsTTY bool) {
|
||||
txt, err := em.PlainText()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\r\nspike: PlainText (%s): %v\r\n", reason, err)
|
||||
return
|
||||
}
|
||||
cur, _ := em.Cursor()
|
||||
scr, _ := em.ActiveScreen()
|
||||
screenName := "primary"
|
||||
if scr == vt.ScreenAlternate {
|
||||
screenName = "alternate"
|
||||
}
|
||||
eol := "\n"
|
||||
if sinkIsTTY {
|
||||
eol = "\r\n"
|
||||
}
|
||||
sep := strings.Repeat("-", 78)
|
||||
header := fmt.Sprintf("[grid dump: %s @ %s | screen=%s cursor=(%d,%d) visible=%v]",
|
||||
reason, time.Now().Format(time.RFC3339Nano), screenName, cur.Col, cur.Row, cur.Visible)
|
||||
fmt.Fprintf(sink, "%s%s%s%s%s%s", eol, sep, eol, header, eol, sep+eol)
|
||||
for _, line := range strings.Split(txt, "\n") {
|
||||
fmt.Fprintf(sink, "%s%s", line, eol)
|
||||
}
|
||||
fmt.Fprintf(sink, "%s%s", sep, eol)
|
||||
|
||||
// If the sink is a file (not the host TTY), print a one-line breadcrumb
|
||||
// to stderr so the user knows the hotkey fired — but only for explicit
|
||||
// user-triggered dumps (hotkey), and only when the child is on the
|
||||
// primary screen. Skipping idle/final dumps keeps the host terminal
|
||||
// quiet during normal interactive use.
|
||||
if !sinkIsTTY && reason == "hotkey" && scr != vt.ScreenAlternate {
|
||||
fmt.Fprintf(os.Stderr, "\r\nspike: dumped grid -> %s\r\n", sink.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func hostSize() (cols, rows uint16, ok bool) {
|
||||
ws, err := cpty.GetsizeFull(os.Stdin)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if ws.Cols == 0 || ws.Rows == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return ws.Cols, ws.Rows, true
|
||||
}
|
||||
82
cmd/spike/testdata/run-matrix.sh
vendored
Executable file
82
cmd/spike/testdata/run-matrix.sh
vendored
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# Spike test matrix. Runs ./bin/spike against each target in sequence so a
|
||||
# human can confirm by eye that the grid dump matches what the program would
|
||||
# render in their own terminal.
|
||||
#
|
||||
# Run this from the project root:
|
||||
# make spike
|
||||
# ./cmd/spike/testdata/run-matrix.sh
|
||||
#
|
||||
# Each target's raw PTY recording lands in spike-<pid>.bytes and the final
|
||||
# grid dump is appended to spike-report-<timestamp>.txt.
|
||||
|
||||
set -u
|
||||
|
||||
ROOT=$(cd "$(dirname "$0")/../../.." && pwd)
|
||||
BIN=${SPIKE_BIN:-$ROOT/bin/spike}
|
||||
REPORT=$ROOT/spike-report-$(date +%Y%m%dT%H%M%S).txt
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo "spike binary not found at $BIN — run 'make spike' first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
note() { printf '\n=== %s ===\n' "$*" | tee -a "$REPORT"; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
run_case() {
|
||||
local label=$1; shift
|
||||
note "$label"
|
||||
printf 'argv: %q ' "$@" | tee -a "$REPORT"
|
||||
printf '\n'
|
||||
printf 'Press Ctrl-] in the spike to dump the grid.\n'
|
||||
printf 'Grid dumps go to spike-<pid>.grid.log (tail -f in another terminal to watch live).\n'
|
||||
printf 'The child must exit cleanly to advance.\n'
|
||||
printf 'Press Enter when ready to start this case (or s to skip)... '
|
||||
read -r ans
|
||||
if [[ "$ans" == "s" ]]; then
|
||||
echo 'skipped' | tee -a "$REPORT"
|
||||
return
|
||||
fi
|
||||
# Tee spike's stderr so grid dumps are visible AND captured in the report.
|
||||
# Without this, Ctrl-] dumps end up in $REPORT silently and look like
|
||||
# the hotkey did nothing.
|
||||
"$BIN" -- "$@" 2> >(tee -a "$REPORT" >&2)
|
||||
echo "---" >> "$REPORT"
|
||||
}
|
||||
|
||||
note "spike test matrix — $(date -Is)"
|
||||
"$BIN" -h 2>>"$REPORT" || true
|
||||
|
||||
# 1. Trivial stream-mode sanity check.
|
||||
run_case "echo + sleep (stream sanity)" sh -c 'echo hello; sleep 1'
|
||||
|
||||
# 2. Interactive shell.
|
||||
run_case "bash -i (prompt + history)" bash -i
|
||||
|
||||
# 3. Alt-screen line editor.
|
||||
if have vim; then
|
||||
run_case "vim README.md (alt-screen)" vim "$ROOT/SPEC.md"
|
||||
else
|
||||
echo "skip vim: not installed" | tee -a "$REPORT"
|
||||
fi
|
||||
|
||||
# 4. Alt-screen continuous redraw.
|
||||
if have htop; then
|
||||
run_case "htop (alt-screen, redraw)" htop
|
||||
else
|
||||
echo "skip htop: not installed" | tee -a "$REPORT"
|
||||
fi
|
||||
|
||||
# 5–7. Real targets — the actual point of the spike.
|
||||
for agent in claude opencode codex; do
|
||||
if have "$agent"; then
|
||||
run_case "$agent (real target)" "$agent"
|
||||
else
|
||||
echo "skip $agent: not installed" | tee -a "$REPORT"
|
||||
fi
|
||||
done
|
||||
|
||||
note "matrix complete. report: $REPORT"
|
||||
echo
|
||||
echo "Next step: write up the per-target verdict in SPIKE-REPORT.md."
|
||||
Reference in New Issue
Block a user