feat(prompt): make the plan handoff a versioned, validated contract
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:
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
294
crates/tireless-core/src/plan.rs
Normal file
294
crates/tireless-core/src/plan.rs
Normal 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 { .. }))
|
||||
);
|
||||
}
|
||||
}
|
||||
154
crates/tireless-core/src/prompt.rs
Normal file
154
crates/tireless-core/src/prompt.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
78
crates/tireless-entities/src/plan.rs
Normal file
78
crates/tireless-entities/src/plan.rs
Normal 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>,
|
||||
}
|
||||
Reference in New Issue
Block a user