
Perpetuum
- 1.9k installs
- Updated July 21, 2026
- zc277584121/perpetuum
perpetuum is an agent orchestration skill that runs long-lived scheduled work in a git worktree with documented metadata and human escalation queues for developers who need autonomous multi-cycle agent jobs.
About
perpetuum is a zc277584121/perpetuum skill for running scheduled agent work inside an isolated git worktree with persistent task metadata and an escalations file for human review. Setup documents task name, branch, parent repo, merge target, trigger type, and expected cadence—example configs run every ~40 minutes for ~20 cycles over ~2 days. perpetuum does not rely on metadata fields at runtime but preserves them for humans and future agents auditing the job. Developers reach for perpetuum when adversarial testing, long refactors, or multi-day agent loops need git isolation plus a clear escalation path instead of a single chat session.
- Task metadata block: worktree path, branch, merge target, schedule trigger, expected cadence
- Human-owned escalation queue: open questions with options; human moves items to Resolved
- Designed for schedule-driven cycles (example basis: adversarial-testing)
- Explicit parent repo and started-from SHA lineage for safe parallel agent work
- Agent surfaces trade-offs; humans edit decisions in place as the control signal
Perpetuum by the numbers
- 1,921 all-time installs (skills.sh)
- +187 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #657 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/zc277584121/perpetuum --skill perpetuumAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.9k |
|---|---|
| Last updated | July 21, 2026 |
| Repository | zc277584121/perpetuum ↗ |
How do you run scheduled agent jobs in a git worktree?
Run long-lived scheduled agent work in a git worktree with documented metadata and human escalation queues.
Who is it for?
Developers running multi-day autonomous agent loops who need git worktree isolation and a human escalation queue.
Skip if: One-shot chat tasks or teams without git worktrees who only need a single immediate code edit.
When should I use this skill?
User wants scheduled agent runs, long-lived worktree jobs, adversarial testing cycles, or human escalation during autonomous work.
What you get
Git worktree branch, task metadata file, escalation log, and completed agent cycles ready to merge.
- task metadata file
- escalations log
- worktree branch commits
By the numbers
- Example cadence: ~40 min intervals, ~20 cycles, ~2 days total
- Documents 8 metadata fields including worktree path and merge target
Files
perpetuum
A persistent three-layer "explore → execute → escalate" loop on top of cc-use. Designed for tasks whose value grows with how many findings or improvements an agent can produce over hours or days.
What ships here is a framework, not fixed source code. The architecture (three layers, two prompts, three trigger families, file-as-contract, async escalation) is what's fixed and what makes the loop reliable. Everything else — the wording in prompts/1_explore.md and prompts/2_execute.md, the bash in trigger.sh, the classification logic the middle agent applies, even the exact structure of plan.md / inbox.md / escalations.md — are templates to adapt to the user's specific task, domain, project, and language. When you set up a new task with the user, treat the files copied from examples/ as starting points and rewrite them to fit. Don't treat them as immutable scripts. This is what keeps a small skill flexible enough to handle adversarial testing, ML hyperparameter sweeps, PR review, migrations, and style polish without code changes.
This file is a router. Every mechanism below has a one-paragraph summary here; details live in references/. Read the relevant reference when the user's intent matches the corresponding section.
When to invoke this skill
- The user wants to start a new persistent loop on a project
(bug hunting, style distillation, PR/issue triage, observability scanning, etc.)
- The user wants to inspect, pause, resume, stop, or nudge an
already-running loop
- The user wants to answer an escalation the loop has surfaced
- The user wants to run multiple loops in parallel on the same
project via git worktree
If the user's intent isn't clear from "use perpetuum" alone, ask before doing anything.
Route by intent
| User wants to… | Read |
|---|---|
| Start a new task | references/setup.md + skim examples/ |
| See current status | references/status.md |
| Pause / resume / stop / kill | references/control.md |
| Push an instruction / answer an escalation | references/feedback.md |
| Parallel lines via git worktree | references/worktree.md |
Write or adjust trigger.sh | references/trigger.md |
| Understand the design rationale | references/design.md |
How it works (architecture in one screen)
Layer 4 you + host agent monitor + relay (optional)
↓
Layer 3 trigger.sh heartbeat: paste prompts, wait for done
↓ flags, sleep, loop
Layer 2 middle agent (tmux) judge + dispatch, two prompts:
↕ 1_explore.md (plan)
2_execute.md (dispatch + judge)
Layer 1 inner agent fresh context per dispatch — has no
(cc-use) memory of previous cycles, cannot
rubber-stamp known behaviorThe split between Layer 1 (producer, fresh context) and Layer 2 (judge, persistent context) is the GAN-like discriminator / generator separation that prevents the self-certifying problem of /goal or Ralph Loop. Layer 1 reports back to Layer 2; Layer 2 commits, fixes, or escalates.
Mechanisms (one paragraph each)
Each mechanism is summarized here; for the full procedure see the linked reference.
- Ratchet (monotonic progress). The middle agent judges each
Layer-1 proposal before committing; rejected proposals never become commits. Accepted ones land as a local git commit, giving the branch a clean append-only log that doubles as the durability mechanism across sessions. Same family of idea as Karpathy's AutoResearch ratchet, with the judge slightly earlier in the loop. Details in references/design.md.
- Exploration vs exploitation split. Two prompts per cycle.
prompts/1_explore.md is divergent (list dimensions, sample broadly, populate the backlog). prompts/2_execute.md is convergent (work through the backlog, commit or escalate). Lexically-sorted files mean you can drop a 3_reflect.md in to add a reflection phase without touching code. Details in references/trigger.md.
- Async human escalation.
escalations.mdis the channel for
ambiguous decisions the agent can't make alone. Agent writes Open items with A/B/C options; human writes answers in Resolved. New cycles run while questions sit unanswered — the loop never blocks on the human. Details in references/feedback.md.
- Inbox (human → agent push).
inbox.mdis where the user nudges
the agent: SKIP, PRIORITIZE, ADD, STOP, DIRECTION, NOTE, or plain natural language. Read at every cycle's explore phase. Details in references/feedback.md.
- Trigger abstraction. Three trigger types —
schedule(every N
minutes), conditional (poll an external state like gh pr list), webhook (event-driven). Same Layer 2 and Layer 1; only Layer 3 differs. Default in examples is schedule with a 2-minute interval — see cost note below. Details in references/trigger.md.
- Control signals.
touch .paused/rm .pausedto pause and
resume; touch .stop_after_current to gracefully stop after the current cycle; pkill -f trigger.sh + tmux kill-session for hard stop. File-level signals, no new protocol. Details in references/control.md.
- Parallel lines via git worktree. For several perpetuum tasks
on the same project, use git worktree add so each task gets its own branch and directory. _meta.md records the worktree metadata. Details in references/worktree.md.
- File-as-contract.
plan.mdis agent-maintained (humans should
route changes through inbox.md); inbox.md is human-write; escalations.md is bidirectional with the "agent writes Open, human writes Resolved" convention. Nothing is enforced at the filesystem level — it's a convention, not a lock. Details in references/feedback.md.
Task layout
When a task is initialized:
<project-or-worktree>/
└── .perpetuum/
└── <task-name>/
├── _meta.md worktree/branch metadata
├── trigger.sh per-task; adjusted during setup
├── prompts/
│ ├── 1_explore.md prompt 1: plan
│ └── 2_execute.md prompt 2: dispatch + judge + record
├── plan.md agent-maintained state machine
├── inbox.md human → agent
├── escalations.md agent ↔ human
├── trigger.log
└── state/
└── .cycle_done_* per-cycle sync flags (transient)Any file matching prompts/[0-9]+(\.[0-9]+)?_*.md is fed to Layer 2 in lexical order, one per cycle phase. Default is 2 (prompts/1_explore.md, prompts/2_execute.md); add prompts/3_reflect.md for a reflection phase, prompts/1.5_check.md to insert a step between, etc.
Keeping prompts in a subdirectory separates editable templates from runtime state (plan.md / inbox.md / escalations.md) — users can edit the prompts/ dir freely without mixing prompt edits and state edits.
What the state files actually look like
Use these as templates when you generate or judge file content. Match the structure; render the content in the user's language per the language rule below.
plan.md — agent-maintained:
## Pending
- [ ] [auth] test expired token refresh
- [ ] [parse] malformed XML input
## Done
- [x] (cycle 3) [auth] login flow
- operation: cli login --user x
- observed: 200 + valid JWT
- status: PASS
- [x] (cycle 5) [parse] xss in error envelope
- status: [FIXED] commit:abc1234inbox.md — user writes; agent reads and absorbs every cycle:
## Pending
- SKIP: postgres backend, not shipping
- PRIORITIZE: PR #123 first
- NOTE: I'm OOO Friday, no urgent escalations
## Processed
- (cycle 6) SKIP applied — removed 4 postgres items from plan.mdescalations.md — agent writes ## Open items; user fills ## Resolved:
## Open
### (cycle 4) off-by-one in --range flag
A: 1-based inclusive (matches head/tail/sed)
B: 0-based half-open (matches array semantics in most languages)
C: leave both, document the discrepancy
## ResolvedSetting up a new task
Setup is the longest interaction this skill has with the user. Don't rush it — a mis-launched perpetuum task wastes hours and tokens. Walk the user through the steps below. Detail for each step lives in references/setup.md; this section is the overview.
1. Prerequisites — confirm both are present
These are the only two things the skill depends on. Don't proceed if either is missing.
- `cc-use` skill must be available in whichever skill environment
the current host agent uses. Layer 2 of perpetuum dispatches every unit of work to a fresh inner agent via cc-use. Without it, there is no Layer 1.
Install it the same way perpetuum was installed:
npx skills add zc277584121/cc-use --all -gAfter installing, the user may need to reload skills before the current agent can see it. The exact command varies by host agent — Claude Code has /reload-skills; other agents may need a TUI restart. Follow that agent's docs.
- `tmux` must be installed locally. Verify with
tmux -V. The
middle agent (Layer 2) lives in a persistent tmux session for the duration of the task.
If cc-use is missing, ask the user whether to install it. If tmux is missing, point them to their package manager and stop — perpetuum cannot run without it.
2. Suitability gate — judge fit with the user
Not every task fits this architecture. A bad fit wastes the user's tokens. Don't skip this step.
- Strong fit: "find more of X" / "converge toward Y" / "watch
for Z" tasks. Dimensional structure. Per-finding independence.
- Poor fit: one-shot tasks; strongly linear builds; tasks needing
synchronous human decisions; tasks shorter than ~30 minutes total.
If the task is borderline, reshape it with the user (e.g. turn "refactor X" into "scan X module-by-module and surface one smell per module"), or recommend a non-perpetuum approach (single agent run, one-off cc-use dispatch). Full questionnaire in references/setup.md.
3. Pick an example and create the task directory
Look at examples/ for the closest task shape and copy that directory's contents to <project>/.perpetuum/<task-name>/. Then customize:
prompts/1_explore.md— replace generic dimension hints with this
project's actual axes; use the user's language
prompts/2_execute.md— set the--projectabsolute path; adjust
commit-style and classification policy to the project
trigger.sh— setMIDDLE_SESSIONto something unique, adjust
MAX_ITER, decide trigger type (schedule / conditional / webhook). The script also reads an AGENT_CMD env var: default is Claude Code, but users on Codex / Cursor / etc. can override before launch (e.g. AGENT_CMD="codex --dangerously-bypass-approvals-and-sandbox" or the safer AGENT_CMD="codex --full-auto"). Mention this explicitly to non-Claude-Code users.
_meta.md— fill in worktree path, branch, parent repo, merge
target
- Leave
plan.md,inbox.md,escalations.mdempty (their skeletons
are already in the example)
chmod +x trigger.sh
For parallel tasks on the same project, set up via git worktree first — see references/worktree.md.
4. Cost / cadence confirmation — say this out loud
The default SLEEP_BETWEEN_CYCLES in schedule-type examples is 2 minutes, intended for full throttle. With MAX_ITER=20, the loop burns through all 20 cycles in a few hours; each cycle costs O(a few inner-agent dispatches via cc-use).
Before launching, ask the user:
- Do they have token budget for ~MAX_ITER cycles at this cadence?
- Are they on a usage-based plan (cost matters) or a flat plan (rate
limits matter)?
- Will they babysit the first few cycles, or launch and walk away?
If they hesitate: bump SLEEP_BETWEEN_CYCLES (1800 = 30 min, 3600 = 1 hour), reduce MAX_ITER, or switch to the conditional trigger pattern (only fires when there's real new work).
Don't skip this step. A "$X overnight" surprise is the easiest way to make the user pull the plug on perpetuum forever.
5. Optional first-cycle trial
For first-time users, suggest a trial with MAX_ITER=1:
sed -i.bak 's/^MAX_ITER=.*/MAX_ITER=1/' .perpetuum/<task>/trigger.sh
.perpetuum/<task>/trigger.sh # foreground, watch one cycle
mv .perpetuum/<task>/trigger.sh.bak .perpetuum/<task>/trigger.shInspect plan.md, escalations.md, and git log after the trial. Adjust prompts if anything went sideways before running 20 cycles.
6. Suggest .gitignore and launch
echo '.perpetuum/' >> <project>/.gitignore # unless the user wants state in git
nohup .perpetuum/<task>/trigger.sh > /dev/null 2>&1 &Or hand the launch command to the user to start when they're ready.
Known Codex compatibility quirks (already handled)
For Codex CLI users, two tmux/TUI quirks are handled by the default trigger.sh. No user action needed; documented here so you know what the extra send-key lines are for:
1. Codex tmux "Enter doesn't commit" bug (openai/codex#12645). send_prompt uses the same five-step sequence cc-use uses (C-u → paste-buffer -d → Enter → C-m → Enter).
2. "Create a plan?" popup on complex prompts. Codex pops this on long planning-style prompts (our 1_explore.md triggers it). send_prompt detects Codex via $AGENT_CMD and sends Escape after paste to dismiss it.
The execute prompts in examples/ instruct the middle agent to escalate any cc-use failure rather than work around it locally — surface it as a blocked-on-environment item in escalations.md and stop the cycle, do not fake a fresh inner context within your own conversation.
Core invariants (do not violate)
These are the things that keep the loop honest. Don't weaken them when adjusting prompts or scripts:
1. Every accepted finding becomes a local git commit. 2. plan.md is agent-maintained; humans route changes through inbox.md. 3. Layer 1 always runs in fresh context (per cc-use delegate, not a reused inner session within a cycle). 4. Layer 2's prompts are atomic and lexically ordered; don't fuse them. 5. Sync uses .cycle_done_* flag + tmux silence fallback + total timeout — all three are needed; don't drop one.
After-setup briefing
After launching, walk the user through these in their language. The user just handed a coding agent the keys to their codebase; they need to know how to drive.
- How a cycle runs. Trigger fires → middle agent reads
plan.md
/ inbox.md → pastes prompt 1 to plan, then prompt 2 to dispatch via cc-use → judges each result → commits, escalates, or records. Then sleep, repeat. The whole thing keeps going across cycles, restarts, and your absence.
- What they can edit, and how.
inbox.md— yes, anytime, write a one-liner under## Pendingescalations.md— yes, write answers in## Resolvedprompts/1_explore.md/prompts/2_execute.md— yes, the next cycle picks up
edits
trigger.sh— yes for cadence /MAX_ITERplan.md— avoid, agent-owned; route changes through
inbox.md
_meta.md— static after setup
- Talk to you or edit files — both work. They can say things in
natural language to the host agent and you translate to file operations; or they can edit files directly in their editor. Make both paths explicit; some users prefer one, some the other.
Translation table for common natural-language requests (English phrasings shown — map equivalent intent in any user language to the same operation):
| User intent | You do |
|---|---|
| "pause" / "stop for now" / "hold on" | touch .perpetuum/<task>/.paused |
| "resume" / "keep going" / "start again" | rm .perpetuum/<task>/.paused |
| "stop gracefully" / "finish this cycle and stop" | touch .perpetuum/<task>/.stop_after_current |
| "kill it" / "force stop" / "just stop" | pkill -f trigger.sh ; tmux kill-session -t middle-<task> |
| "skip X" / "don't bother with X" | append SKIP: X to inbox.md ## Pending |
| "prioritize Y" / "Y first" | append PRIORITIZE: Y to inbox.md |
| "add a test/scan for Z" | append ADD: Z to inbox.md |
| "change direction to W" | append DIRECTION: W to inbox.md |
| "for question X, pick option A" | edit the matching escalations.md Open item, add the answer, move to ## Resolved |
| "what's the status?" / "what's it doing?" | tail trigger.log + summarize plan.md Pending/Done counts + list any unresolved escalations |
- Reset the cost expectation. Reiterate what
SLEEP_BETWEEN_CYCLES
and MAX_ITER are set to and what that implies for spend over the next N hours. The cost conversation in step 4 happened before they knew the system; remind them now.
Don't skip the briefing. A user who doesn't know they can pause and edit inbox.md will pkill the loop in panic the first time they want to change anything.
Language rule
The markdown files this skill generates (plan.md, inbox.md, escalations.md, the two prompt files, and any text written into them at runtime) are for the user to read.
If the user speaks any non-English language and doesn't explicitly ask for another, generate all human-facing content (prompts, plan items, escalation entries, status messages) in that language.
Boundary: this rule applies only to perpetuum's own files and to the perpetuum ↔ human interaction. It does not apply to the project's own code, code comments, commit messages, documentation, or anything the inner agent produces as part of the actual task work — those follow the project's existing conventions.
File names, config keys, shell variables, and scripts are always English (cross-language stable).
Task metadata
Filled in once during setup. Reference only — perpetuum does not
rely on these fields at runtime, but humans and future agents will.
- task name: <TASK_NAME>
- created: <YYYY-MM-DD>
- worktree path: <absolute path of this worktree>
- branch: <current git branch>
- started from: <branch>@<sha>
- parent repo: <absolute path of parent repo, same as worktree path if no worktree used>
- merge target: <branch the user intends to merge to, or "n/a">
- trigger type: schedule
- example basis: adversarial-testing
- expected cadence: every ~40 min, ~20 cycles total, ~2 days end-to-end
Escalations
Questions the agent surfaces for human judgment. Each item has
context + the specific question + 2–3 options with trade-offs.
>
When you're ready to answer, edit the item in place to add your
decision, then move it to ## Resolved. The agent does NOT move itemshere — moving is the human's signal of "I've decided".
Open
<!-- Example (delete when you have real ones):
(cycle 3-abc1234) off-by-one between two CLI subcommands
Context: Subcommand A reports line ranges as [start, end] 1-based inclusive. Subcommand B accepts --range start:end as 0-based half-open. Feeding A's output directly into B silently drops the first line. This is the kind of papercut that erodes user trust without ever producing a hard error.
Question: Align on which convention? This is a public-contract decision affecting any consumer of the CLI's structured output.
Options:
- A (recommended): Standardize on 1-based inclusive across both
commands. Smallest change for tooling consumers, matches common shell tools (head, tail, sed). Documented breaking change.
- B: Standardize on 0-based half-open. Matches array semantics
in most languages. Larger blast radius for existing users.
- C: Leave both, document the off-by-one. Cheapest but most
surprising. -->
Resolved
<!-- Human moves answered items here with their decision. -->
Inbox
Write your instructions / nudges / context under "## Pending". The agent
reads this at the start of every cycle and moves processed items to
"## Processed". Plain natural language works; the verbs below are
shorthand the agent recognizes.
Recommended verbs (any one works, mix freely):
SKIP:PRIORITIZE:ADD:STOP:DIRECTION:NOTE:
Pending
<!-- Example items (delete these and write your own):
- SKIP: postgres backend swap — I'm not going to support PG, drop those tests
- PRIORITIZE: PR #123 just landed, look at it before backlog
- ADD: a dimension — behavior under network partition (kill milvus mid-add)
- NOTE: I'm OOO tomorrow, don't escalate anything I'd want to discuss
-->
Processed
<!-- Agent moves processed items here with a one-line note on how they were applied to the plan. -->
Plan
Agent-maintained state machine. Users should **avoid editing this
directly** — route changes through inbox.md. The system won't crashif you edit, but format drift may confuse the next cycle.
Pending
<!-- New items appended by prompts/1_explore.md each cycle. Format:
- [ ] [<dimension>/<sub>] short description
-->
Done
<!-- Completed items moved here by prompts/2_execute.md. Format:
- [x] (cycle <id>) [<dimension>] short title
- operation: ...
- observed: ...
- status: PASS | [FIXED] commit:abc1234 | [FAILED] reason | [BLOCKED] reason
-->
Task: plan this cycle's exploration (do not execute)
You can use any API keys / credentials available in ~/.bashrc during later test phases. They are available to the inner agent too.
Walk through these steps and only these steps:
1. Read the test history file (.perpetuum/<task>/plan.md) to know what was tested before and what fixes were already made.
2. Check the inbox (.perpetuum/<task>/inbox.md):
- For each item in
## Pending, decide how to act (SKIP / PRIORITIZE /
ADD / STOP / DIRECTION / NOTE — treat plain-language items as NOTE)
- Move processed items to
## Processedwith a one-line note on how
they were applied to the plan
- This shapes everything below
3. Look at the existing Pending items in plan.md — anything still uncompleted that should remain for this cycle?
4. Generate new exploration items for this cycle. Do an E2E testing pass over the project. List the testing dimensions that apply to this project, take their Cartesian product, and pick a sample of new combinations to explore.
Examples of dimensions (replace with this project's actual axes when you customize this template):
- Surface: CLI commands × subcommands × flags
- State: empty / first-run / resumed / interrupted / restarted
- Backends: each interchangeable backend (DB, cache, blob store, ...)
- Inputs: small / large / boundary / malformed / unicode
- Connectors / integrations (each provider with available keys)
- TUI flows inside Claude Code / Codex (skills, hooks, MCP servers)
- SDKs (each language client)
- Errors: every documented error path
Balance breadth vs depth — if recent cycles have repeatedly hit the same category, deliberately switch. If the plan has 8+ categories but each is shallow, go deeper instead. You decide; don't lock yourself into a single strategy.
5. Append new items to plan.md under ## Pending. Format each as:
- [ ] [<dimension>/<sub>] short description of what to testDon't number them. Don't add priorities unless you need to.
6. Stop. Do not execute anything yet. Execution is the next prompt. Just record the plan.
7. As the last action, run this shell command (don't forget!):
echo "explore done ${CYCLE_ID}" > .perpetuum/<TASK_NAME>/state/.cycle_done_${CYCLE_ID}Replace <TASK_NAME> with the actual directory name where this file lives. The outer trigger.sh is waiting for this flag and will time out at 20 minutes of silence if you forget.
Task: execute this cycle's plan (dispatch + judge + record)
⚠️ Important: `cc-use` is an installed Agent Skill, not a shell command.
Use it via your host agent's skill mechanism (your host will load
cc-use's SKILL.md and know how to dispatch the inner agent). Do not
run cc-use directly with the Bash tool — that bypasses the skillprotocol and will fail.
>
If your environment does not recognizecc-useas a skill, orcc-use
reports an inner-agent startup failure (a known issue exists for Codex
outer agents in --dangerously-bypass-approvals-and-sandbox mode wherecc-use's hardcoded--ask-for-approval/--sandboxflags clash —
upstream cc-use issue, not perpetuum): **do not fall back to
Bash-running cc-use, do not spawn a sub-agent yourself, do not write
the work into this session's context directly.** Surface it as a
blocked-on-environment escalation to escalations.md and stop thecycle there. The whole point of the three-layer architecture is the
fresh-context inner agent; faking it locally defeats the purpose.
Use the cc-use skill to delegate the actual testing work to an inner agent. You do not run the tests yourself. You plan, dispatch, judge, record, and escalate.
Walk through these steps:
1. Read .perpetuum/<task>/plan.md ## Pending section. Pick items to process this cycle — don't try to do all of them if the list is large; this is best-effort within reasonable time.
2. For each picked item:
a. Dispatch. Call cc-use delegate with:
--project /<absolute-path-to-project-or-worktree>(must be absolute!)--agent claude(orcodex, matching the outer agent family)- The task description: instruct the inner agent to do *ephemeral
CLI / TUI / SDK operations, never* to write persistent unit tests. The inner agent should report what it tried, what it observed, and any anomalies.
b. Judge. When the inner agent returns:
- Clearly correct behavior → mark in plan.md as PASS
- Clearly a bug, simple fix → dispatch a second inner call
asking it to fix; verify the fix; commit with a clean message (no AI trailer); mark FIXED in plan.md with commit hash
- Bug but the fix involves a design decision (public API
rename, deprecation path, UX trade-off) → don't fix. Move to escalations.md. Mark in plan.md with [→]
- Inner agent's test was malformed / missing context → reshape
the task and re-dispatch; do not mark as failure
- Blocked (missing env, external dep down) → mark BLOCKED with
reason
3. Record. Every Done item in plan.md must have three fields:
- [x] (cycle ${CYCLE_ID}) [<dim>] short title
- operation: the exact command(s) or step(s) attempted
- observed: the actual output / behavior
- status: PASS | [FIXED] commit:abc1234 | [FAILED] reason | [BLOCKED] reasonDo not abbreviate. A future agent or human will use these to know what was already exercised.
4. Escalate. For items moved to escalations.md, write:
### (cycle ${CYCLE_ID}) brief title
**Context:** what was being tested, why it matters
**Question:** what's ambiguous, what specifically needs the human's input
**Options:**
- **A:** option with trade-offs
- **B:** option with trade-offs
- **C:** option with trade-offs (optional)Write the escalation in the user's language. Make it answerable in five minutes — include enough evidence so the human doesn't have to reproduce.
5. New follow-up items discovered during this cycle that warrant a future test → append to `plan.md` Pending, do not put in Done.
6. 🔴 Final action — do not forget this!
echo "execute done ${CYCLE_ID}" > .perpetuum/<TASK_NAME>/state/.cycle_done_${CYCLE_ID}Replace <TASK_NAME> with the actual directory name. The outer trigger.sh is waiting for this flag. If you don't write it, the outer process pays a 20-minute silence-fallback penalty for nothing.
No matter how much you did this cycle — fixes, escalations, follow-ups, partial work — the last step must be writing this flag. Do not stop at the prompt waiting for the user to tell you what to do next.
adversarial-testing
Run continuous adversarial / exploratory testing on a CLI / TUI / SDK project. The loop dispatches ephemeral test operations to fresh-context inner agents, judges results, commits real fixes, escalates ambiguous product questions.
Task shape
- "Find more of X" — more bugs, more UX gaps, more inconsistencies
- Cartesian product of dimensions (connector type × state × config × ...)
- Inner agent does throwaway CLI/TUI/SDK operations, not persistent tests
- Middle agent classifies findings into:
- clearly fixable → inner agent fixes + git commit
- clearly broken but design-decision needed → escalations.md
- benign / not a bug → noted in plan.md, moved on
- Trigger type: schedule (every N minutes, M cycles total)
When to use this example
Use when the user has:
- A working project they want continuous quality pressure on
- Clear "real bug" vs "design ambiguity" distinction
- Ability to receive 5-30 small commits per day across multiple cycles
- A bashrc / environment with the relevant API keys / credentials so the
agent can exercise real backends
Files in this template
| File | What you need to customize |
|---|---|
trigger.sh | MIDDLE_SESSION, MAX_ITER, SLEEP_BETWEEN_CYCLES, possibly the timeouts |
prompts/1_explore.md | The "test dimensions examples" block — replace with this project's actual axes |
prompts/2_execute.md | The --project path (must be absolute), commit-style guidance if the project has conventions |
plan.md | Leave empty, agent fills it |
inbox.md | Leave empty, user fills as they go |
escalations.md | Leave empty |
_meta.md | Fill once at init time |
Recommended adaptations
- If the project's CLI has many subcommands, list them explicitly in
prompts/1_explore.md's dimension hints — don't make the agent guess from the binary name.
- If the project has known fragile areas, **list them in the agent's
context** via prompts/1_explore.md so it doesn't redundantly poke at them.
- If the user has strong opinions on what's a "real bug" vs "by design",
reflect that in prompts/2_execute.md's classification guidance.
Real-world reference
This example is the abstraction of a loop dogfooded in early development of perpetuum. In one ~6.5-hour stretch on a moderately-sized backend codebase it produced 4 real bug fixes, 2 escalations with full A/B/C trade-offs, and 25+ planned follow-up items, with the inner agent dispatched 11 times.
#!/usr/bin/env bash
# perpetuum task: adversarial-testing
# Trigger type: schedule
#
# Cycle: paste prompt 1 (explore) → wait done flag (or silence fallback)
# -> paste prompt 2 (execute) → wait done flag (or silence fallback)
# -> sleep, then next cycle, up to MAX_ITER total.
#
# Control:
# pause : touch .paused (resume: remove it)
# stop : touch .stop_after_current (graceful exit after current cycle)
# kill : pkill -f trigger.sh; tmux kill-session -t $MIDDLE_SESSION
set -uo pipefail
# ============================ Configuration ===============================
TASK_DIR="$(cd "$(dirname "$0")" && pwd)"
# PROJECT_ROOT defaults to the worktree root (two levels above .perpetuum/<task>)
# Override if you keep .perpetuum/ at a non-standard depth.
PROJECT_ROOT="$(cd "$TASK_DIR/../.." && pwd)"
# Unique tmux session name. Two perpetuum tasks must not share this.
MIDDLE_SESSION="middle-adv-$(basename "$PROJECT_ROOT")"
# Inner-agent command for Layer 2 (the persistent agent TUI in tmux).
# Default: Claude Code with permissions bypassed.
# For Codex CLI users, override before running, e.g.:
# AGENT_CMD="codex --dangerously-bypass-approvals-and-sandbox" .perpetuum/<task>/trigger.sh
# Or (safer, sandboxed workspace writes only):
# AGENT_CMD="codex --full-auto" .perpetuum/<task>/trigger.sh
# Other coding-CLI agents (Cursor, Windsurf, etc.) work too — set AGENT_CMD
# to whatever command starts that agent in your terminal.
AGENT_CMD="${AGENT_CMD:-claude --dangerously-skip-permissions}"
MAX_ITER=20
SLEEP_BETWEEN_CYCLES=120 # 2 min — see SKILL.md cost note before running
WAIT_PHASE_TIMEOUT=21600 # 6h per phase before force-end (generous)
SILENCE_THRESHOLD=1200 # 20 min tmux silence = phase done (fallback)
POLL_INTERVAL=30 # check every 30s
TUI_BOOT_WAIT=25 # wait after spawning fresh CC TUI
# ==========================================================================
LOG="$TASK_DIR/trigger.log"
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
ensure_middle_session() {
if ! tmux has-session -t "$MIDDLE_SESSION" 2>/dev/null; then
log "Starting $MIDDLE_SESSION (cwd=$PROJECT_ROOT)"
tmux new-session -d -s "$MIDDLE_SESSION" -c "$PROJECT_ROOT" \
"$AGENT_CMD"
sleep "$TUI_BOOT_WAIT"
fi
}
# Send a multi-line prompt into the middle TUI using the same key sequence
# cc-use uses: C-u (clear input) → load-buffer from tmpfile → paste-buffer -d
# → Enter → C-m → Enter. The triple-submit handles a known Codex CLI TUI
# quirk in tmux where Enter alone sometimes doesn't commit
# (https://github.com/openai/codex/issues/12645). Claude Code accepts the
# same sequence without issue, so we don't branch by agent.
send_prompt() {
local prompt_text="$1"
local tmp
tmp=$(mktemp)
printf '%s' "$prompt_text" > "$tmp"
tmux send-keys -t "$MIDDLE_SESSION" C-u
tmux load-buffer -b pp_prompt "$tmp"
tmux paste-buffer -d -b pp_prompt -t "$MIDDLE_SESSION"
rm -f "$tmp"
sleep 0.5
# Codex-specific: dismiss the "Create a plan?" suggestion that Codex
# sometimes pops up when it detects a complex prompt
# (our explore phase prompt is exactly the kind of thing that triggers
# it). Claude Code doesn't have this popup and Escape may interfere
# with its TUI modals there, so we only send it for Codex. The branch
# is keyed on AGENT_CMD content (no hardcoded agent name in the loop)
# so future agents can opt in by matching their command string here.
case "$AGENT_CMD" in
codex*) tmux send-keys -t "$MIDDLE_SESSION" Escape; sleep 0.3 ;;
esac
tmux send-keys -t "$MIDDLE_SESSION" Enter
sleep 0.7
tmux send-keys -t "$MIDDLE_SESSION" C-m
sleep 0.7
tmux send-keys -t "$MIDDLE_SESSION" Enter
}
# Three-layered sync: flag file → tmux pane silence → total timeout
wait_for_done() {
local cycle_id="$1"
local timeout="$2"
local flag="$TASK_DIR/state/.cycle_done_${cycle_id}"
local start
start=$(date +%s)
local prev=""
local silent=0
log " waiting for: $(basename "$flag") (timeout=${timeout}s, silence=${SILENCE_THRESHOLD}s)"
while true; do
sleep "$POLL_INTERVAL"
if [ -f "$flag" ]; then
log " -> done via flag: $(cat "$flag")"
rm -f "$flag"
return 0
fi
local snap
snap=$(tmux capture-pane -t "$MIDDLE_SESSION" -p 2>/dev/null | sha256sum | awk '{print $1}')
if [ "$snap" = "$prev" ]; then
silent=$((silent + POLL_INTERVAL))
if [ "$silent" -ge "$SILENCE_THRESHOLD" ]; then
log " -> done via silence (${silent}s)"
return 0
fi
else
silent=0
prev="$snap"
fi
if [ $(($(date +%s) - start)) -ge "$timeout" ]; then
log " -> force-end via timeout"
return 1
fi
done
}
run_cycle() {
local cycle_id="$1"
for prompt_file in $(ls "$TASK_DIR"/prompts/[0-9]*_*.md 2>/dev/null | sort); do
local phase
phase=$(basename "$prompt_file" .md | sed 's/[^a-zA-Z0-9_]/_/g')
log "[$phase] sending prompt"
local prompt_text
prompt_text=$(sed "s/\${CYCLE_ID}/${cycle_id}-${phase}/g" "$prompt_file")
send_prompt "$prompt_text"
wait_for_done "${cycle_id}-${phase}" "$WAIT_PHASE_TIMEOUT"
log "[$phase] complete"
done
}
check_pause() {
while [ -f "$TASK_DIR/.paused" ]; do
log "paused, waiting for $TASK_DIR/.paused removal..."
sleep 60
done
}
check_stop() {
[ -f "$TASK_DIR/.stop_after_current" ]
}
# =============================== Main loop ================================
main() {
mkdir -p "$TASK_DIR/state"
touch "$TASK_DIR/plan.md" "$TASK_DIR/inbox.md" "$TASK_DIR/escalations.md"
log ""
log "===== perpetuum adversarial-testing started, MAX_ITER=$MAX_ITER ====="
log "project: $PROJECT_ROOT"
log "session: $MIDDLE_SESSION"
log ""
ensure_middle_session
for ITER in $(seq 1 "$MAX_ITER"); do
check_pause
check_stop && { log "graceful stop requested"; break; }
log ""
log "########## ITER $ITER / $MAX_ITER ##########"
run_cycle "${ITER}-$(date +%s)"
check_stop && { log "graceful stop after cycle $ITER"; break; }
if [ "$ITER" -lt "$MAX_ITER" ]; then
log "Sleeping ${SLEEP_BETWEEN_CYCLES}s before next cycle..."
sleep "$SLEEP_BETWEEN_CYCLES"
fi
done
log ""
log "===== complete after $ITER iterations ====="
}
main "$@"
Task metadata
- task name: <TASK_NAME>
- created: <YYYY-MM-DD>
- worktree path: <abs path>
- branch: <branch>
- started from: <branch>@<sha>
- parent repo: <abs path>
- merge target: <branch or n/a>
- trigger type: schedule
- document: draft.md
- rough length: <N words>
- audience: <who you're writing for>
- stance: <what argument the article makes, in one line>
- example basis: article-polish
Escalations
Cases the blind judge couldn't decide on, or where the human's
editorial taste is needed.
Open
<!-- Examples:
(cycle 7) [intro] judge tied on opening hook
Background: Current opening is matter-of-fact ("This article covers X"). Edit makes it rhetorical-question style ("What if X?"). Blind judge said both work, depends on intent.
Question: Which voice fits the article's overall stance?
Options:
- A (current): matter-of-fact, suits technical/skeptical tone
- B (edit): rhetorical question, suits persuasive/inspirational
- C: rewrite both, try a third approach (concrete vignette)
(both versions below for reference) -->
Resolved
Inbox
Verbs:SKIP:PRIORITIZE:ADD:STOP:DIRECTION:NOTE:
Pending
<!-- Examples:
- STOP: don't touch the opening sentence, I want to keep it as is
- DIRECTION: focus on tightening this week, not restructuring
- ADD: a constraint — no rhetorical questions (per style_notes.md)
- NOTE: I added a new section "deployment" at the bottom; agent should
treat it as a fresh target for polishing -->
Processed
Plan
Agent-maintained. Route changes through inbox.md.Pending
<!-- Format:
- [ ] [<section>] [<edit kind>] short description
- diagnosis: <3-line summary of what's wrong>
-->
Done
<!-- Format:
- [x] (cycle <id>) [<section>] [<edit kind>] short title
- diagnosis: <from explore phase>
- edit summary: what changed
- judge's verdict: A | B | tied
- status: KEPT commit:abc1234 | REVERTED | ESCALATED
-->
Task: choose the next paragraph to polish (plan only)
Steps:
1. Read draft.md end-to-end. Don't skim.
2. Read plan.md Done to see which sections have been recently touched, with what edit kinds, and what survived (KEPT) vs reverted.
3. Read inbox.md and style_notes.md (if exists). Apply.
4. Choose one section / paragraph to work on. Prioritize using:
- Sections never touched this run
- Sections with the largest gap between "what it tries to say" and
"what it actually conveys"
- Sections that other readers would skip / get bored / get lost on
- Avoid: the strongest paragraphs (don't fix what isn't broken)
- Avoid: sections touched in the last 3 cycles (rotation)
5. Decide what kind of edit:
- Clarity (untangle phrasing)
- Tightness (cut redundancy)
- Flow (transitions, paragraph breaks)
- Argument strength (sharpen claim, better evidence)
- Voice (tone consistency, persona)
- Opening / closing hook
- Structure (move or merge)
6. Write a 3-line diagnosis of the chosen section: what's wrong right now, in what way, and what direction the edit should go. This is the "before summary" — saved into plan.md Pending.
7. Append to plan.md Pending:
- [ ] [<section>] [<edit kind>] short description
- diagnosis: <3-line summary of what's wrong>8. Do not edit yet.
9. Final action:
echo "explore done ${CYCLE_ID}" > .perpetuum/<TASK_NAME>/state/.cycle_done_${CYCLE_ID}Task: edit + blind-judge + keep or revert
Steps:
1. Read the Pending item from plan.md (top one). Note the diagnosis.
2. Save a snapshot of the current section text (you'll need it for the blind judge):
- Copy current text of the target section into a string
BEFORE_TEXT
⚠️ Important: `cc-use` is an installed Agent Skill, not a shell command.
Use it via your host agent's skill mechanism (your host will load
cc-use's SKILL.md and know how to dispatch the inner agent). Do not
run cc-use directly with the Bash tool — that bypasses the skillprotocol and will fail.
>
If your environment does not recognizecc-useas a skill, orcc-use
reports an inner-agent startup failure (a known issue exists for Codex
outer agents in --dangerously-bypass-approvals-and-sandbox mode wherecc-use's hardcoded--ask-for-approval/--sandboxflags clash —
upstream cc-use issue, not perpetuum): **do not fall back to
Bash-running cc-use, do not spawn a sub-agent yourself, do not write
the work into this session's context directly.** Surface it as a
blocked-on-environment escalation to escalations.md and stop thecycle there. The whole point of the three-layer architecture is the
fresh-context inner agent; faking it locally defeats the purpose.
3. Apply the edit. Either directly or via cc-use dispatch (preferred for non-trivial edits — the fresh inner agent has no priors).
4. Save the new text as AFTER_TEXT.
5. Blind judge via a separate cc-use dispatch. This is the discriminator. The fresh inner agent has no context of what edit was made. Ask:
Here are two versions of the same paragraph from an article about
[topic]. Label them A and B in random order (you decide which is
which, don't tell me). Pick the better version with reasons.
Criteria: [list — clarity, tightness, persuasiveness, voice
consistency, factual accuracy]. If they are roughly equivalent,
say so.
>
A:
[randomized order — either BEFORE or AFTER]
>
B:
[the other one]
The dispatch must include the article's overall topic / argument so the judge isn't picking purely on stylistic local quality.
6. Decision based on judge's verdict:
- Judge picks AFTER → commit:
git commit -m "polish: <section>: <edit kind> — <one-line>" Move to Done, status KEPT.
- Judge picks BEFORE → revert:
git checkout draft.md
Move to Done, status REVERTED.
- Judge says tied → escalate. Move to escalations.md with both
versions and the judge's reasoning. Mark [→] in plan.md.
7. Record. Every Done item:
- [x] (cycle ${CYCLE_ID}) [<section>] [<edit kind>] short title
- diagnosis: <from explore phase>
- edit summary: what changed
- judge's verdict: A | B | tied
- status: KEPT commit:abc1234 | REVERTED | ESCALATED8. 🔴 Final action:
echo "execute done ${CYCLE_ID}" > .perpetuum/<TASK_NAME>/state/.cycle_done_${CYCLE_ID}Anti-self-certifying guardrails
- The blind judge must be a separate cc-use dispatch (fresh
context). Do not judge the edit yourself using the middle TUI's conversation history — you've seen the edit being made and will be biased.
- The randomization of A/B order is non-negotiable. Without it the
judge defaults to "A" disproportionately.
- If the judge consistently picks "AFTER" for 5 cycles straight, that
is a suspicious signal (either you're a great editor or the judge is biased). Note it in plan.md as a flag for the human.
article-polish
Iteratively read and polish a single document — an article, a doc page, a README, a proposal. Each cycle picks one paragraph, improves it, and either keeps or reverts based on judgment.
Task shape vs style-distill
style-distill has a frozen scalar oracle (similarity to target corpus). article-polish does not — it relies on the agent's judgment of "this is better writing now". So the keep/revert decision is qualitative.
Use this when:
- You don't have a target corpus
- You want general writing improvement (clarity, tightness, flow,
argument strength) rather than mimicry of a specific voice
- You accept that the ratchet is softer (an LLM judging "better" is
not as reliable as a numeric score)
Use style-distill instead when:
- You have a target style and corpus
- You want strict ratchet semantics
Task shape
- "Make this article as good as it can be"
- One paragraph per cycle, focused
- Keep/revert based on a multi-criteria LLM judgment (clarity +
tightness + factual + voice)
- Trigger type: schedule (every ~15–30 min)
Files
| File | Customize |
|---|---|
trigger.sh | MAX_ITER, SLEEP_BETWEEN_CYCLES |
prompts/1_explore.md | Genre-specific quality criteria for your doc |
prompts/2_execute.md | Edit + judge logic; what counts as "better" |
draft.md | Your starting document |
style_notes.md | (Optional) constraints — voice, audience, tone, hard rules |
How keep/revert works without a scalar oracle
The middle agent is the judge. To avoid self-certifying:
1. Before edit: save current text + write 3-line summary of its weaknesses 2. After edit: write 3-line summary of how the edit addressed those weaknesses 3. Compare both texts using a separate cc-use dispatch (fresh inner agent, no context of what you just did) asking it to pick the better version blindly (A vs B) with reasons 4. If blind judge picks the new version → commit. If old → revert. If tied → escalate to human.
This is the GAN-style discriminator-without-shared-context pattern.
Anti-patterns
- Don't let the middle agent grade its own edits without the fresh
inner judge. It will confirmation-bias toward keeping its work.
- Don't keep editing the same paragraph cycle after cycle. Force
rotation — prompts/1_explore.md includes a "least recently touched" heuristic.
- Don't measure "length reduction" as quality. Sometimes shorter is
better, sometimes not.
#!/usr/bin/env bash
# perpetuum task: article-polish
# Trigger type: schedule
set -uo pipefail
TASK_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$TASK_DIR/../.." && pwd)"
MIDDLE_SESSION="middle-polish-$(basename "$PROJECT_ROOT")"
# Inner-agent command for Layer 2 (the persistent agent TUI in tmux).
# Default: Claude Code with permissions bypassed.
# For Codex CLI users, override before running, e.g.:
# AGENT_CMD="codex --dangerously-bypass-approvals-and-sandbox" .perpetuum/<task>/trigger.sh
# Or (safer, sandboxed workspace writes only):
# AGENT_CMD="codex --full-auto" .perpetuum/<task>/trigger.sh
# Other coding-CLI agents (Cursor, Windsurf, etc.) work too — set AGENT_CMD
# to whatever command starts that agent in your terminal.
AGENT_CMD="${AGENT_CMD:-claude --dangerously-skip-permissions}"
MAX_ITER=30
SLEEP_BETWEEN_CYCLES=120 # 2 min — see SKILL.md cost note before running
WAIT_PHASE_TIMEOUT=2400
SILENCE_THRESHOLD=600
POLL_INTERVAL=20
TUI_BOOT_WAIT=25
LOG="$TASK_DIR/trigger.log"
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
ensure_middle_session() {
if ! tmux has-session -t "$MIDDLE_SESSION" 2>/dev/null; then
log "Starting $MIDDLE_SESSION (cwd=$PROJECT_ROOT)"
tmux new-session -d -s "$MIDDLE_SESSION" -c "$PROJECT_ROOT" "$AGENT_CMD"
sleep "$TUI_BOOT_WAIT"
fi
}
# Send a multi-line prompt into the middle TUI using the same key sequence
# cc-use uses: C-u (clear input) → load-buffer from tmpfile → paste-buffer -d
# → Enter → C-m → Enter. The triple-submit handles a known Codex CLI TUI
# quirk in tmux where Enter alone sometimes doesn't commit
# (https://github.com/openai/codex/issues/12645). Claude Code accepts the
# same sequence without issue, so we don't branch by agent.
send_prompt() {
local prompt_text="$1"
local tmp
tmp=$(mktemp)
printf '%s' "$prompt_text" > "$tmp"
tmux send-keys -t "$MIDDLE_SESSION" C-u
tmux load-buffer -b pp_prompt "$tmp"
tmux paste-buffer -d -b pp_prompt -t "$MIDDLE_SESSION"
rm -f "$tmp"
sleep 0.5
# Codex-specific: dismiss the "Create a plan?" suggestion that Codex
# sometimes pops up when it detects a complex prompt
# (our explore phase prompt is exactly the kind of thing that triggers
# it). Claude Code doesn't have this popup and Escape may interfere
# with its TUI modals there, so we only send it for Codex. The branch
# is keyed on AGENT_CMD content (no hardcoded agent name in the loop)
# so future agents can opt in by matching their command string here.
case "$AGENT_CMD" in
codex*) tmux send-keys -t "$MIDDLE_SESSION" Escape; sleep 0.3 ;;
esac
tmux send-keys -t "$MIDDLE_SESSION" Enter
sleep 0.7
tmux send-keys -t "$MIDDLE_SESSION" C-m
sleep 0.7
tmux send-keys -t "$MIDDLE_SESSION" Enter
}
wait_for_done() {
local cid="$1" tout="$2"
local flag="$TASK_DIR/state/.cycle_done_${cid}"
local start; start=$(date +%s); local prev=""; local silent=0
log " waiting for: $(basename "$flag")"
while true; do
sleep "$POLL_INTERVAL"
if [ -f "$flag" ]; then log " -> flag: $(cat "$flag")"; rm -f "$flag"; return 0; fi
local snap; snap=$(tmux capture-pane -t "$MIDDLE_SESSION" -p 2>/dev/null | sha256sum | awk '{print $1}')
if [ "$snap" = "$prev" ]; then
silent=$((silent + POLL_INTERVAL))
[ "$silent" -ge "$SILENCE_THRESHOLD" ] && { log " -> silence"; return 0; }
else silent=0; prev="$snap"; fi
[ $(($(date +%s) - start)) -ge "$tout" ] && return 1
done
}
run_cycle() {
local cid="$1"
for pf in $(ls "$TASK_DIR"/prompts/[0-9]*_*.md 2>/dev/null | sort); do
local phase; phase=$(basename "$pf" .md | sed 's/[^a-zA-Z0-9_]/_/g')
log "[$phase] sending prompt"
local pt; pt=$(sed "s/\${CYCLE_ID}/${cid}-${phase}/g" "$pf")
send_prompt "$pt"
wait_for_done "${cid}-${phase}" "$WAIT_PHASE_TIMEOUT"
log "[$phase] complete"
done
}
check_pause() { while [ -f "$TASK_DIR/.paused" ]; do log "paused..."; sleep 60; done; }
check_stop() { [ -f "$TASK_DIR/.stop_after_current" ]; }
main() {
mkdir -p "$TASK_DIR/state"
touch "$TASK_DIR/plan.md" "$TASK_DIR/inbox.md" "$TASK_DIR/escalations.md"
[ -f "$TASK_DIR/draft.md" ] || { log "ERROR: draft.md required"; exit 1; }
log ""
log "===== article-polish started, MAX_ITER=$MAX_ITER ====="
ensure_middle_session
for ITER in $(seq 1 "$MAX_ITER"); do
check_pause; check_stop && { log "graceful stop"; break; }
log ""
log "########## ITER $ITER / $MAX_ITER ##########"
run_cycle "${ITER}-$(date +%s)"
check_stop && break
[ "$ITER" -lt "$MAX_ITER" ] && { log "sleeping..."; sleep "$SLEEP_BETWEEN_CYCLES"; }
done
log "===== complete after $ITER iterations ====="
}
main "$@"
Task metadata
- task name: <TASK_NAME>
- created: <YYYY-MM-DD>
- worktree path: <abs path>
- branch: <branch>
- started from: <branch>@<sha>
- parent repo: <abs path>
- merge target: <branch or n/a>
- trigger type: conditional
- watched repo: <owner/repo>
- watch query: <gh search query>
- auto-comment policy: drafts only (default) | auto-post on dup/invalid (explicit opt-in)
- example basis: github-watcher
Escalations
Issues/PRs where the agent thinks human judgment is needed.
Open
<!-- Format:
(cycle <id>) [#<number>] short title
Background: what the issue/PR is about, why it's not obviously fixable, what's at stake.
Question: the specific design / direction call needed.
Options:
- A: option with trade-offs
- B: option with trade-offs
- C: (optional) option with trade-offs
-->
Resolved
Inbox
Write nudges / instructions / context here. Agent reads at every
cycle start and moves processed items to "## Processed".
Verbs:SKIP:PRIORITIZE:ADD:STOP:DIRECTION:NOTE:
Pending
<!-- Examples (delete and write your own):
- DIRECTION: focus only on
buglabel this week, defer feature-reqs - SKIP: #1234 (won't fix)
- NOTE: I'll review escalations Friday morning, no rush
- PRIORITIZE: anything from user @foo, they file high-quality bugs
-->
Processed
Plan
Agent-maintained. Route changes through inbox.md.Pending
<!-- Format:
- [ ] [#<number>] [<kind>] short title — first thought on disposition
Kind: bug | pr | feature-req | dup | question | other -->
Done
<!-- Format:
- [x] (cycle <id>) [#<number>] [<kind>] short title
- investigation: ...
- finding: ...
- disposition: [FIXED commit:xxx] | [APPROVED] | [CLOSED-DRAFT] | [→ escalated] | [BLOCKED reason]
-->
Task: triage new GitHub activity (plan only, do not execute)
The outer trigger.sh has detected new issues / PRs on ${REPO} updated since the last cycle. Your job is to look at them and plan how to handle each. Do not act on them yet — execution is the next prompt.
Steps:
1. Read plan.md to see what's already been handled (or escalated) in previous cycles. Avoid redundant work.
2. Read inbox.md ## Pending. Apply each item to your priorities for this cycle. Move processed items to ## Processed with a one-line note.
3. List the new items. Use gh to fetch their content:
gh pr list --repo ${REPO} --search "updated:>$(cat .perpetuum/<task>/state/last_seen) state:open" \
--json number,title,author,body,labels,headRefNameOr for issues:
gh issue list --repo ${REPO} --search "updated:>$(cat .perpetuum/<task>/state/last_seen) state:open" \
--json number,title,author,body,labels4. For each new item, do a quick categorization:
- Bug report with reproducible steps → plan to verify + investigate
- PR fixing something → plan to read the diff + judge
- Feature request → plan to write a thoughtful response or
escalate if it's a real direction question
- Duplicate / spam / off-topic → plan to comment/close with a
short message
- Question / how-to → plan to answer or point to docs
5. Append items to plan.md ## Pending in priority order. Use this format:
- [ ] [#<number>] [<kind>] short title — first thought on dispositionKind = bug | pr | feature-req | dup | question | other.
6. Don't execute anything yet. No commits, no comments, no fix attempts. Just plan.
7. Final action (don't forget):
echo "explore done ${CYCLE_ID}" > .perpetuum/<TASK_NAME>/state/.cycle_done_${CYCLE_ID}Task: process the triaged GitHub items
For each item in plan.md ## Pending that the explore phase added
⚠️ Important: `cc-use` is an installed Agent Skill, not a shell command.
Use it via your host agent's skill mechanism (your host will load
cc-use's SKILL.md and know how to dispatch the inner agent). Do not
run cc-use directly with the Bash tool — that bypasses the skillprotocol and will fail.
>
If your environment does not recognizecc-useas a skill, orcc-use
reports an inner-agent startup failure (a known issue exists for Codex
outer agents in --dangerously-bypass-approvals-and-sandbox mode wherecc-use's hardcoded--ask-for-approval/--sandboxflags clash —
upstream cc-use issue, not perpetuum): **do not fall back to
Bash-running cc-use, do not spawn a sub-agent yourself, do not write
the work into this session's context directly.** Surface it as a
blocked-on-environment escalation to escalations.md and stop thecycle there. The whole point of the three-layer architecture is the
fresh-context inner agent; faking it locally defeats the purpose.
this cycle, dispatch to the inner agent via cc-use. You judge what the inner agent finds and decide what to commit / comment / escalate.
Steps:
1. For each Pending item this cycle, dispatch a focused task to the inner agent. Use cc-use delegate:
--project /<absolute-path-to-${REPO}-checkout>--agent claude(or your agent family)- Task: read PR/issue #N, attempt the relevant investigation
(reproduce, read diff, check the docs, etc.), report back with:
- what they found
- their suggested disposition (fix / comment-and-close / merge-with-tweak / escalate)
- relevant evidence (file paths, line numbers, error messages)
2. When the inner agent returns, classify:
a. Clear bug, simple obvious fix:
- Dispatch a second inner call asking to implement the fix
- Verify locally
- Commit with clean message (no AI trailer)
- Optionally push to a branch and have inner agent draft a PR comment
- Mark
[FIXED]in plan.md with commit SHA
b. Clear PR you can accept:
- Inner agent checks the PR out, runs the relevant tests, reports
- You write the approval comment (or escalate if uncertain)
- Mark
[APPROVED]in plan.md
c. Duplicate / invalid / off-topic:
- Draft a courteous reply explaining
- Suggest the user post the comment (don't auto-post unless they
have configured perpetuum to auto-comment — check _meta.md or inbox.md for "auto-comment: yes")
- Mark
[CLOSED-DRAFT]and put the draft text in plan.md
d. Needs design decision:
- Move to
escalations.mdwith full context + 2–3 options - Mark
[→]in plan.md
e. Inner agent couldn't determine:
- Reshape and re-dispatch with more context, OR
- Mark
[BLOCKED]with what's missing
3. Record. Every Done item must have:
- [x] (cycle ${CYCLE_ID}) [#<number>] [<kind>] short title
- investigation: what the inner agent did
- finding: what they reported
- disposition: [FIXED commit:xxx] / [APPROVED] / [CLOSED-DRAFT] / [→ escalated] / [BLOCKED reason]4. New follow-up tasks discovered → append to plan.md Pending.
5. 🔴 Final action:
echo "execute done ${CYCLE_ID}" > .perpetuum/<TASK_NAME>/state/.cycle_done_${CYCLE_ID}No matter how many PRs you triaged, the last action is writing this flag. Don't sit at the prompt waiting for the user.
Safety guardrails
- Never push directly to `main` / production branches. Always use a
feature branch and let the human merge.
- Never auto-comment on issues unless
_meta.mdexplicitly enables
it. Default is: agent drafts, human posts.
- Never close issues automatically. Drafts only.
- Never approve or merge PRs. Inner agent investigates and reports;
human merges.
The point of perpetuum-on-GitHub is to amplify the maintainer's review bandwidth, not to replace their judgment on what goes into the project.
github-watcher
Watch a GitHub repository for new issues / PRs (or external feed events) and let perpetuum triage each as it appears. Inner agent reads the issue/PR, classifies it, optionally fixes simple ones, escalates the ambiguous ones.
Task shape
- Conditional trigger — cycles fire only when new GitHub activity
arrives, not on a fixed schedule
- "More is better" — every triaged item is a unit of progress
- Inner agent can read the issue, check related code, attempt simple
fixes, draft replies, all in fresh context
- Middle agent classifies:
- clearly a bug with obvious fix → commit fix, comment on issue
- clearly invalid / duplicate → suggest comment for user to send
- needs human design decision → escalation
- Trigger type: conditional (polls
gh pr list/gh issue list)
When to use this example
- Project has a steady stream of issues / PRs (more than a few per week)
- You can give the agent
GITHUB_TOKENwith read+comment access - You're willing to review the agent's classifications before pushing
comments / merging fixes (or you're OK with auto-commit-to-branch and PR for review)
When NOT to use this example
- One-off triage (just use Claude Code interactively)
- Project where every issue requires deep human judgment (use perpetuum
for monitoring + escalation, but expect most items to land in escalations.md)
Required environment
ghCLI installed and authenticated (gh auth statusreturns OK)GITHUB_TOKENexported in~/.bashrc(or available to the agent)tmux+cc-use(perpetuum baseline)
Files
| File | What to customize |
|---|---|
trigger.sh | REPO, WATCH_QUERY (which issues/PRs to track), POLL_FREQ |
prompts/1_explore.md | Triage rubric for this project's domain |
prompts/2_execute.md | Commit / comment policies (push or PR? comment language?) |
_meta.md | Fill in once |
How the loop runs differently from schedule type
loop forever:
if .paused exists: wait
if .stop_after_current exists: exit
query: gh pr list (updated since last_seen)
if new items found:
update last_seen
increment cycle counter
paste prompt 1 → wait
paste prompt 2 → wait
else:
log "no change" and sleep POLL_FREQSo cycle count grows only when real work happens. MAX_ITER=20 means "after 20 real cycles, stop and let me review" — not "stop after 20 polls".
#!/usr/bin/env bash
# perpetuum task: github-watcher
# Trigger type: conditional (polls gh, fires cycle on change)
#
# Cycle fires when `gh pr list` returns items updated after our last_seen
# timestamp. Otherwise the loop just polls and sleeps.
set -uo pipefail
# ============================ Configuration ===============================
TASK_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$TASK_DIR/../.." && pwd)"
MIDDLE_SESSION="middle-gh-$(basename "$PROJECT_ROOT")"
# === Customize for your repo ===
REPO="owner/repo" # <-- CHANGE ME
WATCH_QUERY="state:open" # what to consider "new activity"
# Examples:
# WATCH_QUERY="state:open label:bug" # only bug-labeled
# WATCH_QUERY="state:open author:not-team" # only outside contributions
# ===============================
# Inner-agent command for Layer 2 (the persistent agent TUI in tmux).
# Default: Claude Code with permissions bypassed.
# For Codex CLI users, override before running, e.g.:
# AGENT_CMD="codex --dangerously-bypass-approvals-and-sandbox" .perpetuum/<task>/trigger.sh
# Or (safer, sandboxed workspace writes only):
# AGENT_CMD="codex --full-auto" .perpetuum/<task>/trigger.sh
# Other coding-CLI agents (Cursor, Windsurf, etc.) work too — set AGENT_CMD
# to whatever command starts that agent in your terminal.
AGENT_CMD="${AGENT_CMD:-claude --dangerously-skip-permissions}"
MAX_ITER=20 # how many real cycles before we stop
POLL_FREQ=3600 # 1 hour between polls
WAIT_PHASE_TIMEOUT=10800 # 3h per phase
SILENCE_THRESHOLD=900 # 15 min silence = phase done
POLL_INTERVAL=30
TUI_BOOT_WAIT=25
# ==========================================================================
LOG="$TASK_DIR/trigger.log"
LAST_SEEN_FILE="$TASK_DIR/state/last_seen"
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
ensure_middle_session() {
if ! tmux has-session -t "$MIDDLE_SESSION" 2>/dev/null; then
log "Starting $MIDDLE_SESSION (cwd=$PROJECT_ROOT)"
tmux new-session -d -s "$MIDDLE_SESSION" -c "$PROJECT_ROOT" \
"$AGENT_CMD"
sleep "$TUI_BOOT_WAIT"
fi
}
# Send a multi-line prompt into the middle TUI using the same key sequence
# cc-use uses: C-u (clear input) → load-buffer from tmpfile → paste-buffer -d
# → Enter → C-m → Enter. The triple-submit handles a known Codex CLI TUI
# quirk in tmux where Enter alone sometimes doesn't commit
# (https://github.com/openai/codex/issues/12645). Claude Code accepts the
# same sequence without issue, so we don't branch by agent.
send_prompt() {
local prompt_text="$1"
local tmp
tmp=$(mktemp)
printf '%s' "$prompt_text" > "$tmp"
tmux send-keys -t "$MIDDLE_SESSION" C-u
tmux load-buffer -b pp_prompt "$tmp"
tmux paste-buffer -d -b pp_prompt -t "$MIDDLE_SESSION"
rm -f "$tmp"
sleep 0.5
# Codex-specific: dismiss the "Create a plan?" suggestion that Codex
# sometimes pops up when it detects a complex prompt
# (our explore phase prompt is exactly the kind of thing that triggers
# it). Claude Code doesn't have this popup and Escape may interfere
# with its TUI modals there, so we only send it for Codex. The branch
# is keyed on AGENT_CMD content (no hardcoded agent name in the loop)
# so future agents can opt in by matching their command string here.
case "$AGENT_CMD" in
codex*) tmux send-keys -t "$MIDDLE_SESSION" Escape; sleep 0.3 ;;
esac
tmux send-keys -t "$MIDDLE_SESSION" Enter
sleep 0.7
tmux send-keys -t "$MIDDLE_SESSION" C-m
sleep 0.7
tmux send-keys -t "$MIDDLE_SESSION" Enter
}
wait_for_done() {
local cycle_id="$1"
local timeout="$2"
local flag="$TASK_DIR/state/.cycle_done_${cycle_id}"
local start; start=$(date +%s)
local prev=""
local silent=0
log " waiting for: $(basename "$flag")"
while true; do
sleep "$POLL_INTERVAL"
if [ -f "$flag" ]; then
log " -> done via flag: $(cat "$flag")"
rm -f "$flag"; return 0
fi
local snap
snap=$(tmux capture-pane -t "$MIDDLE_SESSION" -p 2>/dev/null | sha256sum | awk '{print $1}')
if [ "$snap" = "$prev" ]; then
silent=$((silent + POLL_INTERVAL))
[ "$silent" -ge "$SILENCE_THRESHOLD" ] && { log " -> done via silence"; return 0; }
else
silent=0; prev="$snap"
fi
[ $(($(date +%s) - start)) -ge "$timeout" ] && { log " -> timeout"; return 1; }
done
}
run_cycle() {
local cycle_id="$1"
for prompt_file in $(ls "$TASK_DIR"/prompts/[0-9]*_*.md 2>/dev/null | sort); do
local phase
phase=$(basename "$prompt_file" .md | sed 's/[^a-zA-Z0-9_]/_/g')
log "[$phase] sending prompt"
local prompt_text
prompt_text=$(sed -e "s/\${CYCLE_ID}/${cycle_id}-${phase}/g" \
-e "s|\${REPO}|${REPO}|g" "$prompt_file")
send_prompt "$prompt_text"
wait_for_done "${cycle_id}-${phase}" "$WAIT_PHASE_TIMEOUT"
log "[$phase] complete"
done
}
check_pause() {
while [ -f "$TASK_DIR/.paused" ]; do
log "paused, waiting for .paused removal..."
sleep 60
done
}
check_stop() { [ -f "$TASK_DIR/.stop_after_current" ]; }
# Returns nonzero stdout if there is new activity since last_seen.
check_for_new_activity() {
local since
since=$(cat "$LAST_SEEN_FILE")
gh pr list --repo "$REPO" \
--search "updated:>$since $WATCH_QUERY" \
--json number,title,updatedAt 2>/dev/null \
| jq -r '.[].number' \
| head -20
}
# =============================== Main loop ================================
main() {
mkdir -p "$TASK_DIR/state"
touch "$TASK_DIR/plan.md" "$TASK_DIR/inbox.md" "$TASK_DIR/escalations.md"
if [ ! -f "$LAST_SEEN_FILE" ]; then
date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ > "$LAST_SEEN_FILE"
log "first run; backfilling last_seen to 24h ago: $(cat "$LAST_SEEN_FILE")"
fi
log ""
log "===== github-watcher started, MAX_ITER=$MAX_ITER, repo=$REPO ====="
local iter=0
while [ "$iter" -lt "$MAX_ITER" ]; do
check_pause
check_stop && { log "graceful stop requested"; break; }
local new_items
new_items=$(check_for_new_activity)
if [ -z "$new_items" ]; then
log "no new activity since $(cat "$LAST_SEEN_FILE"); sleeping ${POLL_FREQ}s"
sleep "$POLL_FREQ"
continue
fi
iter=$((iter + 1))
log ""
log "########## CYCLE $iter / $MAX_ITER triggered ##########"
log "new items: $(echo "$new_items" | tr '\n' ' ')"
ensure_middle_session
run_cycle "${iter}-$(date +%s)"
# Update last_seen so we don't reprocess. Use the time we started
# checking, not "now", to avoid race with items arriving mid-cycle.
date -u +%Y-%m-%dT%H:%M:%SZ > "$LAST_SEEN_FILE"
check_stop && { log "graceful stop"; break; }
done
log ""
log "===== complete after $iter real cycles ====="
}
main "$@"
Task metadata
- task name: <TASK_NAME>
- created: <YYYY-MM-DD>
- worktree path: <abs path>
- branch: <branch — strongly recommend a feature branch for this>
- started from: <branch>@<sha>
- parent repo: <abs path>
- merge target: <branch>
- trigger type: schedule
- conventions doc: <path to conventions.md, or inline below>
Conventions for this codebase
<!-- Fill in before running. The agent reads this to keep additions consistent. -->
- Log library: <e.g. slog | zap | structlog>
- Log levels:
- DEBUG: <when>
- INFO: <when>
- WARN: <when>
- ERROR: <when>
- Metric library: <e.g. prometheus | opentelemetry>
- Metric naming: <e.g. service.subsystem.event_unit>
- Trace library: <e.g. otel>
- Span boundaries: <which external calls deserve a span>
- PII: <fields never to log>
Escalations
Observability additions where convention/policy choice is needed.
Open
<!-- Examples:
(cycle 3) [worker] [counter] should failed jobs have a per-reason breakdown?
Background: Worker currently emits worker.jobs.failed counter without labels. There are ~6 distinct failure reasons (timeout, auth, embedding-quota, etc).
Question: Add a reason label so we can break down failures by type. But labels carry cardinality cost.
Options:
- A: Add
reasonlabel, restrict to ≤10 enumerated values. - B: Keep aggregate counter, add separate counters per reason
(worker.jobs.failed.auth, worker.jobs.failed.timeout, ...).
- C: Keep as is, expect operators to grep logs for reason.
-->
Resolved
Inbox
Verbs:SKIP:PRIORITIZE:ADD:STOP:DIRECTION:NOTE:
Pending
<!-- Examples:
- DIRECTION: focus on external-call traces this week, defer counters
- SKIP: don't touch the worker module, I'm refactoring it
- ADD: convention reminder — all timing metrics use seconds (float), not ms
- STOP: no new metrics in the hot path (cmd/serve/req.go), too high cardinality
-->
Processed
Plan
Agent-maintained. Route changes through inbox.md.Pending
<!-- Format:
- [ ] [<module>] [<obs kind>] description
obs kinds: error-path | state-transition | external-call | config | counter -->
Done
<!-- Format:
- [x] (cycle <id>) [<module>] [<obs kind>] short title
- finding: file:line — current behavior
- proposal: what to add
- status: [ADDED] commit:abc1234 | [→ escalated] | [SKIP] reason | [FALSE-POSITIVE]
-->
Task: choose what to scan this cycle (plan only)
You are scanning the project for observability gaps — places where, in production, you would be blind. Missing logs at error paths. Missing metrics around state changes. Missing trace spans around external calls.
Steps:
1. Read plan.md Done to see which modules / files / dimensions have been scanned, and what was added vs escalated.
2. Read inbox.md ## Pending and apply.
3. Read the project structure once if you haven't this cycle:
find . -type d -not -path '*/.*' -not -path '*/node_modules/*' \
-not -path '*/__pycache__/*' | head -50List the modules / packages.
4. Pick a target for this cycle. Prefer:
- Modules that have never been scanned (check plan.md Done)
- Modules that handle external integrations (highest gap risk)
- Modules with recent commits that didn't touch logging
- Don't repeat: skip modules scanned in the last 5 cycles
5. Decide what kind of observability to focus on this cycle:
- Error path coverage — every
except/catch/if err
branch has a log?
- State transitions — every important state mutation has an
audit event?
- External calls — every network / RPC / DB call has a trace
span and latency metric?
- Configuration — startup logs the effective config?
- Counters — important domain events have counters
(requests by route, jobs by status, etc)?
One kind per cycle. Don't try to do them all at once.
6. Append to plan.md Pending:
- [ ] [<module>] [<obs kind>] short description of what to look for7. Do not start scanning yet.
8. Final action:
echo "explore done ${CYCLE_ID}" > .perpetuum/<TASK_NAME>/state/.cycle_done_${CYCLE_ID}Task: scan + propose + commit or escalate
⚠️ Important: `cc-use` is an installed Agent Skill, not a shell command.
Use it via your host agent's skill mechanism (your host will load
cc-use's SKILL.md and know how to dispatch the inner agent). Do not
run cc-use directly with the Bash tool — that bypasses the skillprotocol and will fail.
>
If your environment does not recognizecc-useas a skill, orcc-use
reports an inner-agent startup failure (a known issue exists for Codex
outer agents in --dangerously-bypass-approvals-and-sandbox mode wherecc-use's hardcoded--ask-for-approval/--sandboxflags clash —
upstream cc-use issue, not perpetuum): **do not fall back to
Bash-running cc-use, do not spawn a sub-agent yourself, do not write
the work into this session's context directly.** Surface it as a
blocked-on-environment escalation to escalations.md and stop thecycle there. The whole point of the three-layer architecture is the
fresh-context inner agent; faking it locally defeats the purpose.
Use cc-use to dispatch the scan to the inner agent. Take its findings, classify each, and either commit the obvious additions or escalate the opinionated ones.
Steps:
1. Read plan.md Pending. Process the items planned this cycle.
2. For each, dispatch to the inner agent:
cc-use delegate --project /<abs-path> --agent claude- Task: "Scan
<module>for<obs kind>gaps. For each candidate,
report: file path, line range, current behavior, what's missing (log/metric/span/event), and a suggested addition consistent with the project's existing observability conventions."
- Important: ask the inner agent to *read existing logs in the
same module first* to learn the convention before proposing new ones. Otherwise you'll get style drift.
3. For each finding the inner agent reports, classify:
a. Clearly missing, obvious addition matching existing conventions:
- Dispatch a second inner call to make the addition
- Verify the project still builds / tests still pass
- Commit:
obsv(<module>): add <log/metric/span> for <event> - Mark
[ADDED]in plan.md with commit SHA
b. Missing but addition involves a convention choice: (new metric naming, ambiguous log level, new label dimension)
- Don't add. Move to
escalations.mdwith options. - Mark
[→]in plan.md
c. Looks like a gap but actually intentional silence: (high-cardinality counter, expected silent path)
- Don't add. Note in plan.md as
[SKIP]with reason.
d. False positive (inner agent misread the code):
- Mark
[FALSE-POSITIVE]with brief note.
4. Record. Every Done item:
- [x] (cycle ${CYCLE_ID}) [<module>] [<obs kind>] short title
- finding: file:line — what's currently happening
- proposal: what observation to add
- status: [ADDED] commit:abc1234 | [→ escalated] | [SKIP] reason | [FALSE-POSITIVE]5. New follow-up scan targets discovered → append to Pending.
6. 🔴 Final action:
echo "execute done ${CYCLE_ID}" > .perpetuum/<TASK_NAME>/state/.cycle_done_${CYCLE_ID}Quality bar — what counts as a real gap
- Real gap: in production you'd say "I have no idea why this
happened". Add it.
- Cosmetic gap: you'd be slightly more comfortable but it's
already reconstructible from existing data. Skip.
- Volume risk: adding this log would create 10× current log
volume. Escalate (don't decide unilaterally).
- PII concern: adding this would log a user identifier without
obvious need. Escalate.
The goal is useful observability, not complete observability. A firehose of logs is its own outage.
observability-gap
Scan a codebase for missing observability — error paths that don't log, metrics that don't exist, traces that don't cover important spans, configuration drift between expected and actual.
This example is observation-mode of adversarial-testing. Instead of "find bugs", it's "find places where you'd be blind in production".
Task shape
- Iterate over the codebase by module / package / directory
- For each, inspect error paths, exception handlers, key state transitions
- Flag any place that:
- catches an exception but doesn't log
- returns an error to caller without metrics
- changes important state without an audit log entry
- has no trace span around an external call
- Categorize fixes:
- simple obvious additions → commit
- opinionated (log level, metric naming) → escalate
- Trigger type: schedule (every ~30 min)
Why this is worth its own example
In early dogfooding on a real codebase, the adversarial-testing example spontaneously discovered several observability commits (per-component counts, startup config logs, failed-job WARNING signals, auth-failure metrics). Carving it out into a dedicated task type lets you:
- Focus the loop entirely on this dimension
- Use a different inner-agent prompt that biases toward "what would
I want to see in a Loki dashboard" rather than "is this correct"
- Avoid mixing "fix bugs" commits with "add log" commits in history
Files
| File | Customize |
|---|---|
trigger.sh | MIDDLE_SESSION, MAX_ITER |
prompts/1_explore.md | Module list / scan strategy |
prompts/2_execute.md | Logging conventions (log level taxonomy, metric naming) |
_meta.md | Once |
Conventions worth committing to
Before running, decide:
- Log levels: when do you use DEBUG vs INFO vs WARN vs ERROR?
- Metric naming: prefix convention, units, labels
- Trace span boundaries: which calls deserve their own span?
- Error vs warning: when does a failure deserve a metric counter
separate from the existing error log?
Put these in _meta.md or a conventions.md you reference from prompts/2_execute.md. Otherwise the agent will make ad-hoc choices and your codebase will end up with inconsistent observability — which is worse than less observability.
Strong recommendation: run on a feature branch
Observability commits accumulate fast (10–30 per day at peak). Merge them as a batch when you're satisfied, rather than letting them trickle into main one by one.
#!/usr/bin/env bash
# perpetuum task: observability-gap
# Trigger type: schedule
set -uo pipefail
TASK_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$TASK_DIR/../.." && pwd)"
MIDDLE_SESSION="middle-obsv-$(basename "$PROJECT_ROOT")"
# Inner-agent command for Layer 2 (the persistent agent TUI in tmux).
# Default: Claude Code with permissions bypassed.
# For Codex CLI users, override before running, e.g.:
# AGENT_CMD="codex --dangerously-bypass-approvals-and-sandbox" .perpetuum/<task>/trigger.sh
# Or (safer, sandboxed workspace writes only):
# AGENT_CMD="codex --full-auto" .perpetuum/<task>/trigger.sh
# Other coding-CLI agents (Cursor, Windsurf, etc.) work too — set AGENT_CMD
# to whatever command starts that agent in your terminal.
AGENT_CMD="${AGENT_CMD:-claude --dangerously-skip-permissions}"
MAX_ITER=20
SLEEP_BETWEEN_CYCLES=120 # 2 min — see SKILL.md cost note before running
WAIT_PHASE_TIMEOUT=14400 # 4h
SILENCE_THRESHOLD=900
POLL_INTERVAL=30
TUI_BOOT_WAIT=25
LOG="$TASK_DIR/trigger.log"
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
ensure_middle_session() {
if ! tmux has-session -t "$MIDDLE_SESSION" 2>/dev/null; then
log "Starting $MIDDLE_SESSION (cwd=$PROJECT_ROOT)"
tmux new-session -d -s "$MIDDLE_SESSION" -c "$PROJECT_ROOT" "$AGENT_CMD"
sleep "$TUI_BOOT_WAIT"
fi
}
# Send a multi-line prompt into the middle TUI using the same key sequence
# cc-use uses: C-u (clear input) → load-buffer from tmpfile → paste-buffer -d
# → Enter → C-m → Enter. The triple-submit handles a known Codex CLI TUI
# quirk in tmux where Enter alone sometimes doesn't commit
# (https://github.com/openai/codex/issues/12645). Claude Code accepts the
# same sequence without issue, so we don't branch by agent.
send_prompt() {
local prompt_text="$1"
local tmp
tmp=$(mktemp)
printf '%s' "$prompt_text" > "$tmp"
tmux send-keys -t "$MIDDLE_SESSION" C-u
tmux load-buffer -b pp_prompt "$tmp"
tmux paste-buffer -d -b pp_prompt -t "$MIDDLE_SESSION"
rm -f "$tmp"
sleep 0.5
# Codex-specific: dismiss the "Create a plan?" suggestion that Codex
# sometimes pops up when it detects a complex prompt
# (our explore phase prompt is exactly the kind of thing that triggers
# it). Claude Code doesn't have this popup and Escape may interfere
# with its TUI modals there, so we only send it for Codex. The branch
# is keyed on AGENT_CMD content (no hardcoded agent name in the loop)
# so future agents can opt in by matching their command string here.
case "$AGENT_CMD" in
codex*) tmux send-keys -t "$MIDDLE_SESSION" Escape; sleep 0.3 ;;
esac
tmux send-keys -t "$MIDDLE_SESSION" Enter
sleep 0.7
tmux send-keys -t "$MIDDLE_SESSION" C-m
sleep 0.7
tmux send-keys -t "$MIDDLE_SESSION" Enter
}
wait_for_done() {
local cid="$1" tout="$2"
local flag="$TASK_DIR/state/.cycle_done_${cid}"
local start; start=$(date +%s); local prev=""; local silent=0
log " waiting for: $(basename "$flag")"
while true; do
sleep "$POLL_INTERVAL"
if [ -f "$flag" ]; then log " -> flag: $(cat "$flag")"; rm -f "$flag"; return 0; fi
local snap; snap=$(tmux capture-pane -t "$MIDDLE_SESSION" -p 2>/dev/null | sha256sum | awk '{print $1}')
if [ "$snap" = "$prev" ]; then
silent=$((silent + POLL_INTERVAL))
[ "$silent" -ge "$SILENCE_THRESHOLD" ] && { log " -> silence"; return 0; }
else silent=0; prev="$snap"; fi
[ $(($(date +%s) - start)) -ge "$tout" ] && return 1
done
}
run_cycle() {
local cid="$1"
for pf in $(ls "$TASK_DIR"/prompts/[0-9]*_*.md 2>/dev/null | sort); do
local phase; phase=$(basename "$pf" .md | sed 's/[^a-zA-Z0-9_]/_/g')
log "[$phase] sending prompt"
local pt; pt=$(sed "s/\${CYCLE_ID}/${cid}-${phase}/g" "$pf")
send_prompt "$pt"
wait_for_done "${cid}-${phase}" "$WAIT_PHASE_TIMEOUT"
log "[$phase] complete"
done
}
check_pause() { while [ -f "$TASK_DIR/.paused" ]; do log "paused..."; sleep 60; done; }
check_stop() { [ -f "$TASK_DIR/.stop_after_current" ]; }
main() {
mkdir -p "$TASK_DIR/state"
touch "$TASK_DIR/plan.md" "$TASK_DIR/inbox.md" "$TASK_DIR/escalations.md"
log ""
log "===== observability-gap started, MAX_ITER=$MAX_ITER ====="
ensure_middle_session
for ITER in $(seq 1 "$MAX_ITER"); do
check_pause; check_stop && { log "graceful stop"; break; }
log ""
log "########## ITER $ITER / $MAX_ITER ##########"
run_cycle "${ITER}-$(date +%s)"
check_stop && break
[ "$ITER" -lt "$MAX_ITER" ] && { log "sleeping..."; sleep "$SLEEP_BETWEEN_CYCLES"; }
done
log "===== complete after $ITER iterations ====="
}
main "$@"
Examples
Each subdirectory here is a fully-formed .perpetuum/<task>/ template that an agent can copy into a user's project as a starting point.
| Example | Task shape | Trigger type |
|---|---|---|
adversarial-testing/ | "Find bugs and improvements in a project, multi-dimensional exploration, commit fixes, escalate ambiguous design questions" | schedule |
github-watcher/ | "Watch new issues/PRs on a repo, process each as it appears" | conditional |
style-distill/ | "Iteratively rewrite a draft article to converge on a target author's style" | schedule + scalar oracle |
article-polish/ | "Reread a single document repeatedly and improve one paragraph per cycle" | schedule |
observability-gap/ | "Scan codebase for missing logs / metrics / error paths" | schedule |
When picking an example, look at the shape of the task, not the domain. A task about polishing API documentation is closer to article-polish than to adversarial-testing, even though both involve a codebase.
Anatomy of an example
Each example contains the full set of files a .perpetuum/<task>/ directory needs:
<example>/
├── README.md describes the task shape and what to customize
├── _meta.md template with placeholders
├── trigger.sh customized for this task type
├── prompts/
│ ├── 1_explore.md prompt 1 customized for this task
│ └── 2_execute.md prompt 2 customized for this task
├── plan.md empty skeleton
├── inbox.md empty skeleton
└── escalations.md empty skeletonWhen the user picks an example during references/setup.md, copy the entire directory and adapt each file. The README in each example tells you exactly what to change.
Adding a new example
If you find a recurring task shape that doesn't fit existing examples, add one. The bar:
- It must pass the suitability gate (
references/setup.mddescribes it) - Its
prompts/1_explore.mdandprompts/2_execute.mdshould be 80%+ reusable for the
task family, with only the domain-specific section needing customization
- It must demonstrate at least one distinct idea (different trigger
type, different oracle, different escalation pattern)
Don't add an example just because it's a different domain — that's just a customization of an existing example.
Task metadata
- task name: <TASK_NAME>
- created: <YYYY-MM-DD>
- worktree path: <abs path>
- branch: <branch>
- started from: <branch>@<sha>
- parent repo: <abs path>
- merge target: <branch or n/a>
- trigger type: schedule
- target author: <name / description>
- corpus size: <N> articles, ~<M> words total
- oracle: style_score.py (tfidf cosine + stylometric L1)
- starting score: <initial>
- target score: <aspiration, optional>
- example basis: style-distill
Escalations
The oracle decides keep-or-revert; escalations exist for cases the
oracle can't decide on. Mainly: oracle broken, plateau reached,
suspected reward hacking, "is the draft actually getting better?"
Open
<!-- Examples:
(cycle 8) score plateau — 10 cycles without a KEPT edit
Background: Last 10 cycles all REVERTED. Current score stuck at 0.612.
Question: Are we done (good enough), or has the oracle been exhausted (no more gradient signal), or is there a better corpus to swap in?
Options:
- A: Declare done; the draft is close enough to target style for purpose.
- B: Add more target_corpus files (suggested: 5–10 more from same author).
- C: Tighten EPSILON to allow smaller improvements (risky — may overfit).
-->
Resolved
Inbox
Verbs:SKIP:PRIORITIZE:ADD:STOP:DIRECTION:NOTE:
Pending
<!-- Examples:
- DIRECTION: focus on sentence rhythm this week, not vocabulary
- STOP: don't touch the opening paragraph, I like it
- NOTE: I'm replacing target_corpus/article_003.md with a better one,
expect a one-time score shift after my next push -->
Processed
Plan
Agent-maintained. Route changes through inbox.md.Pending
<!-- Format:
- [ ] [<section>] [<edit kind>] short description
edit kinds: sentence-length | vocabulary | rhythm | reorg | opening | closing | transition | voice -->
Done
<!-- Format:
- [x] (cycle <id>) [<section>] [<edit kind>] short description
- operation: what was rewritten
- observed: score before → after (delta)
- status: KEPT commit:abc1234 | REVERTED | ESCALATED
-->
Task: plan this cycle's stylistic edit (do not edit yet)
You are iteratively rewriting draft.md to match the style of the target author whose work is in target_corpus/. The score function style_score.py is the oracle — higher score = closer to target.
Steps:
1. Read draft.md (current state) and plan.md (history of edits and scores). Note the current score (last score_after in Done log).
2. Read inbox.md ## Pending and apply.
3. Read 2–3 random files from target_corpus/ to refresh your sense of the target style — not to copy specific phrases, but to feel the rhythm, vocabulary, sentence length, paragraph structure.
4. Look at draft.md and pick one section / paragraph / sentence to work on this cycle. The choice should:
- Be small enough to edit in one focused pass (one paragraph, one
transition, one opening line)
- Be where the gap between current and target style feels biggest
(clunky phrasing, mismatched tone, wrong cadence)
- Avoid sections recently edited (check plan.md Done)
5. Decide what kind of edit:
- sentence-length adjustment
- vocabulary swap (toward target's word choice)
- rhythm / cadence
- paragraph reorganization
- opening / closing line strengthening
- transition tightening
- voice / persona (active vs passive, distance, formality)
6. Append to plan.md ## Pending:
- [ ] [<section>] [<edit kind>] short description of intended change7. Don't make the edit yet. Execution next.
8. Final action:
echo "explore done ${CYCLE_ID}" > .perpetuum/<TASK_NAME>/state/.cycle_done_${CYCLE_ID}A note on overfitting
If recent cycles have all been the same edit kind, deliberately switch — pick a different stylistic axis. The risk with style-distill is the agent finds one cheap way to bump the score (e.g. shortening all sentences) and optimizes only that.
Task: execute the planned edit and ratchet
The ratchet runs locally. You apply the edit, score it, and either commit or revert based on the score delta. No human judgment involved in keep-or-revert — the oracle decides.
Steps:
1. Read plan.md ## Pending — there should be ≥1 Pending item from the explore phase. Pick the top one.
2. Record current state:
cd .perpetuum/<TASK_NAME>
SCORE_BEFORE=$(python style_score.py target_corpus/ draft.md)3. Dispatch the edit. You can either:
- Edit
draft.mddirectly (simple cases)
⚠️ Important: `cc-use` is an installed Agent Skill, not a shell command.
Use it via your host agent's skill mechanism (your host will load
cc-use's SKILL.md and know how to dispatch the inner agent). Do not
run cc-use directly with the Bash tool — that bypasses the skillprotocol and will fail.
>
If your environment does not recognizecc-useas a skill, orcc-use
reports an inner-agent startup failure (a known issue exists for Codex
outer agents in --dangerously-bypass-approvals-and-sandbox mode wherecc-use's hardcoded--ask-for-approval/--sandboxflags clash —
upstream cc-use issue, not perpetuum): **do not fall back to
Bash-running cc-use, do not spawn a sub-agent yourself, do not write
the work into this session's context directly.** Surface it as a
blocked-on-environment escalation to escalations.md and stop thecycle there. The whole point of the three-layer architecture is the
fresh-context inner agent; faking it locally defeats the purpose.
- Dispatch via
cc-use delegateif the edit is involved
(--project /<abs-path>, --agent claude, task = "rewrite the <section> of draft.md to <intended change>; don't change other sections")
4. Score the edit:
SCORE_AFTER=$(python style_score.py target_corpus/ draft.md)5. Ratchet decision:
IMPROVEMENT=$(python -c "print(${SCORE_AFTER} - ${SCORE_BEFORE})")
# If improvement > epsilon (defined in style_score.py top), keep.- Improvement positive (above epsilon):
git add draft.mdgit commit -m "style: <section>: <one-line description>"- In
plan.mdmove item to Done withstatus: KEPT score:<before>→<after> - No improvement (or worse):
git checkout draft.md(revert)- In
plan.mdmove item to Done withstatus: REVERTED score:<before>=<after> - Optionally append a follow-up Pending item with a different approach
6. No escalation for keep/revert — that's the oracle's job. But escalate if:
- The oracle has clearly broken (NaN, OOM, can't import)
- The corpus is too sparse (score swings wildly between cycles)
- You suspect the oracle is gameable and the score is improving
but the writing is getting worse — write this to escalations.md so the human can sanity-check
7. Record. Every Done item:
- [x] (cycle ${CYCLE_ID}) [<section>] [<edit kind>] short desc
- operation: what was rewritten and how
- observed: score <before> → <after> (delta: <diff>)
- status: KEPT commit:abc1234 | REVERTED | ESCALATED8. 🔴 Final action:
echo "execute done ${CYCLE_ID}" > .perpetuum/<TASK_NAME>/state/.cycle_done_${CYCLE_ID}When to stop
If the score plateaus for many cycles (e.g. 10 cycles in a row with no KEPT edits), write that observation to escalations.md — it's a signal the user should either:
- Reread the draft and decide if it's actually good (might be done!)
- Switch oracle (the current oracle has been exhausted)
- Add more target_corpus files (more signal for the agent to chase)
style-distill
Iteratively rewrite a draft article so its style converges on a target author's corpus. This is the text-space analog of Karpathy's AutoResearch: fixed corpus as evaluator, draft as editable asset, similarity score as scalar metric, monotonic ratchet keeps only improvements.
Task shape
- Optimization-under-fixed-metric — not a "find more bugs" loop, but
a "make the draft closer to target" loop
- The corpus and the scoring function are frozen during the run.
Only the draft changes.
- Each cycle:
1. explore: pick a paragraph or stylistic axis to work on 2. execute: rewrite, score, keep-if-improved else revert
- Trigger type: schedule (every ~20 min, until target score reached
or MAX_ITER exhausted)
When to use this example
- You want a new article to read like a specific author (yourself,
someone you've studied, a brand voice)
- You have a corpus of that author's work (~10–50 articles, plain text)
- You can write or accept a scoring function (start with cosine
similarity of embeddings; refine later)
When NOT to use this example
- You want creative novelty more than style fidelity (the ratchet
punishes novelty)
- The target style is too narrow (one paragraph of corpus → overfits
to that exact wording)
- You don't have time to inspect intermediate drafts (this loop can
optimize toward a degenerate "looks like target" without being good writing; sanity-check periodically)
Required setup
.perpetuum/<task>/
├── trigger.sh, prompts/1_explore.md, prompts/2_execute.md, plan.md, inbox.md,
│ escalations.md, _meta.md (standard)
├── draft.md ← the article you're polishing (you write the v0)
├── target_corpus/ ← directory of target author's articles
│ ├── article_001.md
│ ├── article_002.md
│ └── ...
└── style_score.py ← scoring function (see template)The agent commits to a git branch on every accepted edit. Reverts are git reset --hard HEAD~1. The branch history is the ratchet log.
How the ratchet works
agent reads draft.md + plan.md
agent edits one section of draft.md
score_before = python style_score.py target_corpus/ draft.md@HEAD~1
score_after = python style_score.py target_corpus/ draft.md
if score_after > score_before + EPSILON:
git commit # keep
mark Done in plan.md with old→new score
else:
git reset --hard HEAD~1 # revert
mark Done in plan.md with old=new=score and "no improvement"EPSILON lives at the top of style_score.py. Small enough that real improvements pass, big enough that noise doesn't.
Files
| File | Customize |
|---|---|
trigger.sh | MAX_ITER, SLEEP_BETWEEN_CYCLES (shorter than testing — 15–20 min is fine) |
prompts/1_explore.md | The "style dimensions" the agent should sample |
prompts/2_execute.md | The exact style_score.py call signature you use |
style_score.py | Replace the placeholder logic with your real scoring |
target_corpus/ | Put the target author's articles here |
draft.md | Your starting draft |
Anti-overfitting checklist
- Don't put just 1-2 corpus files. Aim for ≥10.
- Don't reuse the same corpus you used to write the v0 draft.
- Periodically read what the agent produced — does it still read as
a coherent argument, or has it become "stylistic mush"?
- Consider a second oracle (LLM judge of "is this actually good writing?")
layered on top of similarity score.
#!/usr/bin/env python3
"""
style_score.py — frozen oracle for style-distill perpetuum task.
This file is part of the contract: it is the EVALUATOR and should not be
modified during a run. Modifying it invalidates score comparisons across
cycles.
USAGE:
python style_score.py <target_corpus_dir> <draft_file>
OUTPUT:
a single float on stdout (higher = closer to target)
PLACEHOLDER IMPLEMENTATION:
This uses a simple cosine-similarity-of-tfidf approach as a starting
point. Replace with a more sophisticated metric for production use:
- sentence-embedding cosine (sbert)
- style classifier probability (fine-tune a small model)
- composite (similarity + LLM judge)
WARNING:
Beware reward hacking. A score function that the agent can game without
actually matching style is worse than no score function. Validate
manually every 10 cycles or so.
"""
import sys
import re
from pathlib import Path
from collections import Counter
# Improvement threshold. Score deltas smaller than this are noise.
EPSILON = 0.005
def tokenize(text: str) -> list[str]:
text = text.lower()
return re.findall(r"[a-zA-Z一-鿿]+", text)
def tf(tokens: list[str]) -> dict[str, float]:
if not tokens:
return {}
counts = Counter(tokens)
total = sum(counts.values())
return {t: c / total for t, c in counts.items()}
def cosine(a: dict[str, float], b: dict[str, float]) -> float:
keys = set(a) & set(b)
if not keys:
return 0.0
num = sum(a[k] * b[k] for k in keys)
da = sum(v * v for v in a.values()) ** 0.5
db = sum(v * v for v in b.values()) ** 0.5
if da == 0 or db == 0:
return 0.0
return num / (da * db)
def style_features(text: str) -> dict[str, float]:
"""
Composite stylometric features beyond raw tf.
"""
tokens = tokenize(text)
sents = re.split(r"[.!?。!?]+", text)
sents = [s.strip() for s in sents if s.strip()]
sent_lens = [len(tokenize(s)) for s in sents] or [0]
return {
"_avg_sent_len": sum(sent_lens) / max(1, len(sent_lens)),
"_sent_count": len(sents),
"_token_count": len(tokens),
"_long_word_ratio": (
sum(1 for t in tokens if len(t) >= 8) / max(1, len(tokens))
),
"_punct_density": (
sum(text.count(p) for p in ",;:—()") / max(1, len(text))
),
}
def score(target_dir: Path, draft_path: Path) -> float:
"""
Composite score: tfidf cosine + stylometric L1 closeness.
Higher = closer to target. Range roughly 0..1.
"""
target_texts = [p.read_text() for p in target_dir.glob("*.md")]
target_texts += [p.read_text() for p in target_dir.glob("*.txt")]
if not target_texts:
raise SystemExit(f"no .md or .txt files in {target_dir}")
target_blob = "\n\n".join(target_texts)
target_tf = tf(tokenize(target_blob))
target_style = style_features(target_blob)
draft_text = draft_path.read_text()
draft_tf = tf(tokenize(draft_text))
draft_style = style_features(draft_text)
tf_sim = cosine(target_tf, draft_tf)
# Stylometric L1 distance, normalized into similarity [0,1]
style_dist = 0.0
for k, v in target_style.items():
dv = draft_style.get(k, 0)
if abs(v) + abs(dv) > 0:
style_dist += abs(v - dv) / (abs(v) + abs(dv) + 1e-9)
style_dist /= max(1, len(target_style))
style_sim = 1.0 - min(1.0, style_dist)
# Weighted composite
return 0.6 * tf_sim + 0.4 * style_sim
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit("usage: python style_score.py <target_corpus_dir> <draft_file>")
target_dir = Path(sys.argv[1])
draft_path = Path(sys.argv[2])
print(f"{score(target_dir, draft_path):.6f}")
target_corpus
Place the target author's articles here, one per file.
Format
- Plain
.mdor.txtfiles - One article per file (not concatenated)
- File names don't matter; the score function globs
*.mdand*.txt
Recommended size
- Minimum: 5 articles (oracle has too little signal otherwise)
- Comfortable: 10–30 articles (most use cases)
- Diminishing returns past ~50 (oracle saturates)
What to include
- Articles representative of the style you want
- Same author, similar genre / register (don't mix the author's
technical blog with their fiction unless that's intentional)
- Recent enough to reflect current voice (style evolves over years)
What NOT to include
- The starting
draft.mditself (you'd be measuring distance to itself) - Articles you wrote with help from the same target (circular oracle)
- Translations (the score function is roughly language-aware via the
unicode range pattern, but mixing languages confuses it)
After populating
Run a baseline:
python ../style_score.py . ../draft.md…to see your starting score. Then launch trigger.sh.
Replacing files mid-run
You can swap in new corpus files between cycles, but this shifts the oracle. Existing score history becomes incomparable. Note the swap in inbox.md so the agent (and future you) knows about the discontinuity.
#!/usr/bin/env bash
# perpetuum task: style-distill
# Trigger type: schedule
#
# Cycle: explore (pick what to rewrite) → execute (rewrite + score + ratchet)
# Ratchet is git-based: every accepted edit is a commit; reverts undo.
set -uo pipefail
TASK_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$TASK_DIR/../.." && pwd)"
MIDDLE_SESSION="middle-style-$(basename "$PROJECT_ROOT")"
# Inner-agent command for Layer 2 (the persistent agent TUI in tmux).
# Default: Claude Code with permissions bypassed.
# For Codex CLI users, override before running, e.g.:
# AGENT_CMD="codex --dangerously-bypass-approvals-and-sandbox" .perpetuum/<task>/trigger.sh
# Or (safer, sandboxed workspace writes only):
# AGENT_CMD="codex --full-auto" .perpetuum/<task>/trigger.sh
# Other coding-CLI agents (Cursor, Windsurf, etc.) work too — set AGENT_CMD
# to whatever command starts that agent in your terminal.
AGENT_CMD="${AGENT_CMD:-claude --dangerously-skip-permissions}"
MAX_ITER=50 # style runs typically need many cycles
SLEEP_BETWEEN_CYCLES=120 # 2 min — see SKILL.md cost note before running
WAIT_PHASE_TIMEOUT=3600
SILENCE_THRESHOLD=600 # 10 min — text edits are quicker
POLL_INTERVAL=20
TUI_BOOT_WAIT=25
LOG="$TASK_DIR/trigger.log"
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
ensure_middle_session() {
if ! tmux has-session -t "$MIDDLE_SESSION" 2>/dev/null; then
log "Starting $MIDDLE_SESSION (cwd=$PROJECT_ROOT)"
tmux new-session -d -s "$MIDDLE_SESSION" -c "$PROJECT_ROOT" \
"$AGENT_CMD"
sleep "$TUI_BOOT_WAIT"
fi
}
# Send a multi-line prompt into the middle TUI using the same key sequence
# cc-use uses: C-u (clear input) → load-buffer from tmpfile → paste-buffer -d
# → Enter → C-m → Enter. The triple-submit handles a known Codex CLI TUI
# quirk in tmux where Enter alone sometimes doesn't commit
# (https://github.com/openai/codex/issues/12645). Claude Code accepts the
# same sequence without issue, so we don't branch by agent.
send_prompt() {
local prompt_text="$1"
local tmp
tmp=$(mktemp)
printf '%s' "$prompt_text" > "$tmp"
tmux send-keys -t "$MIDDLE_SESSION" C-u
tmux load-buffer -b pp_prompt "$tmp"
tmux paste-buffer -d -b pp_prompt -t "$MIDDLE_SESSION"
rm -f "$tmp"
sleep 0.5
# Codex-specific: dismiss the "Create a plan?" suggestion that Codex
# sometimes pops up when it detects a complex prompt
# (our explore phase prompt is exactly the kind of thing that triggers
# it). Claude Code doesn't have this popup and Escape may interfere
# with its TUI modals there, so we only send it for Codex. The branch
# is keyed on AGENT_CMD content (no hardcoded agent name in the loop)
# so future agents can opt in by matching their command string here.
case "$AGENT_CMD" in
codex*) tmux send-keys -t "$MIDDLE_SESSION" Escape; sleep 0.3 ;;
esac
tmux send-keys -t "$MIDDLE_SESSION" Enter
sleep 0.7
tmux send-keys -t "$MIDDLE_SESSION" C-m
sleep 0.7
tmux send-keys -t "$MIDDLE_SESSION" Enter
}
wait_for_done() {
local cycle_id="$1"; local timeout="$2"
local flag="$TASK_DIR/state/.cycle_done_${cycle_id}"
local start; start=$(date +%s); local prev=""; local silent=0
log " waiting for: $(basename "$flag")"
while true; do
sleep "$POLL_INTERVAL"
if [ -f "$flag" ]; then
log " -> done via flag: $(cat "$flag")"
rm -f "$flag"; return 0
fi
local snap; snap=$(tmux capture-pane -t "$MIDDLE_SESSION" -p 2>/dev/null | sha256sum | awk '{print $1}')
if [ "$snap" = "$prev" ]; then
silent=$((silent + POLL_INTERVAL))
[ "$silent" -ge "$SILENCE_THRESHOLD" ] && { log " -> silence"; return 0; }
else silent=0; prev="$snap"; fi
[ $(($(date +%s) - start)) -ge "$timeout" ] && return 1
done
}
run_cycle() {
local cycle_id="$1"
for prompt_file in $(ls "$TASK_DIR"/prompts/[0-9]*_*.md 2>/dev/null | sort); do
local phase; phase=$(basename "$prompt_file" .md | sed 's/[^a-zA-Z0-9_]/_/g')
log "[$phase] sending prompt"
local pt; pt=$(sed "s/\${CYCLE_ID}/${cycle_id}-${phase}/g" "$prompt_file")
send_prompt "$pt"
wait_for_done "${cycle_id}-${phase}" "$WAIT_PHASE_TIMEOUT"
log "[$phase] complete"
done
}
check_pause() {
while [ -f "$TASK_DIR/.paused" ]; do
log "paused..."
sleep 60
done
}
check_stop() { [ -f "$TASK_DIR/.stop_after_current" ]; }
main() {
mkdir -p "$TASK_DIR/state"
touch "$TASK_DIR/plan.md" "$TASK_DIR/inbox.md" "$TASK_DIR/escalations.md"
if [ ! -f "$TASK_DIR/draft.md" ]; then
log "ERROR: $TASK_DIR/draft.md not found. Create your v0 draft first."
exit 1
fi
if [ ! -d "$TASK_DIR/target_corpus" ] || [ -z "$(ls "$TASK_DIR/target_corpus" 2>/dev/null)" ]; then
log "ERROR: $TASK_DIR/target_corpus/ missing or empty. Populate with target author's articles."
exit 1
fi
log ""
log "===== style-distill started, MAX_ITER=$MAX_ITER ====="
ensure_middle_session
for ITER in $(seq 1 "$MAX_ITER"); do
check_pause
check_stop && { log "graceful stop"; break; }
log ""
log "########## ITER $ITER / $MAX_ITER ##########"
run_cycle "${ITER}-$(date +%s)"
check_stop && break
if [ "$ITER" -lt "$MAX_ITER" ]; then
log "sleeping ${SLEEP_BETWEEN_CYCLES}s..."
sleep "$SLEEP_BETWEEN_CYCLES"
fi
done
log "===== complete after $ITER iterations ====="
}
main "$@"
Control: pause / resume / stop / kill
Read this when the user wants to control a running perpetuum task — pause it, resume it, stop it gracefully, or kill it.
The four control states
All control is done by file signals plus standard process / tmux commands. No new protocol, no message bus, no API.
| State | How to enter | How to leave |
|---|---|---|
| Running | (default after launch) | any of below |
| Paused | touch .perpetuum/<task>/.paused | rm .perpetuum/<task>/.paused |
| Stopped (graceful) | touch .perpetuum/<task>/.stop_after_current | (process exits naturally; relaunch trigger.sh to resume) |
| Killed (hard) | pkill -f trigger.sh then tmux kill-session -t middle-<task> | relaunch trigger.sh |
What each one actually does
Pause
trigger.sh finishes the current cycle (does not interrupt mid-cycle — prompts in flight complete normally), then enters a polling loop that checks for .paused every 60 seconds. The middle CC TUI stays alive in tmux, the inner cc-use session stays alive. Nothing is lost.
Resume removes the flag; on the next poll trigger.sh proceeds to the next cycle. Resume costs nothing.
Use case: "I want to read the latest plan.md and write some inbox items before the next cycle picks them up."
Stop (graceful)
trigger.sh finishes the current cycle, checks the flag, exits cleanly. The middle CC TUI and inner cc-use session are still alive in tmux (they don't know trigger.sh stopped). plan.md / escalations.md are in a clean state because the cycle finished.
To resume: just run trigger.sh again. It will reuse the middle session if it's still there, otherwise start a fresh one. State is in files; it picks up where it left off.
Use case: "I'm done for the week, stop cleanly. I'll restart Monday."
Kill (hard)
Just kill the process and optionally the tmux session. There may be a half-written cycle, escalation, or commit. Usually the next launch recovers because plan.md is mostly self-consistent and worst case re-does a small piece of work.
Use case: "Something is wrong, just stop." (Or: process is wedged.)
Natural language mapping
When the user talks to you ("Layer 4"), translate their language into the right command. Map liberally:
| User says | You do |
|---|---|
| pause / stop for now / hold on | touch .paused |
| resume / keep going / start again | rm .paused |
| stop after this round / wrap up | touch .stop_after_current |
| kill it / force stop | pkill -f trigger.sh; tmux kill-session -t middle-<task> |
| start again / relaunch | nohup .perpetuum/<task>/trigger.sh > /dev/null 2>&1 & |
| is it paused? / is it running? | check both: ls .paused 2>/dev/null and pgrep -f trigger.sh |
For ambiguous wording ("stop"), ask if they mean pause (resumable in seconds) or graceful stop (resumable later, but needs relaunch).
How to confirm state
Quick read-only check:
TASK=.perpetuum/<task>
echo "trigger.sh running: $(pgrep -f $TASK/trigger.sh | head -1 || echo no)"
echo "paused flag: $(test -f $TASK/.paused && echo yes || echo no)"
echo "stop flag: $(test -f $TASK/.stop_after_current && echo yes || echo no)"
echo "middle tmux: $(tmux has-session -t middle-<task> 2>/dev/null && echo alive || echo dead)"
echo "last log line: $(tail -1 $TASK/trigger.log)"Use this when the user asks "is it running?" or "what state is it in?"
When multiple tasks exist
If the user has multiple perpetuum tasks (e.g. one per worktree), their natural-language reference is often ambiguous: "pause the testing one". Clarify by listing what's actually running:
ls -1 .perpetuum/*/trigger.sh 2>/dev/null | while read t; do
TD=$(dirname "$t")
echo "- $(basename $TD): $(pgrep -fc $t > /dev/null && echo running || echo not running)"
done…then ask which one they meant.
Design notes: why perpetuum is shaped the way it is
This document is the long-form rationale. You don't need to read it to use perpetuum. Read it if you're modifying the skill, designing a new example, or trying to understand why certain decisions are non-negotiable.
Three core problems, three core solutions
This is the framing the whole project is organized around. Every mechanism elsewhere in this doc either implements one of these three solutions or supports one of them. (The README has the same framing with ASCII diagrams; this section is the prose version for people reading the design docs directly.)
Problem 1 — Vague goal, wide operating space → drift
/goal and Ralph-style loops take whatever sentence the user typed as the goal and give the agent unlimited interpretive freedom. With nothing actively pulling the agent back to the main thread, or vetting its mid-run output, it wanders off-target.
perpetuum's answer (a two-part mechanism in Layer 2):
- A suitability gate at setup forces a narrow, judgeable goal
up front. Vague tasks get reshaped or rejected before init.
- Every cycle, Layer 2 runs two prompts:
prompts/1_explore.md
re-checks direction (where should we go next, what's still pending, are we drifting?) and prompts/2_execute.md dispatches Layer 1 work and judges the result before it can become a commit. The plan-then-judge cycle is what stops "fake progress" from accumulating.
This is the discriminator/generator separation from GANs, but applied at the loop level rather than at the model level.
Problem 2 — No continuation mechanism → one short run and done
/goal is single-session. Even "infinite loop" variants are blindly time-triggered, no concept of event or condition. But real "do more of this" work — find more bugs, fit a metric tighter, watch for new PRs — needs the loop to span sessions, restarts, and different kinds of trigger.
perpetuum's answer (Layer 3):
Layer 3 (trigger.sh) abstracts triggers into three families:
schedule— every N minutes, run a cycleconditional— poll an external state (gh pr list, file watch,
log alert) and only run when something changed
webhook— react to event-driven input
All three feed the same Layer 2/Layer 1 stack. Arbitrarily many cycles can then be stitched together on the time axis — across sessions, machine reboots, days, weeks.
Problem 3 — Human-in-the-loop is a wall → ambiguity freezes everything
Traditional loops have no way to keep going when they hit something they can't decide alone. They guess wrong or stop and wait — and if the user isn't at the terminal, "wait" means dead.
perpetuum's answer (three async channels):
escalations.md— agent writes ambiguous decisions with A/B/C
options to a queue. The user fills answers in when convenient. The loop never blocks; it notes "this one's escalated, moving on to the next" and continues.
inbox.md— user pushes instructions in (SKIP, PRIORITIZE,
DIRECTION, etc.) whenever they want. The next cycle's explore phase reads and applies them.
- Layer 4 — the host coding agent the user is talking to. It
monitors all the files, translates natural-language requests into file operations, and coordinates. The user doesn't have to know the file layout to drive the loop.
Git history doubles as the durability + audit log: the middle agent judges each Layer-1 proposal before it becomes a commit, so rejected outputs never enter history in the first place. The branch stays clean and append-only.
How the three solutions compose
narrow goal + Layer 2 plan/judge × Layer 3 trigger abstraction × async escalation + inbox + git
(P1 solved) (P2 solved) (P3 solved)
= actually perpetualRemove any one of the three and the loop fails in a specific way:
- Without P1's solution, even with infinite triggers and async human
support, the agent drifts and produces garbage forever.
- Without P2's solution, you have one safe high-quality run that
ends. Not "perpetual".
- Without P3's solution, the first ambiguity freezes the loop and
needs a human in the loop synchronously.
The core invariant
Every accepted finding becomes a local git commit. Without this, there is no ratchet. Without a ratchet, the loop has nothing to make "progress" mean. Without "progress", you have Ralph Loop — which is fine for short tasks but degenerates over long runs because there is no rollback handle for bad changes.
Everything else in perpetuum exists to support this invariant safely:
- The three-layer split prevents the same agent from being judge and
executor (which leads to "self-certifying" bad commits)
- The two-prompt sequence separates "what should we do next" from
"do it and judge it" (which prevents premature commits during planning)
- The escalation channel handles cases where committing would be
premature (the agent doesn't know, only the human does)
- The pause / stop signals let the user inspect state without
damaging the ratchet history
Eight supporting ideas
The three solutions above are perpetuum's reason to exist. The implementation borrows from eight smaller, already-named ideas. None is original to perpetuum; what's original is how they're combined to make the three solutions practical to run.
| # | Idea | Where it shows up in perpetuum |
|---|---|---|
| 1 | Discriminator / Generator separation (GANs) | Layer 2 judges, Layer 1 generates. They never share context. Implements P1's judge half. |
| 2 | Monotonic ratchet | Every Done item is a commit. plan.md [x] is append-only by convention. Safety net for P3 (off-track steps undone silently). |
| 3 | Three-layer architecture (stupid → smart → stupid) | trigger.sh is dumb, middle is smart, inner is dumb. The structural shape of P1 + P3 together. |
| 4 | Exploration vs Exploitation prompt split | prompts/1_explore.md plans, prompts/2_execute.md does. Implements P1's plan half. |
| 5 | File-based persistent memory | plan.md + inbox.md + escalations.md + git log. No vector DB, no embeddings. Enables P2 (state survives triggered cycles) and P3 (async channels are just files). |
| 6 | Asynchronous human escalation | escalations.md never blocks the loop. New cycles still run. Implements P3's escalations channel. |
| 7 | Trigger abstraction | schedule / conditional / webhook are all valid Layer 3 implementations. Same Layer 2/1. Implements P2. |
| 8 | File-as-contract | Who can edit which file is a convention, not enforcement. Cleaner than role-based access. Makes P3's shared-file channels safe in practice. |
Removing any one of these breaks something:
- Remove (1) → middle agent self-certifies; quality drops over time
- Remove (2) → no rollback; bad commits accumulate; can't tell progress from noise
- Remove (3) → middle agent's context bloats with execution noise; long runs degrade
- Remove (4) → planning and execution mix; agent commits during planning, or plans during execution
- Remove (5) → state lost across sessions; restart loses everything
- Remove (6) → human must be online; loop stops whenever a tough question appears
- Remove (7) → can't adapt to event-driven tasks; only schedule works
- Remove (8) → users edit plan.md mid-cycle; race conditions; format drift
Architecture as inheritance
Ralph Loop: bash while + single prompt
↓
add ratchet
↓
recursive-improve: improve → run → eval → keep or revert
↓
add file-contract + frozen evaluator
↓
Karpathy AutoResearch: 3 files (one frozen) + scalar metric + git ratchet
↓
add: judge/executor separation
add: human escalation
add: trigger abstraction
add: exploration/exploitation split
↓
PerpetuumEach step adds something the previous lacked. None of the prior art combines all eight pieces.
Why "perpetual" is ironic
Physics: perpetual motion machines are impossible.
This skill: the loop does stop — when MAX_ITER is hit, when the user sends graceful stop, when token budget runs out, when the host machine reboots. What it gives you is continuity of state across stops.
A perpetuum task can be paused at noon, resumed at 11pm, killed in a crash recovery, and relaunched the next morning — and the next cycle picks up exactly where it left off, because state is files and git.
That's the actual product. The "perpetual motion" framing is the marketing.
Why two prompts, not one, not three
Why not one
If prompt 1 and prompt 2 are merged, the middle agent will sometimes plan-then-execute mid-thought (good when it works, terrible when it commits something that wasn't fully planned). The hard split forces "plan everything that's going in plan.md Pending → stop → execute from Pending only".
Why two is the default
prompts/1_explore.md and prompts/2_execute.md cover the two cognitive modes: divergent (what could be done) and convergent (do what's in the plan). The middle agent switches mode cleanly because they are physically different prompts pasted into the TUI at different times.
When three (or more) helps
If your task has a distinct reflection phase ("look at what was done this cycle, identify patterns, adjust plan.md for next cycle"), add 3_reflect.md. trigger.sh picks it up automatically (lexical sort of prompts/[0-9]*_*.md).
If your task has a distinct check-the-world phase before exploring (e.g. "fetch GitHub PR list and write it to inbox before planning"), add 1.5_check.md or 0_fetch.md.
The general rule: each phase should be atomic and have a clear question it answers. Don't add a phase just for the sake of structure.
Why agents must always re-read plan.md at cycle start
Layer 2 is a persistent CC TUI. Its conversation context accumulates across cycles. Without forced re-reads, it would rely on "I remember from earlier we decided to skip postgres" — which is fragile if the user edited plan.md or inbox.md between cycles.
The two prompt templates explicitly say "read plan.md / inbox.md / escalations.md history" at the start. This makes the agent's behavior deterministic on file state, not on conversation memory. This is the same trick Ralph Loop uses (fresh context every cycle); perpetuum gets most of the benefit without paying the cold-start cost every cycle.
Why .perpetuum/ is dot-prefixed
- Hidden from
lsby default — reduces clutter in user's project view - Signals "machine-managed directory, not human-managed source"
- Easy to
.gitignore(.perpetuum/) without affecting anything else - Mirrors
.git/,.cc-use/, etc — established UNIX convention
Why we trust silence as a fallback sync signal
The done-flag is the primary sync. Silence (20 min tmux pane unchanged) is the fallback. The total timeout is the safety net.
Three layers because the agent sometimes forgets to write the flag. First-cycle prompts in our own dogfooding showed this: the agent finishes the work, returns to the prompt waiting state, and never runs the final echo > .cycle_done_* command. Silence catches this. Total timeout catches the case where everything wedges.
Without silence fallback, every forgotten flag means a 6-hour stall. Without total timeout, a real wedge means infinite hang. Without the flag, every cycle pays the 20-minute silence wait even when work finishes in 5 minutes.
All three are needed. None is redundant.
What perpetuum is not
- Not a model-level optimizer (use Darwin Gödel for that)
- Not a skill-evolution framework (use EvoSkills for that)
- Not a persona-distillation skill (use nuwa for the one-shot version,
or build a perpetuum task with style-distill as its goal for the iterative version)
- Not a CI/CD replacement (perpetuum can call CI, but doesn't replace it)
- Not a benchmark runner (although it can run benchmarks; see
Karpathy AutoResearch for the cleaner-shaped solution to that)
- Not a substitute for the user thinking about their problem. The
suitability gate exists because perpetuum on the wrong task is just an expensive way to burn tokens.
Open design questions
These are real and unresolved. Don't hide them from advanced users.
1. Local-optima trap. The ratchet is greedy. If prompts/1_explore.md's "breadth vs depth balance" guidance fails, the agent will dig the same well over and over. Mitigations: explicit category-switch directives in inbox; periodic human review; running multiple tasks on different worktrees with different priors. 2. Inner agent priors. Layer 1 has some prior from training, even without conversation context. For tasks where neutrality matters (e.g. fairness audits), this is a limit. Mitigations: prompt instructions, multiple Layer 1 dispatches with different framings. 3. Reward hacking. If you wire plan.md Done count to a reward signal anywhere, the agent will optimize for that count. Don't. Keep oracle and incentive structurally separate. 4. Sync edge cases. Network blips, tmux respawns, token-limit pauses all cause sync glitches. The three-layer sync handles most. We learn new edge cases as we run more tasks.
Status: inspecting a running task
Read this when the user asks "how's it going?" or invokes this skill with no arguments.
Default behavior when invoked with no arguments
If the user invokes the skill with no specific instruction, treat it as "report current status". That means:
1. List all perpetuum tasks in the current project (or all known locations) 2. For each, report:
- is
trigger.shrunning? - is it paused?
- which cycle is it on?
- how many Done items in plan.md, how many Pending?
- how many unresolved escalations?
- last commit?
3. Surface any unanswered escalations prominently — those are things only the human can move forward.
Keep the summary short. Detail on demand.
Status-gathering commands (read-only, do not disturb)
TASK=.perpetuum/<task-name>
# Process state
pgrep -af "$TASK/trigger.sh" | head -1
test -f $TASK/.paused && echo "PAUSED" || echo "not paused"
test -f $TASK/.stop_after_current && echo "STOP REQUESTED"
# Where are we
tail -20 $TASK/trigger.log
# Plan summary
echo "Pending: $(grep -c '^- \[ \]' $TASK/plan.md)"
echo "Done: $(grep -c '^- \[x\]' $TASK/plan.md)"
echo "Esc'd: $(grep -c '^- \[→\]' $TASK/plan.md)"
# Escalations to show the human
awk '/^## Open/,/^## Resolved/' $TASK/escalations.md
# Recent commits
git -C <project-root> log --oneline -10Looking inside the loop (without disturbing it)
If the user wants to see what the agent is actually thinking right now:
# Attach read-only to the middle CC TUI (will not affect it)
tmux attach -t middle-<task> -r
# Or just snapshot the current screen
tmux capture-pane -t middle-<task> -p
# Look at inner cc-use snapshots
ls .cc-use/state/ccu-*/screens/ | tail -5Important: `tmux attach -r` (read-only) is safe. Without -r, you would steal the active client and the agent would notice (cursor position changes, etc.). Always use -r.
Producing a readable summary for the user
When reporting back, structure it like this:
## adversarial-testing
Running (cycle 13/20, sleeping until ~06:35)
- Plan: 47 Done / 12 Pending / 2 Escalated
- Commits since start: 18
- ⚠ 2 unanswered escalations:
- (cycle 4) ambiguous CLI flag off-by-one — A: align to 1-based / B: 0-based / C: leave
- (cycle 8) tool semantics — A: literal match / B: rename verb / C: dual verb
- Last log line: "execute done 13-1780464144e"Lead with what needs the user's attention (escalations), then the running stats, then logs/commits. The user might only read the top line.
When a task looks stuck
If a cycle has been running far longer than expected:
| Symptom | Probable cause |
|---|---|
trigger.log last line is "waiting for done flag" for hours | inner agent is in a long delegate (probably fine); check tmux snapshot |
| Last log line is hours old, no new log entry | trigger.sh might have crashed; pgrep -f trigger.sh to confirm |
Many cycle_done residual flags in state/ | sync got desynchronized; manual rm and relaunch is usually fine |
tmux has-session returns no for middle session | middle CC died; the next cycle will recreate it but state continuity in conversation is lost (files are fine) |
Recovery is almost always: stop, delete stale signals, relaunch. State is in files; loss of in-flight cycle costs one round of work, not the whole project.
Related skills
FAQ
What does perpetuum store in task metadata?
perpetuum records task name, creation date, worktree path, branch, parent repo, merge target, trigger type, example basis, and expected cadence. These fields support human and agent audit trails but are not required at runtime.
How long can a perpetuum job run?
perpetuum example configurations schedule cycles every ~40 minutes for ~20 total cycles over roughly two days. Cadence and cycle count are configurable in setup metadata for longer or shorter autonomous runs.