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:
rob thijssen
2026-08-07 15:36:35 +03:00
parent c49b531bcd
commit 581e6ae738
10 changed files with 469 additions and 16 deletions

View File

@@ -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"

View File

@@ -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]

View File

@@ -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();

View File

@@ -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));
}
}

View File

@@ -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
);
}
}
}

View File

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

View File

@@ -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";

View File

@@ -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
View 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.

View File

@@ -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.