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.
|
||||
|
||||
[api]
|
||||
# Loopback: nginx on the same host fronts it. Registered in
|
||||
# architecture/port-allocations.md.
|
||||
bind = "127.0.0.1:23296"
|
||||
# Registered in architecture/port-allocations.md.
|
||||
#
|
||||
# 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]
|
||||
# mTLS, passwordless (architecture/generic.md §5). The host cert identifies the
|
||||
@@ -42,21 +51,41 @@ default_interval_seconds = 300
|
||||
jitter_seconds = 30
|
||||
|
||||
[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"
|
||||
mode_discover = "tireless/discover"
|
||||
mode_plan = "tireless/plan"
|
||||
mode_implement = "tireless/implement"
|
||||
force_cc = "tireless/agent:cc"
|
||||
force_oc = "tireless/agent:oc"
|
||||
# Written by tireless, never by an operator.
|
||||
state_proposed = "tireless/proposed"
|
||||
state_claimed = "tireless/claimed"
|
||||
state_blocked = "tireless/blocked"
|
||||
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]
|
||||
# System prompts are compiled into the binary from prompt/*.md and are versioned
|
||||
# as a set (see prompt/readme.md). Point these at files on disk to override —
|
||||
# useful for iterating on plan quality without a redeploy. An override must
|
||||
# declare the same `contract-version:` as the build, or the service refuses to
|
||||
# start rather than run a mismatched pair.
|
||||
# start rather than run a mismatched set.
|
||||
# discover_cc = "/etc/tireless/prompt/discover.cc.md"
|
||||
# plan_cc = "/etc/tireless/prompt/plan.cc.md"
|
||||
# implement_oc = "/etc/tireless/prompt/implement.oc.md"
|
||||
# implement_cc = "/etc/tireless/prompt/implement.cc.md"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
//! [`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;
|
||||
use tireless_entities::{AgentKind, Error, JobKind};
|
||||
|
||||
/// Bumped whenever the plan contract changes shape. Both prompt files carry a
|
||||
/// `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.
|
||||
#[derive(Debug, Clone)]
|
||||
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.
|
||||
pub plan_cc: String,
|
||||
/// The whole system prompt for OpenCode implementation agents.
|
||||
@@ -47,8 +49,14 @@ pub struct PromptSet {
|
||||
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> {
|
||||
pub fn load(
|
||||
discover_cc: &str,
|
||||
plan_cc: &str,
|
||||
implement_oc: &str,
|
||||
implement_cc: &str,
|
||||
) -> Result<Self, Error> {
|
||||
for (name, body) in [
|
||||
("discover.cc.md", discover_cc),
|
||||
("plan.cc.md", plan_cc),
|
||||
("implement.oc.md", implement_oc),
|
||||
("implement.cc.md", implement_cc),
|
||||
@@ -65,6 +73,7 @@ impl PromptSet {
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
discover_cc: discover_cc.to_string(),
|
||||
plan_cc: plan_cc.to_string(),
|
||||
implement_oc: implement_oc.to_string(),
|
||||
implement_cc: implement_cc.to_string(),
|
||||
@@ -74,11 +83,50 @@ impl PromptSet {
|
||||
/// The prompts shipped with this build.
|
||||
pub fn builtin() -> Result<Self, Error> {
|
||||
Self::load(
|
||||
include_str!("../../../prompt/discover.cc.md"),
|
||||
include_str!("../../../prompt/plan.cc.md"),
|
||||
include_str!("../../../prompt/implement.oc.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.
|
||||
@@ -130,13 +178,65 @@ mod tests {
|
||||
"contract-version: {}\n\n# x\n",
|
||||
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]
|
||||
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());
|
||||
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]
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
//! Which agent handles which job.
|
||||
//!
|
||||
//! The rule, in one sentence: **Claude Code gets judgement, OpenCode gets
|
||||
//! specification.** Planning is always Claude Code because decomposition is the
|
||||
//! high-judgement, low-volume task. Implementation goes to OpenCode when a
|
||||
//! tireless plan already specified the work, and to Claude Code when it did not.
|
||||
//! specification.** Discovery and planning are always Claude Code, because
|
||||
//! deciding what to build and decomposing it are the high-judgement, low-volume
|
||||
//! 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.
|
||||
|
||||
@@ -36,6 +43,14 @@ pub fn route(job: &Job, labels: &[String], protocol: &LabelProtocol) -> RouteDec
|
||||
}
|
||||
|
||||
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.
|
||||
JobKind::Plan => RouteDecision {
|
||||
agent: AgentKind::ClaudeCode,
|
||||
@@ -90,6 +105,13 @@ mod tests {
|
||||
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]
|
||||
fn planned_implementation_goes_to_opencode() {
|
||||
let p = LabelProtocol::default();
|
||||
|
||||
@@ -6,16 +6,55 @@ use uuid::Uuid;
|
||||
use crate::forge::IssueRef;
|
||||
|
||||
/// 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)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
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.
|
||||
Plan,
|
||||
/// Implement the issue and open a pull request.
|
||||
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.
|
||||
///
|
||||
/// 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 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
|
||||
/// state — Postgres is (see `doc/plan/design.md` §4). Labels are a best-effort
|
||||
/// 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)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
#[ts(export)]
|
||||
pub struct LabelProtocol {
|
||||
/// Opt-in marker. Without it tireless never touches an issue, regardless of
|
||||
/// 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,
|
||||
/// 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`.
|
||||
pub mode_plan: String,
|
||||
/// Requests an implementation and a pull request. Default: `tireless/implement`.
|
||||
@@ -21,6 +32,11 @@ pub struct LabelProtocol {
|
||||
pub force_cc: String,
|
||||
/// Forces the OpenCode lane. Default: `tireless/agent:oc`.
|
||||
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`.
|
||||
pub state_claimed: String,
|
||||
/// Written by tireless when it needs a human. Default: `tireless/blocked`.
|
||||
@@ -33,13 +49,87 @@ impl Default for LabelProtocol {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
opt_in: "tireless".into(),
|
||||
mode_discover: "tireless/discover".into(),
|
||||
mode_plan: "tireless/plan".into(),
|
||||
mode_implement: "tireless/implement".into(),
|
||||
force_cc: "tireless/agent:cc".into(),
|
||||
force_oc: "tireless/agent:oc".into(),
|
||||
state_proposed: "tireless/proposed".into(),
|
||||
state_claimed: "tireless/claimed".into(),
|
||||
state_blocked: "tireless/blocked".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 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 plan::{Acceptance, ChildSpec, FileTouch, PlanSpec, PlannedChild};
|
||||
pub use repo::{PollSchedule, TrackedRepo};
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
|
||||
/**
|
||||
* 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
|
||||
* state — Postgres is (see `doc/plan/design.md` §4). Labels are a best-effort
|
||||
* 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 = {
|
||||
/**
|
||||
* Opt-in marker. Without it tireless never touches an issue, regardless of
|
||||
* 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,
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
@@ -30,6 +42,13 @@ force_cc: string,
|
||||
* Forces the OpenCode lane. Default: `tireless/agent:oc`.
|
||||
*/
|
||||
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`.
|
||||
*/
|
||||
|
||||
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
|
||||
|
||||
Three prompts, one contract. They are versioned together and must be edited
|
||||
together.
|
||||
Four prompts, one set. They are versioned together and must be edited together.
|
||||
|
||||
| File | Surface | Used for |
|
||||
| --- | --- | --- |
|
||||
| `discover.cc.md` | Claude Code `--append-system-prompt` | `Discover` 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.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.
|
||||
`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.
|
||||
|
||||
`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
|
||||
section `ChildSpec` requires — so adding a field without updating the prompts
|
||||
fails the build rather than every future planning run.
|
||||
|
||||
Reference in New Issue
Block a user