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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ jobs:
working-directory: web
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Tasks.test.tsx validates the generated starters with the real jig binary.
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 24.18.0
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ A definition is data, not a script. Five stock ones ship in
| `smoke.yaml` | the install check: one read-only phase, repo-independent |
| `scout.yaml` | read-only recon: two agent phases and the hand-off between them |
| `two-phase.yaml` | an agent phase that writes, verified by a code phase |
| `plan-build-test.yaml` | a **code-phase repair edge**: a red suite routes back to the builder |
| `plan-build-test.yaml` | a **code-phase repair edge**: a red suite returns to planning, rebuilds, and retests |
| `simple-sdlc.yaml` | an **agent-phase repair edge** (review → revise → re-review), a conditional retest, and per-phase commit messages |
| `factory.yaml` | the **software factory**: plan → build → commit → test → a panel of specialist reviewers looping the builder until they approve, then a deterministic **risk gate** that holds high-risk work for a person |

Expand Down
14 changes: 14 additions & 0 deletions Start Jig.command
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"
echo "Opening Jig…"
if ! go build -o bin/jig ./cmd/jig; then
echo "Jig could not build. Read the message above, then press Enter to close."
read -r
exit 1
fi
if ! ./bin/jig start; then
echo "Jig could not start. Read the message above, then press Enter to close."
read -r
exit 1
fi
9 changes: 8 additions & 1 deletion cmd/jig/definitions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func TestPlanBuildTestPassesWhenTheBuildSatisfiesTheTestCommand(t *testing.T) {
assertPhasesRan(t, traceEvents(t, report.TracePath), "plan", "build", "test")
}

func TestPlanBuildTestRoutesAFailingSuiteBackToTheBuilderAndRerunsIt(t *testing.T) {
func TestPlanBuildTestReplansThenBuildsAndRetestsAFailingSuite(t *testing.T) {
repo := makeFixture(t)
scriptRuntime(t,
map[string]any{"text": envelopeJSON(t, map[string]any{
Expand All @@ -145,6 +145,10 @@ func TestPlanBuildTestRoutesAFailingSuiteBackToTheBuilderAndRerunsIt(t *testing.
}),
},
// Dispatched by the edge with the failing adapter envelope in hand.
map[string]any{"text": envelopeJSON(t, map[string]any{
"status": "success", "summary": "revised plan: create the missing marker",
"notes_for_next_agent": "create built.txt and preserve notes.txt",
})},
map[string]any{
"files": map[string]any{"built.txt": "ok\n"},
"text": envelopeJSON(t, map[string]any{
Expand All @@ -166,6 +170,9 @@ func TestPlanBuildTestRoutesAFailingSuiteBackToTheBuilderAndRerunsIt(t *testing.
if entries := phaseStarts(events, "build"); entries != 2 {
t.Fatalf("build ran %d time(s), want 2 (the original and the repair)", entries)
}
if entries := phaseStarts(events, "plan"); entries != 2 {
t.Fatalf("plan ran %d time(s), want initial plan and repair plan", entries)
}
if entries := phaseStarts(events, "test"); entries != 2 {
t.Fatalf("test ran %d time(s), want 2 (the failure and the rerun)", entries)
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/jig/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var version = "dev"
const usage = `jig — a local-first software factory

Usage:
jig start Start the UI and Codex together and open your browser
jig serve Start the control plane (loopback HTTP + embedded UI)
jig worker Start the single implicit worker
jig run Run a definition directly against a local repository
Expand Down Expand Up @@ -53,6 +54,8 @@ func run(ctx context.Context, args []string) int {
return 2
}
switch args[0] {
case "start":
return startCommand(ctx, args[1:], os.Stdout, os.Stderr)
case "run":
return runCommand(ctx, args[1:], os.Stdout, os.Stderr)
case "serve":
Expand Down
153 changes: 153 additions & 0 deletions cmd/jig/start.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package main

import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os/exec"
"runtime"
"time"
)

// Use the existing commands so startup preserves their cancellation and
// worker-drain behavior. Stop the worker before stopping an owned server.
func startCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
flags := flag.NewFlagSet("jig start", flag.ContinueOnError)
flags.SetOutput(stderr)
noOpen := flags.Bool("no-open", false, "do not open the browser")
if err := flags.Parse(args); err != nil {
return exitUsage
}
if flags.NArg() != 0 {
fmt.Fprintln(stderr, "Usage: jig start [--no-open]")
return exitUsage
}
url := "http://" + defaultServerAuthority
client := &http.Client{Timeout: time.Second}
var heartbeatAfter time.Time
running, ready, err := localCodexReady(ctx, client, url, heartbeatAfter)
if err != nil {
fmt.Fprintln(stderr, "jig start:", err)
return exitInfraFailed
}
serverCtx, stopServer := context.WithCancel(context.WithoutCancel(ctx))
defer stopServer()
var serverDone chan int
if !running {
// A restarted server retains recent heartbeats from stopped workers.
heartbeatAfter = time.Now()
fmt.Fprintln(stdout, "Starting Jig…")
serverDone = make(chan int, 1)
go func() { serverDone <- serveCommand(serverCtx, []string{"--no-github-poll"}, stdout, stderr) }()
defer func() { stopServer(); <-serverDone }()
}
workerCtx, stopWorker := context.WithCancel(context.WithoutCancel(ctx))
defer stopWorker()
var workerDone chan int
defer func() {
if workerDone != nil {
stopWorker()
<-workerDone
}
}()
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
deadline := time.NewTimer(30 * time.Second)
defer deadline.Stop()
for !ready {
if running && workerDone == nil {
fmt.Fprintln(stdout, "Starting your signed-in Codex worker…")
workerDone = make(chan int, 1)
go func() { workerDone <- workerCommand(workerCtx, []string{"--runtime", "codex"}, stdout, stderr) }()
}
select {
case <-ctx.Done():
return exitAccepted
case code := <-serverDone:
serverDone <- code
return exitInfraFailed
case code := <-workerDone:
workerDone <- code
return exitInfraFailed
case <-deadline.C:
fmt.Fprintln(stderr, "Jig could not become ready in 30 seconds. Check the startup messages above.")
return exitInfraFailed
case <-ticker.C:
running, ready, err = localCodexReady(ctx, client, url, heartbeatAfter)
if err != nil {
fmt.Fprintln(stderr, "jig start:", err)
return exitInfraFailed
}
}
}
fmt.Fprintf(stdout, "Jig is ready: %s\nChoose a project, describe your task, and press Start task.\n", url)
if !*noOpen {
var command string
switch runtime.GOOS {
case "darwin":
command = "open"
case "linux":
command = "xdg-open"
}
if command != "" {
openCtx, cancelOpen := context.WithTimeout(ctx, 10*time.Second)
err := exec.CommandContext(openCtx, command, url).Run()
cancelOpen()
if err != nil {
fmt.Fprintln(stderr, "Open this address in your browser:", url)
}
}
}
if workerDone == nil && serverDone == nil {
return exitAccepted
}
fmt.Fprintln(stdout, "Leave this window open while using Jig. Press Ctrl+C to stop.")
select {
case <-ctx.Done():
return exitAccepted
case code := <-workerDone:
workerDone <- code
return exitInfraFailed
case code := <-serverDone:
serverDone <- code
return exitInfraFailed
}
}

func localCodexReady(ctx context.Context, client *http.Client, url string, heartbeatAfter time.Time) (running, ready bool, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+"/api/workers", nil)
if err != nil {
return false, false, err
}
response, err := client.Do(req)
if err != nil {
return false, false, nil
}
defer response.Body.Close()
var fleet struct {
Workers []struct {
Live bool `json:"live"`
Heartbeat time.Time `json:"last_heartbeat"`
Runtimes []struct {
Name string `json:"name"`
} `json:"runtimes"`
} `json:"workers"`
}
if response.StatusCode != http.StatusOK {
return true, false, fmt.Errorf("port 8383 did not return Jig's worker status (%s)", response.Status)
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&fleet); err != nil {
return true, false, fmt.Errorf("cannot read Jig worker status: %w", err)
}
for _, worker := range fleet.Workers {
for _, capability := range worker.Runtimes {
if worker.Live && capability.Name == "codex" && (heartbeatAfter.IsZero() || !worker.Heartbeat.Before(heartbeatAfter)) {
return true, true, nil
}
}
}
return true, false, nil
}
47 changes: 47 additions & 0 deletions cmd/jig/start_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package main

import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)

func TestLocalCodexReady(t *testing.T) {
for _, test := range []struct {
name, body string
ready, bad bool
}{
{"ready", `{"workers":[{"live":true,"runtimes":[{"name":"codex"}]}]}`, true, false},
{"stale", `{"workers":[{"live":false,"runtimes":[{"name":"codex"}]}]}`, false, false},
{"other runtime", `{"workers":[{"live":true,"runtimes":[{"name":"claude-code"}]}]}`, false, false},
{"malformed", `<html>another application</html>`, false, true},
} {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/workers" {
t.Errorf("unexpected path %s", r.URL.Path)
}
io.WriteString(w, test.body)
}))
defer server.Close()
running, ready, err := localCodexReady(context.Background(), server.Client(), server.URL, time.Time{})
if !running || ready != test.ready || (err != nil) != test.bad {
t.Fatalf("running=%t ready=%t err=%v", running, ready, err)
}
if test.ready {
_, ready, _ := localCodexReady(context.Background(), server.Client(), server.URL, time.Now())
if ready {
t.Fatal("old worker heartbeat must not count after server restart")
}
}
})
}
server := httptest.NewServer(http.NotFoundHandler())
server.Close()
if running, _, err := localCodexReady(context.Background(), server.Client(), server.URL, time.Time{}); running || err != nil {
t.Fatalf("stopped server: running=%t err=%v", running, err)
}
}
31 changes: 31 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,37 @@ If that printed a version, the whole toolchain requirement is satisfied. The
UI is committed and embedded, and the SQLite driver is pure Go, so there is
no second build step and no native dependency to install.

## Use the UI with Codex

On a Mac, double-click **Start Jig.command** in the project folder. It builds
Jig, starts the UI and your signed-in Codex worker, and opens the browser.
Leave its terminal window open while you work. Press Ctrl+C to stop the
services it started. Opening it again reuses an already-ready session.

From a terminal, the equivalent is:

```sh
./bin/jig start
```

Open <http://127.0.0.1:8383>. Choose a Git project folder or repository,
use **Let agents improve it**, and press **Start task**. The goal box is optional:
agents inspect the project, choose one useful improvement, and write the directive
and plan themselves. Choose **Get an answer** for read-only research or
**Make changes** when you already have a specific directive.
The UI creates the workflow for you; no YAML or run command is needed.

Ask reads the project and returns an answer. Make a change runs
plan → build → test. Failed tests go back to planning, followed by another
build and test, for up to three repair rounds without waiting for human input.
Its results stay
in a separate working folder for review, with no automatic push or PR.
Work starts at the selected project's latest commit, so commit any changes
you want included before starting. **Options** lets you choose your Codex
model and a test command when automatic detection does not fit your project.
Codex uses its existing login; a ChatGPT login remains subject to your plan's
usage limits.

---

## 2. The smoke run
Expand Down
17 changes: 12 additions & 5 deletions examples/definitions/plan-build-test.yaml
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# plan-build-test.yaml — the code-phase repair edge (U9, KTD2). Plan the
# change, build it, then run the repository's own test command; a failing
# test suite routes its adapter envelope back to the builder and re-runs the
# suite, bounded by the edge's budget.
# test suite routes its adapter envelope back to the planner, runs the
# builder with the revised plan, and re-runs the suite, budget bounded.
#
# jig run --def examples/definitions/plan-build-test.yaml <repo> "add a --version flag"
#
# The repair edge is the whole point. A code phase's failure is nonzero exit
# (R8), and the adapter envelope that carries the exit status and output tail
# enters the repair loop through exactly the same door an agent's failing
# report would — so "the tests broke" is a workflow state, not an incident.
# `budget: 2` bounds it and `exhausted: fail-job` means a suite that is still
# red after two repair rounds fails the job rather than shipping.
# `budget: 3` bounds it and `exhausted: fail-job` means a suite that is still
# red after three repair rounds fails the job rather than shipping.
#
# TWO THINGS TO EDIT before using this on your own repository:
#
Expand Down Expand Up @@ -43,6 +43,11 @@ roster:
touch, in what order, and how the result will be verified by the
repository's own test command.

If the previous report contains failed tests, diagnose that failure
and revise the plan before the builder runs again. Pass the failure
evidence and a concrete repair directive in notes_for_next_agent.
Preserve the user's scope. Never weaken tests to make them pass.

Your final JSON must also include:
- "notes_for_next_agent": the plan itself, concrete enough to
execute without re-deriving it
Expand All @@ -63,6 +68,8 @@ roster:

The plan and any previous phase's report are above. Make the change,
then stop — the test suite runs as its own phase.
On repairs, report all paths changed relative to the original commit,
including changes already present from the previous build.

Your final JSON must also include:
- "changed_files": the repo-relative paths of every file you
Expand All @@ -89,5 +96,5 @@ phases:
elif [ -f go.mod ]; then go test ./...;
elif [ -f package.json ]; then npm test --silent;
else echo "plan-build-test: no test command detected — edit the test phase"; exit 1; fi
on_fail: {run: build, then: rerun-self, budget: 2, exhausted: fail-job}
on_fail: {run: plan, then: rerun-chain, budget: 3, exhausted: fail-job}
acceptance: [all_phases_passed, artifacts_exist, diff_matches_claims]
Loading
Loading