diff --git a/asset/config/config.toml.tmpl b/asset/config/config.toml.tmpl index 95206fe..b51a7a1 100644 --- a/asset/config/config.toml.tmpl +++ b/asset/config/config.toml.tmpl @@ -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" diff --git a/crates/tireless-core/src/prompt.rs b/crates/tireless-core/src/prompt.rs index 762eecb..2aeedde 100644 --- a/crates/tireless-core/src/prompt.rs +++ b/crates/tireless-core/src/prompt.rs @@ -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 { + pub fn load( + discover_cc: &str, + plan_cc: &str, + implement_oc: &str, + implement_cc: &str, + ) -> Result { 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::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 { + let builtin = Self::builtin()?; + let read = |path: Option<&std::path::PathBuf>, fallback: &str| -> Result { + 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] diff --git a/crates/tireless-core/src/routing.rs b/crates/tireless-core/src/routing.rs index 4e1a0df..7b40aa3 100644 --- a/crates/tireless-core/src/routing.rs +++ b/crates/tireless-core/src/routing.rs @@ -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(); diff --git a/crates/tireless-entities/src/job.rs b/crates/tireless-entities/src/job.rs index 4c52d04..be3a07b 100644 --- a/crates/tireless-entities/src/job.rs +++ b/crates/tireless-entities/src/job.rs @@ -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, pub updated_at: DateTime, } + +#[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)); + } +} diff --git a/crates/tireless-entities/src/label.rs b/crates/tireless-entities/src/label.rs index 59fd386..140cac9 100644 --- a/crates/tireless-entities/src/label.rs +++ b/crates/tireless-entities/src/label.rs @@ -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 + ); + } + } +} diff --git a/crates/tireless-entities/src/lib.rs b/crates/tireless-entities/src/lib.rs index bedf171..5cd816e 100644 --- a/crates/tireless-entities/src/lib.rs +++ b/crates/tireless-entities/src/lib.rs @@ -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}; diff --git a/dashboard/src/api/generated/JobKind.ts b/dashboard/src/api/generated/JobKind.ts index e142d33..70eb6ee 100644 --- a/dashboard/src/api/generated/JobKind.ts +++ b/dashboard/src/api/generated/JobKind.ts @@ -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"; diff --git a/dashboard/src/api/generated/LabelProtocol.ts b/dashboard/src/api/generated/LabelProtocol.ts index c291b95..1f5d898 100644 --- a/dashboard/src/api/generated/LabelProtocol.ts +++ b/dashboard/src/api/generated/LabelProtocol.ts @@ -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`. */ diff --git a/prompt/discover.cc.md b/prompt/discover.cc.md new file mode 100644 index 0000000..8385d53 --- /dev/null +++ b/prompt/discover.cc.md @@ -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 +## + +**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. diff --git a/prompt/readme.md b/prompt/readme.md index 075bd76..869dac1 100644 --- a/prompt/readme.md +++ b/prompt/readme.md @@ -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.