feat(discover): add the discovery lane and the autonomy boundary
The docs described a reactive executor: every entry point was a human label, and the only issue tireless ever created was a plan child. Nothing surveyed a repo or proposed work, which is the half that makes this continuous rather than on-demand. Add JobKind::Discover, routed always to Claude Code (proposing work is the highest-judgement, lowest-volume task), the tireless/discover and tireless/proposed labels, and prompt/discover.cc.md as a fourth member of the versioned prompt set. The contract version does not move: the plan structure is unchanged, and bumping for less than a shape change trains people to bump reflexively. With discovery comes the question of where the loop closes, which was previously unspecified — routing inferred that plan children are auto-admitted, but nothing said so. State it as a rule and enforce it: Admission is inherited, never invented. may_opt_in() lets tireless label a plan child, because a human admitted its parent, and refuses to label a discovered issue, because nothing has been admitted. It is a function rather than a config flag on purpose: the failure it prevents is unbounded, not merely wrong, so relaxing it should require review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
This commit is contained in:
@@ -8,9 +8,18 @@
|
|||||||
# asset/systemd/tireless-runner.service.
|
# asset/systemd/tireless-runner.service.
|
||||||
|
|
||||||
[api]
|
[api]
|
||||||
# Loopback: nginx on the same host fronts it. Registered in
|
# Registered in architecture/port-allocations.md.
|
||||||
# architecture/port-allocations.md.
|
#
|
||||||
bind = "127.0.0.1:23296"
|
# NOT loopback: the nginx that fronts this runs on the hanzalova proxy, not on
|
||||||
|
# bob (doc/plan/design.md §6.2), so the API has to be reachable across the mesh.
|
||||||
|
# The boundary is therefore firewalld plus the mesh itself — asset/firewalld/
|
||||||
|
# opens 23296, and nothing outside the mesh can route to it.
|
||||||
|
#
|
||||||
|
# If ingress is ever moved onto bob alongside the API, change this back to
|
||||||
|
# 127.0.0.1 and drop the firewalld service; the two decisions belong together
|
||||||
|
# and disagreeing about them is how you get a service that is either
|
||||||
|
# unreachable or wider open than intended.
|
||||||
|
bind = "0.0.0.0:23296"
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# mTLS, passwordless (architecture/generic.md §5). The host cert identifies the
|
# mTLS, passwordless (architecture/generic.md §5). The host cert identifies the
|
||||||
@@ -42,21 +51,41 @@ default_interval_seconds = 300
|
|||||||
jitter_seconds = 30
|
jitter_seconds = 30
|
||||||
|
|
||||||
[labels]
|
[labels]
|
||||||
|
# `tireless` admits an issue; `tireless/*` says what to do with it. Any label
|
||||||
|
# omitted here keeps its default, so overriding one does not mean restating all.
|
||||||
opt_in = "tireless"
|
opt_in = "tireless"
|
||||||
|
mode_discover = "tireless/discover"
|
||||||
mode_plan = "tireless/plan"
|
mode_plan = "tireless/plan"
|
||||||
mode_implement = "tireless/implement"
|
mode_implement = "tireless/implement"
|
||||||
force_cc = "tireless/agent:cc"
|
force_cc = "tireless/agent:cc"
|
||||||
force_oc = "tireless/agent:oc"
|
force_oc = "tireless/agent:oc"
|
||||||
|
# Written by tireless, never by an operator.
|
||||||
|
state_proposed = "tireless/proposed"
|
||||||
state_claimed = "tireless/claimed"
|
state_claimed = "tireless/claimed"
|
||||||
state_blocked = "tireless/blocked"
|
state_blocked = "tireless/blocked"
|
||||||
state_done = "tireless/done"
|
state_done = "tireless/done"
|
||||||
|
|
||||||
|
[discover]
|
||||||
|
# The discovery lane surveys a repo and proposes issues. It is anchored to a
|
||||||
|
# long-lived tracking issue carrying `tireless` + `tireless/discover`, so unlike
|
||||||
|
# the other lanes it recurs against the same issue — the cooldown is what keeps
|
||||||
|
# it from re-running on every poll.
|
||||||
|
#
|
||||||
|
# Proposals are created WITHOUT the opt-in label and wait for a human. That is
|
||||||
|
# the autonomy boundary (doc/plan/design.md §2.5), and it is enforced in code by
|
||||||
|
# `tireless_entities::may_opt_in`, not by this file.
|
||||||
|
cooldown_hours = 168
|
||||||
|
# A survey wanting to file more than this has misunderstood the job. The excess
|
||||||
|
# is dropped and reported rather than opened.
|
||||||
|
max_proposals_per_run = 8
|
||||||
|
|
||||||
[prompt]
|
[prompt]
|
||||||
# System prompts are compiled into the binary from prompt/*.md and are versioned
|
# 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 —
|
# 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
|
# 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
|
# declare the same `contract-version:` as the build, or the service refuses to
|
||||||
# start rather than run a mismatched pair.
|
# start rather than run a mismatched set.
|
||||||
|
# discover_cc = "/etc/tireless/prompt/discover.cc.md"
|
||||||
# plan_cc = "/etc/tireless/prompt/plan.cc.md"
|
# plan_cc = "/etc/tireless/prompt/plan.cc.md"
|
||||||
# implement_oc = "/etc/tireless/prompt/implement.oc.md"
|
# implement_oc = "/etc/tireless/prompt/implement.oc.md"
|
||||||
# implement_cc = "/etc/tireless/prompt/implement.cc.md"
|
# implement_cc = "/etc/tireless/prompt/implement.cc.md"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
//! [`SYSTEM_PROMPT_CONTRACT_VERSION`] exists to make that breakage loud. Both
|
//! [`SYSTEM_PROMPT_CONTRACT_VERSION`] exists to make that breakage loud. Both
|
||||||
//! prompt files declare it; [`PromptSet::load`] refuses a mismatched pair.
|
//! prompt files declare it; [`PromptSet::load`] refuses a mismatched pair.
|
||||||
|
|
||||||
use tireless_entities::Error;
|
use tireless_entities::{AgentKind, Error, JobKind};
|
||||||
|
|
||||||
/// Bumped whenever the plan contract changes shape. Both prompt files carry a
|
/// Bumped whenever the plan contract changes shape. Both prompt files carry a
|
||||||
/// `contract-version:` line that must match.
|
/// `contract-version:` line that must match.
|
||||||
@@ -35,6 +35,8 @@ pub const SYSTEM_PROMPT_CONTRACT_VERSION: u32 = 1;
|
|||||||
/// The paired prompts, loaded and checked together.
|
/// The paired prompts, loaded and checked together.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PromptSet {
|
pub struct PromptSet {
|
||||||
|
/// Appended to Claude Code's system prompt for `Discover` jobs.
|
||||||
|
pub discover_cc: String,
|
||||||
/// Appended to Claude Code's system prompt for `Plan` jobs.
|
/// Appended to Claude Code's system prompt for `Plan` jobs.
|
||||||
pub plan_cc: String,
|
pub plan_cc: String,
|
||||||
/// The whole system prompt for OpenCode implementation agents.
|
/// The whole system prompt for OpenCode implementation agents.
|
||||||
@@ -47,8 +49,14 @@ pub struct PromptSet {
|
|||||||
impl PromptSet {
|
impl PromptSet {
|
||||||
/// Load a prompt set, verifying every member declares the same contract
|
/// Load a prompt set, verifying every member declares the same contract
|
||||||
/// version as this build.
|
/// version as this build.
|
||||||
pub fn load(plan_cc: &str, implement_oc: &str, implement_cc: &str) -> Result<Self, Error> {
|
pub fn load(
|
||||||
|
discover_cc: &str,
|
||||||
|
plan_cc: &str,
|
||||||
|
implement_oc: &str,
|
||||||
|
implement_cc: &str,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
for (name, body) in [
|
for (name, body) in [
|
||||||
|
("discover.cc.md", discover_cc),
|
||||||
("plan.cc.md", plan_cc),
|
("plan.cc.md", plan_cc),
|
||||||
("implement.oc.md", implement_oc),
|
("implement.oc.md", implement_oc),
|
||||||
("implement.cc.md", implement_cc),
|
("implement.cc.md", implement_cc),
|
||||||
@@ -65,6 +73,7 @@ impl PromptSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
discover_cc: discover_cc.to_string(),
|
||||||
plan_cc: plan_cc.to_string(),
|
plan_cc: plan_cc.to_string(),
|
||||||
implement_oc: implement_oc.to_string(),
|
implement_oc: implement_oc.to_string(),
|
||||||
implement_cc: implement_cc.to_string(),
|
implement_cc: implement_cc.to_string(),
|
||||||
@@ -74,11 +83,50 @@ impl PromptSet {
|
|||||||
/// The prompts shipped with this build.
|
/// The prompts shipped with this build.
|
||||||
pub fn builtin() -> Result<Self, Error> {
|
pub fn builtin() -> Result<Self, Error> {
|
||||||
Self::load(
|
Self::load(
|
||||||
|
include_str!("../../../prompt/discover.cc.md"),
|
||||||
include_str!("../../../prompt/plan.cc.md"),
|
include_str!("../../../prompt/plan.cc.md"),
|
||||||
include_str!("../../../prompt/implement.oc.md"),
|
include_str!("../../../prompt/implement.oc.md"),
|
||||||
include_str!("../../../prompt/implement.cc.md"),
|
include_str!("../../../prompt/implement.cc.md"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load the set, substituting any file the operator overrode.
|
||||||
|
///
|
||||||
|
/// Overrides are per-file but the set is validated whole: an override that
|
||||||
|
/// declares a stale `contract-version:` fails here, at startup, rather than
|
||||||
|
/// on the first planning run three hours later. Files not overridden fall
|
||||||
|
/// back to the ones compiled into this binary, so a partial override is a
|
||||||
|
/// supported thing to do rather than an accident waiting to happen.
|
||||||
|
pub fn resolve(overrides: &crate::config::PromptOverrides) -> Result<Self, Error> {
|
||||||
|
let builtin = Self::builtin()?;
|
||||||
|
let read = |path: Option<&std::path::PathBuf>, fallback: &str| -> Result<String, Error> {
|
||||||
|
match path {
|
||||||
|
None => Ok(fallback.to_string()),
|
||||||
|
Some(p) => std::fs::read_to_string(p)
|
||||||
|
.map_err(|e| Error::Config(format!("prompt override {}: {e}", p.display()))),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self::load(
|
||||||
|
&read(overrides.discover_cc.as_ref(), &builtin.discover_cc)?,
|
||||||
|
&read(overrides.plan_cc.as_ref(), &builtin.plan_cc)?,
|
||||||
|
&read(overrides.implement_oc.as_ref(), &builtin.implement_oc)?,
|
||||||
|
&read(overrides.implement_cc.as_ref(), &builtin.implement_cc)?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prompt for a job kind, and whether it replaces or appends.
|
||||||
|
///
|
||||||
|
/// Only the OpenCode lane owns a whole system prompt; every Claude Code
|
||||||
|
/// prompt is an append. Callers must respect that — see
|
||||||
|
/// [`crate::prompt`] module docs and `tireless_agent::claude::SYSTEM_PROMPT_FLAG`.
|
||||||
|
pub fn for_job(&self, kind: JobKind, agent: AgentKind) -> &str {
|
||||||
|
match (kind, agent) {
|
||||||
|
(JobKind::Discover, _) => &self.discover_cc,
|
||||||
|
(JobKind::Plan, _) => &self.plan_cc,
|
||||||
|
(JobKind::Implement, AgentKind::Opencode) => &self.implement_oc,
|
||||||
|
(JobKind::Implement, AgentKind::ClaudeCode) => &self.implement_cc,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `contract-version: N` from a prompt file's leading metadata block.
|
/// Read `contract-version: N` from a prompt file's leading metadata block.
|
||||||
@@ -130,13 +178,65 @@ mod tests {
|
|||||||
"contract-version: {}\n\n# x\n",
|
"contract-version: {}\n\n# x\n",
|
||||||
SYSTEM_PROMPT_CONTRACT_VERSION + 1
|
SYSTEM_PROMPT_CONTRACT_VERSION + 1
|
||||||
);
|
);
|
||||||
assert!(PromptSet::load(&good, &stale, &good).is_err());
|
// Every position in the set must be checked, not just the first.
|
||||||
|
assert!(PromptSet::load(&stale, &good, &good, &good).is_err());
|
||||||
|
assert!(PromptSet::load(&good, &stale, &good, &good).is_err());
|
||||||
|
assert!(PromptSet::load(&good, &good, &stale, &good).is_err());
|
||||||
|
assert!(PromptSet::load(&good, &good, &good, &stale).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_undeclared_version_is_refused() {
|
fn an_undeclared_version_is_refused() {
|
||||||
let good = format!("contract-version: {SYSTEM_PROMPT_CONTRACT_VERSION}\n\n# x\n");
|
let good = format!("contract-version: {SYSTEM_PROMPT_CONTRACT_VERSION}\n\n# x\n");
|
||||||
assert!(PromptSet::load(&good, "# no metadata\n", &good).is_err());
|
assert!(PromptSet::load(&good, &good, "# no metadata\n", &good).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_job_kind_has_a_prompt() {
|
||||||
|
let set = PromptSet::builtin().expect("load");
|
||||||
|
for (kind, agent) in [
|
||||||
|
(JobKind::Discover, AgentKind::ClaudeCode),
|
||||||
|
(JobKind::Plan, AgentKind::ClaudeCode),
|
||||||
|
(JobKind::Implement, AgentKind::ClaudeCode),
|
||||||
|
(JobKind::Implement, AgentKind::Opencode),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!set.for_job(kind, agent).is_empty(),
|
||||||
|
"no prompt for {kind:?} on {agent:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_discovery_prompt_states_that_proposals_are_not_opted_in() {
|
||||||
|
// The autonomy boundary has to be visible to the model doing the
|
||||||
|
// proposing, not only to the code creating the issues: a planner that
|
||||||
|
// believes its output will be acted on immediately writes differently
|
||||||
|
// from one that knows a human decides. See doc/plan/design.md §2.5.
|
||||||
|
let set = PromptSet::builtin().expect("load");
|
||||||
|
assert!(
|
||||||
|
set.discover_cc.contains("without the opt-in label"),
|
||||||
|
"discovery prompt does not tell the model its proposals await a human"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_no_overrides_the_set_resolves_to_the_builtin_one() {
|
||||||
|
let set = PromptSet::resolve(&crate::config::PromptOverrides::default()).expect("resolve");
|
||||||
|
assert_eq!(set.plan_cc, PromptSet::builtin().expect("builtin").plan_cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_override_pointing_at_a_missing_file_is_a_startup_error() {
|
||||||
|
// Not a silent fall-back to the builtin prompt: an operator who put a
|
||||||
|
// path in config.toml intends that file to be in use, and quietly
|
||||||
|
// running something else is how you spend a week debugging plan quality
|
||||||
|
// against a prompt you are not actually running.
|
||||||
|
let overrides = crate::config::PromptOverrides {
|
||||||
|
plan_cc: Some("/nonexistent/plan.cc.md".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(PromptSet::resolve(&overrides).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
//! Which agent handles which job.
|
//! Which agent handles which job.
|
||||||
//!
|
//!
|
||||||
//! The rule, in one sentence: **Claude Code gets judgement, OpenCode gets
|
//! The rule, in one sentence: **Claude Code gets judgement, OpenCode gets
|
||||||
//! specification.** Planning is always Claude Code because decomposition is the
|
//! specification.** Discovery and planning are always Claude Code, because
|
||||||
//! high-judgement, low-volume task. Implementation goes to OpenCode when a
|
//! deciding what to build and decomposing it are the high-judgement, low-volume
|
||||||
//! tireless plan already specified the work, and to Claude Code when it did not.
|
//! tasks. Implementation goes to OpenCode when a tireless plan already specified
|
||||||
|
//! the work, and to Claude Code when it did not.
|
||||||
|
//!
|
||||||
|
//! Note the economics this produces: the two lanes that *create* work are the
|
||||||
|
//! expensive ones, and they are also the ones bounded to a handful of runs per
|
||||||
|
//! window. The lane that consumes the work — the bulk of it, by volume — is
|
||||||
|
//! free. Spending is highest where a mistake is cheapest to notice, which is the
|
||||||
|
//! same ordering the staged plan uses.
|
||||||
//!
|
//!
|
||||||
//! An explicit label always wins, so an operator can override any inference.
|
//! An explicit label always wins, so an operator can override any inference.
|
||||||
|
|
||||||
@@ -36,6 +43,14 @@ pub fn route(job: &Job, labels: &[String], protocol: &LabelProtocol) -> RouteDec
|
|||||||
}
|
}
|
||||||
|
|
||||||
match job.kind {
|
match job.kind {
|
||||||
|
// Proposing work is the highest-judgement task in the system and the
|
||||||
|
// lowest volume: a survey runs on a cadence measured in days, and its
|
||||||
|
// output sets what everything downstream spends its budget on. There is
|
||||||
|
// no version of this that belongs on the cheap lane.
|
||||||
|
JobKind::Discover => RouteDecision {
|
||||||
|
agent: AgentKind::ClaudeCode,
|
||||||
|
reason: "discovery is always cc",
|
||||||
|
},
|
||||||
// Decomposition is judgement work and low volume: always the strong model.
|
// Decomposition is judgement work and low volume: always the strong model.
|
||||||
JobKind::Plan => RouteDecision {
|
JobKind::Plan => RouteDecision {
|
||||||
agent: AgentKind::ClaudeCode,
|
agent: AgentKind::ClaudeCode,
|
||||||
@@ -90,6 +105,13 @@ mod tests {
|
|||||||
assert_eq!(d.agent, AgentKind::ClaudeCode);
|
assert_eq!(d.agent, AgentKind::ClaudeCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovery_always_goes_to_claude_code() {
|
||||||
|
let p = LabelProtocol::default();
|
||||||
|
let d = route(&job(JobKind::Discover, None), &[], &p);
|
||||||
|
assert_eq!(d.agent, AgentKind::ClaudeCode);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn planned_implementation_goes_to_opencode() {
|
fn planned_implementation_goes_to_opencode() {
|
||||||
let p = LabelProtocol::default();
|
let p = LabelProtocol::default();
|
||||||
|
|||||||
@@ -6,16 +6,55 @@ use uuid::Uuid;
|
|||||||
use crate::forge::IssueRef;
|
use crate::forge::IssueRef;
|
||||||
|
|
||||||
/// What tireless was asked to do with an issue.
|
/// What tireless was asked to do with an issue.
|
||||||
|
///
|
||||||
|
/// The three kinds form the loop described in `doc/plan/design.md` §2.1:
|
||||||
|
/// discovery proposes work, planning specifies it, implementation delivers it.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub enum JobKind {
|
pub enum JobKind {
|
||||||
|
/// Survey a repository and propose issues worth opening. Produces issues,
|
||||||
|
/// none of which are opted in — see [`may_opt_in`].
|
||||||
|
///
|
||||||
|
/// Anchored to a long-lived tracking issue rather than to a repository, so
|
||||||
|
/// that a discovery run has somewhere to report and an operator has the
|
||||||
|
/// usual label control over it. See `doc/plan/design.md` §2.6.
|
||||||
|
Discover,
|
||||||
/// Decompose the issue into an epic and child issues. Produces issues, not code.
|
/// Decompose the issue into an epic and child issues. Produces issues, not code.
|
||||||
Plan,
|
Plan,
|
||||||
/// Implement the issue and open a pull request.
|
/// Implement the issue and open a pull request.
|
||||||
Implement,
|
Implement,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// May tireless place the opt-in label on an issue it just created?
|
||||||
|
///
|
||||||
|
/// **Admission is inherited, never invented.** This is the mechanical form of
|
||||||
|
/// the autonomy boundary in `doc/plan/design.md` §2.5, and it is the single
|
||||||
|
/// constraint that keeps the loop from feeding itself:
|
||||||
|
///
|
||||||
|
/// - A **plan child** descends from an issue a human opted in. The human's
|
||||||
|
/// admission of the parent covers it, so tireless labels it and the work
|
||||||
|
/// proceeds without a second approval.
|
||||||
|
/// - A **discovered issue** has no admitted ancestor. Nobody has agreed the work
|
||||||
|
/// is worth doing, only that it might be. It is created *unlabelled* and waits
|
||||||
|
/// for a human, whatever else is true about it.
|
||||||
|
///
|
||||||
|
/// Without this, a discovery run that proposes twenty issues would enqueue
|
||||||
|
/// twenty planning jobs, each proposing children of its own, until the window
|
||||||
|
/// budget ran out. The gate is not a policy that could be relaxed once the
|
||||||
|
/// system is trusted; it is what makes "how much work is in flight" a number the
|
||||||
|
/// operator chose.
|
||||||
|
pub fn may_opt_in(created_by: JobKind) -> bool {
|
||||||
|
match created_by {
|
||||||
|
// Children of an admitted parent inherit its admission.
|
||||||
|
JobKind::Plan => true,
|
||||||
|
// Proposals have no admitted ancestor. A human decides.
|
||||||
|
JobKind::Discover => false,
|
||||||
|
// Implementation creates pull requests, not issues.
|
||||||
|
JobKind::Implement => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Job lifecycle.
|
/// Job lifecycle.
|
||||||
///
|
///
|
||||||
/// A job is the unit of work-claiming. Claiming is a Postgres row transition
|
/// A job is the unit of work-claiming. Claiming is a Postgres row transition
|
||||||
@@ -72,3 +111,36 @@ pub struct Job {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn terminal_states_are_never_reclaimed() {
|
||||||
|
for state in [
|
||||||
|
JobState::Delivered,
|
||||||
|
JobState::Blocked,
|
||||||
|
JobState::Failed,
|
||||||
|
JobState::Abandoned,
|
||||||
|
] {
|
||||||
|
assert!(state.is_terminal(), "{state:?} must be terminal");
|
||||||
|
}
|
||||||
|
for state in [JobState::Pending, JobState::Claimed, JobState::Running] {
|
||||||
|
assert!(!state.is_terminal(), "{state:?} must not be terminal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovery_output_is_never_opted_in() {
|
||||||
|
// The autonomy boundary. If this ever returns true, a discovery run can
|
||||||
|
// enqueue its own follow-on work and the loop no longer has a human in
|
||||||
|
// it. See doc/plan/design.md §2.5.
|
||||||
|
assert!(!may_opt_in(JobKind::Discover));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_children_inherit_admission_from_their_parent() {
|
||||||
|
assert!(may_opt_in(JobKind::Plan));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,12 +7,23 @@ use ts_rs::TS;
|
|||||||
/// issue in, and how tireless reports back. They are **not** the authority on
|
/// issue in, and how tireless reports back. They are **not** the authority on
|
||||||
/// state — Postgres is (see `doc/plan/design.md` §4). Labels are a best-effort
|
/// state — Postgres is (see `doc/plan/design.md` §4). Labels are a best-effort
|
||||||
/// mirror, reconciled on every poll.
|
/// mirror, reconciled on every poll.
|
||||||
|
///
|
||||||
|
/// `#[serde(default)]` is on the struct so an operator can override one label in
|
||||||
|
/// `config.toml` without restating the other nine — a config that must be
|
||||||
|
/// complete to be valid is one people copy from a stale example.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct LabelProtocol {
|
pub struct LabelProtocol {
|
||||||
/// Opt-in marker. Without it tireless never touches an issue, regardless of
|
/// Opt-in marker. Without it tireless never touches an issue, regardless of
|
||||||
/// any other label present. Default: `tireless`.
|
/// any other label present. Default: `tireless`.
|
||||||
|
///
|
||||||
|
/// Written by a human, and by tireless **only** when admission is inherited
|
||||||
|
/// from an opted-in parent — see [`crate::job::may_opt_in`].
|
||||||
pub opt_in: String,
|
pub opt_in: String,
|
||||||
|
/// Requests a survey of the repository for work worth proposing. Applied to
|
||||||
|
/// a long-lived tracking issue. Default: `tireless/discover`.
|
||||||
|
pub mode_discover: String,
|
||||||
/// Requests decomposition into an epic plus child issues. Default: `tireless/plan`.
|
/// Requests decomposition into an epic plus child issues. Default: `tireless/plan`.
|
||||||
pub mode_plan: String,
|
pub mode_plan: String,
|
||||||
/// Requests an implementation and a pull request. Default: `tireless/implement`.
|
/// Requests an implementation and a pull request. Default: `tireless/implement`.
|
||||||
@@ -21,6 +32,11 @@ pub struct LabelProtocol {
|
|||||||
pub force_cc: String,
|
pub force_cc: String,
|
||||||
/// Forces the OpenCode lane. Default: `tireless/agent:oc`.
|
/// Forces the OpenCode lane. Default: `tireless/agent:oc`.
|
||||||
pub force_oc: String,
|
pub force_oc: String,
|
||||||
|
/// Written by tireless on an issue it proposed during discovery. Marks
|
||||||
|
/// provenance, and marks that the issue is *awaiting a human decision* — a
|
||||||
|
/// proposed issue deliberately does not carry [`Self::opt_in`], so it cannot
|
||||||
|
/// act on itself. Default: `tireless/proposed`.
|
||||||
|
pub state_proposed: String,
|
||||||
/// Written by tireless while a job holds the issue. Default: `tireless/claimed`.
|
/// Written by tireless while a job holds the issue. Default: `tireless/claimed`.
|
||||||
pub state_claimed: String,
|
pub state_claimed: String,
|
||||||
/// Written by tireless when it needs a human. Default: `tireless/blocked`.
|
/// Written by tireless when it needs a human. Default: `tireless/blocked`.
|
||||||
@@ -33,13 +49,87 @@ impl Default for LabelProtocol {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
opt_in: "tireless".into(),
|
opt_in: "tireless".into(),
|
||||||
|
mode_discover: "tireless/discover".into(),
|
||||||
mode_plan: "tireless/plan".into(),
|
mode_plan: "tireless/plan".into(),
|
||||||
mode_implement: "tireless/implement".into(),
|
mode_implement: "tireless/implement".into(),
|
||||||
force_cc: "tireless/agent:cc".into(),
|
force_cc: "tireless/agent:cc".into(),
|
||||||
force_oc: "tireless/agent:oc".into(),
|
force_oc: "tireless/agent:oc".into(),
|
||||||
|
state_proposed: "tireless/proposed".into(),
|
||||||
state_claimed: "tireless/claimed".into(),
|
state_claimed: "tireless/claimed".into(),
|
||||||
state_blocked: "tireless/blocked".into(),
|
state_blocked: "tireless/blocked".into(),
|
||||||
state_done: "tireless/done".into(),
|
state_done: "tireless/done".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl LabelProtocol {
|
||||||
|
/// Labels tireless writes. Everything else in the protocol is the operator's
|
||||||
|
/// to apply, and tireless only reads it.
|
||||||
|
///
|
||||||
|
/// [`Self::opt_in`] is deliberately absent even though tireless does write
|
||||||
|
/// it on inherited-admission children: it is not *tireless's* label, it is a
|
||||||
|
/// human's, and the one case where tireless applies it is narrow enough to
|
||||||
|
/// be gated by [`crate::job::may_opt_in`] rather than implied by membership
|
||||||
|
/// here.
|
||||||
|
pub fn written_by_tireless(&self) -> [&str; 4] {
|
||||||
|
[
|
||||||
|
&self.state_proposed,
|
||||||
|
&self.state_claimed,
|
||||||
|
&self.state_blocked,
|
||||||
|
&self.state_done,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_opt_in_label_is_not_one_tireless_writes_freely() {
|
||||||
|
let p = LabelProtocol::default();
|
||||||
|
assert!(
|
||||||
|
!p.written_by_tireless().contains(&p.opt_in.as_str()),
|
||||||
|
"opt-in is a human's label; tireless applies it only via may_opt_in"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_label_is_distinct() {
|
||||||
|
// A protocol where two roles collapse to the same string would make
|
||||||
|
// reconciliation silently wrong rather than loudly broken.
|
||||||
|
let p = LabelProtocol::default();
|
||||||
|
let all = [
|
||||||
|
&p.opt_in,
|
||||||
|
&p.mode_discover,
|
||||||
|
&p.mode_plan,
|
||||||
|
&p.mode_implement,
|
||||||
|
&p.force_cc,
|
||||||
|
&p.force_oc,
|
||||||
|
&p.state_proposed,
|
||||||
|
&p.state_claimed,
|
||||||
|
&p.state_blocked,
|
||||||
|
&p.state_done,
|
||||||
|
];
|
||||||
|
let mut seen: Vec<&String> = Vec::new();
|
||||||
|
for label in all {
|
||||||
|
assert!(!seen.contains(&label), "duplicate label {label:?}");
|
||||||
|
seen.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_labels_are_namespaced_under_the_opt_in_label() {
|
||||||
|
// `tireless` opts in; `tireless/*` says how. A mode label outside the
|
||||||
|
// namespace would be easy to apply without realising it does nothing on
|
||||||
|
// its own.
|
||||||
|
let p = LabelProtocol::default();
|
||||||
|
for label in [&p.mode_discover, &p.mode_plan, &p.mode_implement] {
|
||||||
|
assert!(
|
||||||
|
label.starts_with(&format!("{}/", p.opt_in)),
|
||||||
|
"{label:?} is not namespaced under {:?}",
|
||||||
|
p.opt_in
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub mod run;
|
|||||||
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use forge::{Forge, IssueRef, PullRequestRef};
|
pub use forge::{Forge, IssueRef, PullRequestRef};
|
||||||
pub use job::{Job, JobKind, JobState};
|
pub use job::{Job, JobKind, JobState, may_opt_in};
|
||||||
pub use label::LabelProtocol;
|
pub use label::LabelProtocol;
|
||||||
pub use plan::{Acceptance, ChildSpec, FileTouch, PlanSpec, PlannedChild};
|
pub use plan::{Acceptance, ChildSpec, FileTouch, PlanSpec, PlannedChild};
|
||||||
pub use repo::{PollSchedule, TrackedRepo};
|
pub use repo::{PollSchedule, TrackedRepo};
|
||||||
|
|||||||
@@ -2,5 +2,8 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* What tireless was asked to do with an issue.
|
* What tireless was asked to do with an issue.
|
||||||
|
*
|
||||||
|
* The three kinds form the loop described in `doc/plan/design.md` §2.1:
|
||||||
|
* discovery proposes work, planning specifies it, implementation delivers it.
|
||||||
*/
|
*/
|
||||||
export type JobKind = "plan" | "implement";
|
export type JobKind = "discover" | "plan" | "implement";
|
||||||
|
|||||||
@@ -7,13 +7,25 @@
|
|||||||
* issue in, and how tireless reports back. They are **not** the authority on
|
* issue in, and how tireless reports back. They are **not** the authority on
|
||||||
* state — Postgres is (see `doc/plan/design.md` §4). Labels are a best-effort
|
* state — Postgres is (see `doc/plan/design.md` §4). Labels are a best-effort
|
||||||
* mirror, reconciled on every poll.
|
* mirror, reconciled on every poll.
|
||||||
|
*
|
||||||
|
* `#[serde(default)]` is on the struct so an operator can override one label in
|
||||||
|
* `config.toml` without restating the other nine — a config that must be
|
||||||
|
* complete to be valid is one people copy from a stale example.
|
||||||
*/
|
*/
|
||||||
export type LabelProtocol = {
|
export type LabelProtocol = {
|
||||||
/**
|
/**
|
||||||
* Opt-in marker. Without it tireless never touches an issue, regardless of
|
* Opt-in marker. Without it tireless never touches an issue, regardless of
|
||||||
* any other label present. Default: `tireless`.
|
* any other label present. Default: `tireless`.
|
||||||
|
*
|
||||||
|
* Written by a human, and by tireless **only** when admission is inherited
|
||||||
|
* from an opted-in parent — see [`crate::job::may_opt_in`].
|
||||||
*/
|
*/
|
||||||
opt_in: string,
|
opt_in: string,
|
||||||
|
/**
|
||||||
|
* Requests a survey of the repository for work worth proposing. Applied to
|
||||||
|
* a long-lived tracking issue. Default: `tireless/discover`.
|
||||||
|
*/
|
||||||
|
mode_discover: string,
|
||||||
/**
|
/**
|
||||||
* Requests decomposition into an epic plus child issues. Default: `tireless/plan`.
|
* Requests decomposition into an epic plus child issues. Default: `tireless/plan`.
|
||||||
*/
|
*/
|
||||||
@@ -30,6 +42,13 @@ force_cc: string,
|
|||||||
* Forces the OpenCode lane. Default: `tireless/agent:oc`.
|
* Forces the OpenCode lane. Default: `tireless/agent:oc`.
|
||||||
*/
|
*/
|
||||||
force_oc: string,
|
force_oc: string,
|
||||||
|
/**
|
||||||
|
* Written by tireless on an issue it proposed during discovery. Marks
|
||||||
|
* provenance, and marks that the issue is *awaiting a human decision* — a
|
||||||
|
* proposed issue deliberately does not carry [`Self::opt_in`], so it cannot
|
||||||
|
* act on itself. Default: `tireless/proposed`.
|
||||||
|
*/
|
||||||
|
state_proposed: string,
|
||||||
/**
|
/**
|
||||||
* Written by tireless while a job holds the issue. Default: `tireless/claimed`.
|
* Written by tireless while a job holds the issue. Default: `tireless/claimed`.
|
||||||
*/
|
*/
|
||||||
|
|||||||
103
prompt/discover.cc.md
Normal file
103
prompt/discover.cc.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
contract-version: 1
|
||||||
|
surface: claude-code --append-system-prompt
|
||||||
|
job-kind: Discover
|
||||||
|
|
||||||
|
# Proposing work
|
||||||
|
|
||||||
|
You are surveying a repository and proposing issues worth opening. You are not
|
||||||
|
implementing anything, and you are not writing plans. Your output is a short list
|
||||||
|
of proposals that a human will read and decide on.
|
||||||
|
|
||||||
|
Assume the operator is competent, busy, and already knows the obvious. They are
|
||||||
|
not asking you what a linter would tell them. They are asking what they would
|
||||||
|
notice themselves if they had a free afternoon to read their own repository —
|
||||||
|
and would then be annoyed to have missed.
|
||||||
|
|
||||||
|
## What you are looking for
|
||||||
|
|
||||||
|
Read the repository as its maintainer, not as a reviewer of a diff. Useful
|
||||||
|
proposals usually come from one of these:
|
||||||
|
|
||||||
|
- **Stated intent that was never finished.** Design documents, `TODO` comments,
|
||||||
|
readme promises, config options nothing reads, functions nobody calls. A gap
|
||||||
|
between what the docs claim and what the code does is the highest-value thing
|
||||||
|
you can find, because it misleads every future reader until it is closed.
|
||||||
|
- **Load-bearing assumptions with no test.** Something the design says is
|
||||||
|
guaranteed, where nothing would fail if it stopped being true.
|
||||||
|
- **Repeated friction.** The same workaround in three places, a manual step in
|
||||||
|
a runbook that could be a command, a failure mode the commit history shows
|
||||||
|
being fixed more than once.
|
||||||
|
- **Work the roadmap already implies but never itemised.** If a staged plan says
|
||||||
|
a stage exists but never lists what it contains, itemising it is real work.
|
||||||
|
|
||||||
|
## What is not worth proposing
|
||||||
|
|
||||||
|
Say nothing rather than pad the list.
|
||||||
|
|
||||||
|
- Style, formatting, or anything the project's own lint and format gate would
|
||||||
|
catch. It is already caught.
|
||||||
|
- Speculative abstraction, "consider extracting", or refactors with no named
|
||||||
|
problem behind them.
|
||||||
|
- Restating a limitation the documentation already acknowledges as deliberate. A
|
||||||
|
documented trade-off is a decision, not a defect. If you think a decision is
|
||||||
|
wrong, argue the decision explicitly — do not propose the work as if nobody
|
||||||
|
had thought about it.
|
||||||
|
- Anything the repository's own conventions file marks as an invariant, unless
|
||||||
|
you are proposing to strengthen it. Those exist because something depended on
|
||||||
|
them, and the reasoning is usually written down next to them. Read it first.
|
||||||
|
|
||||||
|
## How to judge what to include
|
||||||
|
|
||||||
|
Prefer few, specific, and defensible. Five proposals a maintainer acts on beat
|
||||||
|
twenty they have to triage. Before including a proposal, ask:
|
||||||
|
|
||||||
|
- Can I name the file or the behaviour, not just the theme?
|
||||||
|
- Would the maintainer agree this is a real problem, or would they have to be
|
||||||
|
persuaded first? If persuaded — say so, and make the argument.
|
||||||
|
- Is it doable as one reviewable change? If it is obviously several, propose it
|
||||||
|
as one issue and say it will need decomposing.
|
||||||
|
|
||||||
|
Rank the list by value to the operator, best first. Do not pretend the ranking is
|
||||||
|
objective; it is your judgement, which is what was wanted.
|
||||||
|
|
||||||
|
## Your output
|
||||||
|
|
||||||
|
For each proposal:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## <title, as an issue title would read>
|
||||||
|
|
||||||
|
**Why this matters** — the problem, in the maintainer's terms. Name what is
|
||||||
|
currently wrong or missing, and what it costs.
|
||||||
|
|
||||||
|
**Evidence** — the files, lines, or documents you are reasoning from. Specific
|
||||||
|
enough that the reader can check you.
|
||||||
|
|
||||||
|
**Shape of the work** — a sentence or two on what closing it involves. Not a
|
||||||
|
plan; enough to judge the size.
|
||||||
|
|
||||||
|
**Confidence** — what you are sure of and what you are inferring. If you did not
|
||||||
|
read something you would have liked to, say so.
|
||||||
|
```
|
||||||
|
|
||||||
|
Then a closing paragraph: what you looked at, what you deliberately skipped, and
|
||||||
|
anything you noticed that you decided was *not* worth an issue and why. That last
|
||||||
|
part is as useful as the list — it tells the operator what has already been
|
||||||
|
considered and dismissed, so nobody re-derives it next month.
|
||||||
|
|
||||||
|
## What happens to your output
|
||||||
|
|
||||||
|
Each proposal becomes an issue, created **without the opt-in label**. Nothing you
|
||||||
|
propose will be planned or implemented until a human reads it and admits it. So:
|
||||||
|
|
||||||
|
- You are not spending anyone's budget by proposing something speculative — but
|
||||||
|
you are spending their attention, which is scarcer. Weigh accordingly.
|
||||||
|
- Do not write as though the work is already agreed. Propose, and make the case.
|
||||||
|
- If you genuinely believe something is urgent or dangerous, say so plainly at
|
||||||
|
the top rather than relying on list position to carry it.
|
||||||
|
|
||||||
|
If the survey turns up nothing worth proposing, say that and stop. A short,
|
||||||
|
honest "nothing significant since the last survey" is a correct and useful
|
||||||
|
outcome. Manufacturing proposals to look productive is the one failure mode this
|
||||||
|
job has, and it is expensive: it costs the operator's attention, which is the
|
||||||
|
resource the whole system exists to protect.
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# System prompts
|
# System prompts
|
||||||
|
|
||||||
Three prompts, one contract. They are versioned together and must be edited
|
Four prompts, one set. They are versioned together and must be edited together.
|
||||||
together.
|
|
||||||
|
|
||||||
| File | Surface | Used for |
|
| File | Surface | Used for |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `discover.cc.md` | Claude Code `--append-system-prompt` | `Discover` jobs |
|
||||||
| `plan.cc.md` | Claude Code `--append-system-prompt` | `Plan` jobs |
|
| `plan.cc.md` | Claude Code `--append-system-prompt` | `Plan` jobs |
|
||||||
| `implement.oc.md` | OpenCode `AgentConfig.prompt` | `Implement` jobs descended from a tireless plan |
|
| `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 |
|
| `implement.cc.md` | Claude Code `--append-system-prompt` | `Implement` jobs on unplanned, human-written issues |
|
||||||
@@ -28,9 +28,24 @@ request rather than as an error.
|
|||||||
|
|
||||||
`contract-version:` on the first line of each file guards this.
|
`contract-version:` on the first line of each file guards this.
|
||||||
`PromptSet::load` refuses a mismatched set, and `SYSTEM_PROMPT_CONTRACT_VERSION`
|
`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
|
in `tireless-core/src/prompt.rs` is the version this build expects. Bump all five
|
||||||
when the structure changes.
|
when the structure changes.
|
||||||
|
|
||||||
|
`discover.cc.md` does not emit a `ChildSpec` — its output is prose proposals that
|
||||||
|
a human admits and a later `Plan` job decomposes. It is in the versioned set
|
||||||
|
anyway, because it writes the issue bodies `plan.cc.md` later reads, and because
|
||||||
|
a prompt outside the set is a prompt nobody remembers to update. Adding it did
|
||||||
|
**not** bump the version: the plan structure did not change, and bumping for
|
||||||
|
anything less than a shape change trains people to bump reflexively.
|
||||||
|
|
||||||
|
## The autonomy boundary lives in the prompts too
|
||||||
|
|
||||||
|
`discover.cc.md` tells the model its proposals are created without the opt-in
|
||||||
|
label and wait for a human. That is not decoration — a model that believes its
|
||||||
|
output will be acted on immediately writes differently from one that knows it is
|
||||||
|
making a case to a reader. `tireless_core::prompt` has a test asserting the
|
||||||
|
sentence is still there.
|
||||||
|
|
||||||
Tests in `tireless-core::prompt` assert that the prompts actually mention every
|
Tests in `tireless-core::prompt` assert that the prompts actually mention every
|
||||||
section `ChildSpec` requires — so adding a field without updating the prompts
|
section `ChildSpec` requires — so adding a field without updating the prompts
|
||||||
fails the build rather than every future planning run.
|
fails the build rather than every future planning run.
|
||||||
|
|||||||
Reference in New Issue
Block a user