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

@@ -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>,
}