From 7b8308d34e9fab551b5ca5813e0eec551e290fec Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Sun, 2 Aug 2026 15:26:05 +0300 Subject: [PATCH] feat(prompt): make the plan handoff a versioned, validated contract helexa#179's application-owned system prompts let tireless shape both ends of the cc->oc handoff, so the "will a 27B execute an Opus plan" risk becomes a tunable rather than a hope. Three prompts, versioned as one set: plan.cc.md tells Claude Code it is writing for a literal, absent reader; implement.oc.md tells OpenCode to execute exactly that and report rather than improvise; implement.cc.md covers unplanned issues. PromptSet::load refuses a mismatched set, and tests assert the prompts mention every section ChildSpec requires. The middle is validated, not trusted. plan::validate rejects a plan before any implementation job is enqueued unless every child carries a runnable acceptance command (a stopping condition) and a non-empty out-of-scope list (a boundary) -- the two sections a small model needs and a human reader does not. Dangling and cyclic dependencies are caught too, and implementation_order derives the start order. cc uses --append-system-prompt, never --system-prompt: replacing Claude Code's default discards the tool-use scaffolding that makes it a coding agent. Pin bumped to 2.1.220, the version this flag surface was verified against. The oc path depends on helexa#179's passthrough guarantee, which is still open and unverified for qwen3 arch templating. Stage 5 now opens with a PONG probe rather than debugging it through a failed implementation run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DHhHtohxcdk1PL3tfnYJdH --- CLAUDE.md | 18 ++ asset/config/config.toml.tmpl | 14 + crates/tireless-agent/src/claude.rs | 15 +- crates/tireless-core/src/lib.rs | 4 + crates/tireless-core/src/plan.rs | 294 ++++++++++++++++++++ crates/tireless-core/src/prompt.rs | 154 ++++++++++ crates/tireless-entities/src/lib.rs | 2 + crates/tireless-entities/src/plan.rs | 78 ++++++ dashboard/src/api/generated/Acceptance.ts | 3 + dashboard/src/api/generated/ChildSpec.ts | 44 +++ dashboard/src/api/generated/FileTouch.ts | 3 + dashboard/src/api/generated/PlanSpec.ts | 15 + dashboard/src/api/generated/PlannedChild.ts | 9 + doc/plan/design.md | 100 ++++++- prompt/implement.cc.md | 55 ++++ prompt/implement.oc.md | 78 ++++++ prompt/plan.cc.md | 93 +++++++ prompt/readme.md | 80 ++++++ script/infra-setup.sh | 2 +- 19 files changed, 1053 insertions(+), 8 deletions(-) create mode 100644 crates/tireless-core/src/plan.rs create mode 100644 crates/tireless-core/src/prompt.rs create mode 100644 crates/tireless-entities/src/plan.rs create mode 100644 dashboard/src/api/generated/Acceptance.ts create mode 100644 dashboard/src/api/generated/ChildSpec.ts create mode 100644 dashboard/src/api/generated/FileTouch.ts create mode 100644 dashboard/src/api/generated/PlanSpec.ts create mode 100644 dashboard/src/api/generated/PlannedChild.ts create mode 100644 prompt/implement.cc.md create mode 100644 prompt/implement.oc.md create mode 100644 prompt/plan.cc.md create mode 100644 prompt/readme.md diff --git a/CLAUDE.md b/CLAUDE.md index 714a722..63e4ee1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,24 @@ tests. If one seems redundant, read design.md §3 before touching it. best-effort mirror. Do not make a decision by reading a label that could be made by reading the database. +7. **Never use `--system-prompt` for Claude Code; append instead.** Replacing + Claude Code's default discards the tool-use scaffolding that makes it a + coding agent. `SYSTEM_PROMPT_FLAG` is `--append-system-prompt` for this + reason. + +8. **The three prompts in `prompt/` are one contract — edit them together.** + `plan.cc.md` emits the structure `implement.oc.md` consumes and + `tireless_core::plan::validate` enforces. Changing one alone breaks the + handoff silently, as a bad pull request rather than an error. Bump + `contract-version:` in all three plus `SYSTEM_PROMPT_CONTRACT_VERSION` + together; `PromptSet::load` refuses a mismatched set. + +9. **A plan is validated, not trusted.** Never enqueue implementation work from + a plan that has not passed `plan::validate`. The `Acceptance` (runnable + stopping condition) and `Out of scope` (boundary) requirements exist because + a small model needs them; do not relax them because a plan looks fine to a + human reader. + ## Quality gate Before considering a change complete: diff --git a/asset/config/config.toml.tmpl b/asset/config/config.toml.tmpl index 0414deb..548c977 100644 --- a/asset/config/config.toml.tmpl +++ b/asset/config/config.toml.tmpl @@ -51,6 +51,16 @@ state_claimed = "tireless/claimed" state_blocked = "tireless/blocked" state_done = "tireless/done" +[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. +# plan_cc = "/etc/tireless/prompt/plan.cc.md" +# implement_oc = "/etc/tireless/prompt/implement.oc.md" +# implement_cc = "/etc/tireless/prompt/implement.cc.md" + [work] # Per-repo bare mirrors and per-job clones live here. root = "/var/lib/tireless" @@ -80,6 +90,10 @@ failure_threshold = 3 provider = "lair-helexa" model = "Qwen/Qwen3.6-27B" base_url = "http://hanzalova.internal:31313/v1" +# The implementation system prompt reaches the model through +# OpenCode -> cortex -> neuron, relying on helexa's faithful-passthrough +# guarantee (helexa/helexa#179, still open). If a run behaves as though it never +# saw its prompt, suspect the passthrough before the prompt. max_concurrent = 2 max_runs_per_window = 240 window_hours = 5 diff --git a/crates/tireless-agent/src/claude.rs b/crates/tireless-agent/src/claude.rs index b4d29b6..146695d 100644 --- a/crates/tireless-agent/src/claude.rs +++ b/crates/tireless-agent/src/claude.rs @@ -9,7 +9,20 @@ use std::path::PathBuf; /// Pinned rather than `@latest`: an unattended driver should not pick up a new /// agent version between one job and the next. -pub const CLAUDE_PACKAGE: &str = "@anthropic-ai/claude-code@2.1.119"; +/// +/// This is the version whose flag surface tireless was verified against — +/// specifically `--append-system-prompt`, which the planning and implementation +/// prompts depend on. Bumping it means re-checking that surface, not just the +/// number. +pub const CLAUDE_PACKAGE: &str = "@anthropic-ai/claude-code@2.1.220"; + +/// How tireless applies its system prompt to Claude Code. +/// +/// **Append, never replace.** Claude Code also accepts `--system-prompt`, which +/// discards its default entirely — including the tool-use and repository +/// navigation scaffolding that makes it a coding agent. Replacing yields a less +/// capable agent, not a more obedient one. See `prompt/readme.md`. +pub const SYSTEM_PROMPT_FLAG: &str = "--append-system-prompt"; pub struct ClaudeCodeExecutor { /// Home directory of the service account. Claude Code keeps its credentials diff --git a/crates/tireless-core/src/lib.rs b/crates/tireless-core/src/lib.rs index b90c90d..2c0e465 100644 --- a/crates/tireless-core/src/lib.rs +++ b/crates/tireless-core/src/lib.rs @@ -5,8 +5,12 @@ //! `tireless-agent` (process orchestration). pub mod budget; +pub mod plan; pub mod port; +pub mod prompt; pub mod routing; pub use budget::{Governor, LaneBudget, LimitSignal, Verdict}; +pub use plan::{PlanDefect, implementation_order, validate}; +pub use prompt::{PromptSet, SYSTEM_PROMPT_CONTRACT_VERSION}; pub use routing::{RouteDecision, route}; diff --git a/crates/tireless-core/src/plan.rs b/crates/tireless-core/src/plan.rs new file mode 100644 index 0000000..621b9e7 --- /dev/null +++ b/crates/tireless-core/src/plan.rs @@ -0,0 +1,294 @@ +//! Parsing and validating tireless-authored plans. +//! +//! A planning run's output is not trusted because a strong model produced it. +//! It is parsed and checked against [`tireless_entities::ChildSpec`] before any +//! implementation job is enqueued. +//! +//! This is the cheapest possible place to catch a bad plan. A plan that fails +//! validation costs one comment on an issue and a `tireless/blocked` label, +//! within seconds of the planning run finishing. The same plan, unvalidated, +//! costs an OpenCode run against a repo, a branch, and an operator's review +//! attention before anyone notices the spec was unusable. + +use tireless_entities::{Acceptance, ChildSpec, FileTouch, PlanSpec}; + +/// Why a plan was rejected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlanDefect { + MissingSection { + child: String, + section: &'static str, + }, + /// The planner named no files at all. It has not thought about where the + /// change lands. + NoFiles { child: String }, + /// Acceptance is prose-only. An implementing model has no way to know when + /// it is finished, which is the single most common cause of a run that + /// keeps going and rewrites things it was not asked to. + NoRunnableAcceptance { child: String }, + /// No boundary was set, so nothing stops the implementer expanding scope. + NoBoundary { child: String }, + /// A dependency names a child that is not in the plan. + UnknownDependency { child: String, depends_on: String }, + /// Dependencies form a cycle, so no implementation order exists. + DependencyCycle { children: Vec }, +} + +impl std::fmt::Display for PlanDefect { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingSection { child, section } => { + write!(f, "child {child:?} is missing its `{section}` section") + } + Self::NoFiles { child } => { + write!(f, "child {child:?} names no files it expects to change") + } + Self::NoRunnableAcceptance { child } => write!( + f, + "child {child:?} has no runnable acceptance command, so an \ + implementer has no stopping condition" + ), + Self::NoBoundary { child } => write!( + f, + "child {child:?} declares nothing out of scope, so nothing bounds \ + the implementation" + ), + Self::UnknownDependency { child, depends_on } => write!( + f, + "child {child:?} depends on {depends_on:?}, which is not in the plan" + ), + Self::DependencyCycle { children } => { + write!(f, "dependency cycle among {children:?}") + } + } + } +} + +/// Check a plan is executable. Returns every defect, not just the first — a +/// planning re-run is expensive, so the feedback comment should be complete. +pub fn validate(plan: &PlanSpec) -> Vec { + let mut defects = Vec::new(); + + for child in &plan.children { + let name = child.title.clone(); + let spec = &child.spec; + + if spec.goal.trim().is_empty() { + defects.push(PlanDefect::MissingSection { + child: name.clone(), + section: "goal", + }); + } + if spec.steps.is_empty() { + defects.push(PlanDefect::MissingSection { + child: name.clone(), + section: "steps", + }); + } + if spec.files.is_empty() { + defects.push(PlanDefect::NoFiles { + child: name.clone(), + }); + } + if spec.acceptance.is_empty() { + defects.push(PlanDefect::MissingSection { + child: name.clone(), + section: "acceptance", + }); + } else if !spec.acceptance.iter().any(Acceptance::is_command) { + defects.push(PlanDefect::NoRunnableAcceptance { + child: name.clone(), + }); + } + if spec.out_of_scope.is_empty() { + defects.push(PlanDefect::NoBoundary { + child: name.clone(), + }); + } + + for dep in &child.depends_on { + if !plan.children.iter().any(|c| &c.title == dep) { + defects.push(PlanDefect::UnknownDependency { + child: name.clone(), + depends_on: dep.clone(), + }); + } + } + } + + if let Some(cycle) = find_cycle(plan) { + defects.push(PlanDefect::DependencyCycle { children: cycle }); + } + + defects +} + +/// Implementation order: children whose dependencies are all satisfied come +/// first. Returns `None` if the graph does not admit an order. +pub fn implementation_order(plan: &PlanSpec) -> Option> { + let mut remaining: Vec<&str> = plan.children.iter().map(|c| c.title.as_str()).collect(); + let mut ordered: Vec<&str> = Vec::new(); + + while !remaining.is_empty() { + // Take every child whose dependencies are already ordered. Taking all + // ready children per round (rather than one) keeps siblings that could + // run concurrently adjacent in the output. + let ready: Vec<&str> = remaining + .iter() + .copied() + .filter(|title| { + let child = plan + .children + .iter() + .find(|c| c.title == *title) + .expect("present"); + child + .depends_on + .iter() + .all(|dep| ordered.contains(&dep.as_str()) || !title_exists(plan, dep)) + }) + .collect(); + + if ready.is_empty() { + return None; // cycle + } + remaining.retain(|t| !ready.contains(t)); + ordered.extend(ready); + } + Some(ordered) +} + +fn title_exists(plan: &PlanSpec, title: &str) -> bool { + plan.children.iter().any(|c| c.title == title) +} + +fn find_cycle(plan: &PlanSpec) -> Option> { + if implementation_order(plan).is_some() { + return None; + } + // Everything that could not be ordered participates in, or depends on, a cycle. + Some(plan.children.iter().map(|c| c.title.clone()).collect()) +} + +/// A well-formed child, for tests and for the dry-run executor. +pub fn example_child() -> ChildSpec { + ChildSpec { + goal: "Add an ETag cache to the Gitea poller so repeat polls are conditional.".into(), + files: vec![FileTouch { + path: "crates/tireless-data/src/forge.rs".into(), + note: "store and send If-None-Match".into(), + }], + steps: vec![ + "Add `last_etag` to the repo row on read.".into(), + "Send `If-None-Match` when it is present.".into(), + "Treat 304 as an empty result.".into(), + ], + acceptance: vec![ + Acceptance::Command("cargo test -p tireless-data".into()), + Acceptance::Criterion("A second poll within the interval logs a 304.".into()), + ], + out_of_scope: vec!["Do not change the poll scheduling logic.".into()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tireless_entities::PlannedChild; + + fn child(title: &str, depends_on: &[&str]) -> PlannedChild { + PlannedChild { + title: title.into(), + spec: example_child(), + depends_on: depends_on.iter().map(|s| s.to_string()).collect(), + } + } + + fn plan(children: Vec) -> PlanSpec { + PlanSpec { + epic_title: "epic".into(), + epic_goal: "goal".into(), + children, + } + } + + #[test] + fn a_well_formed_plan_validates() { + assert!(validate(&plan(vec![child("a", &[])])).is_empty()); + } + + #[test] + fn prose_only_acceptance_is_rejected() { + let mut c = child("a", &[]); + c.spec.acceptance = vec![Acceptance::Criterion("it works".into())]; + let defects = validate(&plan(vec![c])); + assert!(matches!( + defects[0], + PlanDefect::NoRunnableAcceptance { .. } + )); + } + + #[test] + fn a_plan_without_a_boundary_is_rejected() { + let mut c = child("a", &[]); + c.spec.out_of_scope.clear(); + let defects = validate(&plan(vec![c])); + assert!(matches!(defects[0], PlanDefect::NoBoundary { .. })); + } + + #[test] + fn a_child_naming_no_files_is_rejected() { + let mut c = child("a", &[]); + c.spec.files.clear(); + let defects = validate(&plan(vec![c])); + assert!(matches!(defects[0], PlanDefect::NoFiles { .. })); + } + + #[test] + fn every_defect_is_reported_not_just_the_first() { + let mut c = child("a", &[]); + c.spec.files.clear(); + c.spec.out_of_scope.clear(); + c.spec.goal = " ".into(); + let defects = validate(&plan(vec![c])); + assert_eq!(defects.len(), 3, "got {defects:?}"); + } + + #[test] + fn a_dependency_on_a_missing_child_is_rejected() { + let defects = validate(&plan(vec![child("a", &["nonexistent"])])); + assert!( + defects + .iter() + .any(|d| matches!(d, PlanDefect::UnknownDependency { .. })) + ); + } + + #[test] + fn dependencies_determine_implementation_order() { + let p = plan(vec![ + child("c", &["b"]), + child("a", &[]), + child("b", &["a"]), + ]); + assert_eq!(implementation_order(&p), Some(vec!["a", "b", "c"])); + } + + #[test] + fn independent_children_all_come_out() { + let p = plan(vec![child("a", &[]), child("b", &[])]); + let order = implementation_order(&p).expect("orderable"); + assert_eq!(order.len(), 2); + } + + #[test] + fn a_cycle_has_no_order_and_is_reported() { + let p = plan(vec![child("a", &["b"]), child("b", &["a"])]); + assert_eq!(implementation_order(&p), None); + assert!( + validate(&p) + .iter() + .any(|d| matches!(d, PlanDefect::DependencyCycle { .. })) + ); + } +} diff --git a/crates/tireless-core/src/prompt.rs b/crates/tireless-core/src/prompt.rs new file mode 100644 index 0000000..762eecb --- /dev/null +++ b/crates/tireless-core/src/prompt.rs @@ -0,0 +1,154 @@ +//! System prompts, as a versioned pair. +//! +//! tireless tailors both agents by system prompt, but the two surfaces differ +//! in what they will let you do, and the difference is not cosmetic: +//! +//! - **Claude Code** — `--append-system-prompt`. tireless *appends*; it does not +//! replace. Claude Code's default system prompt carries the tool-use and +//! repository-navigation scaffolding that makes it a coding agent, and +//! `--system-prompt` discards all of it. Replacing gets you a less capable +//! agent, not a more obedient one. +//! +//! - **OpenCode** — `AgentConfig.prompt`. tireless owns the whole system prompt, +//! which travels through OpenCode to helexa cortex and on to a neuron. That +//! path depends on helexa's faithful-passthrough guarantee +//! (helexa/helexa#179): no injection, no rewriting, no defaults. +//! +//! ## The pair is one contract +//! +//! The planning prompt tells Claude Code what a plan must contain. The +//! implementation prompt tells OpenCode to execute exactly that and nothing +//! more. Both describe the same structure — [`tireless_entities::ChildSpec`] — +//! from opposite ends. Editing one without the other silently breaks the +//! handoff: the planner emits a shape the implementer is not expecting, and the +//! failure shows up as a puzzlingly bad PR rather than as an error. +//! +//! [`SYSTEM_PROMPT_CONTRACT_VERSION`] exists to make that breakage loud. Both +//! prompt files declare it; [`PromptSet::load`] refuses a mismatched pair. + +use tireless_entities::Error; + +/// Bumped whenever the plan contract changes shape. Both prompt files carry a +/// `contract-version:` line that must match. +pub const SYSTEM_PROMPT_CONTRACT_VERSION: u32 = 1; + +/// The paired prompts, loaded and checked together. +#[derive(Debug, Clone)] +pub struct PromptSet { + /// Appended to Claude Code's system prompt for `Plan` jobs. + pub plan_cc: String, + /// The whole system prompt for OpenCode implementation agents. + pub implement_oc: String, + /// Appended to Claude Code's system prompt for `Implement` jobs on + /// unplanned, human-written issues. + pub implement_cc: String, +} + +impl PromptSet { + /// Load a prompt set, verifying every member declares the same contract + /// version as this build. + pub fn load(plan_cc: &str, implement_oc: &str, implement_cc: &str) -> Result { + for (name, body) in [ + ("plan.cc.md", plan_cc), + ("implement.oc.md", implement_oc), + ("implement.cc.md", implement_cc), + ] { + let declared = parse_contract_version(body).ok_or_else(|| { + Error::Config(format!("prompt {name} declares no `contract-version:`")) + })?; + if declared != SYSTEM_PROMPT_CONTRACT_VERSION { + return Err(Error::Config(format!( + "prompt {name} declares contract-version {declared}, but this build \ + expects {SYSTEM_PROMPT_CONTRACT_VERSION}; the planning and \ + implementation prompts must be updated together" + ))); + } + } + Ok(Self { + plan_cc: plan_cc.to_string(), + implement_oc: implement_oc.to_string(), + implement_cc: implement_cc.to_string(), + }) + } + + /// The prompts shipped with this build. + pub fn builtin() -> Result { + Self::load( + include_str!("../../../prompt/plan.cc.md"), + include_str!("../../../prompt/implement.oc.md"), + include_str!("../../../prompt/implement.cc.md"), + ) + } +} + +/// Read `contract-version: N` from a prompt file's leading metadata block. +fn parse_contract_version(body: &str) -> Option { + body.lines() + .take_while(|l| !l.starts_with("# ")) + .find_map(|l| l.trim().strip_prefix("contract-version:")) + .and_then(|v| v.trim().parse().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_shipped_prompts_are_a_consistent_set() { + PromptSet::builtin().expect("shipped prompts must load"); + } + + #[test] + fn the_planning_prompt_names_every_required_section() { + // If a section is added to ChildSpec but not to the planning prompt, the + // planner will not emit it and every plan will fail validation. Catch + // that here rather than on a live run. + let set = PromptSet::builtin().expect("load"); + for section in ["Goal", "Files", "Steps", "Acceptance", "Out of scope"] { + assert!( + set.plan_cc.contains(section), + "planning prompt does not mention required section {section:?}" + ); + } + } + + #[test] + fn the_implementation_prompt_names_the_same_sections() { + let set = PromptSet::builtin().expect("load"); + for section in ["Acceptance", "Out of scope"] { + assert!( + set.implement_oc.contains(section), + "implementation prompt does not mention {section:?}" + ); + } + } + + #[test] + fn a_mismatched_pair_is_refused() { + let good = format!("contract-version: {SYSTEM_PROMPT_CONTRACT_VERSION}\n\n# x\n"); + let stale = format!( + "contract-version: {}\n\n# x\n", + SYSTEM_PROMPT_CONTRACT_VERSION + 1 + ); + assert!(PromptSet::load(&good, &stale, &good).is_err()); + } + + #[test] + fn an_undeclared_version_is_refused() { + let good = format!("contract-version: {SYSTEM_PROMPT_CONTRACT_VERSION}\n\n# x\n"); + assert!(PromptSet::load(&good, "# no metadata\n", &good).is_err()); + } + + #[test] + fn the_version_is_read_only_from_leading_metadata() { + // A `contract-version:` mentioned in prose below the title must not be + // mistaken for the declaration. + let body = format!( + "contract-version: {SYSTEM_PROMPT_CONTRACT_VERSION}\n\n# Title\n\ncontract-version: 99\n" + ); + assert_eq!( + parse_contract_version(&body), + Some(SYSTEM_PROMPT_CONTRACT_VERSION) + ); + } +} diff --git a/crates/tireless-entities/src/lib.rs b/crates/tireless-entities/src/lib.rs index fe71cbf..bedf171 100644 --- a/crates/tireless-entities/src/lib.rs +++ b/crates/tireless-entities/src/lib.rs @@ -8,6 +8,7 @@ pub mod error; pub mod forge; pub mod job; pub mod label; +pub mod plan; pub mod repo; pub mod run; @@ -15,5 +16,6 @@ pub use error::Error; pub use forge::{Forge, IssueRef, PullRequestRef}; pub use job::{Job, JobKind, JobState}; pub use label::LabelProtocol; +pub use plan::{Acceptance, ChildSpec, FileTouch, PlanSpec, PlannedChild}; pub use repo::{PollSchedule, TrackedRepo}; pub use run::{AgentKind, AgentRun, BillingMode, RunOutcome}; diff --git a/crates/tireless-entities/src/plan.rs b/crates/tireless-entities/src/plan.rs new file mode 100644 index 0000000..9a4f53b --- /dev/null +++ b/crates/tireless-entities/src/plan.rs @@ -0,0 +1,78 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// The required structure of a tireless-authored child issue. +/// +/// This type is one half of a contract. The Claude Code planning prompt is told +/// to emit exactly these sections; the OpenCode implementation prompt is told to +/// work from exactly these sections and nothing else. Neither prompt may be +/// changed without the other — see `prompt/readme.md`. +/// +/// The contract exists because a 27B model executing a plan needs two things a +/// human reader does not: an explicit **stopping condition** (`acceptance`) and +/// an explicit **boundary** (`out_of_scope`). Without the first it does not know +/// when it is finished; without the second it improvises past the task. Both are +/// therefore mandatory, and a plan lacking either is rejected before any +/// implementation job is enqueued. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ChildSpec { + /// What changes and why. One paragraph. + pub goal: String, + /// Files expected to change, each with a note on what changes there. + /// Advisory rather than binding — but a plan that cannot name any file it + /// expects to touch has not been thought through. + pub files: Vec, + /// Ordered implementation steps. + pub steps: Vec, + /// How to know the work is done. At least one must be a runnable command; + /// prose-only acceptance gives an implementing model no stopping condition. + pub acceptance: Vec, + /// What the implementer must *not* do. The boundary that keeps a capable + /// model from redesigning adjacent code it was not asked to touch. + pub out_of_scope: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct FileTouch { + pub path: String, + pub note: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "snake_case", tag = "kind", content = "value")] +pub enum Acceptance { + /// A command that must exit zero. The stopping condition. + Command(String), + /// A prose criterion, for things no command can check. + Criterion(String), +} + +impl Acceptance { + pub fn is_command(&self) -> bool { + matches!(self, Self::Command(_)) + } +} + +/// A full plan: the epic framing plus its children. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct PlanSpec { + /// Title for the epic issue. + pub epic_title: String, + /// Framing for the epic body: what the whole change achieves. + pub epic_goal: String, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct PlannedChild { + pub title: String, + pub spec: ChildSpec, + /// Titles of siblings that must land first. Used to order implementation + /// jobs so a child is never started before its dependency is merged. + pub depends_on: Vec, +} diff --git a/dashboard/src/api/generated/Acceptance.ts b/dashboard/src/api/generated/Acceptance.ts new file mode 100644 index 0000000..0ea9ba7 --- /dev/null +++ b/dashboard/src/api/generated/Acceptance.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Acceptance = { "kind": "command", "value": string } | { "kind": "criterion", "value": string }; diff --git a/dashboard/src/api/generated/ChildSpec.ts b/dashboard/src/api/generated/ChildSpec.ts new file mode 100644 index 0000000..cb997f3 --- /dev/null +++ b/dashboard/src/api/generated/ChildSpec.ts @@ -0,0 +1,44 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Acceptance } from "./Acceptance"; +import type { FileTouch } from "./FileTouch"; + +/** + * The required structure of a tireless-authored child issue. + * + * This type is one half of a contract. The Claude Code planning prompt is told + * to emit exactly these sections; the OpenCode implementation prompt is told to + * work from exactly these sections and nothing else. Neither prompt may be + * changed without the other — see `prompt/readme.md`. + * + * The contract exists because a 27B model executing a plan needs two things a + * human reader does not: an explicit **stopping condition** (`acceptance`) and + * an explicit **boundary** (`out_of_scope`). Without the first it does not know + * when it is finished; without the second it improvises past the task. Both are + * therefore mandatory, and a plan lacking either is rejected before any + * implementation job is enqueued. + */ +export type ChildSpec = { +/** + * What changes and why. One paragraph. + */ +goal: string, +/** + * Files expected to change, each with a note on what changes there. + * Advisory rather than binding — but a plan that cannot name any file it + * expects to touch has not been thought through. + */ +files: Array, +/** + * Ordered implementation steps. + */ +steps: Array, +/** + * How to know the work is done. At least one must be a runnable command; + * prose-only acceptance gives an implementing model no stopping condition. + */ +acceptance: Array, +/** + * What the implementer must *not* do. The boundary that keeps a capable + * model from redesigning adjacent code it was not asked to touch. + */ +out_of_scope: Array, }; diff --git a/dashboard/src/api/generated/FileTouch.ts b/dashboard/src/api/generated/FileTouch.ts new file mode 100644 index 0000000..798a243 --- /dev/null +++ b/dashboard/src/api/generated/FileTouch.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type FileTouch = { path: string, note: string, }; diff --git a/dashboard/src/api/generated/PlanSpec.ts b/dashboard/src/api/generated/PlanSpec.ts new file mode 100644 index 0000000..8e989e4 --- /dev/null +++ b/dashboard/src/api/generated/PlanSpec.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PlannedChild } from "./PlannedChild"; + +/** + * A full plan: the epic framing plus its children. + */ +export type PlanSpec = { +/** + * Title for the epic issue. + */ +epic_title: string, +/** + * Framing for the epic body: what the whole change achieves. + */ +epic_goal: string, children: Array, }; diff --git a/dashboard/src/api/generated/PlannedChild.ts b/dashboard/src/api/generated/PlannedChild.ts new file mode 100644 index 0000000..12646ef --- /dev/null +++ b/dashboard/src/api/generated/PlannedChild.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChildSpec } from "./ChildSpec"; + +export type PlannedChild = { title: string, spec: ChildSpec, +/** + * Titles of siblings that must land first. Used to order implementation + * jobs so a child is never started before its dependency is merged. + */ +depends_on: Array, }; diff --git a/doc/plan/design.md b/doc/plan/design.md index b653bcf..765a597 100644 --- a/doc/plan/design.md +++ b/doc/plan/design.md @@ -95,6 +95,65 @@ This also produces a pleasing economic shape. The subscription is spent on the scarce thing (planning, and interpreting under-specified work), while the bulk of mechanical implementation runs on hardware already sitting in the office. +### 2.4 The plan contract + +The handoff between the lanes is the load-bearing interface in this design: an +Opus-authored plan must be specific enough for a 27B model to execute alone. That +is not left to chance in either direction. + +**Both ends are shaped by system prompts.** `prompt/plan.cc.md` tells Claude Code +it is writing for a literal, competent, absent reader that cannot ask questions +and will fill any gap with an invention. `prompt/implement.oc.md` tells OpenCode +to execute the specification faithfully, stop when acceptance passes, and report +rather than improvise. They are two halves of one contract and are versioned +together — see `prompt/readme.md`. + +The surfaces are not symmetric, and the difference matters: + +| Lane | Mechanism | Ownership | +| --- | --- | --- | +| Claude Code | `--append-system-prompt` | Anthropic owns the base prompt; tireless appends | +| OpenCode | `AgentConfig.prompt` | tireless owns the whole prompt | + +Claude Code also offers `--system-prompt`, which replaces its default outright. +tireless does not use it: the default carries the tool-use and repository +navigation scaffolding that makes Claude Code a coding agent, and discarding it +yields a less capable agent rather than a more obedient one. + +**The middle is validated, not trusted.** A plan is parsed into +`tireless_entities::ChildSpec` and checked by `tireless_core::plan::validate` +before any implementation job is enqueued. Every child must carry five sections, +two of which exist specifically because a small model needs them and a human +reader does not: + +- **Acceptance** must include at least one runnable command. Without a stopping + condition a literal implementer does not know when it is finished, and keeps + going — usually by rewriting adjacent code it was not asked to touch. +- **Out of scope** must be non-empty. Without a declared boundary nothing stops a + capable model expanding the work. + +Plans are also checked for dangling and cyclic dependencies, and +`implementation_order` derives the order in which children may be started. + +This makes the risk cheap to discover. A plan that fails validation costs one +comment and a `tireless/blocked` label, seconds after the planning run. The same +plan unvalidated costs an OpenCode run, a branch, and an operator's review +attention before anyone notices the spec was unusable. + +**Dependency: helexa faithful passthrough.** The OpenCode system prompt reaches +the model via OpenCode → cortex → neuron, which relies on helexa's guarantee of +no injection, no rewriting, no defaults +([helexa/helexa#179](https://git.lair.cafe/helexa/helexa/issues/179)). + +That issue is **open** — the principle was decided 2026-07-17, but verification +and documentation are outstanding, and two unchecked items bear directly here: +neuron chat templating applying the system role correctly per arch family +*including `/no_think` interaction* (Qwen3.6-27B is the target model), and +behaviour with multiple system messages. Stage 5 is effectively the second +consumer of that guarantee after the chat SPA, and is well placed to surface +exactly those bugs. Debugging order follows from this: if an OpenCode run behaves +as though it never saw its prompt, suspect the passthrough before the prompt. + --- ## 3. Constraints @@ -382,12 +441,19 @@ Spawn the pinned CLI, read `stream-json`, capture session id for `--resume`, record `apiKeySource` as billing mode, consume `rate_limit_event` into the governor, enforce budgets and the circuit breaker. +Apply `prompt/plan.cc.md` via `--append-system-prompt`; parse the result into +`PlanSpec` and gate it through `plan::validate` before creating any issue. + First real capability: `tireless/plan` on a real issue produces an epic and child issues. -*Done when:* a planning run completes against a real issue, the children are -sensible, the journal shows the billing mode, and an artificially lowered window -budget demonstrably holds the lane. +*Done when:* a planning run completes against a real issue, the plan passes +validation, the children are sensible **when read as an implementer would read +them** — cold, with no other context — the journal shows the billing mode, and an +artificially lowered window budget demonstrably holds the lane. + +This is also where plan quality is judged, while the only cost of a bad plan is a +comment thread. Iterate on `prompt/plan.cc.md` here, not in stage 5. *Why planning first:* the output is issues, not code. A bad plan is a comment thread; a bad implementation is a branch. Start where mistakes are cheapest. @@ -404,11 +470,19 @@ than duplicates. ### Stage 5 — OpenCode executor Spawn `opencode serve` on loopback with a per-spawn password, drive it over HTTP, -target helexa cortex. Enforce `assert_not_anthropic` from config. Route -plan-descended implementation jobs here. +target helexa cortex. Enforce `assert_not_anthropic` from config. Register a +custom OpenCode agent carrying `prompt/implement.oc.md` as its system prompt. +Route plan-descended implementation jobs here. + +**Start with a passthrough probe.** Before wiring anything real, confirm a system +prompt reaches Qwen3.6-27B intact through OpenCode → cortex → neuron — the +"reply only with the word PONG" test named in helexa#179. That guarantee is not +yet verified (§2.4), and discovering it fails here is far cheaper than debugging +it through a failed implementation run. *Done when:* a child issue created by a stage-3 planning run is implemented -end-to-end by OpenCode on the GPU fleet, with zero subscription usage. +end-to-end by OpenCode on the GPU fleet, with zero subscription usage, and the +run demonstrably respected its `Out of scope` section. ### Stage 6 — Scheduling and dashboard control @@ -475,6 +549,20 @@ models implement — rests on tireless-authored plans being specific enough for fails, the fallback is routing more implementation to Claude Code, which costs subscription budget but not a redesign. +The plan contract (§2.4) narrows this considerably: paired system prompts shape +both ends, and structural validation rejects a plan lacking a runnable stopping +condition or a declared boundary before any implementation job is enqueued. What +remains genuinely unknown is *semantic* quality — whether a plan that satisfies +the schema is also correct and specific enough in substance. No validator +catches a well-formed plan that is simply wrong about the codebase, and there is +no unit test for whether a prompt produces good plans. That is measured on real +issues in stage 3, before stage 5 spends anything on acting on them. + +**The OpenCode prompt path is unverified end to end.** It depends on helexa#179, +which is open (§2.4). Worth confirming with a trivial probe — a system prompt +that measurably changes output — at the start of stage 5 rather than debugging it +through a failed implementation run. + **Not yet decided:** whether a failed implementation should automatically open a `tireless/blocked` issue describing what it could not do, or simply comment on the original. Deferred to stage 4, when there is real failure data to look at. diff --git a/prompt/implement.cc.md b/prompt/implement.cc.md new file mode 100644 index 0000000..1023aef --- /dev/null +++ b/prompt/implement.cc.md @@ -0,0 +1,55 @@ +contract-version: 1 +surface: claude-code --append-system-prompt +job-kind: Implement (unplanned) + +# Implementing an unplanned issue + +You are implementing an issue written by a human, which has **not** been through +tireless planning. There is no spec: no file list, no acceptance commands, no +declared boundary. Interpreting the request is part of your job, which is why +this work was routed to you rather than to the local implementation model. + +Work as a careful colleague would. + +## Rules + +**Establish the boundary yourself, then hold it.** The issue almost certainly +does not say what is out of scope. Decide what the smallest complete change is, +state that decision in the pull request description, and stay inside it. +Adjacent problems you notice get *mentioned*, not fixed. + +**Find your own stopping condition.** No acceptance command was given, so +identify one: the test that ought to pass, the command that ought to succeed. +Run it. If the repository has no way to verify the change, say so explicitly in +the PR rather than implying verification you did not do. + +**Interpret ambiguity the way a careful colleague would.** Make routine judgement +calls and record them. Where different readings would produce materially +different work, implement the most defensible one and flag the alternative in the +pull request — do not silently pick and move on, and do not stall waiting for an +answer that cannot arrive. + +**If the issue is too vague to implement, say so and stop.** A one-line issue with +no discernible acceptance criteria is a planning problem, not an implementation +problem. Report what is missing and suggest the issue be relabelled +`tireless/plan`. That is a correct outcome, not a failure. + +**Match the surrounding code.** Naming, error handling, comment density, test +placement. A repository `CLAUDE.md`, `AGENTS.md`, or contributing guide outranks +your defaults. + +**Never fake a green result.** Do not weaken assertions, skip tests, or +special-case values to make something pass. Report failures with their output. + +## Your output + +A branch and a pull request describing: + +1. what you implemented, and the scope you decided on; +2. how you verified it, with the commands you actually ran; +3. judgement calls you made where another reading was defensible; +4. anything you noticed but deliberately left alone. + +Be honest about what is unfinished or unverified. This work goes to a human +reviewer who is deciding whether to merge it, and an accurate account of what you +did not do is worth more than a confident summary that overstates. diff --git a/prompt/implement.oc.md b/prompt/implement.oc.md new file mode 100644 index 0000000..4ee7b7f --- /dev/null +++ b/prompt/implement.oc.md @@ -0,0 +1,78 @@ +contract-version: 1 +surface: opencode AgentConfig.prompt +job-kind: Implement (plan-descended) + +# Implementing a specified change + +You are implementing one child issue from a plan that has already been written, +reviewed for structure, and approved. Your job is to execute that specification +faithfully and open a pull request. It is not to improve the plan. + +The issue you have been given contains five sections. They are the whole of your +brief: + +- **Goal** — what changes and why. +- **Files** — where the change goes. +- **Steps** — what to do, in order. +- **Acceptance** — how you know you are finished. +- **Out of scope** — what you must not touch. + +## Rules + +**Do not deviate from the plan.** If a step seems wrong, inefficient, or +incomplete, that is a signal to stop and report — not to improvise a better +approach. The plan was written with context you do not have. A change that +improves on the plan is still a change nobody asked for, and it will be rejected +in review even if it is an improvement. + +**Stop when Acceptance passes.** Run the acceptance commands. When they exit +zero and the criteria are met, you are done. Do not continue looking for things +to improve. Do not tidy adjacent code. Do not fix unrelated warnings you noticed +along the way. Finishing early is correct behaviour, not a failure of diligence. + +**Respect Out of scope absolutely.** The listed items are off limits even if +touching them would make your change cleaner, even if they contain an obvious +bug, and even if a step seems to require it. If a step genuinely cannot be +completed without touching something out of scope, that is a contradiction in the +plan: stop and report it. + +**Stay inside the named files where you can.** The Files section is where the +change is expected to land. Touching a file it does not name is sometimes +unavoidable — a new import, a call site that must be updated for the code to +compile. That is fine. Rewriting a file it does not name is not. + +**Match the surrounding code.** Follow the conventions of the files you are +editing: naming, error handling, comment density, test placement. Your change +should be hard to pick out of a diff by style alone. When the repository has a +`CLAUDE.md`, `AGENTS.md`, or contributing guide, it outranks your defaults. + +**Write the tests the plan asks for, and no more.** If Acceptance names a test +command, make it pass honestly. Never weaken an assertion, skip a test, or +special-case a value to get a green result. A test that passes because you +narrowed it is worse than a failing test, because it hides the failure. + +## When you cannot proceed + +Stop and report. Do not guess, and do not deliver a partial change quietly. +Report rather than continue when: + +- a step is ambiguous and the readings lead to materially different code; +- the plan contradicts what you find in the repository; +- completing a step would require going out of scope; +- an acceptance command fails for a reason the plan does not cover; +- a file the plan names does not exist, or does not contain what was described. + +Say plainly what you found, which step you stopped at, and what you would need in +order to continue. A precise report is a useful outcome. A confident pull request +built on a guess is not — it costs a reviewer more than the work was worth. + +## Your output + +A branch containing the change, and a pull request describing: + +1. what you did, referenced against the plan's steps; +2. the acceptance commands you ran, and their results; +3. anything you noticed but deliberately left alone, because it was out of scope. + +That third item matters. It is how observations reach the operator without you +acting on them unilaterally. diff --git a/prompt/plan.cc.md b/prompt/plan.cc.md new file mode 100644 index 0000000..a30e59f --- /dev/null +++ b/prompt/plan.cc.md @@ -0,0 +1,93 @@ +contract-version: 1 +surface: claude-code --append-system-prompt +job-kind: Plan + +# Planning for downstream execution + +You are decomposing an issue into an epic and child issues. Your plans are not +read by a person and then implemented by that person. Each child issue is handed, +on its own and without you present, to a **27-billion-parameter local model** that +will attempt to implement it end to end and open a pull request. + +That model is competent but literal. It has no access to your reasoning, cannot +ask you a follow-up question, and will not notice an omission — it will fill the +gap with an invention. Everything it needs must be in the child issue. + +Write for that reader. This is the single constraint that should shape every +decision you make here. + +## What this means in practice + +**Decompose until each child is unambiguous, then stop.** A child that requires +judgement about *what* to build has been under-decomposed; that judgement is your +job, not the implementer's. A child so small it has no coherent acceptance test +has been over-decomposed and adds review overhead without reducing risk. Aim for +children that touch a handful of files and land as one reviewable commit. + +**Name the files.** Before writing a child, look at the repository and find where +the change actually goes. "Update the poller" is not a plan; "add `last_etag` +handling to `crates/tireless-data/src/forge.rs`" is. If you cannot name a file, +you have not investigated enough to plan the work. + +**Give every child a runnable stopping condition.** The implementer needs to know +when it is finished. A command that exits zero is the only reliable form of this. +"Ensure it works correctly" gives a literal model nothing, and it will keep going +— usually by rewriting adjacent code it was not asked to touch. + +**Bound every child explicitly.** State what must *not* change. This is the +section that most reliably prevents scope creep, and it is the one you will be +most tempted to leave empty because the boundary feels obvious to you. It is not +obvious to the implementer. + +**Order the work.** If child B needs child A merged first, say so by title. Do +not rely on the reader inferring an order from the numbering. + +**Prefer describing the change to prescribing the code.** Say what must become +true and how to verify it. Do not paste implementations — a literal implementer +will transcribe your sketch verbatim, including its mistakes, instead of writing +code that fits the surrounding file. + +## Required structure + +Emit an epic, then one section per child. Every child must carry **all five** +headings below. A child missing any of them is rejected automatically and the +whole plan is returned to you, so check before you finish. + +```markdown +## Goal +One paragraph: what changes, and why. Enough context that the implementer +understands the intent, not only the mechanics. + +## Files +- `path/to/file.ext` — what changes here +- `path/to/other.ext` — what changes here + +## Steps +1. Ordered, concrete actions. +2. Each one a thing the implementer can actually do. + +## Acceptance +- `command that must exit zero` <- at least one of these is REQUIRED +- Prose criterion for anything no command can check. + +## Out of scope +- What must not be touched, and briefly why. +``` + +If a child depends on another, add `Depends on: ` beneath +its heading. + +## Before you finish + +Re-read each child as if you were the implementing model: no memory of this +conversation, no ability to ask, no view of the other children. Ask of each one: + +- Could I start work from this alone? +- Do I know which files to open? +- Do I know when to stop? +- Do I know what I must leave alone? + +If the answer to any of these is no, fix the child rather than trusting that it +will work out. A vague child does not fail loudly — it produces a confident, +plausible, wrong pull request, which costs more to review than it would have cost +you to specify properly. diff --git a/prompt/readme.md b/prompt/readme.md new file mode 100644 index 0000000..90a2762 --- /dev/null +++ b/prompt/readme.md @@ -0,0 +1,80 @@ +# System prompts + +Three prompts, one contract. They are versioned together and must be edited +together. + +| File | Surface | Used for | +| --- | --- | --- | +| `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 | + +## The contract + +`plan.cc.md` tells Claude Code what a child issue must contain. +`implement.oc.md` tells OpenCode to execute exactly that and nothing more. Both +describe the same structure — `tireless_entities::ChildSpec` — from opposite +ends: + +``` +plan.cc.md ──emits──▶ ChildSpec ──validated by──▶ tireless_core::plan::validate + │ + └──consumed by──▶ implement.oc.md +``` + +Change the shape and all three must move together, or the planner emits something +the implementer is not expecting and the failure surfaces as a puzzlingly bad pull +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 +when the structure changes. + +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. + +## Append, don't replace (Claude Code) + +Both Claude Code prompts are **appended** via `--append-system-prompt`. Claude +Code also offers `--system-prompt`, which replaces its default entirely — do not +use it. The default carries the tool-use and repository-navigation scaffolding +that makes Claude Code a coding agent at all. Replacing it produces a less +capable agent, not a more obedient one. + +## Faithful passthrough (OpenCode) + +`implement.oc.md` is the whole system prompt for the OpenCode agent. It travels +OpenCode → helexa cortex → neuron, and depends on helexa's passthrough guarantee: +no injection, no rewriting, no defaults +([helexa/helexa#179](https://git.lair.cafe/helexa/helexa/issues/179)). + +That issue is **open**. The principle was decided 2026-07-17; verification and +documentation are outstanding. Two of its unchecked items bear directly on this +prompt: + +- neuron chat templating applying the system role correctly per arch family, + *including interaction with `/no_think` handling* — and Qwen3.6-27B is the + model this prompt targets; +- behaviour when multiple system messages are present. + +tireless stage 5 is effectively the second consumer of that guarantee after the +chat SPA, and a good way to surface exactly those bugs. If an OpenCode run +behaves as though it never saw this prompt, suspect the passthrough before +suspecting the prompt. + +## Editing guidance + +These prompts are behavioural specifications, and they are load-bearing in a way +ordinary documentation is not — a weakened instruction here becomes a bad pull +request three hours later, in a repository, unattended. + +- Prefer stating the constraint *and the reason for it*. Both models follow a + rule better when the rationale is present, and the rationale is what lets a + model resolve a case the rule did not anticipate. +- Keep the "stop and report" paths prominent. The most expensive failure mode is + not an agent that gives up; it is an agent that guesses confidently and + produces plausible, wrong work. +- Test changes against a real issue before merging. There is no unit test for + whether a prompt produces good plans. diff --git a/script/infra-setup.sh b/script/infra-setup.sh index c58122c..9eebd51 100755 --- a/script/infra-setup.sh +++ b/script/infra-setup.sh @@ -106,7 +106,7 @@ Remaining one-time steps (operator, on the target host): The OAuth flow is interactive and must be completed *as the service account*, because Claude Code reads credentials from $HOME: - sudo -u tireless -H /usr/bin/npx -y @anthropic-ai/claude-code@2.1.119 + sudo -u tireless -H /usr/bin/npx -y @anthropic-ai/claude-code@2.1.220 # then: /login, and complete the browser flow This writes /var/lib/tireless/.claude.json. The token refreshes in place,