pi-htn turns a one-off agent session into a reusable, validated plan. The big model authors a Hierarchical Task Network as YAML data — never code; an offline symbolic validator proves it decomposes soundly before it can be saved; and a trusted TypeScript runtime then replays it, calling bound tools deterministically while a small model only fills per-task arguments.
Figure 1 — The /htn extension binds five subsystems around a YAML-data plan core. Author once, replay many times.
Why this shape
HTN decomposition with symbolic backtracking beats ad-hoc LLM planning on multi-step, branching tasks, and it splits cost cleanly: the big model authors once (rare), a small/local model fills arguments on every replay (cheap). The design mirrors Tree-Planner (arXiv:2310.08582), which cut tokens ~92% and error-corrections ~40% by separating one plan-sampling call from many grounded execution calls.
Subsystem index
Command Surface
src/index.ts · commands/The /htn dispatcher: author · run · watch · settings · list, plus Tab completion.
Authoring Pipeline
recorder · author · validator · storeTrace → big-model best-of-N → offline gate → saved domain.
Compile + Plan Engine
compiler · predicates · htn · vendor/gameplanhtnYAML → GamePlanHTN Domain → findPlan with symbolic backtracking.
Execution Runtime
executor · smallModel · toolRegistry · exec · checkpointsAsync loop: fill args, invoke tool, apply effects, replan on failure.
PR Watcher + Self-Learning
watcher/ · learn/playbookPoll CI, classify, heal a round, re-check; playbook remembers what worked.
Three configured domains
demo/ · src/domains/pr-ci.yamlpr-doctor · fix-red-test · k8s-medic — repair ladders expressed purely as data.
Key external dependencies
| Dependency | Role |
|---|---|
vendor/gameplanhtn (vendored) | The planner / backtracker only. Its operators are synchronous; pi-htn wraps it with its own async execution loop. |
@earendil-works/pi-coding-agent (peer) | Host extension API — registers the /htn command, provides pi.exec() and the session tree. |
| OpenAI-compatible model endpoint | llama.cpp / vLLM server. Defaults to the shared Zosma devserver so inference stays off the laptop. |
yaml, loglevel | Domain parsing and structured run logs under ~/.pi-htn/logs/. |
gh CLI (runtime) | The PR watcher's eyes and hands: gh pr checks as the verifier, gh for comments / reruns. |
Layer 1Command Surface (Extension Layer)
The extension entry point src/index.ts registers a single /htn
command and dispatches to subcommands. It is intentionally thin: it parses input (e.g. intent=bug body=…
into a world-state object), wires the correct engine path, and reports a one-line run notice. Tab completion is
data-driven from src/commands/complete.ts — it autocompletes subcommands, setting
keys, and stored domain names.
/htn author <name>— read the session trace, ask the big model for N candidate domains, gate each, save the best passing one./htn run <name> [input]— compile a stored domain and execute it; reports mode (live/dry-run/live: <tools>)./htn watch <pr> [domain]— heal a PR's CI until merge-ready./htn settings— panel persisting model endpoint, default domain, heal rounds, poll interval to~/.pi-htn/config.json./htn list— list stored domains.
Figure 2 — The dispatcher routes; each subcommand owns its own engine wiring.
Layer 2Authoring Pipeline
Authoring is rare and runs on the big model. The Recorder parses the active session JSONL (an id/parentId tree) and extracts the ordered tool calls. The Author turns that trace into a prompt that asks the model to extract reusable structure — which steps are conditional, the branch predicates, observed failures — not just replay the literal trace. It requests N=3 candidate YAML domains.
Every candidate passes through the Validator, the reliability gate:
for each synthetic world state it runs findPlan (decomposition only, no operators fire) and requires a
non-empty plan. Best-of-N keeps the best passing candidate; the symbolic validator is the hard gate, and
scoring only ranks among passers. The winner is written by the DomainStore to
~/.pi-htn/domains/<name>.yaml.
Figure 3 — A successful trace becomes a reusable, validated YAML domain. The gate is symbolic, not statistical.
Layer 3Compile + Plan Engine
The Compiler walks the YAML task tree and builds the plain-object structure
GamePlanHTN's Domain constructor understands. Conditions compile through the
Predicate DSL ({eq:[k,v]}, {has:k}, {not:…});
effects compile to PlanAndExecute state writes. Crucially, the compiled planning operator is a
no-op that reports success so decomposition completes — real side-effects run later, in the
Executor (this is "execution mode B"). Because GamePlanHTN's PrimitiveTask drops unknown fields, the
Executor recovers the tool binding by task name.
The engine seeds every referenced world-state key before init() so planning-mode state
lookups resolve, then Domain.findPlan produces a plan with symbolic backtracking. GamePlanHTN is
vendored under vendor/gameplanhtn/ and used purely as the planner/backtracker.
Figure 4 — YAML data is compiled to a vendored GamePlanHTN domain and decomposed. Operators plan as no-ops.
Layer 4Execution Runtime
The Executor is pi-htn's own async loop (GamePlanHTN's operators are synchronous).
It calls findPlan, switches the context out of planning mode so setState mutates the real
world state, then runs each primitive: if the operator carries a prompt, the
SmallModel fills arguments (mode B); the ToolRegistry
invokes the bound tool. exec.ts binds one tool per name — an
exec: block becomes a real shell-backed tool via the injected pi.exec() runner; tools
without one fall back to a safe dry-run echo, so mixed live/dry-run domains work.
Effects are applied literal-first, then $result.* overrides with real tool output.
A non-zero exit throws: the loop increments a replan counter and re-enters findPlan; after
maxReplans (default 5) the circuit breaker aborts. Every step is appended to a
Checkpoint JSONL linked to the session-tree node.
Figure 5 — Replay loop. A throwing tool trips a replan; the just-tried rung is excluded by its *_tried guard so the planner climbs the ladder.
Layer 5PR Watcher + Self-Learning
The watcher (src/watcher/, also runnable standalone via
bin/pi-htn-watch.ts) polls gh pr checks and collapses per-check rows into
a single state: any failure → red, else any pending → pending, else green. On red it classifies a
failure_class (informed by the playbook prior), runs one heal round through executeDomain
with the pr-ci ladder, then re-checks gh as the verifier. The set of
already-tried strategy ids is carried forward as tried_<id>, so across rounds the HTN excludes
spent rungs and climbs the ladder — genuine cross-round backtracking. When every rung is exhausted it escalates.
The Playbook (~/.pi-htn/playbook.jsonl) is the self-learning memory:
it records (repo, pr, failureClass, strategy, ok) per attempt and computes a Laplace-smoothed success
rate per strategy, so the watcher can try the historically-most-successful rung first.
Figure 6 — Poll → classify → heal → re-check, with gh as the verifier and the playbook as durable memory.
Cross-subsystem flows
Two end-to-end paths tie the subsystems together: the author→run lifecycle (how a domain is born and replayed) and the heal loop (how structural backtracking converges).
Flow A — Author → Run lifecycle
- Record.
/htn author <name>reads the session JSONL via the Recorder and extracts the ordered tool calls. - Generalize. The Author builds a prompt; the big model returns N=3 candidate YAML domains.
- Gate. The Validator decomposes each candidate against synthetic states; only candidates that always yield a non-empty plan survive.
- Persist. The best passer is written to
~/.pi-htn/domains/<name>.yaml. - Replay. Later,
/htn runresolves the domain (repo → store → builtin), compiles it, and the Executor runs each primitive — small model fills args, bound tool runs deterministically, effects mutate world state, each step checkpointed.
The split is the whole point: step 2 (expensive) happens once; step 5 (cheap, small model) happens on every replay.
Flow B — The heal loop & why the backtracking is real
Escalation is HTN-native — driven by preconditions over world state, not by re-asking the
model. Each repair rung is a sequence: apply-<x> (effect sets
<x>_tried: true) → verify (throws if still failing). The repair root is a
select whose rungs are guarded by { not: { eq: [<x>_tried, true] } }.
▸ verify ................ ✗ still red ↩ backtrack ............. just-tried rung excluded by *_tried guard ▸ planner ............... decomposes to the NEXT rung (alternate-branch backtrack) ▸ verify ................ ✓ GREEN → report
When verify throws, the Executor replans; the just-tried rung is no longer valid, so the planner
decomposes to the next rung — a genuine alternate-branch backtrack, not a retry loop. A guaranteed-green
terminal rung (revert / restore) makes the ladder always converge; a hopeless case trips the circuit
breaker and escalates.
Note — classification + the model's patch are hoisted to plan input (e.g.
failure_class, approach, line): the Executor plans the whole domain
up-front, so a mid-plan $result effect cannot gate a select. The tiny model is called
once before planning; the HTN owns structure and backtracking.
The three configured domains
pi-htn ships three worked domains in demo/, plus a generic built-in ladder at src/domains/pr-ci.yaml. Each is pure YAML data — the same engine runs all of them. They share one reliability recipe: let the small model try the narrow, creative patch first; let the HTN structurally backtrack to a deterministic, guaranteed-converging safety net.
| Domain | Heals | Verifier | Model does | Terminal safety net |
|---|---|---|---|---|
| pr-doctor | Red CI on a PR | verify-green.sh / gh checks | Classifies failure (flaky?) | revert rung |
| fix-red-test | A red regression test | scoped verify-test.sh | Emits the guard line to insert | canonical-template revert (guaranteed green) |
| k8s-medic | A wedged k8s deployment | kubectl rollout status | Proposes a pullable image | restore last-known-good image |
1 · pr-doctor demo/pr-doctor
The original "a 4B model heals a red PR" demo. A tiny model classifies the failure into a
failure_class (hoisted to plan input); the HTN then walks a repair ladder, verifying after each rung.
repair (select, until fixed) ├─ m-rerun if failure_class=flaky → apply-rerun → verify ├─ m-lint generic lint/format pass → verify └─ m-revert revert the offending change → verify (terminal) report one-sentence PR comment
Rung 1 (m-rerun) is guarded by {eq:[failure_class, flaky]} so it only fires for
flaky failures; rung 2 (m-lint) is the generic format/lint fallback; rung 3 (m-revert)
is the terminal safety net. Each rung sets <x>_tried and a last_strategy label so
a failed verify backtracks cleanly to the next rung. The repair scripts are just shell — swapping the
local stand-ins for real gh commands (gh run rerun --failed, gh pr comment)
leaves the domain unchanged; only tool bindings move.
2 · fix-red-test demo/fix-red-test
Harder than pr-doctor: the model must produce the actual code patch, not just a class label. It reads
the failing test plus the buggy function and emits a concrete guard statement; that guess is hoisted to plan
input as approach + line.
repair (select, until fixed) ├─ m-guard apply the 4B model's emitted guard line → verify ├─ m-dedupe deterministic strip-then-append (known-good) → verify └─ m-revert rewrite from canonical template (guaranteed) → verify report one-sentence PR comment via gh
This is where the backtracking is honestly visible: live, the model emitted
if (systemPrompt.endsWith(WIKI_STATUS_BLOCK)) return systemPrompt; — plausible, but the regression
test seeds the footer mid-prompt, so endsWith is false and the duplication persists.
m-guard verifies RED, the HTN backtracks, and the deterministic m-dedupe rung lands
GREEN. Rungs 2–3 are known-good idempotency fixes that guarantee convergence; verify
runs only the scoped regression test, so the gate is deterministic and unaffected by unrelated local-env noise.
3 · k8s-medic demo/k8s-medic
A different domain entirely — live infrastructure. A deployment is wedged (e.g.
ImagePullBackOff from a bad image tag). The model reads the diagnosis (pod failure reason + current
image) and proposes a corrected, pullable image, hoisted to plan input as action + image.
heal (select, until healthy) ├─ m-apply apply model's proposed image (tool: kset) → verify rollout └─ m-restore restore last-known-good image (tool: krestore) → verify rollout report model writes an incident note; report.sh annotates the deploy
verify-rollout.sh runs kubectl rollout status --timeout; a non-Ready rollout exits
non-zero → throws → replan. The terminal m-restore rung restores the pinned last-known-good image,
so the ladder cannot fail to converge even when the model is wrong.
Load-bearing gotcha: buildExecRegistry binds one exec spec per tool
name (first wins). Two rungs needing different args ({{image}} vs {{good_image}})
must therefore use different tool names (kset vs krestore) — otherwise
the second silently reuses the first's command.
Built-in · pr-ci src/domains/pr-ci.yaml
The generic, safe-by-default ladder the watcher uses
when a repo ships no domain of its own. It only does universally-safe moves (re-run flaky CI, apply standard
formatters). Resolution is most-specific-first — a repo's own
<repoDir>/.pi-htn/pr-ci.yaml wins over the user's ~/.pi-htn/domains/ store, which
wins over this built-in — so a project's real repair ladder (real test/lint commands, a revert rung) travels with
the repo.
Architectural decisions
1 · The LLM authors a plan as YAML data, never code
A trusted TypeScript runtime executes the data; the model never emits executable code.
Let the model emit a script / code actions per step.
Data is inspectable, diffable, and gateable offline. It is the foundation of the Reliability-first priority order.
2 · Symbolic validator as the hard gate, best-of-N only ranks
A candidate is saved only if findPlan yields a non-empty plan for every synthetic state; scoring breaks ties among passers.
Trust the model's single best output; score-only selection.
A statistical score can't prove decomposition soundness; a symbolic check can. Capability rides on top of a hard reliability floor.
3 · Execution mode B — operators plan as no-ops, effects run in the Executor
Compiled operators return success during planning; real side-effects fire in pi-htn's own async loop.
Run side-effects inside GamePlanHTN's synchronous operators.
Keeps planning pure and deterministic, and lets execution be async, replannable, and checkpointed without forking the vendored planner.
4 · Hoist model decisions to plan input
Classification / patch guesses (failure_class, approach, line, image) are computed once before planning and seeded into world state.
Gate a select on a mid-plan $result effect.
The Executor plans the whole domain up-front, so a mid-plan result can't gate a branch. Hoisting keeps the HTN in charge of structure and backtracking.
5 · A guaranteed-green terminal rung in every repair ladder
Each healing domain ends with a deterministic, known-good move (revert / restore / lint).
Stop after the model's attempts; escalate immediately on failure.
Structural convergence: the model gets to try first, but the ladder cannot fail to converge. A hopeless case still trips the circuit breaker.
6 · Vendor GamePlanHTN; own the async execution loop
GamePlanHTN is vendored as the planner/backtracker only; the execution loop is pi-htn's own code.
Fork GamePlanHTN to make its operators async.
Its symbolic backtracking is the valuable part; its synchronous operators are not. A thin wrapper plus an external loop avoids carrying a fork.
7 · Domain resolution precedence: repo → store → built-in
A repo's .pi-htn/<name>.yaml wins over the user's global store, which wins over the shipped built-in.
Single global domain directory.
Reusability: a project's own repair ladder is version-controlled and travels with the repo, while safe defaults still ship in the box.
8 · Inference defaults off the laptop
Default endpoint is the shared Zosma devserver; precedence is env var > saved settings > built-in default.
Default to a local model process.
Cost/ergonomics: replay never cooks the local machine, while --base/--model or a local llama-swap remain one flag away.
Fact-check verification
Claims in this document were verified against the pi-htn source tree on 2026-06-19. Summary below.
| Claim | Source of truth | Status |
|---|---|---|
| Authoring samples N=3 candidates | src/index.ts N_CANDIDATES = 3 | ✓ verified |
| Circuit breaker at maxReplans default 5 | src/executor.ts opts.maxReplans ?? 5 | ✓ verified |
| Default endpoint devserver:8010, model qwopus-coder-9b | src/config.ts DEFAULT_MODEL_BASE/_ID | ✓ verified |
| Domain resolution repo → store → built-in | src/domains.ts resolveDomainYaml | ✓ verified |
| One exec spec per tool name (first wins) | src/exec.ts buildExecRegistry | ✓ verified |
Effects applied literal-first, then $result.* | src/executor.ts apply order | ✓ verified |
| pr-doctor repair ladder rungs | demo/pr-doctor/pr-doctor.yaml | ⚠ corrected — ladder is m-rerun → m-lint → m-revert (3 rungs; terminal is revert, not lint) |
| built-in pr-ci does only rerun + lint-fix | src/domains/pr-ci.yaml | ✓ verified |
| fix-red-test ladder m-guard → m-dedupe → m-revert | demo/fix-red-test/fix-red-test.yaml | ✓ verified |
| k8s-medic: kset / krestore, restore-last-known-good terminal | demo/k8s-medic/k8s-medic.yaml | ✓ verified |
One claim was corrected in place (pr-doctor's terminal rung). No claims were silently passed.