Skip to main content

mecha_core/
charter.rs

1//! The charter: what mecha is for, in the owner's own words.
2//!
3//! `docs/GOAL-SYSTEM-DESIGN.md` §11 is the design. The short form: a small,
4//! ordered list of standing priorities, written once by a person and read by
5//! every run from then on. It exists so that a goal error can eventually be
6//! signed *against* something — "did this help what mecha is for" — rather
7//! than every evaluative signal in the system being a cost or a correction,
8//! which is the gap the whole goal-system arc is closing.
9//!
10//! ## The safety argument is the skills argument, verbatim
11//!
12//! No `mecha charter learn`, no registry, nothing derived from a session, and
13//! **no way for a model to author or edit one** — see [`crate::skill`]'s
14//! module doc for the incident this is copied from (Snyk found 36.8% of
15//! published Agent Skills carrying a security flaw; Datadog's sharper finding
16//! is that a cloned repository can bring one into a trusted session even
17//! without an install step). A model that could edit its own charter could
18//! edit its way around every other guardrail.
19//!
20//! **The invariant is about the author, not about the verb**, and this file
21//! used to state it as the latter — "there is deliberately no write path here
22//! at all". That was wrong by the time it was written: the TUI's `/charter`
23//! already handed the file to `$EDITOR`, and the web settings page already
24//! POSTed a validated save. What every one of those surfaces has in common is
25//! the thing that actually matters, and it is worth stating positively so the
26//! next surface copies the right rule:
27//!
28//! > **The owner may edit the charter from anywhere. Every `[[line]]` is
29//! > typed by a person, and no model — privileged, quarantined or otherwise —
30//! > ever composes, suggests or edits one.**
31//!
32//! So a surface may create the comments-only [`TEMPLATE`] and hand over an
33//! editor; it may validate and refuse; it may not put words in the file. This
34//! module itself only ever *reads*: the write is the owner's editor or a
35//! validated save at a surface, never a derivation in here, which is what
36//! keeps "a model authored this" impossible rather than merely discouraged.
37//!
38//! **Global only, and there is no config field to point it elsewhere.** A
39//! `mecha.toml` arrives with a cloned repository, and a repo that could hand
40//! your agent standing priorities is the `[[trigger]]` rule in a worse
41//! costume. Triggers and skills both keep this guarantee by never having a
42//! configurable path in `Config`/`ConfigLayer` at all rather than by relying
43//! on callers to choose the global loader, and this module follows the same
44//! shape: [`Charter::default_path`] is the only path there is.
45//!
46//! **Loading it arms no taint.** It is the user's own words, exactly like the
47//! system prompt — the same argument `crate::skill` makes, and for the same
48//! reason this module has no dependency on `crate::agent::Taint` at all: the
49//! absence is the enforcement, not a rule someone has to remember to apply.
50//!
51//! ## Ordered, not weighted
52//!
53//! §11 is explicit that priority is the file's line order and there is no
54//! priority field: value conflict — "protect the owner" against "don't let a
55//! colleague down" — is the measured cause of goal drift, and a weighted sum
56//! can always be outvoted by enough small goods (*"this is urgent for many
57//! people"*). A lexicographic order cannot be outvoted that way, so this
58//! module preserves the TOML array's order exactly rather than sorting it —
59//! unlike [`crate::skill::SkillStore`], which sorts because its block is a
60//! menu the model chooses from and filesystem order is not an order. A
61//! charter's order *is* the content.
62//!
63//! ## Rendered directly, never lazily loaded
64//!
65//! Unlike a skill, there is no progressive disclosure and no tool call: §11
66//! says this rides in the cached prefix "like `RULES_CHAR_BUDGET`" — i.e. it
67//! is rendered straight into the system prompt every run, the same way the
68//! learned-rules block is, because a handful of standing priorities is cheap
69//! enough to always carry and too important to make conditional on the model
70//! deciding to ask for it.
71
72use anyhow::{bail, Context, Result};
73use serde::Deserialize;
74use std::collections::BTreeSet;
75use std::path::{Path, PathBuf};
76
77/// The charter's whole rendered form is meant to fit this many characters —
78/// checked by [`Charter::over_budget`], never enforced by [`Charter::load`].
79///
80/// Moves with [`crate::learning::RULES_CHAR_BUDGET`] in spirit — this rides
81/// in every run's cached prefix exactly like the learned-rules block — but is
82/// smaller: a charter is a handful of standing priorities, not a per-domain
83/// accumulation, and a lexicographic order only stays legible while it stays
84/// short enough for a person to hold in mind at once. Argued, not measured:
85/// there is no corpus yet of how many lines a charter needs before it stops
86/// being read carefully.
87pub const CHARTER_CHAR_BUDGET: usize = 2000;
88
89/// One standing priority.
90///
91/// **Denies unknown fields**, unlike [`crate::skill::Skill`]'s frontmatter —
92/// that leniency is for portability across harnesses that might author a
93/// `SKILL.md`, and nothing else authors a `charter.toml`. A stray `priority`
94/// or `rank` key is exactly the field §11 says there deliberately is none of;
95/// silently dropping it would let an owner write one, believe it did
96/// something, and never find out it didn't.
97#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct CharterLine {
100    /// What a [`crate::goal::GoalRef::Charter`] names. Unique within a
101    /// charter — see [`Charter::load`].
102    pub id: String,
103    pub text: String,
104}
105
106/// The owner's charter, in file order.
107///
108/// **Order is rank.** See the module doc — there is no field for it because
109/// a second statement of priority disagrees with the first the moment either
110/// is edited, the same rule `TASK-AGENT-DESIGN.md` R1 gives task urgency.
111#[derive(Debug, Clone, Default, PartialEq, Eq)]
112pub struct Charter {
113    lines: Vec<CharterLine>,
114}
115
116/// The file's own shape: `[[line]]` tables, in the order they appear.
117///
118/// **Denies unknown top-level tables too** — `[[lines]]` (plural) or any
119/// other typo'd name would otherwise vanish silently rather than parse as an
120/// error, which is worse when it sits *beside* correctly-named lines: the
121/// charter would load non-empty, `over_budget` would be false, and the
122/// owner's ranking would be silently short whatever the typo'd entries were,
123/// with nothing anywhere saying so.
124#[derive(Debug, Default, Deserialize)]
125#[serde(deny_unknown_fields)]
126struct RawCharter {
127    #[serde(default, rename = "line")]
128    line: Vec<CharterLine>,
129}
130
131impl Charter {
132    /// `~/.mecha/charter.toml` — the only path there is. See the module doc:
133    /// this is deliberately not a `Config` field, because a configurable path
134    /// is a path a project layer could set.
135    pub fn default_path() -> Result<PathBuf> {
136        Ok(crate::work::mecha_home()?.join("charter.toml"))
137    }
138
139    /// Read and validate `path`. A missing file is an **empty** charter, not
140    /// an error — a machine nobody has written one for yet must still start,
141    /// on [`crate::skill::SkillStore::load`]'s rule for a missing directory.
142    pub fn load(path: &Path) -> Result<Charter> {
143        let text = match std::fs::read_to_string(path) {
144            Ok(text) => text,
145            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Charter::default()),
146            Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
147        };
148        Charter::parse(&text).with_context(|| format!("parsing {}", path.display()))
149    }
150
151    /// Parse and validate charter TOML that has not been written anywhere
152    /// yet. The seam a surface that *accepts* an edit needs — the web
153    /// settings page validates a proposed charter with exactly the reader
154    /// every run will load it through, and refuses the save on an error,
155    /// so a file that reaches disk is one that will load. `load` goes
156    /// through here so the two can never diverge on what "valid" means.
157    pub fn parse(text: &str) -> Result<Charter> {
158        let raw: RawCharter = toml::from_str(text)?;
159        Charter::validate(raw.line)
160    }
161
162    /// Only the conditions that make a line *ambiguous or unusable* refuse
163    /// the whole document. Crossing [`CHARTER_CHAR_BUDGET`] is deliberately
164    /// **not** one of them — see [`Charter::over_budget`] — on the
165    /// `over_budget_domains` precedent for the learned-rules cap
166    /// (`crate::learning`): a document that costs more of the cached prefix
167    /// than argued still means exactly what it says, and dropping the whole
168    /// charter because an eleventh line pushed it over a budget would un-rank
169    /// every priority in it over a problem that is really about cost, not
170    /// validity.
171    fn validate(lines: Vec<CharterLine>) -> Result<Charter> {
172        let mut seen = BTreeSet::new();
173        for line in &lines {
174            if line.id.trim().is_empty() {
175                bail!("a charter line has an empty `id`");
176            }
177            if line.text.trim().is_empty() {
178                bail!("charter line `{}` has empty `text`", line.id);
179            }
180            // Trimmed, to match both the emptiness check two lines up and
181            // `GoalRef::from_str` (`goal.rs`), which trims an id it parses —
182            // `"x"` and `"x "` must collide here or they answer to the same
183            // `charter:x` reference without this check ever having noticed.
184            if !seen.insert(line.id.trim()) {
185                // Ambiguous rather than merely untidy: a `GoalRef::Charter(id)`
186                // naming a duplicated id would point at whichever line a
187                // lookup happened to find first, silently.
188                bail!(
189                    "charter line id `{}` is used more than once — a goal reference \
190                     naming it would not know which line it meant",
191                    line.id
192                );
193            }
194        }
195        // Checking uniqueness on the trimmed form and then keeping the
196        // untrimmed one would make the guarantee above a check rather than a
197        // fact: `id = "x "` would still render with the trailing space and a
198        // future `GoalRef::Charter("x")` lookup — itself trimmed, per
199        // `goal.rs` — would miss a line recorded as `"x "`. Store what was
200        // checked.
201        let lines = lines
202            .into_iter()
203            .map(|l| CharterLine {
204                id: l.id.trim().to_string(),
205                ..l
206            })
207            .collect();
208        Ok(Charter { lines })
209    }
210
211    pub fn lines(&self) -> &[CharterLine] {
212        &self.lines
213    }
214
215    /// How many characters the charter actually costs when rendered into the
216    /// system prompt — [`prompt_block`]'s own length, not just the authored
217    /// `id`/`text` content. The header and the per-line `"N. `id` — "`
218    /// formatting ride in the cached prefix too, so measuring only the
219    /// authored text would under-report the true cost by a few hundred
220    /// characters of fixed overhead. What [`Charter::over_budget`] checks
221    /// against [`CHARTER_CHAR_BUDGET`], and the same number every message
222    /// that quotes "characters" beside "the prompt" means.
223    pub fn char_count(&self) -> usize {
224        prompt_block(self).map_or(0, |b| b.chars().count())
225    }
226
227    /// Costs more of the cached prefix than [`CHARTER_CHAR_BUDGET`] argues
228    /// for. Not refused by [`Charter::load`] — see its doc comment — so a
229    /// caller that cares (today: `mecha doctor`) checks this after loading.
230    pub fn over_budget(&self) -> bool {
231        self.char_count() > CHARTER_CHAR_BUDGET
232    }
233
234    pub fn is_empty(&self) -> bool {
235        self.lines.is_empty()
236    }
237}
238
239/// The comments-only template a surface may write when no charter exists
240/// yet, so the first edit never starts from an empty buffer — which is how
241/// a first charter ends up shaped wrong. **No active `[[line]]` entries**:
242/// a template that shipped priorities would be mecha authoring the charter,
243/// the one thing every surface here refuses (§11), and the test on this
244/// constant fails on any uncommented line. Lives here so the TUI's `e` and
245/// the web settings editor hand out the same bytes rather than two copies
246/// that drift. The commented example exists because the costliest authoring
247/// mistake (§11) is a "never disappoint"-shaped line, and the place to say
248/// so is inside the file being edited.
249pub const TEMPLATE: &str = "\
250# Your charter: standing priorities, in your own words, ranked highest
251# first — ORDER IS RANK. There is no priority field; when two lines
252# conflict, the higher one wins outright, and re-ranking is moving a line.
253#
254# mecha only ever reads this file. Each entry is:
255#
256#   [[line]]
257#   id = \"a-short-stable-slug\"     # unique; goal references point at it
258#   text = \"The priority itself, one or two sentences.\"
259#
260# One authoring trap, from the design doc: a line shaped like \"never
261# disappoint anyone\" produces sycophancy and withheld bad news. Point it
262# the other way — e.g.:
263#
264#   [[line]]
265#   id = \"tell-the-truth-early\"
266#   text = \"Tell me the truth early, especially when it disappoints.\"
267";
268
269/// The block rendered straight into the system prompt. `None` when the
270/// charter is empty, so a machine with no charter authored yet sends no block
271/// at all — the same reason [`crate::skill::prompt_block`] returns `None` on
272/// an empty store.
273pub fn prompt_block(charter: &Charter) -> Option<String> {
274    if charter.is_empty() {
275        return None;
276    }
277    // No instruction to cite a line's id via `serves`, deliberately: `todo`'s
278    // schema documents only `task:<id>` there (`tool/todo.rs`), and this
279    // block is unconditional — rendered whether or not `todo` is even in the
280    // tool surface (a narrow `--tool` allowlist, Slack's own set). Asking for
281    // a citation with nowhere reliable to put it, or a tool that may not
282    // exist, is worse than not asking; wiring that up is the appraisal
283    // consumer's job (see the rung 10 note in `GOAL-SYSTEM-DESIGN.md`), not
284    // this block's.
285    let mut out = String::from(
286        "## Charter\n\n\
287         Standing priorities the owner has written for you, ranked highest first \
288         and listed in that order. They are not weighted: when two conflict, the \
289         higher one wins outright, whatever the lower one would otherwise argue \
290         for — no amount of urgency on a lower line outranks a higher one.\n\n",
291    );
292    for (i, line) in charter.lines().iter().enumerate() {
293        out.push_str(&format!("{}. `{}` — {}\n", i + 1, line.id, line.text));
294    }
295    Some(out.trim_end().to_string())
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    /// The template must never author a priority: a `[[line]]` not behind a
303    /// comment would ship ranked content mecha wrote, which is the invariant
304    /// every charter surface exists to refuse.
305    #[test]
306    fn the_template_carries_no_active_lines() {
307        for l in TEMPLATE.lines() {
308            let l = l.trim();
309            assert!(
310                l.is_empty() || l.starts_with('#'),
311                "template has an uncommented line: {l:?}"
312            );
313        }
314        // And it stays honest as a TOML document: parsing it yields nothing.
315        let c = Charter::parse(TEMPLATE).unwrap();
316        assert!(c.is_empty());
317    }
318
319    fn line(id: &str, text: &str) -> CharterLine {
320        CharterLine {
321            id: id.to_string(),
322            text: text.to_string(),
323        }
324    }
325
326    /// Write `raw` to a scratch `charter.toml` and load it, unique per test
327    /// and thread so parallel tests don't collide on the path.
328    fn write_and_load(raw: &str) -> Result<Charter> {
329        let dir = std::env::temp_dir().join(format!(
330            "mecha-charter-test-{}-{:?}-{:?}",
331            std::process::id(),
332            std::thread::current().id(),
333            std::time::Instant::now()
334        ));
335        std::fs::create_dir_all(&dir).unwrap();
336        let path = dir.join("charter.toml");
337        std::fs::write(&path, raw).unwrap();
338        Charter::load(&path)
339    }
340
341    #[test]
342    fn the_standard_shape_parses_in_file_order() {
343        let raw = r#"
344[[line]]
345id = "protect-the-owner"
346text = "Protect the owner's interests above all else."
347
348[[line]]
349id = "tell-the-truth-early"
350text = "Tell the owner the truth early, especially when it disappoints."
351"#;
352        let charter = write_and_load(raw).unwrap();
353        assert_eq!(
354            charter.lines(),
355            &[
356                line(
357                    "protect-the-owner",
358                    "Protect the owner's interests above all else."
359                ),
360                line(
361                    "tell-the-truth-early",
362                    "Tell the owner the truth early, especially when it disappoints."
363                ),
364            ]
365        );
366    }
367
368    #[test]
369    fn a_missing_file_is_an_empty_charter_not_an_error() {
370        let path = std::env::temp_dir().join("mecha-charter-does-not-exist.toml");
371        let _ = std::fs::remove_file(&path);
372        let charter = Charter::load(&path).unwrap();
373        assert!(charter.is_empty());
374    }
375
376    #[test]
377    fn a_typo_d_table_name_is_a_load_error_not_a_silently_short_charter() {
378        // `[[lines]]` (plural) beside a correctly-named `[[line]]` used to
379        // vanish rather than fail — non-empty, under budget, and quietly
380        // missing whatever the typo'd entries were meant to say.
381        let raw = r#"
382[[line]]
383id = "protect-the-owner"
384text = "Protect the owner's interests above all else."
385
386[[lines]]
387id = "tell-the-truth-early"
388text = "Tell the owner the truth early, especially when it disappoints."
389"#;
390        let e = write_and_load(raw).unwrap_err().to_string();
391        assert!(e.contains("parsing"), "{e}");
392    }
393
394    #[test]
395    fn a_stray_priority_field_on_a_line_is_a_load_error() {
396        // §11: rank is file order and there is deliberately no priority
397        // field. Accepting one silently would let an owner write it and
398        // believe it did something.
399        let raw = r#"
400[[line]]
401id = "a"
402text = "one"
403priority = 1
404"#;
405        assert!(write_and_load(raw).is_err());
406    }
407
408    #[test]
409    fn a_duplicate_id_is_refused_because_a_reference_to_it_would_be_ambiguous() {
410        let e = Charter::validate(vec![line("a", "one"), line("a", "two")])
411            .unwrap_err()
412            .to_string();
413        assert!(e.contains("used more than once"), "{e}");
414    }
415
416    #[test]
417    fn ids_differing_only_by_surrounding_whitespace_still_collide() {
418        // `GoalRef::from_str` trims an id it parses (`goal.rs`), so `"x"` and
419        // `"x "` answer to the same `charter:x` reference — this check has to
420        // trim too, or two visually distinct-looking lines pass as unique
421        // and then can't be told apart by anything that resolves the id.
422        let e = Charter::validate(vec![line("a", "one"), line("a ", "two")])
423            .unwrap_err()
424            .to_string();
425        assert!(e.contains("used more than once"), "{e}");
426    }
427
428    #[test]
429    fn a_surviving_id_is_stored_trimmed_not_just_checked_trimmed() {
430        // Checking uniqueness on the trimmed form and then keeping the
431        // untrimmed one would make the guarantee a check rather than a
432        // fact — a lone `"x "` line would pass validation and then render
433        // with the trailing space, answering to `charter:x` by nothing more
434        // than luck.
435        let charter = Charter::validate(vec![line(" x ", "one")]).unwrap();
436        assert_eq!(charter.lines()[0].id, "x");
437    }
438
439    #[test]
440    fn char_count_is_the_rendered_costs_not_just_the_authored_text() {
441        // The header and the `"1. `id` — "` formatting ride in the cached
442        // prefix too — a budget checked only against authored text would
443        // under-report the true cost, and the messages that quote this
444        // number beside "in the prompt" would be naming a different number
445        // than the one that actually rides there.
446        let charter = Charter::validate(vec![line("a", "short")]).unwrap();
447        assert_eq!(
448            charter.char_count(),
449            prompt_block(&charter).unwrap().chars().count()
450        );
451        assert!(charter.char_count() > "a".len() + "short".len());
452    }
453
454    #[test]
455    fn an_empty_id_or_text_is_refused() {
456        assert!(Charter::validate(vec![line("", "text")]).is_err());
457        assert!(Charter::validate(vec![line("id", "  ")]).is_err());
458    }
459
460    #[test]
461    fn a_charter_over_the_character_budget_still_loads_and_says_so() {
462        // Refusing the whole document over its eleventh line would un-rank
463        // every priority in it for a problem that is about cost, not
464        // validity — the `over_budget_domains` precedent, applied here.
465        let long = "x".repeat(CHARTER_CHAR_BUDGET + 1);
466        let charter = Charter::validate(vec![line("only-line", &long)]).unwrap();
467        assert_eq!(charter.lines().len(), 1);
468        assert!(charter.over_budget());
469    }
470
471    #[test]
472    fn a_charter_under_the_budget_is_not_over_it() {
473        let charter = Charter::validate(vec![line("a", "short")]).unwrap();
474        assert!(!charter.over_budget());
475    }
476
477    #[test]
478    fn an_empty_charter_contributes_no_block_at_all() {
479        assert_eq!(prompt_block(&Charter::default()), None);
480    }
481
482    #[test]
483    fn the_block_lists_lines_in_file_order_not_sorted() {
484        // Unlike skills, order is content: a charter authored `["b-line",
485        // "a-line"]` must render in that order, never alphabetically.
486        let charter = Charter {
487            lines: vec![
488                line("b-line", "second priority"),
489                line("a-line", "first priority"),
490            ],
491        };
492        let block = prompt_block(&charter).unwrap();
493        let b = block.find("b-line").unwrap();
494        let a = block.find("a-line").unwrap();
495        assert!(b < a, "{block}");
496    }
497
498    #[test]
499    fn the_block_explains_the_ordering_is_load_bearing() {
500        let charter = Charter {
501            lines: vec![line("only", "the only priority")],
502        };
503        let block = prompt_block(&charter).unwrap();
504        assert!(block.contains("not weighted"), "{block}");
505    }
506}