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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ UI (from `web/`):
- **Definition changes are validated at save time.** A new definition field
or rule goes in `internal/protocol/definition.go` with a test, and the stock
definitions must still pass `just definitions`.
- **Parallel-group members are read-only and concurrent.** Anything that
- **Parallel-group members are read-only (apart from declared
`build_outputs`) and concurrent.** Anything that
dispatches or replays phases must not run a member outside the group.
- Work on a branch in a sibling worktree, open a PR, never push to `main`.
- `gofmt` everything; match the surrounding comment style, which explains
Expand Down
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ roster: # one entry per agent role
Task: {{prompt}} # the invocation's prompt, frozen at admission
env: [PATH] # env allowlist — the ONLY variables it receives
writes: ["src/**"] # write allowlist; [] is read-only, omitted is unrestricted
# (build output directories go in build_outputs, below)
phases:
- name: build
kind: agent # agent | code
Expand Down Expand Up @@ -174,6 +175,7 @@ acceptance: [all_phases_passed, diff_matches_claims]
publish:
hold_when: "risk == high" # accepted but held for a person; publish retry releases it
ci: {wait: true, timeout: 30m} # accepted only once CI on the PR head is green
build_outputs: ["bin/**"] # gitignored directories ANY role may write, read-only roles included
```

Rules worth knowing before you write one:
Expand Down Expand Up @@ -262,6 +264,32 @@ Rules worth knowing before you write one:
send by that role may cost; Claude Code enforces it as `--max-budget-usd`.
Codex has no such flag, and a cap it cannot enforce fails the send rather
than run uncapped — the same posture as a `tools` allowlist there.
- **Build outputs are a grant, not an allowlist entry.** A read-only
reviewer that runs the repository's own check command still writes what
it builds, and `writes: []` would breach on it. `build_outputs:
["bin/**"]` names the directories every role — read-only reviewers and
parallel-group members included — may write without breaching. A grant
covers **gitignored** paths only: a path under a grant that git does not
ignore is ordinary content judged by `writes` — a breach for a read-only
role, whose message says `declared build output is not gitignored`, and a
changed path (committed like any other) for a writing one. Each entry is
exactly one of `<dir>/**`, `**/<dir>/**`, or `<dir>/`, with `<dir>`
literal; anything else (`bin/*.o`, `*/bin/**`, `b?n/**`, the bare `**`)
is refused at save time, and leading or trailing whitespace is refused,
not trimmed. Git reports a wholly ignored directory as one entry and a
grant matches paths as git enumerates them, so declare the directory git
ignores: with `cache/` ignored, `cache/**` covers `cache/bin/x` but
`cache/bin/**` does not. `**/<dir>/**` matches the directory at the root
and nested. A path tracked in HEAD is never an output, and git metadata
never is. Outputs are not changed paths, so jig never stages or publishes
one, and `git add -A` in a commit phase never stages an ignored path; a
role that commits an output itself (`git add -f`) publishes it under its
own `writes`, and from then on it is tracked content. Outputs
written during a parallel group survive the group whatever happened to the
member that wrote them, and the trace attributes them to the group, not a
role. A crashed sequential phase is still rolled back to its snapshot,
its new outputs included. Declare only directories no later phase
executes: a reviewer could otherwise plant what a test phase runs.
- **Code phases can report.** A code phase with `reports_fields: true`
prints a JSON object as its last output line, and those fields join the
envelope view that `if:` guards and `publish.hold_when` read. It is how a
Expand Down Expand Up @@ -353,11 +381,12 @@ Rules worth knowing before you write one:
[review-correctness, review-security, review-maintainability]` runs those
phases at once, as one step of the chain. It is opt-in and narrow: one
group per definition, two or more consecutive agent phases, each with its
own role, every role `writes: []`, and no member's `if:` guard reading a
field a sibling reports. Each member runs in its own ephemeral HOME and
session and is handed the envelope from BEFORE the group, never a
own role, every role `writes: []` (declared `build_outputs` excepted;
outputs written during the group survive it), and no member's `if:` guard
reading a field a sibling reports. Each member runs in its own ephemeral
HOME and session and is handed the envelope from BEFORE the group, never a
sibling's; results merge in declared order, and the next phase gets the
last member's envelope. Any worktree change while the group runs —
last member's envelope. Any other worktree change while the group runs —
including one a crashed member left — rolls back and aborts the attempt.
Rejections resolve after every member finishes: the first member in
declared order that rejected with budget left dispatches its repair target
Expand Down
92 changes: 92 additions & 0 deletions cmd/jig/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -192,3 +193,94 @@ func TestTheParallelFactoryIsTheStockFactoryWithItsPanelGrouped(t *testing.T) {
t.Fatal("factory-parallel.yaml differs from factory.yaml beyond its name and its parallel group")
}
}

// factoryFixture is a repository whose `make test` passes, optionally with
// a .gitignore listing bin/ so the stock build_outputs grant covers it.
func factoryFixture(t *testing.T, ignoreBin bool) string {
t.Helper()
if _, err := exec.LookPath("make"); err != nil {
t.Skip("make is not installed; the stock test command detects it first")
}
repo := initRepo(t)
writeFile(t, repo, "Makefile", "test:\n\t@true\n")
if ignoreBin {
writeFile(t, repo, ".gitignore", "bin/\n")
}
gitIn(t, repo, "add", ".")
gitIn(t, repo, "commit", "--quiet", "-m", "fixture")
return repo
}

// scriptedFactory is the stock factory exactly as written minus its runtime
// pin: the scripted runtime advertises itself as `scripted`, and a worker
// never claims a definition pinned to a runtime it does not run.
func scriptedFactory(t *testing.T) string {
t.Helper()
source, err := os.ReadFile(stockDefinition("factory.yaml"))
if err != nil {
t.Fatal(err)
}
const pinned = "\nruntime: claude-code\n"
if strings.Count(string(source), pinned) != 1 {
t.Fatalf("factory.yaml does not pin %q exactly once", strings.TrimSpace(pinned))
}
path := filepath.Join(t.TempDir(), "factory.yaml")
writeFile(t, filepath.Dir(path), filepath.Base(path),
strings.Replace(string(source), pinned, "\n", 1))
return path
}

func factoryBuildStep(t *testing.T, files map[string]any) map[string]any {
t.Helper()
files["work.txt"] = "the work\n"
files["~/commit-message"] = "Write the work\n"
return map[string]any{
"files": files,
"text": envelopeJSON(t, map[string]any{
"status": "success", "summary": "wrote work.txt",
"artifacts": []any{"work.txt"}, "changed_files": []any{"work.txt"},
}),
}
}

// The stock commit phases stage with `git add -A`, which never stages a
// gitignored path — so a declared build output never reaches a commit, and
// a reviewer's output that is NOT gitignored is a breach that says why.
func TestStockCommitBuildNeverCommitsAGitignoredBuildOutput(t *testing.T) {
t.Run("gitignored output stays out of HEAD", func(t *testing.T) {
repo := factoryFixture(t, true)
scriptRuntime(t, planStep(t),
factoryBuildStep(t, map[string]any{"bin/jig": "binary"}),
reviewStep(t, true), reviewStep(t, true), reviewStep(t, true))

code, report, stderr := runJigJSON(t, "--def", scriptedFactory(t),
"--data", t.TempDir(), repo, "write the work")
if code != exitAccepted {
t.Fatalf("exit = %d (%s), want %d (stderr: %s)", code, report.Error, exitAccepted, stderr)
}
tree := strings.Split(gitIn(t, repo, "ls-tree", "-r", "--name-only", "HEAD"), "\n")
if !slices.Contains(tree, "work.txt") || slices.Contains(tree, "bin/jig") {
t.Fatalf("HEAD tree = %v, want work.txt and not bin/jig", tree)
}
if _, err := os.Stat(filepath.Join(repo, "bin/jig")); err != nil {
t.Fatalf("the build output did not survive on disk: %v", err)
}
})
t.Run("non-ignored reviewer output is a breach", func(t *testing.T) {
repo := factoryFixture(t, false)
reviewer := reviewStep(t, true)
reviewer["files"] = map[string]any{"bin/x": "a reviewer ran the build"}
scriptRuntime(t, planStep(t), factoryBuildStep(t, map[string]any{}), reviewer)

code, report, stderr := runJigJSON(t, "--def", scriptedFactory(t),
"--data", t.TempDir(), repo, "write the work")
if code == exitAccepted || !strings.Contains(report.Error, "not gitignored") {
t.Fatalf("exit = %d (%s), want an abort naming a non-gitignored output (stderr: %s)",
code, report.Error, stderr)
}
tree := strings.Split(gitIn(t, repo, "ls-tree", "-r", "--name-only", "HEAD"), "\n")
if slices.Contains(tree, "bin/x") {
t.Fatalf("HEAD tree = %v, want no bin/x", tree)
}
})
}
36 changes: 31 additions & 5 deletions examples/definitions/factory-parallel.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
# review-maintainability]` runs the three reviewers at once, each in its own
# ephemeral HOME and session. A group may hold only consecutive agent phases
# whose roles are read-only (`writes: []`) and distinct; any change to the
# worktree while the panel runs aborts the attempt. What changes against the
# worktree while the panel runs, apart from the declared `build_outputs`,
# aborts the attempt. Outputs written during the panel survive it whatever
# happened to the reviewer that wrote them. What changes against the
# sequential panel:
#
# - Prompts. Every reviewer is handed the envelope from before the panel
Expand Down Expand Up @@ -62,6 +64,22 @@
# once, on the same head, before a repair round is spent: a job that passes
# on re-run publishes, reported as flaky; one still red goes to repair.
#
# THE WRITE BOUNDARY AND BUILD OUTPUTS. Every role's `writes` allowlist is
# checked after each phase; the reviewers are read-only (`writes: []`). A
# reviewer that runs the repository's own check command still writes its
# build output, so `build_outputs` names the directories ANY role may write
# without breaching: `bin/**` (`go build -o bin/...`) and `target/**` (Rust,
# Maven). A grant is honoured only for gitignored paths — add the directory
# to `.gitignore`, or a read-only role's write there is a breach that says
# so. Each entry is exactly `<dir>/**`, `**/<dir>/**`, or a literal `<dir>/`
# naming the directory git ignores, and never covers a path tracked in HEAD
# or git metadata. Jig never stages an output: outputs are not changed
# paths. A role that commits one
# itself publishes it, under its own `writes`. Declare only directories no
# later phase EXECUTES: the `test` phase runs `npm test`, so a JavaScript
# dependency directory is deliberately left out — granting it would let a
# reviewer plant what `test` runs.
#
# THE ROSTER. Builders and reviewers are `opus`; the planner and the extra
# high-risk reviewer are `sonnet`. `effort` is medium for building to a plan
# and high for reviewers hunting what the build missed. `budget_usd` caps what
Expand All @@ -70,9 +88,13 @@
#
# EDIT BEFORE USE: the test command (it detects common entry points and
# fails loudly when it finds none), the builder's `writes` allowlist (`**` is
# the weakest boundary jig can enforce — narrow it, R10), and the risk
# classifier's path pattern and line threshold, which should name what is
# dangerous in YOUR repository.
# the weakest boundary jig can enforce — narrow it, R10), the
# `build_outputs` list (declare only directories your build writes and no
# later phase executes; a grant is honoured only for gitignored paths — add
# the directory to `.gitignore`, or a read-only role's write there is a
# breach that says so; a role that commits an output itself publishes it),
# and the risk classifier's path pattern and line threshold, which should
# name what is dangerous in YOUR repository.
name: factory-parallel
runtime: claude-code
roster:
Expand Down Expand Up @@ -283,7 +305,10 @@ phases:
# The commit phases stage with `git add -A` deliberately: by the time a
# code phase runs, the write boundary has already verified every path the
# agent changed against its allowlist and rolled back anything else (R10),
# so what is left in the worktree is exactly the authorized work.
# so what is left in the worktree is exactly the authorized work. `git add
# -A` never stages gitignored paths, so declared build outputs never reach
# these commits; a non-ignored path under a grant is ordinary content the
# boundary has already judged under `writes`.
- name: commit-build
kind: code
owner: builder
Expand Down Expand Up @@ -413,3 +438,4 @@ publish:
rerun: {budget: 1}
on_fail: {run: build, budget: 2}
timeout: 30m
build_outputs: ["bin/**", "target/**"]
32 changes: 28 additions & 4 deletions examples/definitions/factory.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@
# once, on the same head, before a repair round is spent: a job that passes
# on re-run publishes, reported as flaky; one still red goes to repair.
#
# THE WRITE BOUNDARY AND BUILD OUTPUTS. Every role's `writes` allowlist is
# checked after each phase; the reviewers are read-only (`writes: []`). A
# reviewer that runs the repository's own check command still writes its
# build output, so `build_outputs` names the directories ANY role may write
# without breaching: `bin/**` (`go build -o bin/...`) and `target/**` (Rust,
# Maven). A grant is honoured only for gitignored paths — add the directory
# to `.gitignore`, or a read-only role's write there is a breach that says
# so. Each entry is exactly `<dir>/**`, `**/<dir>/**`, or a literal `<dir>/`
# naming the directory git ignores, and never covers a path tracked in HEAD
# or git metadata. Jig never stages an output: outputs are not changed
# paths. A role that commits one
# itself publishes it, under its own `writes`. Declare only directories no
# later phase EXECUTES: the `test` phase runs `npm test`, so a JavaScript
# dependency directory is deliberately left out — granting it would let a
# reviewer plant what `test` runs.
#
# THE ROSTER. Builders and reviewers are `opus`; the planner and the extra
# high-risk reviewer are `sonnet`. `effort` is medium for building to a plan
# and high for reviewers hunting what the build missed. `budget_usd` caps what
Expand All @@ -58,9 +74,13 @@
#
# EDIT BEFORE USE: the test command (it detects common entry points and
# fails loudly when it finds none), the builder's `writes` allowlist (`**` is
# the weakest boundary jig can enforce — narrow it, R10), and the risk
# classifier's path pattern and line threshold, which should name what is
# dangerous in YOUR repository.
# the weakest boundary jig can enforce — narrow it, R10), the
# `build_outputs` list (declare only directories your build writes and no
# later phase executes; a grant is honoured only for gitignored paths — add
# the directory to `.gitignore`, or a read-only role's write there is a
# breach that says so; a role that commits an output itself publishes it),
# and the risk classifier's path pattern and line threshold, which should
# name what is dangerous in YOUR repository.
name: factory
runtime: claude-code
roster:
Expand Down Expand Up @@ -271,7 +291,10 @@ phases:
# The commit phases stage with `git add -A` deliberately: by the time a
# code phase runs, the write boundary has already verified every path the
# agent changed against its allowlist and rolled back anything else (R10),
# so what is left in the worktree is exactly the authorized work.
# so what is left in the worktree is exactly the authorized work. `git add
# -A` never stages gitignored paths, so declared build outputs never reach
# these commits; a non-ignored path under a grant is ordinary content the
# boundary has already judged under `writes`.
- name: commit-build
kind: code
owner: builder
Expand Down Expand Up @@ -400,3 +423,4 @@ publish:
rerun: {budget: 1}
on_fail: {run: build, budget: 2}
timeout: 30m
build_outputs: ["bin/**", "target/**"]
Loading
Loading