1. Overview

The loop is solid in the middle now, but its edges are soft. It still spawns roles into whatever state the checkout happens to be in, and nothing has ever written down what the agent is allowed to do once it starts. In this lesson, we harden both edges: we decide up front the conditions a run may begin under, and the actions the agent may take on its own.

We’ll add a fast check that runs before anything is spawned, then write the harness’s permission policy once, as configuration the agent can’t talk its way around. Then we’ll run a ticket straight through the hardened loop and watch both edges hold.

The harness at this lesson’s refinement lives on the module2-lesson3 checkpoint branch.

2. Failing Before We Spawn

Let’s start with a run that never had a chance. The delegated loop takes a ticket and dispatches the Implementor straight away, into whatever state the working copy is in. If the tree is dirty from a half-finished edit, the checkout is sitting on the wrong branch, or the build tool isn’t on the path, none of that is noticed up front. The Implementor spawns, reads the ticket, makes its change, and only then does something downstream fail, forty minutes and a lot of tokens later.

The waste is the whole problem. A fresh context was spent, a partial change is now sitting on the wrong base, and the run log records work that has to be unwound before anything can be retried. All of it was avoidable, because the conditions that doomed the run were true before the first role ever started.

So we add a check that runs before any role is spawned. A run that would have died forty minutes in dies in ten seconds instead, with nothing spawned and nothing to unwind. Fail before spawning.

The harness already guards the handoff and the commit once the loop is running. This new check guards the state the loop starts from, before any of that begins. It asks three plain questions: are we on the branch this ticket targets, is the working tree clean, and is the build tool available. If any answer is no, the run stops where it stands.

Preflight at the front of the run-ticket loop, with the spawn boundary

3. Building the Preflight Gate

Preflight is a gate like the two we already have, a single-file Java program run with JBang, checked in under harness/gates/ next to the contracts it guards. It follows the same anatomy: the JBang shebang, the //JAVA 25 directive, a main, and the exit-code contract of 0 for pass, 1 for a broken rule, and 2 for a usage error. Here’s main, which runs the three checks in order and stops at the first failure:

///usr/bin/env jbang "$0" "$@" ; exit $?
//JAVA 25

import java.io.File;
import java.nio.file.*;

class Preflight {
    public static void main(String[] args) throws Exception {
        if (args.length == 0) {
            System.err.println("usage: preflight <expected-branch>");
            System.exit(2);
        }
        String branch = capture("git", "rev-parse", "--abbrev-ref", "HEAD").trim();
        if (!branch.equals(args[0])) {
            fail("on branch " + branch + ", expected " + args[0]);
        }
        if (!capture("git", "status", "--porcelain").isBlank()) {
            fail("working tree is not clean");
        }
        if (!onPath("mvn")) {
            fail("mvn not found on PATH");
        }
        System.out.println("OK: preflight clean");
    }
}

The expected branch comes in as the single argument, so the Orchestrator names the branch a given ticket targets rather than the gate hardcoding it. We check for mvn and nothing else. The gate is itself a JBang script, so jbang and a working java had to be present for it to run at all. The //JAVA 25 directive already pins the JDK before the body executes. So listing them as checks would be redundant.

The other difference from the earlier gates is what the checks read. Those take a file argument and compare its contents against a committed fixture. Preflight reads live state instead, shelling out to git and scanning the path, so it needs two small helpers:

    static void fail(String reason) {
        System.err.println("FAIL: " + reason);
        System.exit(1);
    }

    static String capture(String... command) throws Exception {
        Process p = new ProcessBuilder(command).redirectErrorStream(true).start();
        String out = new String(p.getInputStream().readAllBytes());
        p.waitFor();
        return out;
    }

    static boolean onPath(String tool) {
        String path = System.getenv("PATH");
        String[] exts = System.getProperty("os.name").toLowerCase().contains("win")
            ? new String[] { ".cmd", ".bat", ".exe", "" }
            : new String[] { "" };
        for (String dir : path.split(File.pathSeparator)) {
            for (String ext : exts) {
                if (Files.isRegularFile(Path.of(dir, tool + ext))) {
                    return true;
                }
            }
        }
        return false;
    }

Now we wire it in. Preflight is the Orchestrator’s first act, ahead of everything else it does, so these lines go at the top of orchestrator.md:

Before you dispatch anyone, run preflight. From the repo root, run
jbang harness/gates/preflight.java <expected-branch>, passing the branch this
ticket targets as the argument. A non-zero exit stops the run here, before any
role is spawned: the tree is dirty, the checkout is on the wrong branch, or mvn
is missing. Only on exit 0 do you go on to take the ticket ID and find its
section in harness/plan.md.

The entry command still just points at the Orchestrator contract, so wiring preflight into that contract is the whole change and the skill loader needs no edit. Let’s run jbang harness/gates/preflight.java module2-lesson3 on the current clean checkout, and it prints OK: preflight clean and exits 0.

4. Trusting the Gate by Testing It

We trust a gate by testing it, not by reading it, so preflight earns its place the same way the others did, by passing good input and failing bad input on demand. We just saw the good case, where a clean checkout on the right branch with mvn present exits 0.

The bad cases are where the ten-second death actually shows. If we touch a file to dirty the tree and run preflight again, it stops on the second check:

$ jbang harness/gates/preflight.java module2-lesson3
FAIL: working tree is not clean

And if we run it claiming a branch we aren’t on, it stops on the first check and names the mismatch:

$ jbang harness/gates/preflight.java module2-lesson9
FAIL: on branch module2-lesson3, expected module2-lesson9

Each failure returns at once, before any role is dispatched, which is the whole point of putting the check ahead of the spawn. One limitation is worth naming. Unlike the file-argument gates, preflight has no committed good-and-bad pair to test against, because its input is the live environment. Its known-bad is a tree we dirty by hand at authoring time, a transient state rather than a fixture we can check in and rerun from a fresh clone.

5. The Authorization Line

The run now starts safely, but nothing says what the agent may do once it’s running. May it commit? Push? Delete a file it decides is stale? Today the answer is whatever the person at the keyboard clicks when a prompt appears, which is fine while someone is watching and useless the moment no one is.

That’s the real gap. An unattended agent that reaches an action nobody decided on has two bad options: stall forever waiting on a human who isn’t there, or run under blanket trust and do whatever it likes. Neither is a policy. Both are what we get when the permission question is answered per action, at two in the morning, by nobody.

So we answer it once, in advance. The authorization line is a written policy for what the agent may do on its own, decided when we set the harness up rather than mid-run. It sorts every action into three buckets:

  • Reversible, project-scoped actions are pre-authorized.
  • Irreversible or externally visible actions are gated, so a human, not the agent, makes the call.
  • Anything indeterminate stops the run, the safe default when the policy can’t classify what’s being asked.

The harness already draws exactly this line, and it draws it once: it commits, and it never pushes. A commit is local and undoable, so it’s pre-authorized. A push is visible to everyone downstream and stays the human’s call. Writing the policy down doesn’t change the harness’s behavior here. It just makes the decision explicit instead of implicit in a prompt.

Beyond the commit and push line, the general form is a production and non-production split, and it’s the shape we map onto our own infrastructure. Non-production resources are pre-authorized, anything named for production stays gated, and a target the policy can’t resolve stops the run. Jira Lite is a local app with no production side, so this is the transferable principle rather than a line this codebase draws.

One clause to prevent a collision of words. The ticket we run next happens to be about the app’s own role rules, specifically which users are allowed to close a task. That is authorization inside the product, a feature the agent implements. It’s a different thing from the harness’s authorization over the agent, and we keep the two apart.

6. Mapping the Policy Onto Permission Scoping

A policy only bites if something enforces it, and Claude Code enforces permissions itself, outside the model. The prompt shapes what the agent tries. The permission rules decide what it’s allowed, so the policy belongs in the rules. Those rules are three lists, allow, ask, and deny, and the harness’s policy maps straight onto them.

We put them in a committed .claude/settings.json. That file is the team-shared, checked-in policy, distinct from the personal .claude/settings.local.json, the per-machine local overrides that today just allow mvn and jbang for whoever is at the machine. The three buckets from the last section become the three lists, and the three-tier rule rides along in a _comment field at the top. JSON has no comment syntax, so the rule lives in a key the tool ignores, and the file both encodes the policy and explains it:

{
  "permissions": {
    "_comment": [
      "reversible, project-scoped      -> allow",
      "externally visible, never ours  -> deny (a person does it)",
      "externally visible, ask first   -> ask (a human approves)",
      "anything not listed             -> default mode prompts (fail-safe)"
    ],
    "defaultMode": "default",
    "allow": [
      "Read",
      "Bash(mvn *)",
      "Bash(jbang *)",
      "Bash(git add *)",
      "Bash(git commit *)",
      "Bash(git status *)",
      "Bash(git diff *)"
    ],
    "ask": [
      "Bash(gh *)"
    ],
    "deny": [
      "Bash(git push *)"
    ]
  }
}

The allow list is the reversible, project-scoped work: build, run a gate, stage and commit, and read the repo. Everything on it runs without stopping to ask, so the decided work no longer waits on a human. The gated bucket then splits by how firmly we hold an action back from the agent. deny holds git push, which is never the agent’s call to make on its own, so a person pushes by hand when a release is ready. ask is the softer form of the same line, here the GitHub CLI, whose commands reach outside the repo but may go ahead once a human says yes.

A few behaviors make this hold together. Bash matching is space-and-operator aware, so Bash(git push *) matches git push origin main but not a command that merely contains those words. The lists are read in deny, then ask, then allow order, so a deny always wins over an allow. And under default mode, the first use of anything not listed prompts, which is the fail-safe for the indeterminate bucket. An action the policy never classified stops for a human rather than slipping through.

That default mode is a deliberate choice. Locking the run down so it proceeds with no human present, and launching it with a scoped tool list, is the job of a later lesson that actually runs the agent unattended. Here we write the policy and enforce it interactively, one committed file that says what the agent may do.

7. Running a Ticket Through the Hardened Loop

Both edges are in place, so let’s prove them on a real run. The demo ticket, BAH-T7, makes the roles allowed to close a task configurable, replacing the hardcoded manager-or-admin check on the close path with a set read from configuration. It mirrors the reopen toggle a previous ticket added: a field on the typed configuration surface, a default in application.yml, and the close path reading it. We author it at the shape our tickets have reached, into harness/plan.md as a new section:

### BAH-T7. Configurable closer roles

#### Intent
Add jiralite.workflow.close-roles (default MANAGER,ADMIN): the set of roles
allowed to transition a task to CLOSED. Replace the hardcoded MANAGER/ADMIN check
on the close path with a check against the configured set. Boundaries: the CLOSE
transition's role gate only; the reopen role check, every other transition, and
the response shape are unchanged.

#### Mechanical checks (the Evaluator re-runs)

Durable automated tests (join the build):
- At the default, a MANAGER and an ADMIN can still close a DONE task.
- A role outside the configured set is rejected with a 403 on close.
- With close-roles set to a single role, only that role may close.
- The existing suite still passes: mvn test green, mvn checkstyle:check clean.

One-time verification (run once, then discarded):
- No hardcoded Role.MANAGER / Role.ADMIN in the close path; the allowed set
  binds from the typed configuration surface under jiralite.workflow.

#### Acceptance criteria (the Evaluator judges)
- The MANAGER,ADMIN default preserves current close behavior exactly.
- The reopen role check is left untouched.
- The rejection reuses the existing forbidden path rather than a new error shape.

It gets a to do row at the foot of the ledger, and then we run it the way we run anything:

/run-ticket BAH-T7

Here’s what to watch as it goes. Preflight fires first, on the right branch with a clean tree and mvn present, and returns clean, so the loop proceeds and the roles spawn. The Implementor makes the change under the authorization policy, committing but never pushing, and hands back a note under the three fixed headings. The two existing gates run green on the note and the commit. The Evaluator re-runs the mechanical checks in fresh context and judges the acceptance criteria. Then the Orchestrator closes out: run artifacts under harness/runs/BAH-T7/, the ledger row flipped to done, and the metrics footer on the run log. The run rides on the harness we built rather than being a fourth thing to teach.

The run’s own trace, trimmed to the lines that matter:

Preflight: OK: preflight clean            exit 0
Gates:     handoff-completeness OK        commit-scope OK  (7 paths, all under src/)
Evaluator: PASS  (fresh context)  mvn test 59 passed, 0 failures   checkstyle 0 violations
Ledger:    BAH-T7 -> done
Metrics:   wall time 00:10:00 · estimated tokens 210000 · cycles 1 · escalations 0

Preflight ran first and returned clean, so the roles spawned, and every gate stayed green through to a done ledger row.

Let’s verify the end state with mvn test and mvn checkstyle:check, both from the real captured run:

$ mvn test
Tests run: 59, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

$ mvn checkstyle:check
You have 0 Checkstyle violations.
BUILD SUCCESS

8. Conclusion

Both changes in this lesson are the same move made at two edges of the run. An unattended agent’s permissions are a policy you write once, not a prompt you answer at two in the morning, and a run’s starting conditions are checked before anything spawns, not discovered mid-run. Decide once, up front, and the run either starts clean or doesn’t start.

There’s more to tighten later, named here and not built. The commit-scope gate is still coarse, and it could be pinned to the exact paths a ticket declares. The authorization policy could move from the harness to the project level, so each project carries its own line. Both wait for a run that asks for them.