feat(prompt): make the plan handoff a versioned, validated contract
Some checks failed
deploy / build (push) Has been cancelled
deploy / deploy (push) Has been cancelled

helexa#179's application-owned system prompts let tireless shape both ends of
the cc->oc handoff, so the "will a 27B execute an Opus plan" risk becomes a
tunable rather than a hope.

Three prompts, versioned as one set: plan.cc.md tells Claude Code it is writing
for a literal, absent reader; implement.oc.md tells OpenCode to execute exactly
that and report rather than improvise; implement.cc.md covers unplanned issues.
PromptSet::load refuses a mismatched set, and tests assert the prompts mention
every section ChildSpec requires.

The middle is validated, not trusted. plan::validate rejects a plan before any
implementation job is enqueued unless every child carries a runnable acceptance
command (a stopping condition) and a non-empty out-of-scope list (a boundary) --
the two sections a small model needs and a human reader does not. Dangling and
cyclic dependencies are caught too, and implementation_order derives the start
order.

cc uses --append-system-prompt, never --system-prompt: replacing Claude Code's
default discards the tool-use scaffolding that makes it a coding agent. Pin
bumped to 2.1.220, the version this flag surface was verified against.

The oc path depends on helexa#179's passthrough guarantee, which is still open
and unverified for qwen3 arch templating. Stage 5 now opens with a PONG probe
rather than debugging it through a failed implementation run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHhHtohxcdk1PL3tfnYJdH
This commit is contained in:
rob thijssen
2026-08-02 15:26:05 +03:00
parent 4e42f87576
commit 7b8308d34e
19 changed files with 1053 additions and 8 deletions

View File

@@ -39,6 +39,24 @@ tests. If one seems redundant, read design.md §3 before touching it.
best-effort mirror. Do not make a decision by reading a label that could be
made by reading the database.
7. **Never use `--system-prompt` for Claude Code; append instead.** Replacing
Claude Code's default discards the tool-use scaffolding that makes it a
coding agent. `SYSTEM_PROMPT_FLAG` is `--append-system-prompt` for this
reason.
8. **The three prompts in `prompt/` are one contract — edit them together.**
`plan.cc.md` emits the structure `implement.oc.md` consumes and
`tireless_core::plan::validate` enforces. Changing one alone breaks the
handoff silently, as a bad pull request rather than an error. Bump
`contract-version:` in all three plus `SYSTEM_PROMPT_CONTRACT_VERSION`
together; `PromptSet::load` refuses a mismatched set.
9. **A plan is validated, not trusted.** Never enqueue implementation work from
a plan that has not passed `plan::validate`. The `Acceptance` (runnable
stopping condition) and `Out of scope` (boundary) requirements exist because
a small model needs them; do not relax them because a plan looks fine to a
human reader.
## Quality gate
Before considering a change complete:

View File

@@ -51,6 +51,16 @@ state_claimed = "tireless/claimed"
state_blocked = "tireless/blocked"
state_done = "tireless/done"
[prompt]
# System prompts are compiled into the binary from prompt/*.md and are versioned
# as a set (see prompt/readme.md). Point these at files on disk to override —
# useful for iterating on plan quality without a redeploy. An override must
# declare the same `contract-version:` as the build, or the service refuses to
# start rather than run a mismatched pair.
# plan_cc = "/etc/tireless/prompt/plan.cc.md"
# implement_oc = "/etc/tireless/prompt/implement.oc.md"
# implement_cc = "/etc/tireless/prompt/implement.cc.md"
[work]
# Per-repo bare mirrors and per-job clones live here.
root = "/var/lib/tireless"
@@ -80,6 +90,10 @@ failure_threshold = 3
provider = "lair-helexa"
model = "Qwen/Qwen3.6-27B"
base_url = "http://hanzalova.internal:31313/v1"
# The implementation system prompt reaches the model through
# OpenCode -> cortex -> neuron, relying on helexa's faithful-passthrough
# guarantee (helexa/helexa#179, still open). If a run behaves as though it never
# saw its prompt, suspect the passthrough before the prompt.
max_concurrent = 2
max_runs_per_window = 240
window_hours = 5

View File

@@ -9,7 +9,20 @@ use std::path::PathBuf;
/// Pinned rather than `@latest`: an unattended driver should not pick up a new
/// agent version between one job and the next.
pub const CLAUDE_PACKAGE: &str = "@anthropic-ai/claude-code@2.1.119";
///
/// This is the version whose flag surface tireless was verified against —
/// specifically `--append-system-prompt`, which the planning and implementation
/// prompts depend on. Bumping it means re-checking that surface, not just the
/// number.
pub const CLAUDE_PACKAGE: &str = "@anthropic-ai/claude-code@2.1.220";
/// How tireless applies its system prompt to Claude Code.
///
/// **Append, never replace.** Claude Code also accepts `--system-prompt`, which
/// discards its default entirely — including the tool-use and repository
/// navigation scaffolding that makes it a coding agent. Replacing yields a less
/// capable agent, not a more obedient one. See `prompt/readme.md`.
pub const SYSTEM_PROMPT_FLAG: &str = "--append-system-prompt";
pub struct ClaudeCodeExecutor {
/// Home directory of the service account. Claude Code keeps its credentials

View File

@@ -5,8 +5,12 @@
//! `tireless-agent` (process orchestration).
pub mod budget;
pub mod plan;
pub mod port;
pub mod prompt;
pub mod routing;
pub use budget::{Governor, LaneBudget, LimitSignal, Verdict};
pub use plan::{PlanDefect, implementation_order, validate};
pub use prompt::{PromptSet, SYSTEM_PROMPT_CONTRACT_VERSION};
pub use routing::{RouteDecision, route};

View File

@@ -0,0 +1,294 @@
//! Parsing and validating tireless-authored plans.
//!
//! A planning run's output is not trusted because a strong model produced it.
//! It is parsed and checked against [`tireless_entities::ChildSpec`] before any
//! implementation job is enqueued.
//!
//! This is the cheapest possible place to catch a bad plan. A plan that fails
//! validation costs one comment on an issue and a `tireless/blocked` label,
//! within seconds of the planning run finishing. The same plan, unvalidated,
//! costs an OpenCode run against a repo, a branch, and an operator's review
//! attention before anyone notices the spec was unusable.
use tireless_entities::{Acceptance, ChildSpec, FileTouch, PlanSpec};
/// Why a plan was rejected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanDefect {
MissingSection {
child: String,
section: &'static str,
},
/// The planner named no files at all. It has not thought about where the
/// change lands.
NoFiles { child: String },
/// Acceptance is prose-only. An implementing model has no way to know when
/// it is finished, which is the single most common cause of a run that
/// keeps going and rewrites things it was not asked to.
NoRunnableAcceptance { child: String },
/// No boundary was set, so nothing stops the implementer expanding scope.
NoBoundary { child: String },
/// A dependency names a child that is not in the plan.
UnknownDependency { child: String, depends_on: String },
/// Dependencies form a cycle, so no implementation order exists.
DependencyCycle { children: Vec<String> },
}
impl std::fmt::Display for PlanDefect {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingSection { child, section } => {
write!(f, "child {child:?} is missing its `{section}` section")
}
Self::NoFiles { child } => {
write!(f, "child {child:?} names no files it expects to change")
}
Self::NoRunnableAcceptance { child } => write!(
f,
"child {child:?} has no runnable acceptance command, so an \
implementer has no stopping condition"
),
Self::NoBoundary { child } => write!(
f,
"child {child:?} declares nothing out of scope, so nothing bounds \
the implementation"
),
Self::UnknownDependency { child, depends_on } => write!(
f,
"child {child:?} depends on {depends_on:?}, which is not in the plan"
),
Self::DependencyCycle { children } => {
write!(f, "dependency cycle among {children:?}")
}
}
}
}
/// Check a plan is executable. Returns every defect, not just the first — a
/// planning re-run is expensive, so the feedback comment should be complete.
pub fn validate(plan: &PlanSpec) -> Vec<PlanDefect> {
let mut defects = Vec::new();
for child in &plan.children {
let name = child.title.clone();
let spec = &child.spec;
if spec.goal.trim().is_empty() {
defects.push(PlanDefect::MissingSection {
child: name.clone(),
section: "goal",
});
}
if spec.steps.is_empty() {
defects.push(PlanDefect::MissingSection {
child: name.clone(),
section: "steps",
});
}
if spec.files.is_empty() {
defects.push(PlanDefect::NoFiles {
child: name.clone(),
});
}
if spec.acceptance.is_empty() {
defects.push(PlanDefect::MissingSection {
child: name.clone(),
section: "acceptance",
});
} else if !spec.acceptance.iter().any(Acceptance::is_command) {
defects.push(PlanDefect::NoRunnableAcceptance {
child: name.clone(),
});
}
if spec.out_of_scope.is_empty() {
defects.push(PlanDefect::NoBoundary {
child: name.clone(),
});
}
for dep in &child.depends_on {
if !plan.children.iter().any(|c| &c.title == dep) {
defects.push(PlanDefect::UnknownDependency {
child: name.clone(),
depends_on: dep.clone(),
});
}
}
}
if let Some(cycle) = find_cycle(plan) {
defects.push(PlanDefect::DependencyCycle { children: cycle });
}
defects
}
/// Implementation order: children whose dependencies are all satisfied come
/// first. Returns `None` if the graph does not admit an order.
pub fn implementation_order(plan: &PlanSpec) -> Option<Vec<&str>> {
let mut remaining: Vec<&str> = plan.children.iter().map(|c| c.title.as_str()).collect();
let mut ordered: Vec<&str> = Vec::new();
while !remaining.is_empty() {
// Take every child whose dependencies are already ordered. Taking all
// ready children per round (rather than one) keeps siblings that could
// run concurrently adjacent in the output.
let ready: Vec<&str> = remaining
.iter()
.copied()
.filter(|title| {
let child = plan
.children
.iter()
.find(|c| c.title == *title)
.expect("present");
child
.depends_on
.iter()
.all(|dep| ordered.contains(&dep.as_str()) || !title_exists(plan, dep))
})
.collect();
if ready.is_empty() {
return None; // cycle
}
remaining.retain(|t| !ready.contains(t));
ordered.extend(ready);
}
Some(ordered)
}
fn title_exists(plan: &PlanSpec, title: &str) -> bool {
plan.children.iter().any(|c| c.title == title)
}
fn find_cycle(plan: &PlanSpec) -> Option<Vec<String>> {
if implementation_order(plan).is_some() {
return None;
}
// Everything that could not be ordered participates in, or depends on, a cycle.
Some(plan.children.iter().map(|c| c.title.clone()).collect())
}
/// A well-formed child, for tests and for the dry-run executor.
pub fn example_child() -> ChildSpec {
ChildSpec {
goal: "Add an ETag cache to the Gitea poller so repeat polls are conditional.".into(),
files: vec![FileTouch {
path: "crates/tireless-data/src/forge.rs".into(),
note: "store and send If-None-Match".into(),
}],
steps: vec![
"Add `last_etag` to the repo row on read.".into(),
"Send `If-None-Match` when it is present.".into(),
"Treat 304 as an empty result.".into(),
],
acceptance: vec![
Acceptance::Command("cargo test -p tireless-data".into()),
Acceptance::Criterion("A second poll within the interval logs a 304.".into()),
],
out_of_scope: vec!["Do not change the poll scheduling logic.".into()],
}
}
#[cfg(test)]
mod tests {
use super::*;
use tireless_entities::PlannedChild;
fn child(title: &str, depends_on: &[&str]) -> PlannedChild {
PlannedChild {
title: title.into(),
spec: example_child(),
depends_on: depends_on.iter().map(|s| s.to_string()).collect(),
}
}
fn plan(children: Vec<PlannedChild>) -> PlanSpec {
PlanSpec {
epic_title: "epic".into(),
epic_goal: "goal".into(),
children,
}
}
#[test]
fn a_well_formed_plan_validates() {
assert!(validate(&plan(vec![child("a", &[])])).is_empty());
}
#[test]
fn prose_only_acceptance_is_rejected() {
let mut c = child("a", &[]);
c.spec.acceptance = vec![Acceptance::Criterion("it works".into())];
let defects = validate(&plan(vec![c]));
assert!(matches!(
defects[0],
PlanDefect::NoRunnableAcceptance { .. }
));
}
#[test]
fn a_plan_without_a_boundary_is_rejected() {
let mut c = child("a", &[]);
c.spec.out_of_scope.clear();
let defects = validate(&plan(vec![c]));
assert!(matches!(defects[0], PlanDefect::NoBoundary { .. }));
}
#[test]
fn a_child_naming_no_files_is_rejected() {
let mut c = child("a", &[]);
c.spec.files.clear();
let defects = validate(&plan(vec![c]));
assert!(matches!(defects[0], PlanDefect::NoFiles { .. }));
}
#[test]
fn every_defect_is_reported_not_just_the_first() {
let mut c = child("a", &[]);
c.spec.files.clear();
c.spec.out_of_scope.clear();
c.spec.goal = " ".into();
let defects = validate(&plan(vec![c]));
assert_eq!(defects.len(), 3, "got {defects:?}");
}
#[test]
fn a_dependency_on_a_missing_child_is_rejected() {
let defects = validate(&plan(vec![child("a", &["nonexistent"])]));
assert!(
defects
.iter()
.any(|d| matches!(d, PlanDefect::UnknownDependency { .. }))
);
}
#[test]
fn dependencies_determine_implementation_order() {
let p = plan(vec![
child("c", &["b"]),
child("a", &[]),
child("b", &["a"]),
]);
assert_eq!(implementation_order(&p), Some(vec!["a", "b", "c"]));
}
#[test]
fn independent_children_all_come_out() {
let p = plan(vec![child("a", &[]), child("b", &[])]);
let order = implementation_order(&p).expect("orderable");
assert_eq!(order.len(), 2);
}
#[test]
fn a_cycle_has_no_order_and_is_reported() {
let p = plan(vec![child("a", &["b"]), child("b", &["a"])]);
assert_eq!(implementation_order(&p), None);
assert!(
validate(&p)
.iter()
.any(|d| matches!(d, PlanDefect::DependencyCycle { .. }))
);
}
}

View File

@@ -0,0 +1,154 @@
//! System prompts, as a versioned pair.
//!
//! tireless tailors both agents by system prompt, but the two surfaces differ
//! in what they will let you do, and the difference is not cosmetic:
//!
//! - **Claude Code** — `--append-system-prompt`. tireless *appends*; it does not
//! replace. Claude Code's default system prompt carries the tool-use and
//! repository-navigation scaffolding that makes it a coding agent, and
//! `--system-prompt` discards all of it. Replacing gets you a less capable
//! agent, not a more obedient one.
//!
//! - **OpenCode** — `AgentConfig.prompt`. tireless owns the whole system prompt,
//! which travels through OpenCode to helexa cortex and on to a neuron. That
//! path depends on helexa's faithful-passthrough guarantee
//! (helexa/helexa#179): no injection, no rewriting, no defaults.
//!
//! ## The pair is one contract
//!
//! The planning prompt tells Claude Code what a plan must contain. The
//! implementation prompt tells OpenCode to execute exactly that and nothing
//! more. Both describe the same structure — [`tireless_entities::ChildSpec`] —
//! from opposite ends. Editing one without the other silently breaks the
//! handoff: the planner emits a shape the implementer is not expecting, and the
//! failure shows up as a puzzlingly bad PR rather than as an error.
//!
//! [`SYSTEM_PROMPT_CONTRACT_VERSION`] exists to make that breakage loud. Both
//! prompt files declare it; [`PromptSet::load`] refuses a mismatched pair.
use tireless_entities::Error;
/// Bumped whenever the plan contract changes shape. Both prompt files carry a
/// `contract-version:` line that must match.
pub const SYSTEM_PROMPT_CONTRACT_VERSION: u32 = 1;
/// The paired prompts, loaded and checked together.
#[derive(Debug, Clone)]
pub struct PromptSet {
/// Appended to Claude Code's system prompt for `Plan` jobs.
pub plan_cc: String,
/// The whole system prompt for OpenCode implementation agents.
pub implement_oc: String,
/// Appended to Claude Code's system prompt for `Implement` jobs on
/// unplanned, human-written issues.
pub implement_cc: String,
}
impl PromptSet {
/// Load a prompt set, verifying every member declares the same contract
/// version as this build.
pub fn load(plan_cc: &str, implement_oc: &str, implement_cc: &str) -> Result<Self, Error> {
for (name, body) in [
("plan.cc.md", plan_cc),
("implement.oc.md", implement_oc),
("implement.cc.md", implement_cc),
] {
let declared = parse_contract_version(body).ok_or_else(|| {
Error::Config(format!("prompt {name} declares no `contract-version:`"))
})?;
if declared != SYSTEM_PROMPT_CONTRACT_VERSION {
return Err(Error::Config(format!(
"prompt {name} declares contract-version {declared}, but this build \
expects {SYSTEM_PROMPT_CONTRACT_VERSION}; the planning and \
implementation prompts must be updated together"
)));
}
}
Ok(Self {
plan_cc: plan_cc.to_string(),
implement_oc: implement_oc.to_string(),
implement_cc: implement_cc.to_string(),
})
}
/// The prompts shipped with this build.
pub fn builtin() -> Result<Self, Error> {
Self::load(
include_str!("../../../prompt/plan.cc.md"),
include_str!("../../../prompt/implement.oc.md"),
include_str!("../../../prompt/implement.cc.md"),
)
}
}
/// Read `contract-version: N` from a prompt file's leading metadata block.
fn parse_contract_version(body: &str) -> Option<u32> {
body.lines()
.take_while(|l| !l.starts_with("# "))
.find_map(|l| l.trim().strip_prefix("contract-version:"))
.and_then(|v| v.trim().parse().ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shipped_prompts_are_a_consistent_set() {
PromptSet::builtin().expect("shipped prompts must load");
}
#[test]
fn the_planning_prompt_names_every_required_section() {
// If a section is added to ChildSpec but not to the planning prompt, the
// planner will not emit it and every plan will fail validation. Catch
// that here rather than on a live run.
let set = PromptSet::builtin().expect("load");
for section in ["Goal", "Files", "Steps", "Acceptance", "Out of scope"] {
assert!(
set.plan_cc.contains(section),
"planning prompt does not mention required section {section:?}"
);
}
}
#[test]
fn the_implementation_prompt_names_the_same_sections() {
let set = PromptSet::builtin().expect("load");
for section in ["Acceptance", "Out of scope"] {
assert!(
set.implement_oc.contains(section),
"implementation prompt does not mention {section:?}"
);
}
}
#[test]
fn a_mismatched_pair_is_refused() {
let good = format!("contract-version: {SYSTEM_PROMPT_CONTRACT_VERSION}\n\n# x\n");
let stale = format!(
"contract-version: {}\n\n# x\n",
SYSTEM_PROMPT_CONTRACT_VERSION + 1
);
assert!(PromptSet::load(&good, &stale, &good).is_err());
}
#[test]
fn an_undeclared_version_is_refused() {
let good = format!("contract-version: {SYSTEM_PROMPT_CONTRACT_VERSION}\n\n# x\n");
assert!(PromptSet::load(&good, "# no metadata\n", &good).is_err());
}
#[test]
fn the_version_is_read_only_from_leading_metadata() {
// A `contract-version:` mentioned in prose below the title must not be
// mistaken for the declaration.
let body = format!(
"contract-version: {SYSTEM_PROMPT_CONTRACT_VERSION}\n\n# Title\n\ncontract-version: 99\n"
);
assert_eq!(
parse_contract_version(&body),
Some(SYSTEM_PROMPT_CONTRACT_VERSION)
);
}
}

View File

@@ -8,6 +8,7 @@ pub mod error;
pub mod forge;
pub mod job;
pub mod label;
pub mod plan;
pub mod repo;
pub mod run;
@@ -15,5 +16,6 @@ pub use error::Error;
pub use forge::{Forge, IssueRef, PullRequestRef};
pub use job::{Job, JobKind, JobState};
pub use label::LabelProtocol;
pub use plan::{Acceptance, ChildSpec, FileTouch, PlanSpec, PlannedChild};
pub use repo::{PollSchedule, TrackedRepo};
pub use run::{AgentKind, AgentRun, BillingMode, RunOutcome};

View File

@@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
/// The required structure of a tireless-authored child issue.
///
/// This type is one half of a contract. The Claude Code planning prompt is told
/// to emit exactly these sections; the OpenCode implementation prompt is told to
/// work from exactly these sections and nothing else. Neither prompt may be
/// changed without the other — see `prompt/readme.md`.
///
/// The contract exists because a 27B model executing a plan needs two things a
/// human reader does not: an explicit **stopping condition** (`acceptance`) and
/// an explicit **boundary** (`out_of_scope`). Without the first it does not know
/// when it is finished; without the second it improvises past the task. Both are
/// therefore mandatory, and a plan lacking either is rejected before any
/// implementation job is enqueued.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ChildSpec {
/// What changes and why. One paragraph.
pub goal: String,
/// Files expected to change, each with a note on what changes there.
/// Advisory rather than binding — but a plan that cannot name any file it
/// expects to touch has not been thought through.
pub files: Vec<FileTouch>,
/// Ordered implementation steps.
pub steps: Vec<String>,
/// How to know the work is done. At least one must be a runnable command;
/// prose-only acceptance gives an implementing model no stopping condition.
pub acceptance: Vec<Acceptance>,
/// What the implementer must *not* do. The boundary that keeps a capable
/// model from redesigning adjacent code it was not asked to touch.
pub out_of_scope: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct FileTouch {
pub path: String,
pub note: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
pub enum Acceptance {
/// A command that must exit zero. The stopping condition.
Command(String),
/// A prose criterion, for things no command can check.
Criterion(String),
}
impl Acceptance {
pub fn is_command(&self) -> bool {
matches!(self, Self::Command(_))
}
}
/// A full plan: the epic framing plus its children.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct PlanSpec {
/// Title for the epic issue.
pub epic_title: String,
/// Framing for the epic body: what the whole change achieves.
pub epic_goal: String,
pub children: Vec<PlannedChild>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct PlannedChild {
pub title: String,
pub spec: ChildSpec,
/// Titles of siblings that must land first. Used to order implementation
/// jobs so a child is never started before its dependency is merged.
pub depends_on: Vec<String>,
}

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Acceptance = { "kind": "command", "value": string } | { "kind": "criterion", "value": string };

View File

@@ -0,0 +1,44 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Acceptance } from "./Acceptance";
import type { FileTouch } from "./FileTouch";
/**
* The required structure of a tireless-authored child issue.
*
* This type is one half of a contract. The Claude Code planning prompt is told
* to emit exactly these sections; the OpenCode implementation prompt is told to
* work from exactly these sections and nothing else. Neither prompt may be
* changed without the other — see `prompt/readme.md`.
*
* The contract exists because a 27B model executing a plan needs two things a
* human reader does not: an explicit **stopping condition** (`acceptance`) and
* an explicit **boundary** (`out_of_scope`). Without the first it does not know
* when it is finished; without the second it improvises past the task. Both are
* therefore mandatory, and a plan lacking either is rejected before any
* implementation job is enqueued.
*/
export type ChildSpec = {
/**
* What changes and why. One paragraph.
*/
goal: string,
/**
* Files expected to change, each with a note on what changes there.
* Advisory rather than binding — but a plan that cannot name any file it
* expects to touch has not been thought through.
*/
files: Array<FileTouch>,
/**
* Ordered implementation steps.
*/
steps: Array<string>,
/**
* How to know the work is done. At least one must be a runnable command;
* prose-only acceptance gives an implementing model no stopping condition.
*/
acceptance: Array<Acceptance>,
/**
* What the implementer must *not* do. The boundary that keeps a capable
* model from redesigning adjacent code it was not asked to touch.
*/
out_of_scope: Array<string>, };

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FileTouch = { path: string, note: string, };

View File

@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PlannedChild } from "./PlannedChild";
/**
* A full plan: the epic framing plus its children.
*/
export type PlanSpec = {
/**
* Title for the epic issue.
*/
epic_title: string,
/**
* Framing for the epic body: what the whole change achieves.
*/
epic_goal: string, children: Array<PlannedChild>, };

View File

@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ChildSpec } from "./ChildSpec";
export type PlannedChild = { title: string, spec: ChildSpec,
/**
* Titles of siblings that must land first. Used to order implementation
* jobs so a child is never started before its dependency is merged.
*/
depends_on: Array<string>, };

View File

@@ -95,6 +95,65 @@ This also produces a pleasing economic shape. The subscription is spent on the
scarce thing (planning, and interpreting under-specified work), while the bulk
of mechanical implementation runs on hardware already sitting in the office.
### 2.4 The plan contract
The handoff between the lanes is the load-bearing interface in this design: an
Opus-authored plan must be specific enough for a 27B model to execute alone. That
is not left to chance in either direction.
**Both ends are shaped by system prompts.** `prompt/plan.cc.md` tells Claude Code
it is writing for a literal, competent, absent reader that cannot ask questions
and will fill any gap with an invention. `prompt/implement.oc.md` tells OpenCode
to execute the specification faithfully, stop when acceptance passes, and report
rather than improvise. They are two halves of one contract and are versioned
together — see `prompt/readme.md`.
The surfaces are not symmetric, and the difference matters:
| Lane | Mechanism | Ownership |
| --- | --- | --- |
| Claude Code | `--append-system-prompt` | Anthropic owns the base prompt; tireless appends |
| OpenCode | `AgentConfig.prompt` | tireless owns the whole prompt |
Claude Code also offers `--system-prompt`, which replaces its default outright.
tireless does not use it: the default carries the tool-use and repository
navigation scaffolding that makes Claude Code a coding agent, and discarding it
yields a less capable agent rather than a more obedient one.
**The middle is validated, not trusted.** A plan is parsed into
`tireless_entities::ChildSpec` and checked by `tireless_core::plan::validate`
before any implementation job is enqueued. Every child must carry five sections,
two of which exist specifically because a small model needs them and a human
reader does not:
- **Acceptance** must include at least one runnable command. Without a stopping
condition a literal implementer does not know when it is finished, and keeps
going — usually by rewriting adjacent code it was not asked to touch.
- **Out of scope** must be non-empty. Without a declared boundary nothing stops a
capable model expanding the work.
Plans are also checked for dangling and cyclic dependencies, and
`implementation_order` derives the order in which children may be started.
This makes the risk cheap to discover. A plan that fails validation costs one
comment and a `tireless/blocked` label, seconds after the planning run. The same
plan unvalidated costs an OpenCode run, a branch, and an operator's review
attention before anyone notices the spec was unusable.
**Dependency: helexa faithful passthrough.** The OpenCode system prompt reaches
the model via OpenCode → cortex → neuron, which relies on helexa's guarantee of
no injection, no rewriting, no defaults
([helexa/helexa#179](https://git.lair.cafe/helexa/helexa/issues/179)).
That issue is **open** — the principle was decided 2026-07-17, but verification
and documentation are outstanding, and two unchecked items bear directly here:
neuron chat templating applying the system role correctly per arch family
*including `/no_think` interaction* (Qwen3.6-27B is the target model), and
behaviour with multiple system messages. Stage 5 is effectively the second
consumer of that guarantee after the chat SPA, and is well placed to surface
exactly those bugs. Debugging order follows from this: if an OpenCode run behaves
as though it never saw its prompt, suspect the passthrough before the prompt.
---
## 3. Constraints
@@ -382,12 +441,19 @@ Spawn the pinned CLI, read `stream-json`, capture session id for `--resume`,
record `apiKeySource` as billing mode, consume `rate_limit_event` into the
governor, enforce budgets and the circuit breaker.
Apply `prompt/plan.cc.md` via `--append-system-prompt`; parse the result into
`PlanSpec` and gate it through `plan::validate` before creating any issue.
First real capability: `tireless/plan` on a real issue produces an epic and child
issues.
*Done when:* a planning run completes against a real issue, the children are
sensible, the journal shows the billing mode, and an artificially lowered window
budget demonstrably holds the lane.
*Done when:* a planning run completes against a real issue, the plan passes
validation, the children are sensible **when read as an implementer would read
them** — cold, with no other context — the journal shows the billing mode, and an
artificially lowered window budget demonstrably holds the lane.
This is also where plan quality is judged, while the only cost of a bad plan is a
comment thread. Iterate on `prompt/plan.cc.md` here, not in stage 5.
*Why planning first:* the output is issues, not code. A bad plan is a comment
thread; a bad implementation is a branch. Start where mistakes are cheapest.
@@ -404,11 +470,19 @@ than duplicates.
### Stage 5 — OpenCode executor
Spawn `opencode serve` on loopback with a per-spawn password, drive it over HTTP,
target helexa cortex. Enforce `assert_not_anthropic` from config. Route
plan-descended implementation jobs here.
target helexa cortex. Enforce `assert_not_anthropic` from config. Register a
custom OpenCode agent carrying `prompt/implement.oc.md` as its system prompt.
Route plan-descended implementation jobs here.
**Start with a passthrough probe.** Before wiring anything real, confirm a system
prompt reaches Qwen3.6-27B intact through OpenCode → cortex → neuron — the
"reply only with the word PONG" test named in helexa#179. That guarantee is not
yet verified (§2.4), and discovering it fails here is far cheaper than debugging
it through a failed implementation run.
*Done when:* a child issue created by a stage-3 planning run is implemented
end-to-end by OpenCode on the GPU fleet, with zero subscription usage.
end-to-end by OpenCode on the GPU fleet, with zero subscription usage, and the
run demonstrably respected its `Out of scope` section.
### Stage 6 — Scheduling and dashboard control
@@ -475,6 +549,20 @@ models implement — rests on tireless-authored plans being specific enough for
fails, the fallback is routing more implementation to Claude Code, which costs
subscription budget but not a redesign.
The plan contract (§2.4) narrows this considerably: paired system prompts shape
both ends, and structural validation rejects a plan lacking a runnable stopping
condition or a declared boundary before any implementation job is enqueued. What
remains genuinely unknown is *semantic* quality — whether a plan that satisfies
the schema is also correct and specific enough in substance. No validator
catches a well-formed plan that is simply wrong about the codebase, and there is
no unit test for whether a prompt produces good plans. That is measured on real
issues in stage 3, before stage 5 spends anything on acting on them.
**The OpenCode prompt path is unverified end to end.** It depends on helexa#179,
which is open (§2.4). Worth confirming with a trivial probe — a system prompt
that measurably changes output — at the start of stage 5 rather than debugging it
through a failed implementation run.
**Not yet decided:** whether a failed implementation should automatically open a
`tireless/blocked` issue describing what it could not do, or simply comment on
the original. Deferred to stage 4, when there is real failure data to look at.

55
prompt/implement.cc.md Normal file
View File

@@ -0,0 +1,55 @@
contract-version: 1
surface: claude-code --append-system-prompt
job-kind: Implement (unplanned)
# Implementing an unplanned issue
You are implementing an issue written by a human, which has **not** been through
tireless planning. There is no spec: no file list, no acceptance commands, no
declared boundary. Interpreting the request is part of your job, which is why
this work was routed to you rather than to the local implementation model.
Work as a careful colleague would.
## Rules
**Establish the boundary yourself, then hold it.** The issue almost certainly
does not say what is out of scope. Decide what the smallest complete change is,
state that decision in the pull request description, and stay inside it.
Adjacent problems you notice get *mentioned*, not fixed.
**Find your own stopping condition.** No acceptance command was given, so
identify one: the test that ought to pass, the command that ought to succeed.
Run it. If the repository has no way to verify the change, say so explicitly in
the PR rather than implying verification you did not do.
**Interpret ambiguity the way a careful colleague would.** Make routine judgement
calls and record them. Where different readings would produce materially
different work, implement the most defensible one and flag the alternative in the
pull request — do not silently pick and move on, and do not stall waiting for an
answer that cannot arrive.
**If the issue is too vague to implement, say so and stop.** A one-line issue with
no discernible acceptance criteria is a planning problem, not an implementation
problem. Report what is missing and suggest the issue be relabelled
`tireless/plan`. That is a correct outcome, not a failure.
**Match the surrounding code.** Naming, error handling, comment density, test
placement. A repository `CLAUDE.md`, `AGENTS.md`, or contributing guide outranks
your defaults.
**Never fake a green result.** Do not weaken assertions, skip tests, or
special-case values to make something pass. Report failures with their output.
## Your output
A branch and a pull request describing:
1. what you implemented, and the scope you decided on;
2. how you verified it, with the commands you actually ran;
3. judgement calls you made where another reading was defensible;
4. anything you noticed but deliberately left alone.
Be honest about what is unfinished or unverified. This work goes to a human
reviewer who is deciding whether to merge it, and an accurate account of what you
did not do is worth more than a confident summary that overstates.

78
prompt/implement.oc.md Normal file
View File

@@ -0,0 +1,78 @@
contract-version: 1
surface: opencode AgentConfig.prompt
job-kind: Implement (plan-descended)
# Implementing a specified change
You are implementing one child issue from a plan that has already been written,
reviewed for structure, and approved. Your job is to execute that specification
faithfully and open a pull request. It is not to improve the plan.
The issue you have been given contains five sections. They are the whole of your
brief:
- **Goal** — what changes and why.
- **Files** — where the change goes.
- **Steps** — what to do, in order.
- **Acceptance** — how you know you are finished.
- **Out of scope** — what you must not touch.
## Rules
**Do not deviate from the plan.** If a step seems wrong, inefficient, or
incomplete, that is a signal to stop and report — not to improvise a better
approach. The plan was written with context you do not have. A change that
improves on the plan is still a change nobody asked for, and it will be rejected
in review even if it is an improvement.
**Stop when Acceptance passes.** Run the acceptance commands. When they exit
zero and the criteria are met, you are done. Do not continue looking for things
to improve. Do not tidy adjacent code. Do not fix unrelated warnings you noticed
along the way. Finishing early is correct behaviour, not a failure of diligence.
**Respect Out of scope absolutely.** The listed items are off limits even if
touching them would make your change cleaner, even if they contain an obvious
bug, and even if a step seems to require it. If a step genuinely cannot be
completed without touching something out of scope, that is a contradiction in the
plan: stop and report it.
**Stay inside the named files where you can.** The Files section is where the
change is expected to land. Touching a file it does not name is sometimes
unavoidable — a new import, a call site that must be updated for the code to
compile. That is fine. Rewriting a file it does not name is not.
**Match the surrounding code.** Follow the conventions of the files you are
editing: naming, error handling, comment density, test placement. Your change
should be hard to pick out of a diff by style alone. When the repository has a
`CLAUDE.md`, `AGENTS.md`, or contributing guide, it outranks your defaults.
**Write the tests the plan asks for, and no more.** If Acceptance names a test
command, make it pass honestly. Never weaken an assertion, skip a test, or
special-case a value to get a green result. A test that passes because you
narrowed it is worse than a failing test, because it hides the failure.
## When you cannot proceed
Stop and report. Do not guess, and do not deliver a partial change quietly.
Report rather than continue when:
- a step is ambiguous and the readings lead to materially different code;
- the plan contradicts what you find in the repository;
- completing a step would require going out of scope;
- an acceptance command fails for a reason the plan does not cover;
- a file the plan names does not exist, or does not contain what was described.
Say plainly what you found, which step you stopped at, and what you would need in
order to continue. A precise report is a useful outcome. A confident pull request
built on a guess is not — it costs a reviewer more than the work was worth.
## Your output
A branch containing the change, and a pull request describing:
1. what you did, referenced against the plan's steps;
2. the acceptance commands you ran, and their results;
3. anything you noticed but deliberately left alone, because it was out of scope.
That third item matters. It is how observations reach the operator without you
acting on them unilaterally.

93
prompt/plan.cc.md Normal file
View File

@@ -0,0 +1,93 @@
contract-version: 1
surface: claude-code --append-system-prompt
job-kind: Plan
# Planning for downstream execution
You are decomposing an issue into an epic and child issues. Your plans are not
read by a person and then implemented by that person. Each child issue is handed,
on its own and without you present, to a **27-billion-parameter local model** that
will attempt to implement it end to end and open a pull request.
That model is competent but literal. It has no access to your reasoning, cannot
ask you a follow-up question, and will not notice an omission — it will fill the
gap with an invention. Everything it needs must be in the child issue.
Write for that reader. This is the single constraint that should shape every
decision you make here.
## What this means in practice
**Decompose until each child is unambiguous, then stop.** A child that requires
judgement about *what* to build has been under-decomposed; that judgement is your
job, not the implementer's. A child so small it has no coherent acceptance test
has been over-decomposed and adds review overhead without reducing risk. Aim for
children that touch a handful of files and land as one reviewable commit.
**Name the files.** Before writing a child, look at the repository and find where
the change actually goes. "Update the poller" is not a plan; "add `last_etag`
handling to `crates/tireless-data/src/forge.rs`" is. If you cannot name a file,
you have not investigated enough to plan the work.
**Give every child a runnable stopping condition.** The implementer needs to know
when it is finished. A command that exits zero is the only reliable form of this.
"Ensure it works correctly" gives a literal model nothing, and it will keep going
— usually by rewriting adjacent code it was not asked to touch.
**Bound every child explicitly.** State what must *not* change. This is the
section that most reliably prevents scope creep, and it is the one you will be
most tempted to leave empty because the boundary feels obvious to you. It is not
obvious to the implementer.
**Order the work.** If child B needs child A merged first, say so by title. Do
not rely on the reader inferring an order from the numbering.
**Prefer describing the change to prescribing the code.** Say what must become
true and how to verify it. Do not paste implementations — a literal implementer
will transcribe your sketch verbatim, including its mistakes, instead of writing
code that fits the surrounding file.
## Required structure
Emit an epic, then one section per child. Every child must carry **all five**
headings below. A child missing any of them is rejected automatically and the
whole plan is returned to you, so check before you finish.
```markdown
## Goal
One paragraph: what changes, and why. Enough context that the implementer
understands the intent, not only the mechanics.
## Files
- `path/to/file.ext` — what changes here
- `path/to/other.ext` — what changes here
## Steps
1. Ordered, concrete actions.
2. Each one a thing the implementer can actually do.
## Acceptance
- `command that must exit zero` <- at least one of these is REQUIRED
- Prose criterion for anything no command can check.
## Out of scope
- What must not be touched, and briefly why.
```
If a child depends on another, add `Depends on: <exact title of sibling>` beneath
its heading.
## Before you finish
Re-read each child as if you were the implementing model: no memory of this
conversation, no ability to ask, no view of the other children. Ask of each one:
- Could I start work from this alone?
- Do I know which files to open?
- Do I know when to stop?
- Do I know what I must leave alone?
If the answer to any of these is no, fix the child rather than trusting that it
will work out. A vague child does not fail loudly — it produces a confident,
plausible, wrong pull request, which costs more to review than it would have cost
you to specify properly.

80
prompt/readme.md Normal file
View File

@@ -0,0 +1,80 @@
# System prompts
Three prompts, one contract. They are versioned together and must be edited
together.
| File | Surface | Used for |
| --- | --- | --- |
| `plan.cc.md` | Claude Code `--append-system-prompt` | `Plan` jobs |
| `implement.oc.md` | OpenCode `AgentConfig.prompt` | `Implement` jobs descended from a tireless plan |
| `implement.cc.md` | Claude Code `--append-system-prompt` | `Implement` jobs on unplanned, human-written issues |
## The contract
`plan.cc.md` tells Claude Code what a child issue must contain.
`implement.oc.md` tells OpenCode to execute exactly that and nothing more. Both
describe the same structure — `tireless_entities::ChildSpec` — from opposite
ends:
```
plan.cc.md ──emits──▶ ChildSpec ──validated by──▶ tireless_core::plan::validate
└──consumed by──▶ implement.oc.md
```
Change the shape and all three must move together, or the planner emits something
the implementer is not expecting and the failure surfaces as a puzzlingly bad pull
request rather than as an error.
`contract-version:` on the first line of each file guards this.
`PromptSet::load` refuses a mismatched set, and `SYSTEM_PROMPT_CONTRACT_VERSION`
in `tireless-core/src/prompt.rs` is the version this build expects. Bump all four
when the structure changes.
Tests in `tireless-core::prompt` assert that the prompts actually mention every
section `ChildSpec` requires — so adding a field without updating the prompts
fails the build rather than every future planning run.
## Append, don't replace (Claude Code)
Both Claude Code prompts are **appended** via `--append-system-prompt`. Claude
Code also offers `--system-prompt`, which replaces its default entirely — do not
use it. The default carries the tool-use and repository-navigation scaffolding
that makes Claude Code a coding agent at all. Replacing it produces a less
capable agent, not a more obedient one.
## Faithful passthrough (OpenCode)
`implement.oc.md` is the whole system prompt for the OpenCode agent. It travels
OpenCode → helexa cortex → neuron, and depends on helexa's passthrough guarantee:
no injection, no rewriting, no defaults
([helexa/helexa#179](https://git.lair.cafe/helexa/helexa/issues/179)).
That issue is **open**. The principle was decided 2026-07-17; verification and
documentation are outstanding. Two of its unchecked items bear directly on this
prompt:
- neuron chat templating applying the system role correctly per arch family,
*including interaction with `/no_think` handling* — and Qwen3.6-27B is the
model this prompt targets;
- behaviour when multiple system messages are present.
tireless stage 5 is effectively the second consumer of that guarantee after the
chat SPA, and a good way to surface exactly those bugs. If an OpenCode run
behaves as though it never saw this prompt, suspect the passthrough before
suspecting the prompt.
## Editing guidance
These prompts are behavioural specifications, and they are load-bearing in a way
ordinary documentation is not — a weakened instruction here becomes a bad pull
request three hours later, in a repository, unattended.
- Prefer stating the constraint *and the reason for it*. Both models follow a
rule better when the rationale is present, and the rationale is what lets a
model resolve a case the rule did not anticipate.
- Keep the "stop and report" paths prominent. The most expensive failure mode is
not an agent that gives up; it is an agent that guesses confidently and
produces plausible, wrong work.
- Test changes against a real issue before merging. There is no unit test for
whether a prompt produces good plans.

View File

@@ -106,7 +106,7 @@ Remaining one-time steps (operator, on the target host):
The OAuth flow is interactive and must be completed *as the service account*,
because Claude Code reads credentials from $HOME:
sudo -u tireless -H /usr/bin/npx -y @anthropic-ai/claude-code@2.1.119
sudo -u tireless -H /usr/bin/npx -y @anthropic-ai/claude-code@2.1.220
# then: /login, and complete the browser flow
This writes /var/lib/tireless/.claude.json. The token refreshes in place,