From 41364829aebe8f1d859f5cc7725da34e4606c0f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:17:08 +0700 Subject: [PATCH 1/3] tri learn add: a lesson goes into a skill's Traps section once, the decisions in specs/tri/loop/lesson.t27 (Closes #11060) A lesson learned in one lane was lost unless someone edited the right skill by hand, and a lesson pasted twice made two lines. specs/tri/loop/lesson.t27 now holds the decision as pure functions over the skill's file text, with 21 tests: - the Traps section is the first heading, at any level, whose title opens with Traps or Trap, outside a code fence, up to the next heading of its level or a higher one; a row goes after the last non-blank line of it; a file with no Traps section gets one at the end; - the row is `- ()`, with no () when the ref is empty; - the key of a text is its letters and digits, case folded, everything else dropped; a lesson is DUPLICATE when its key is inside the key of one block of the section (a bullet with its continuation lines), and a lesson of fewer than 12 letters and digits only when it is a block's whole words; - refused, writing nothing: an empty lesson, one over 400 bytes (400 is fine), one with a line break or control byte, a secret-shaped one (32+ hex digits, a 40+ run of base64 bytes that changes class often, ghp_ gho_ ghu_ ghs_ ghr_ github_pat_ sk- xoxb- xoxp- AKIA prefixes, BEGIN with PRIVATE KEY), a bad date or ref, a skill name that is not one directory name (`..`, `a/b`, `/x`), and a skill that is no directory under .claude/skills/. `tri learn add --skill "" [--date D] [--ref R]` reads the skill's SKILL.md (or skill.md), asks the generated rules, writes what they return, and prints `: added` or `duplicate`. The glue is cli/tri/src/learn.rs (40 added hand lines) and 4 in main.rs; gen/rust/tri/loop/lesson.rs is t27c gen-rust of the spec. The 44 added hand lines are paid for by deleting 67: the unwired `cmd_simulated_output_wellformed` in bootstrap/src/suite.rs (git grep -w finds no caller; WAVE_LOOP_649_REPORT.md calls it "exists and is unwired"). Census moved, blessed in this commit (tools/census/fetches.txt): `files read` 49 -> 50, because the census reads every file of cli/tri/src and learn.rs is one more; its lines naming a fetch (78) and fetch sites (35) are unchanged. Searched first: `tri experience save` (an episode of counts, no lesson text), `tri skill add` / `fold` (a whole numbered section spooled for a numbered SKILL.md such as ci-gates, no check against what is there), /experience-save and .trinity/experience.md (an append-only log kept by hand), `tri loop plan`. None puts one line in a skill's Traps section once, so this is a new command. No seal is committed: `t27c test-report` is blocked in the lane's sandbox by the zig cache (PermissionDenied), and a seal that says tests blocked is not one. t27b test passes 21 of 21; zig ast-check of the generated Zig is clean. Closes #11060 Co-Authored-By: Claude Sonnet 5.5 --- bootstrap/src/suite.rs | 67 --- cli/tri/src/learn.rs | 40 ++ cli/tri/src/main.rs | 4 + gen/rust/tri/loop/lesson.rs | 881 +++++++++++++++++++++++++++++++++ specs/tri/loop/lesson.t27 | 959 ++++++++++++++++++++++++++++++++++++ tools/census/fetches.txt | 2 +- 6 files changed, 1885 insertions(+), 68 deletions(-) create mode 100644 cli/tri/src/learn.rs create mode 100644 gen/rust/tri/loop/lesson.rs create mode 100644 specs/tri/loop/lesson.t27 diff --git a/bootstrap/src/suite.rs b/bootstrap/src/suite.rs index 1154daf8a4..26439bdff9 100644 --- a/bootstrap/src/suite.rs +++ b/bootstrap/src/suite.rs @@ -1025,73 +1025,6 @@ fn cmd_verilog_no_double_percent(repo: &Path, rel: &str) -> anyhow::Result<()> { ) } -/// W647: the OUTPUT stratum. T57 was a defect that is well-formed Rust, -/// well-formed Verilog, compiles and runs in both, and is wrong only when a -/// human reads what it printed. Static checks stratify -- shape, type, output -- -/// and every gate built in this session lives in the first two. -/// -/// This runs `iverilog` + `vvp` on the generated testbench and reads the -/// printed lines, asserting each `[TEST]`/`[BENCH]` line is well formed: no -/// unconsumed format directive, and a recognised verdict token. -/// -/// **Bounded and named, not total** (T55): running the corpus through a -/// simulator costs 10-20 minutes against the gate's 5, so this walks the specs -/// passed to it and the caller decides which. Its coverage claim is exactly -/// "the specs it was given", stated rather than implied. -fn cmd_simulated_output_wellformed(repo: &Path, rel: &str) -> anyhow::Result<()> { - let exe_ok = |p: &str| { - Command::new(p) - .arg("-V") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - }; - if !exe_ok("iverilog") { - // T51: a phase that did not run must not be reported as one that ran - // clean. The caller prints SKIPPED for this. - return Ok(()); - } - let src = fs::read_to_string(repo.join(rel)) - .with_context(|| format!("reading {}", rel))?; - let v = match crate::compiler::Compiler::compile_verilog_for_simulation(&src) { - Err(_) => return Ok(()), - Ok(v) => v, - }; - let dir = std::env::temp_dir().join(format!("t27_outstratum_{}", std::process::id())); - fs::create_dir_all(&dir)?; - let vpath = dir.join("tb.v"); - let opath = dir.join("tb.out"); - fs::write(&vpath, &v)?; - let build = Command::new("iverilog") - .args(["-g2012", "-o"]) - .arg(&opath) - .arg(&vpath) - .output()?; - if !build.status.success() { - // iverilog rejection is the Icarus phase's business, not this one. - return Ok(()); - } - let run = Command::new("vvp").arg(&opath).output()?; - let text = String::from_utf8_lossy(&run.stdout); - let mut bad: Vec = Vec::new(); - for line in text.lines() { - if !(line.contains("[TEST]") || line.contains("[BENCH]")) { - continue; - } - // An unconsumed directive means the value was never substituted. - if line.contains('%') { - bad.push(format!("unformatted directive survives to output: {}", line.trim())); - } - } - let _ = fs::remove_dir_all(&dir); - if bad.is_empty() { - return Ok(()); - } - anyhow::bail!("simulated output is malformed: {}", bad.join("; ")) -} - fn cmd_parse(repo: &Path, rel: &str) -> anyhow::Result<()> { let exe = t27c_exe()?; let st = Command::new(&exe) diff --git a/cli/tri/src/learn.rs b/cli/tri/src/learn.rs new file mode 100644 index 0000000000..bfb3b70d1e --- /dev/null +++ b/cli/tri/src/learn.rs @@ -0,0 +1,40 @@ +//! `tri learn add`: every decision is specs/tri/loop/lesson.t27 (t27c gen-rust, gen/rust/tri/loop/lesson.rs); this reads the skill's file, asks it, writes what it says. +use anyhow::{bail, Context, Result}; +use clap::Subcommand; +#[path = "../../../gen/rust/tri/loop/lesson.rs"] #[allow(dead_code, unused_parens)] +mod lesson; + +#[derive(Subcommand)] +pub enum LearnCmd { + /// Add one lesson, as the line `- ()`, to the Traps section of a skill's SKILL.md (a Traps section is made at the end of the file when it has none), unless the section already says it: then it prints `duplicate` and changes nothing. It prints the file and the line. It refuses an empty lesson, one over 400 bytes or with a line break, one that looks like a secret, a bad date or ref, and a skill that is no directory under .claude/skills/. Rules: specs/tri/loop/lesson.t27. + Add { + /// The skill: a directory name under .claude/skills/. + #[arg(long)] skill: String, + /// One line, at most 400 bytes. + lesson: String, + /// YYYY-MM-DD; today (UTC) when absent. + #[arg(long)] date: Option, + /// Where it was learned, one word such as #11060; none when absent. + #[arg(long = "ref", default_value = "", value_name = "REF")] cite: String, + }, +} + +pub fn run(cmd: &LearnCmd) -> Result<()> { + let LearnCmd::Add { skill, lesson: said, date, cite } = cmd; + let st = |s: &str| -> &'static str { s.to_string().leak() }; + let dir = crate::find_trinity_root()?.join(".claude/skills").join(skill); + let is_dir = lesson::skill_ok(st(skill)) && std::fs::symlink_metadata(&dir).map_or(false, |m| m.is_dir()); + let md = ["SKILL.md", "skill.md"].iter().map(|n| dir.join(n)).find(|p| is_dir && p.is_file()); + let text = md.as_ref().map(std::fs::read_to_string).transpose()?.unwrap_or_default(); + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + let (f, l, d, c) = (st(&text), st(said), st(date.as_ref().unwrap_or(&today)), st(cite)); + let o = lesson::decide(st(skill), is_dir, f, l, d, c); + if lesson::refused(o) { bail!("refused: {}", lesson::say(o)); } + if o == lesson::O_DUPLICATE { println!("{}", lesson::say(o)); return Ok(()); } + let path = md.context("the skill has no SKILL.md or skill.md")?; + let (mut buf, at) = (vec![0u8; 1024], lesson::patch_at(f)); + let n = lesson::patch(f, l, d, c, &mut buf); + std::fs::write(&path, format!("{}{}{}", &f[..at], String::from_utf8_lossy(&buf[..n]), &f[at..]))?; + println!("{}:{} {}", path.display(), lesson::row_line(f), lesson::say(o)); + Ok(()) +} diff --git a/cli/tri/src/main.rs b/cli/tri/src/main.rs index 8578c85529..e7be822d44 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -26,6 +26,7 @@ mod jumps; mod kinddrift; mod leanreach; mod leanvac; +mod learn; mod ledgers; mod loopclaim; mod misread; @@ -453,6 +454,8 @@ enum Commands { T27oSweep { paths: Vec, #[arg(long, default_value = t27o_ledger::SWEEP_HERE)] repo: String, #[arg(long, default_value_t = t27o_ledger::SWEEP_MAX_BYTES)] max_bytes: usize, #[arg(long)] cache: Option, #[arg(long, default_value_t = t27o_ledger::SWEEP_TOP)] top: usize }, /// Land a lane branch (default: the current one): merge master, then tests with no vacuous pass, seals, types ratchet, dupe subset, push, pull request, auto-merge; the first failing gate stops it with a named code (CONFLICT, TESTS_NOT_CLEAN, SEAL_NOT_CLEAN, TYPES_NOT_CLEAN, DUPE_NOT_SUBSET, PUSH_NOT_CONFIRMED) and a second run prints ALREADY_LANDED; --plan prints the steps and runs none, --check stops before the commit; T27C names the t27c program (default t27c on PATH) (rules: specs/tri/land/land.t27, #11068). Land { branch: Option, #[arg(long, default_value_t = 0)] issue: u64, #[arg(long)] plan: bool, #[arg(long)] check: bool }, + /// Add one lesson to the Traps section of a skill unless the section already says it; `tri learn add --help` has the refusals (rules: specs/tri/loop/lesson.t27, #11060). + Learn { #[command(subcommand)] action: learn::LearnCmd }, } #[derive(Subcommand)] @@ -1373,6 +1376,7 @@ fn main() -> Result<()> { Commands::T27o { file, name, oracle, check, t27c, emit_cell } => std::process::exit(cmd_t27o(file, name.as_deref(), *oracle, *check, t27c.as_deref(), *emit_cell)? as i32), Commands::T27oSweep { paths, repo, max_bytes, cache, top } => std::process::exit(cmd_t27o_sweep(paths, repo, *max_bytes, cache.as_deref(), *top)? as i32), Commands::Land { branch, issue, plan, check } => std::process::exit(cmd_land(branch.as_deref(), *issue, *plan, *check)? as i32), + Commands::Learn { action } => learn::run(action)?, } Ok(()) diff --git a/gen/rust/tri/loop/lesson.rs b/gen/rust/tri/loop/lesson.rs new file mode 100644 index 0000000000..ffa88acc0e --- /dev/null +++ b/gen/rust/tri/loop/lesson.rs @@ -0,0 +1,881 @@ +// Generated from .t27 spec +// DO NOT EDIT — generated by t27c +// NOT LOWERED BY THIS BACKEND: 21 test(s), 2 invariant(s), 0 function(s) with no body. +// This backend emits declarations only. The spec's checks live in +// the Zig and Verilog outputs; do not read this file as verified. + +pub const MAX_LESSON: usize = 400; + +pub const MAX_REF: usize = 80; + +pub const MIN_KEY: usize = 12; + +pub const HEX_RUN: usize = 32; + +pub const B64_RUN: usize = 40; + +pub const TAIL_MIN: usize = 8; + +pub const MAX_SKILL: usize = 64; + +pub const SECTION_HEAD: &'static str = "## Traps\n\n"; + +pub const O_ADD: u8 = 0; + +pub const O_ADD_SECTION: u8 = 1; + +pub const O_DUPLICATE: u8 = 2; + +pub const O_EMPTY: u8 = 3; + +pub const O_CONTROL: u8 = 4; + +pub const O_TOO_LONG: u8 = 5; + +pub const O_SECRET: u8 = 6; + +pub const O_BAD_DATE: u8 = 7; + +pub const O_BAD_REF: u8 = 8; + +pub const O_BAD_SKILL: u8 = 9; + +pub const O_NO_SKILL: u8 = 10; + +pub const OUTCOMES: u8 = 11; + +pub fn lower(b: u8) -> u8 { + if ((b >= 65) && (b <= 90)) { + return (b + 32); + } + return b; +} + +pub fn is_digit(b: u8) -> bool { + return ((b >= 48) && (b <= 57)); +} + +pub fn is_alnum(b: u8) -> bool { + return ((is_digit(b) || ((b >= 65) && (b <= 90))) || ((b >= 97) && (b <= 122))); +} + +pub fn is_hex(b: u8) -> bool { + return ((is_digit(b) || ((b >= 65) && (b <= 70))) || ((b >= 97) && (b <= 102))); +} + +pub fn is_key(b: u8) -> bool { + return (is_alnum(b) || (b >= 128)); +} + +pub fn is_b64(b: u8) -> bool { + return (((is_alnum(b) || (b == 43)) || (b == 47)) || (b == 61)); +} + +pub fn is_tail(b: u8) -> bool { + return ((is_alnum(b) || (b == 95)) || (b == 45)); +} + +pub fn class_of(b: u8) -> u8 { + if ((b >= 97) && (b <= 122)) { + return 1; + } + if ((b >= 65) && (b <= 90)) { + return 2; + } + if is_digit(b) { + return 3; + } + return 0; +} + +pub fn next_key(s: &'static str, k: usize, to: usize) -> usize { + let mut i: usize = k; + while ((i < to) && !(is_key(s.as_bytes()[(i) as usize]))) { + i = (i + 1); + } + return i; +} + +pub fn key_len(s: &'static str, from: usize, to: usize) -> usize { + let mut n: usize = 0; + let mut i: usize = next_key(s, from, to); + while (i < to) { + n = (n + 1); + i = next_key(s, (i + 1), to); + } + return n; +} + +pub fn fold_at(a: &'static str, i: usize, to: usize, b: &'static str, bf: usize, bt: usize) -> bool { + let mut x: usize = next_key(a, i, to); + let mut y: usize = next_key(b, bf, bt); + while (y < bt) { + if (x >= to) { + return false; + } + if (lower(a.as_bytes()[(x) as usize]) != lower(b.as_bytes()[(y) as usize])) { + return false; + } + x = next_key(a, (x + 1), to); + y = next_key(b, (y + 1), bt); + } + return true; +} + +pub fn fold_in(a: &'static str, from: usize, to: usize, b: &'static str, bf: usize, bt: usize) -> bool { + let mut i: usize = next_key(a, from, to); + while (i < to) { + if fold_at(a, i, to, b, bf, bt) { + return true; + } + i = next_key(a, (i + 1), to); + } + return false; +} + +pub fn lesson_from(s: &'static str) -> usize { + let mut k: usize = 0; + while ((k < s.len()) && ((s.as_bytes()[(k) as usize] == 32) || (s.as_bytes()[(k) as usize] == 9))) { + k = (k + 1); + } + return k; +} + +pub fn lesson_to(s: &'static str) -> usize { + let from: usize = lesson_from(s); + let mut k: usize = s.len(); + while ((k > from) && ((s.as_bytes()[((k - 1)) as usize] == 32) || (s.as_bytes()[((k - 1)) as usize] == 9))) { + k = (k - 1); + } + return k; +} + +pub fn has_control(s: &'static str, from: usize, to: usize) -> bool { + let mut k: usize = from; + while (k < to) { + if (((s.as_bytes()[(k) as usize] < 32) && (s.as_bytes()[(k) as usize] != 9)) || (s.as_bytes()[(k) as usize] == 127)) { + return true; + } + k = (k + 1); + } + return false; +} + +pub fn starts_at(s: &'static str, k: usize, to: usize, lit: &'static str) -> bool { + return (((k + lit.len()) <= to) && span_is(s, k, (k + lit.len()), lit)); +} + +pub fn has_text(s: &'static str, from: usize, to: usize, lit: &'static str) -> bool { + let mut k: usize = from; + while ((k + lit.len()) <= to) { + if span_is(s, k, (k + lit.len()), lit) { + return true; + } + k = (k + 1); + } + return false; +} + +pub fn has_hex_run(s: &'static str, from: usize, to: usize) -> bool { + let mut run: usize = 0; + let mut k: usize = from; + while (k < to) { + run = (run + 1); + if !(is_hex(s.as_bytes()[(k) as usize])) { + run = 0; + } + if (run >= HEX_RUN) { + return true; + } + k = (k + 1); + } + return false; +} + +pub fn mixed_run(s: &'static str, a: usize, b: usize) -> bool { + if ((b - a) < B64_RUN) { + return false; + } + let mut lo: bool = false; + let mut up: bool = false; + let mut dg: bool = false; + let mut turns: usize = 0; + let mut k: usize = a; + while (k < b) { + let c: u8 = class_of(s.as_bytes()[(k) as usize]); + if (c == 1) { + lo = true; + } + if (c == 2) { + up = true; + } + if (c == 3) { + dg = true; + } + if ((((k > a) && (c != 0)) && (class_of(s.as_bytes()[((k - 1)) as usize]) != 0)) && (c != class_of(s.as_bytes()[((k - 1)) as usize]))) { + turns = (turns + 1); + } + k = (k + 1); + } + return (((lo && up) && dg) && ((turns * 2) >= (b - a))); +} + +pub fn has_b64_run(s: &'static str, from: usize, to: usize) -> bool { + let mut rs: usize = from; + let mut k: usize = from; + while (k <= to) { + let mut cut: bool = true; + if (k < to) { + cut = !(is_b64(s.as_bytes()[(k) as usize])); + } + if cut { + if mixed_run(s, rs, k) { + return true; + } + rs = (k + 1); + } + k = (k + 1); + } + return false; +} + +pub fn prefix_len(s: &'static str, k: usize, to: usize) -> usize { + if starts_at(s, k, to, "github_pat_") { + return 11; + } + if ((starts_at(s, k, to, "ghp_") || starts_at(s, k, to, "gho_")) || starts_at(s, k, to, "ghu_")) { + return 4; + } + if ((starts_at(s, k, to, "ghs_") || starts_at(s, k, to, "ghr_")) || starts_at(s, k, to, "AKIA")) { + return 4; + } + if (starts_at(s, k, to, "xoxb-") || starts_at(s, k, to, "xoxp-")) { + return 5; + } + if starts_at(s, k, to, "sk-") { + return 3; + } + return 0; +} + +pub fn at_word_start(s: &'static str, k: usize, from: usize) -> bool { + if (k <= from) { + return true; + } + return !(is_alnum(s.as_bytes()[((k - 1)) as usize])); +} + +pub fn has_tail(s: &'static str, k: usize, to: usize) -> bool { + let mut n: usize = 0; + while ((((k + n) < to) && (n < TAIL_MIN)) && is_tail(s.as_bytes()[((k + n)) as usize])) { + n = (n + 1); + } + return (n >= TAIL_MIN); +} + +pub fn has_prefixed_token(s: &'static str, from: usize, to: usize) -> bool { + let mut k: usize = from; + while (k < to) { + let p: usize = prefix_len(s, k, to); + if (((p > 0) && at_word_start(s, k, from)) && has_tail(s, (k + p), to)) { + return true; + } + k = (k + 1); + } + return false; +} + +pub fn secret_shaped(s: &'static str, from: usize, to: usize) -> bool { + if (has_hex_run(s, from, to) || has_b64_run(s, from, to)) { + return true; + } + if has_prefixed_token(s, from, to) { + return true; + } + return (has_text(s, from, to, "BEGIN") && has_text(s, from, to, "PRIVATE KEY")); +} + +pub fn skill_ok(name: &'static str) -> bool { + if ((name.len() == 0) || (name.len() > MAX_SKILL)) { + return false; + } + if !(is_alnum(name.as_bytes()[0])) { + return false; + } + let mut k: usize = 1; + while (k < name.len()) { + if !(is_tail(name.as_bytes()[(k) as usize])) { + return false; + } + k = (k + 1); + } + return true; +} + +pub fn date_at(s: &'static str, a: usize, to: usize) -> bool { + if ((((a + 10) > to) || (s.as_bytes()[((a + 4)) as usize] != 45)) || (s.as_bytes()[((a + 7)) as usize] != 45)) { + return false; + } + let mut k: usize = 0; + while (k < 10) { + if (((k != 4) && (k != 7)) && !(is_digit(s.as_bytes()[((a + k)) as usize]))) { + return false; + } + k = (k + 1); + } + let month: u8 = (((s.as_bytes()[((a + 5)) as usize] - 48) * 10) + (s.as_bytes()[((a + 6)) as usize] - 48)); + let day: u8 = (((s.as_bytes()[((a + 8)) as usize] - 48) * 10) + (s.as_bytes()[((a + 9)) as usize] - 48)); + return ((((month >= 1) && (month <= 12)) && (day >= 1)) && (day <= 31)); +} + +pub fn date_ok(d: &'static str) -> bool { + return ((d.len() == 10) && date_at(d, 0, d.len())); +} + +pub fn ref_ok(r: &'static str) -> bool { + if (r.len() > MAX_REF) { + return false; + } + let mut k: usize = 0; + while (k < r.len()) { + if ((((r.as_bytes()[(k) as usize] <= 32) || (r.as_bytes()[(k) as usize] >= 127)) || (r.as_bytes()[(k) as usize] == 40)) || (r.as_bytes()[(k) as usize] == 41)) { + return false; + } + k = (k + 1); + } + return !(secret_shaped(r, 0, r.len())); +} + +pub fn next_line(f: &'static str, at: usize) -> usize { + let e: usize = line_end(f, at); + if (e >= f.len()) { + return f.len(); + } + return (e + 1); +} + +pub fn blank_line(f: &'static str, at: usize, stop: usize) -> bool { + let mut k: usize = at; + while (k < stop) { + if (((f.as_bytes()[(k) as usize] != 32) && (f.as_bytes()[(k) as usize] != 9)) && (f.as_bytes()[(k) as usize] != 13)) { + return false; + } + k = (k + 1); + } + return true; +} + +pub fn heading_level(f: &'static str, at: usize, stop: usize) -> usize { + let mut n: usize = 0; + while (((at + n) < stop) && (f.as_bytes()[((at + n)) as usize] == 35)) { + n = (n + 1); + } + if ((n == 0) || (n > 6)) { + return 0; + } + if ((((at + n) < stop) && (f.as_bytes()[((at + n)) as usize] != 32)) && (f.as_bytes()[((at + n)) as usize] != 9)) { + return 0; + } + return n; +} + +pub fn fence_char(f: &'static str, at: usize, stop: usize) -> u8 { + let mut k: usize = at; + while (((k < stop) && (k < (at + 3))) && (f.as_bytes()[(k) as usize] == 32)) { + k = (k + 1); + } + if ((k + 3) > stop) { + return 0; + } + if (((f.as_bytes()[(k) as usize] == 96) && (f.as_bytes()[((k + 1)) as usize] == 96)) && (f.as_bytes()[((k + 2)) as usize] == 96)) { + return 96; + } + if (((f.as_bytes()[(k) as usize] == 126) && (f.as_bytes()[((k + 1)) as usize] == 126)) && (f.as_bytes()[((k + 2)) as usize] == 126)) { + return 126; + } + return 0; +} + +pub fn fence_after(fence: u8, fc: u8) -> u8 { + if (fence == 0) { + return fc; + } + if (fc == fence) { + return 0; + } + return fence; +} + +pub fn is_traps(f: &'static str, at: usize, stop: usize) -> bool { + let mut t: usize = at; + while ((t < stop) && (f.as_bytes()[(t) as usize] == 35)) { + t = (t + 1); + } + while ((t < stop) && ((f.as_bytes()[(t) as usize] == 32) || (f.as_bytes()[(t) as usize] == 9))) { + t = (t + 1); + } + if ((t + 4) > stop) { + return false; + } + if ((((lower(f.as_bytes()[(t) as usize]) != 116) || (lower(f.as_bytes()[((t + 1)) as usize]) != 114)) || (lower(f.as_bytes()[((t + 2)) as usize]) != 97)) || (lower(f.as_bytes()[((t + 3)) as usize]) != 112)) { + return false; + } + let mut k: usize = (t + 4); + if ((k < stop) && (lower(f.as_bytes()[(k) as usize]) == 115)) { + k = (k + 1); + } + if (k >= stop) { + return true; + } + return !(is_alnum(f.as_bytes()[(k) as usize])); +} + +pub fn trap_line(f: &'static str) -> usize { + let mut at: usize = 0; + let mut fence: u8 = 0; + while (at < f.len()) { + let stop: usize = line_end(f, at); + let fc: u8 = fence_char(f, at, stop); + if ((((fence == 0) && (fc == 0)) && (heading_level(f, at, stop) > 0)) && is_traps(f, at, stop)) { + return at; + } + fence = fence_after(fence, fc); + at = next_line(f, at); + } + return f.len(); +} + +pub fn section_end(f: &'static str, h: usize) -> usize { + let level: usize = heading_level(f, h, line_end(f, h)); + let mut at: usize = next_line(f, h); + let mut fence: u8 = 0; + while (at < f.len()) { + let stop: usize = line_end(f, at); + let fc: u8 = fence_char(f, at, stop); + if ((fence == 0) && (fc == 0)) { + let l: usize = heading_level(f, at, stop); + if ((l > 0) && (l <= level)) { + return at; + } + } + fence = fence_after(fence, fc); + at = next_line(f, at); + } + return f.len(); +} + +pub fn insert_at(f: &'static str, h: usize) -> usize { + let e: usize = section_end(f, h); + let mut last: usize = next_line(f, h); + let mut at: usize = last; + while (at < e) { + if !(blank_line(f, at, line_end(f, at))) { + last = next_line(f, at); + } + at = next_line(f, at); + } + return last; +} + +pub fn is_bullet(f: &'static str, at: usize, stop: usize) -> bool { + if ((at + 2) > stop) { + return false; + } + return ((((f.as_bytes()[(at) as usize] == 45) || (f.as_bytes()[(at) as usize] == 42)) || (f.as_bytes()[(at) as usize] == 43)) && (f.as_bytes()[((at + 1)) as usize] == 32)); +} + +pub fn body_from(f: &'static str, bs: usize, be: usize) -> usize { + let mut k: usize = bs; + if is_bullet(f, bs, be) { + k = (bs + 2); + } + if ((((k + 11) <= be) && (f.as_bytes()[((k + 10)) as usize] == 32)) && date_at(f, k, be)) { + k = (k + 11); + } + return k; +} + +pub fn body_to(f: &'static str, bs: usize, be: usize) -> usize { + let mut e: usize = be; + while ((e > bs) && (((f.as_bytes()[((e - 1)) as usize] == 32) || (f.as_bytes()[((e - 1)) as usize] == 9)) || (f.as_bytes()[((e - 1)) as usize] == 13))) { + e = (e - 1); + } + if ((e == bs) || (f.as_bytes()[((e - 1)) as usize] != 41)) { + return be; + } + let mut k: usize = (e - 1); + while ((k > bs) && (f.as_bytes()[(k) as usize] != 40)) { + k = (k - 1); + } + if ((f.as_bytes()[(k) as usize] == 40) && (k > bs)) { + return k; + } + return be; +} + +pub fn fold_eq(f: &'static str, a: usize, z: usize, b: &'static str, bf: usize, bt: usize) -> bool { + return ((key_len(f, a, z) == key_len(b, bf, bt)) && fold_at(f, a, z, b, bf, bt)); +} + +pub fn block_says(f: &'static str, bs: usize, be: usize, b: &'static str, bf: usize, bt: usize) -> bool { + if (key_len(b, bf, bt) >= MIN_KEY) { + return fold_in(f, bs, be, b, bf, bt); + } + let from: usize = body_from(f, bs, be); + return (fold_eq(f, from, be, b, bf, bt) || fold_eq(f, from, body_to(f, bs, be), b, bf, bt)); +} + +pub fn said(f: &'static str, from: usize, to: usize, b: &'static str, bf: usize, bt: usize) -> bool { + let mut at: usize = from; + let mut bs: usize = to; + let mut be: usize = to; + while (at < to) { + let stop: usize = line_end(f, at); + let blank: bool = blank_line(f, at, stop); + if (blank || is_bullet(f, at, stop)) { + if ((bs < to) && block_says(f, bs, be, b, bf, bt)) { + return true; + } + bs = to; + } + if !(blank) { + if (bs >= to) { + bs = at; + } + be = stop; + } + at = next_line(f, at); + } + return ((bs < to) && block_says(f, bs, be, b, bf, bt)); +} + +pub fn decide(skill: &'static str, skill_is_dir: bool, f: &'static str, lesson: &'static str, date: &'static str, cite: &'static str) -> u8 { + if !(skill_ok(skill)) { + return O_BAD_SKILL; + } + if !(skill_is_dir) { + return O_NO_SKILL; + } + let lf: usize = lesson_from(lesson); + let lt: usize = lesson_to(lesson); + let keys: usize = key_len(lesson, lf, lt); + if (keys == 0) { + return O_EMPTY; + } + if has_control(lesson, lf, lt) { + return O_CONTROL; + } + if ((lt - lf) > MAX_LESSON) { + return O_TOO_LONG; + } + if secret_shaped(lesson, lf, lt) { + return O_SECRET; + } + if !(date_ok(date)) { + return O_BAD_DATE; + } + if !(ref_ok(cite)) { + return O_BAD_REF; + } + let h: usize = trap_line(f); + if (h >= f.len()) { + return O_ADD_SECTION; + } + if said(f, next_line(f, h), section_end(f, h), lesson, lf, lt) { + return O_DUPLICATE; + } + return O_ADD; +} + +pub fn writes(o: u8) -> bool { + return ((o == O_ADD) || (o == O_ADD_SECTION)); +} + +pub fn refused(o: u8) -> bool { + return (o >= O_EMPTY); +} + +pub fn say(o: u8) -> &'static str { + if (o == O_ADD) { + return "added"; + } + if (o == O_ADD_SECTION) { + return "added, in a new Traps section at the end of the file"; + } + if (o == O_DUPLICATE) { + return "duplicate"; + } + if (o == O_EMPTY) { + return "the lesson has no letter or digit in it"; + } + if (o == O_CONTROL) { + return "the lesson has a line break or another control byte: one lesson is one line"; + } + if (o == O_TOO_LONG) { + return "the lesson is longer than 400 bytes: say it shorter"; + } + if (o == O_SECRET) { + return "the lesson looks like a secret (a long hex or base64-like run, a token prefix, a private key): cite a commit by its first 12 characters, and never paste a key"; + } + if (o == O_BAD_DATE) { + return "the date is not YYYY-MM-DD"; + } + if (o == O_BAD_REF) { + return "the ref is not empty or one word of at most 80 printable characters, with no parenthesis and no secret in it"; + } + if (o == O_BAD_SKILL) { + return "the skill is not one directory name (letters, digits, _ and -)"; + } + return "there is no such directory under .claude/skills/"; +} + +pub fn tail_newlines(f: &'static str) -> usize { + let mut n: usize = 0; + while ((n < f.len()) && (f.as_bytes()[(((f.len() - 1) - n)) as usize] == 10)) { + n = (n + 1); + } + return n; +} + +pub fn patch_at(f: &'static str) -> usize { + let h: usize = trap_line(f); + if (h >= f.len()) { + return f.len(); + } + return insert_at(f, h); +} + +pub fn lead_len(f: &'static str) -> usize { + let h: usize = trap_line(f); + if (h >= f.len()) { + if (f.len() == 0) { + return 0; + } + if (tail_newlines(f) >= 2) { + return 0; + } + return (2 - tail_newlines(f)); + } + let at: usize = insert_at(f, h); + let mut n: usize = 0; + if ((at > 0) && (f.as_bytes()[((at - 1)) as usize] != 10)) { + n = (n + 1); + } + if (at == next_line(f, h)) { + n = (n + 1); + } + return n; +} + +pub fn head_len(f: &'static str) -> usize { + if (trap_line(f) < f.len()) { + return 0; + } + return SECTION_HEAD.len(); +} + +pub fn row_line(f: &'static str) -> usize { + let at: usize = patch_at(f); + let mut n: usize = (1 + lead_len(f)); + if (head_len(f) > 0) { + n = (n + 2); + } + let mut k: usize = 0; + while (k < at) { + if (f.as_bytes()[(k) as usize] == 10) { + n = (n + 1); + } + k = (k + 1); + } + return n; +} + +pub fn put_span(out: &mut [u8], w: usize, s: &'static str, from: usize, to: usize) -> usize { + let mut at: usize = w; + let mut k: usize = from; + while (k < to) { + out[(at) as usize] = s.as_bytes()[(k) as usize]; + at = (at + 1); + k = (k + 1); + } + return at; +} + +pub fn patch(f: &'static str, lesson: &'static str, date: &'static str, cite: &'static str, out: &mut [u8]) -> usize { + let lf: usize = lesson_from(lesson); + let lt: usize = lesson_to(lesson); + let lead: usize = lead_len(f); + let head: usize = head_len(f); + let mut total: usize = ((((((lead + head) + 2) + date.len()) + 1) + (lt - lf)) + 1); + if (cite.len() > 0) { + total = ((total + 3) + cite.len()); + } + if (total > out.len()) { + return 0; + } + let mut w: usize = 0; + while (w < lead) { + out[(w) as usize] = 10; + w = (w + 1); + } + w = put_str(out, out.len(), w, SECTION_HEAD, head); + out[(w) as usize] = 45; + out[((w + 1)) as usize] = 32; + w = put_span(out, (w + 2), date, 0, date.len()); + out[(w) as usize] = 32; + w = put_span(out, (w + 1), lesson, lf, lt); + if (cite.len() > 0) { + out[(w) as usize] = 32; + out[((w + 1)) as usize] = 40; + w = put_span(out, (w + 2), cite, 0, cite.len()); + out[(w) as usize] = 41; + w = (w + 1); + } + out[(w) as usize] = 10; + return (w + 1); +} + +pub const A_HEAD: &'static str = "# Demo\n\nintro line\n\n"; + +pub const A_PRE: &'static str = "# Demo\n\nintro line\n\n## Traps\n\n- `gh` fails TLS inside the sandbox; use curl with the token.\n- the zig cache is not writable, so a seal says tests blocked\n until it is run outside the sandbox (#10500)\n- 2026-10-18 a bench job needs a second approval before it starts (#9000)\n"; + +pub const A_POST: &'static str = "\n## After\n\n- not a trap: a lesson about something else entirely\n"; + +pub const A: &'static str = "# Demo\n\nintro line\n\n## Traps\n\n- `gh` fails TLS inside the sandbox; use curl with the token.\n- the zig cache is not writable, so a seal says tests blocked\n until it is run outside the sandbox (#10500)\n- 2026-10-18 a bench job needs a second approval before it starts (#9000)\n\n## After\n\n- not a trap: a lesson about something else entirely\n"; + +pub const A_ROW: &'static str = "- 2026-10-19 a new lesson that is long enough to keep (#11060)\n"; + +pub const A_AFTER: &'static str = "# Demo\n\nintro line\n\n## Traps\n\n- `gh` fails TLS inside the sandbox; use curl with the token.\n- the zig cache is not writable, so a seal says tests blocked\n until it is run outside the sandbox (#10500)\n- 2026-10-18 a bench job needs a second approval before it starts (#9000)\n- 2026-10-19 a new lesson that is long enough to keep (#11060)\n\n## After\n\n- not a trap: a lesson about something else entirely\n"; + +pub const NEW: &'static str = "a new lesson that is long enough to keep"; + +pub const B: &'static str = "# Demo\n\n## Traps\n\n- one lesson that is long enough to keep"; + +pub const C: &'static str = "# Demo\n\n## Traps\n\n## After\n\nbody\n"; + +pub const D: &'static str = "# Demo\n\nSome text.\n"; + +pub const E_NO_NEWLINE: &'static str = "# Demo\n\nSome text."; + +pub const F: &'static str = "## Notes\n\n```\n## Traps\n```\n\n### Traps met on this Mac\n\n- first lesson that is long enough\n\n#### sub heading\n\n- second lesson that is long enough\n\n### Next\n\n- third lesson\n\n# Top\n"; + +pub const L400: &'static str = "abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghij"; + +pub const L401: &'static str = "abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghijk"; + +pub const SK: &'static str = "ci-gates"; + +pub const DT: &'static str = "2026-10-19"; + +pub fn add(f: &'static str, lesson: &'static str, cite: &'static str) -> u8 { + return decide(SK, true, f, lesson, DT, cite); +} + +pub fn bytes_are(out: impl AsRef<[u8]>, n: usize, lit: &'static str) -> bool { + let out: &[u8] = out.as_ref(); + if (n != lit.len()) { + return false; + } + let mut k: usize = 0; + while (k < n) { + if (out[(k) as usize] != lit.as_bytes()[(k) as usize]) { + return false; + } + k = (k + 1); + } + return true; +} + +pub const G: &'static str = "## Traps\n\n- 2026-10-19 use curl (#5)\n- tls\n- plain words here that are long enough\n"; + +pub const BL: &'static str = "- 2026-10-19 use curl (#5)"; + +pub fn put_str(dst: &mut [u8], cap: usize, w: usize, s: &'static str, s_len: usize) -> usize { + let mut i: usize = 0; + while (i < s_len) { + if ((w + i) < cap) { + dst[((w + i)) as usize] = s.as_bytes()[(i) as usize]; + } + i += 1; + } + return (w + s_len); +} + +pub const V_BEHIND: u8 = 2; + +pub const V_BLOCKED: u8 = 3; + +pub const V_NOT_RUN: u8 = 4; + +pub const V_RED: u8 = 5; + +pub const V_VACUOUS: u8 = 1; + +pub const V_VERIFIED: u8 = 0; + +pub fn line_end(text: &'static str, at: usize) -> usize { + let mut k: usize = at; + while ((k < text.len()) && (text.as_bytes()[(k) as usize] != 10)) { + k = (k + 1); + } + return k; +} + +pub fn name(v: u8) -> &'static str { + if (v == V_VERIFIED) { + return "verified"; + } + if (v == V_VACUOUS) { + return "vacuous"; + } + if (v == V_BEHIND) { + return "behind"; + } + if (v == V_BLOCKED) { + return "blocked"; + } + if (v == V_NOT_RUN) { + return "not-run"; + } + if (v == V_RED) { + return "RED"; + } + return "UNREAD"; +} + +pub fn span_is(text: &'static str, from: usize, to: usize, lit: &'static str) -> bool { + if ((to < from) || ((to - from) != lit.len())) { + return false; + } + let mut k: usize = 0; + while (k < lit.len()) { + if (text.as_bytes()[((from + k)) as usize] != lit.as_bytes()[(k) as usize]) { + return false; + } + k = (k + 1); + } + return true; +} + +pub fn why(v: u8) -> &'static str { + if (v == V_VERIFIED) { + return "verified: t27b passes with runtime asserts"; + } + if (v == V_VACUOUS) { + return "vacuous: it passes with 0 runtime asserts"; + } + if (v == V_BEHIND) { + return "behind: the reference passes, t27b is blocked"; + } + if (v == V_BLOCKED) { + return "blocked: neither the reference nor t27b runs it"; + } + if (v == V_NOT_RUN) { + return "not run: the lab ran only the reference"; + } + if (v == V_RED) { + return "RED: a failure, timeout, crash or mismatch"; + } + return "UNREAD: the lab's result for it cannot be read"; +} + diff --git a/specs/tri/loop/lesson.t27 b/specs/tri/loop/lesson.t27 new file mode 100644 index 0000000000..a6f5aa0e63 --- /dev/null +++ b/specs/tri/loop/lesson.t27 @@ -0,0 +1,959 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/tri/loop/lesson.t27 -- what `tri learn add` decides when a lane saves a lesson into a skill (gHashTag/t27#11060) +; Part of the night-loop epic. A lesson learned in one lane (a sandbox trap, a gate shape, a measured +; number) was lost unless someone edited the right skill by hand, and a lesson pasted twice made two +; lines. This spec is the decision: given the skill's file and a lesson, either one line is added to the +; skill's Traps section, or nothing is written and the answer says why. The command (cli/tri/src/learn.rs, +; through `t27c gen-rust` in gen/rust/tri/loop/lesson.rs) reads the file, asks the functions below, and +; writes what `patch` produced at `patch_at`. It runs no other command and contacts no network or machine. +; -- +; The rules, first refusal wins, in this order (the codes are O_* below): +; - the skill name is one directory name: 1 to 64 bytes of A-Z a-z 0-9 _ -, opening with a letter or a +; digit, so `..`, `a/b`, `.x`, an absolute path or an empty name cannot leave .claude/skills/ (BAD_SKILL); +; a name that is no directory there is NO_SKILL (the command tells the spec, the spec does not look); +; - the lesson, trimmed of spaces and tabs at both ends, with no letter or digit in it is EMPTY; +; - a line break or another control byte (tab excepted) in it is CONTROL: one lesson is one line; +; - more than 400 bytes is TOO_LONG, exactly 400 is fine; +; - SECRET when it looks like a credential: a run of 32 or more hex digits (a git commit is quoted by its +; first 12), a run of 40 or more of A-Z a-z 0-9 + / = holding upper case, lower case and a digit and +; changing class between letters and digits at least once in two (random text does; a file path with a +; capital in it and a long CamelCase name do not), a token prefix (ghp_ gho_ ghu_ ghs_ ghr_ +; github_pat_ sk- xoxb- xoxp- AKIA) that opens a word and is followed by 8 more token bytes, or +; BEGIN together with PRIVATE KEY. This is a heuristic that +; refuses what looks like a key; it can miss one and it is not a secret scanner, which still runs on +; the commit; +; - the date is YYYY-MM-DD (BAD_DATE); the ref is empty or one word of printable ASCII with no +; parenthesis, at most 80 bytes, not secret-shaped (BAD_REF); +; - DUPLICATE when the section already says it. The key of a text is its letters and digits with upper +; case folded to lower, every other ASCII byte dropped (a byte above 127 is kept as it is). A lesson +; is said when its key is a substring of the key of one block of the section, a block being a bullet +; with its continuation lines, or a paragraph. So a repeat, a repeat with other case or punctuation, a +; repeat of a line that carries a date and a ref, and a lesson a hand-written bullet already states +; are all DUPLICATE; a lesson that only has an existing bullet inside it is new. A lesson with fewer +; than 12 letters and digits is too short to be told by what it is inside of, so it is said only when +; its key IS the key of a block, taken without the bullet mark, a leading date and a trailing (ref); +; - otherwise ADD, or ADD_SECTION when the file has no Traps section and one is created at the end. +; -- +; The Traps section is the first heading, at any level, whose title opens with `Traps` or `Trap` (so +; `## Traps`, `## Traps that cost hours` and `## Traps met on this Mac`), outside a code fence. It runs to the +; next heading of the same or a higher level, or to the end of the file. A row goes after the last line of +; the section that is not blank; a section with nothing in it gets a blank line first. +; The row is `- ()`, with no ` ()` when the ref is empty, then a newline. +; Searched before writing (FR-001, DRY): `tri experience save` writes an episode of counts (no lesson text), +; `tri skill add` and `tri skill fold` spool a whole numbered section for a numbered SKILL.md (ci-gates) and +; check nothing against what is there, `/experience-save` and .trinity/experience.md are an append-only log +; kept by hand, specs/tri/loop/tick.t27 plans a tick. None puts one line in the Traps section of a skill +; once, so this is new; put_str is imported from specs/claw/text.t27. +; ASCII only (L3) +; phi^2 + 1/phi^2 = 3 | TRINITY + +module TriLoopLesson; + +use tri::lab::receipt::line_end; +use tri::lab::receipt::span_is; +use claw::text::put_str; + +pub const MAX_LESSON : usize = 400; +pub const MAX_REF : usize = 80; +pub const MIN_KEY : usize = 12; +pub const HEX_RUN : usize = 32; +pub const B64_RUN : usize = 40; +pub const TAIL_MIN : usize = 8; +pub const MAX_SKILL : usize = 64; +pub const SECTION_HEAD : string = "## Traps\n\n"; + +// What was decided. ADD and ADD_SECTION write; DUPLICATE writes nothing and is not a failure; the rest refuse. +pub const O_ADD : u8 = 0; +pub const O_ADD_SECTION : u8 = 1; +pub const O_DUPLICATE : u8 = 2; +pub const O_EMPTY : u8 = 3; +pub const O_CONTROL : u8 = 4; +pub const O_TOO_LONG : u8 = 5; +pub const O_SECRET : u8 = 6; +pub const O_BAD_DATE : u8 = 7; +pub const O_BAD_REF : u8 = 8; +pub const O_BAD_SKILL : u8 = 9; +pub const O_NO_SKILL : u8 = 10; +pub const OUTCOMES : u8 = 11; + +invariant the_outcomes_are_numbered { assert O_ADD == 0 and O_DUPLICATE == 2 and O_SECRET == 6 and O_NO_SKILL == 10 and OUTCOMES == 11; } +invariant the_limits_are_the_issues { assert MAX_LESSON == 400 and HEX_RUN == 32 and B64_RUN == 40 and MIN_KEY == 12; } + +// ---- bytes ------------------------------------------------------------------------------------------ + +pub fn lower(b: u8) -> u8 { + if (b >= 65 and b <= 90) { return b + 32; } + return b; +} + +pub fn is_digit(b: u8) -> bool { + return b >= 48 and b <= 57; +} + +pub fn is_alnum(b: u8) -> bool { + return is_digit(b) or (b >= 65 and b <= 90) or (b >= 97 and b <= 122); +} + +pub fn is_hex(b: u8) -> bool { + return is_digit(b) or (b >= 65 and b <= 70) or (b >= 97 and b <= 102); +} + +/// A byte that stays in a key: a letter, a digit, or anything above 127. +pub fn is_key(b: u8) -> bool { + return is_alnum(b) or b >= 128; +} + +pub fn is_b64(b: u8) -> bool { + return is_alnum(b) or b == 43 or b == 47 or b == 61; +} + +/// The bytes a token may continue with after its prefix. +pub fn is_tail(b: u8) -> bool { + return is_alnum(b) or b == 95 or b == 45; +} + +/// 1 lower case, 2 upper case, 3 digit, 0 anything else. +pub fn class_of(b: u8) -> u8 { + if (b >= 97 and b <= 122) { return 1; } + if (b >= 65 and b <= 90) { return 2; } + if (is_digit(b)) { return 3; } + return 0; +} + +// ---- the key of a text ------------------------------------------------------------------------------ + +/// The first offset at or after k, below to, of a byte that stays in a key; to when there is none. +pub fn next_key(s: string, k: usize, to: usize) -> usize { + var i : usize = k; + while (i < to and !is_key(s[i])) { + i = i + 1; + } + return i; +} + +/// How many bytes of s[from..to] stay in its key. +pub fn key_len(s: string, from: usize, to: usize) -> usize { + var n : usize = 0; + var i : usize = next_key(s, from, to); + while (i < to) { + n = n + 1; + i = next_key(s, i + 1, to); + } + return n; +} + +/// Does the key of b[bf..bt] open the key of a[i..to], whatever follows it? +pub fn fold_at(a: string, i: usize, to: usize, b: string, bf: usize, bt: usize) -> bool { + var x : usize = next_key(a, i, to); + var y : usize = next_key(b, bf, bt); + while (y < bt) { + if (x >= to) { return false; } + if (lower(a[x]) != lower(b[y])) { return false; } + x = next_key(a, x + 1, to); + y = next_key(b, y + 1, bt); + } + return true; +} + +/// Is the key of b[bf..bt] a substring of the key of a[from..to]? +pub fn fold_in(a: string, from: usize, to: usize, b: string, bf: usize, bt: usize) -> bool { + var i : usize = next_key(a, from, to); + while (i < to) { + if (fold_at(a, i, to, b, bf, bt)) { return true; } + i = next_key(a, i + 1, to); + } + return false; +} + +// ---- the lesson ------------------------------------------------------------------------------------- + +/// Where the lesson starts once the spaces and tabs before it are skipped. +pub fn lesson_from(s: string) -> usize { + var k : usize = 0; + while (k < s.len and (s[k] == 32 or s[k] == 9)) { + k = k + 1; + } + return k; +} + +/// Where the lesson ends once the spaces and tabs after it are dropped; never before lesson_from. +pub fn lesson_to(s: string) -> usize { + const from : usize = lesson_from(s); + var k : usize = s.len; + while (k > from and (s[k - 1] == 32 or s[k - 1] == 9)) { + k = k - 1; + } + return k; +} + +/// Is there a line break or another control byte, a tab apart, in s[from..to]? +pub fn has_control(s: string, from: usize, to: usize) -> bool { + var k : usize = from; + while (k < to) { + if ((s[k] < 32 and s[k] != 9) or s[k] == 127) { return true; } + k = k + 1; + } + return false; +} + +// ---- what looks like a secret ----------------------------------------------------------------------- + +/// Is lit at s[k..], inside to? +pub fn starts_at(s: string, k: usize, to: usize, lit: string) -> bool { + return k + lit.len <= to and span_is(s, k, k + lit.len, lit); +} + +pub fn has_text(s: string, from: usize, to: usize, lit: string) -> bool { + var k : usize = from; + while (k + lit.len <= to) { + if (span_is(s, k, k + lit.len, lit)) { return true; } + k = k + 1; + } + return false; +} + +/// A run of HEX_RUN hex digits or more. +pub fn has_hex_run(s: string, from: usize, to: usize) -> bool { + var run : usize = 0; + var k : usize = from; + while (k < to) { + run = run + 1; + if (!is_hex(s[k])) { run = 0; } + if (run >= HEX_RUN) { return true; } + k = k + 1; + } + return false; +} + +/// Is the run s[a..b] of base64 bytes key-shaped: long, all three classes, and changing class often? +pub fn mixed_run(s: string, a: usize, b: usize) -> bool { + if (b - a < B64_RUN) { return false; } + var lo : bool = false; + var up : bool = false; + var dg : bool = false; + var turns : usize = 0; + var k : usize = a; + while (k < b) { + const c : u8 = class_of(s[k]); + if (c == 1) { lo = true; } + if (c == 2) { up = true; } + if (c == 3) { dg = true; } + if (k > a and c != 0 and class_of(s[k - 1]) != 0 and c != class_of(s[k - 1])) { turns = turns + 1; } + k = k + 1; + } + return lo and up and dg and turns * 2 >= b - a; +} + +pub fn has_b64_run(s: string, from: usize, to: usize) -> bool { + var rs : usize = from; + var k : usize = from; + while (k <= to) { + var cut : bool = true; + if (k < to) { cut = !is_b64(s[k]); } + if (cut) { + if (mixed_run(s, rs, k)) { return true; } + rs = k + 1; + } + k = k + 1; + } + return false; +} + +/// The length of the token prefix that opens at s[k..], 0 when none does. +pub fn prefix_len(s: string, k: usize, to: usize) -> usize { + if (starts_at(s, k, to, "github_pat_")) { return 11; } + if (starts_at(s, k, to, "ghp_") or starts_at(s, k, to, "gho_") or starts_at(s, k, to, "ghu_")) { return 4; } + if (starts_at(s, k, to, "ghs_") or starts_at(s, k, to, "ghr_") or starts_at(s, k, to, "AKIA")) { return 4; } + if (starts_at(s, k, to, "xoxb-") or starts_at(s, k, to, "xoxp-")) { return 5; } + if (starts_at(s, k, to, "sk-")) { return 3; } + return 0; +} + +pub fn at_word_start(s: string, k: usize, from: usize) -> bool { + if (k <= from) { return true; } + return !is_alnum(s[k - 1]); +} + +/// Do TAIL_MIN token bytes follow at s[k..]? +pub fn has_tail(s: string, k: usize, to: usize) -> bool { + var n : usize = 0; + while (k + n < to and n < TAIL_MIN and is_tail(s[k + n])) { + n = n + 1; + } + return n >= TAIL_MIN; +} + +pub fn has_prefixed_token(s: string, from: usize, to: usize) -> bool { + var k : usize = from; + while (k < to) { + const p : usize = prefix_len(s, k, to); + if (p > 0 and at_word_start(s, k, from) and has_tail(s, k + p, to)) { return true; } + k = k + 1; + } + return false; +} + +pub fn secret_shaped(s: string, from: usize, to: usize) -> bool { + if (has_hex_run(s, from, to) or has_b64_run(s, from, to)) { return true; } + if (has_prefixed_token(s, from, to)) { return true; } + return has_text(s, from, to, "BEGIN") and has_text(s, from, to, "PRIVATE KEY"); +} + +// ---- the arguments ---------------------------------------------------------------------------------- + +/// A skill name: one directory name under .claude/skills/. +pub fn skill_ok(name: string) -> bool { + if (name.len == 0 or name.len > MAX_SKILL) { return false; } + if (!is_alnum(name[0])) { return false; } + var k : usize = 1; + while (k < name.len) { + if (!is_tail(name[k])) { return false; } + k = k + 1; + } + return true; +} + +/// Is s[a..a+10] a date, YYYY-MM-DD with the month 01 to 12 and the day 01 to 31, and is it inside to? +pub fn date_at(s: string, a: usize, to: usize) -> bool { + if (a + 10 > to or s[a + 4] != 45 or s[a + 7] != 45) { return false; } + var k : usize = 0; + while (k < 10) { + if (k != 4 and k != 7 and !is_digit(s[a + k])) { return false; } + k = k + 1; + } + const month : u8 = (s[a + 5] - 48) * 10 + (s[a + 6] - 48); + const day : u8 = (s[a + 8] - 48) * 10 + (s[a + 9] - 48); + return month >= 1 and month <= 12 and day >= 1 and day <= 31; +} + +/// A date, and nothing else. +pub fn date_ok(d: string) -> bool { + return d.len == 10 and date_at(d, 0, d.len); +} + +/// Empty, or one word of printable ASCII, no parenthesis, short, and not secret-shaped. +pub fn ref_ok(r: string) -> bool { + if (r.len > MAX_REF) { return false; } + var k : usize = 0; + while (k < r.len) { + if (r[k] <= 32 or r[k] >= 127 or r[k] == 40 or r[k] == 41) { return false; } + k = k + 1; + } + return !secret_shaped(r, 0, r.len); +} + +// ---- the Traps section ------------------------------------------------------------------------------ + +/// The offset after the line that starts at `at`: past its newline, at most f.len. +pub fn next_line(f: string, at: usize) -> usize { + const e : usize = line_end(f, at); + if (e >= f.len) { return f.len; } + return e + 1; +} + +/// Only spaces, tabs and carriage returns. +pub fn blank_line(f: string, at: usize, stop: usize) -> bool { + var k : usize = at; + while (k < stop) { + if (f[k] != 32 and f[k] != 9 and f[k] != 13) { return false; } + k = k + 1; + } + return true; +} + +/// The number of `#` that open the line when a space or the end follows (1 to 6), else 0. +pub fn heading_level(f: string, at: usize, stop: usize) -> usize { + var n : usize = 0; + while (at + n < stop and f[at + n] == 35) { + n = n + 1; + } + if (n == 0 or n > 6) { return 0; } + if (at + n < stop and f[at + n] != 32 and f[at + n] != 9) { return 0; } + return n; +} + +/// The code fence the line opens or closes: 96 for ``` and 126 for ~~~ (up to three spaces before it), else 0. +pub fn fence_char(f: string, at: usize, stop: usize) -> u8 { + var k : usize = at; + while (k < stop and k < at + 3 and f[k] == 32) { + k = k + 1; + } + if (k + 3 > stop) { return 0; } + if (f[k] == 96 and f[k + 1] == 96 and f[k + 2] == 96) { return 96; } + if (f[k] == 126 and f[k + 1] == 126 and f[k + 2] == 126) { return 126; } + return 0; +} + +/// The fence state after a line: outside (0) a line that opens a fence enters it, inside a line with the same fence leaves it. +pub fn fence_after(fence: u8, fc: u8) -> u8 { + if (fence == 0) { return fc; } + if (fc == fence) { return 0; } + return fence; +} + +/// Does the title of the heading line open with `Trap` or `Traps`, any case, and no letter after it? +pub fn is_traps(f: string, at: usize, stop: usize) -> bool { + var t : usize = at; + while (t < stop and f[t] == 35) { + t = t + 1; + } + while (t < stop and (f[t] == 32 or f[t] == 9)) { + t = t + 1; + } + if (t + 4 > stop) { return false; } + if (lower(f[t]) != 116 or lower(f[t + 1]) != 114 or lower(f[t + 2]) != 97 or lower(f[t + 3]) != 112) { return false; } + var k : usize = t + 4; + if (k < stop and lower(f[k]) == 115) { k = k + 1; } + if (k >= stop) { return true; } + return !is_alnum(f[k]); +} + +/// The start of the line that opens the first Traps section outside a code fence; f.len when there is none. +pub fn trap_line(f: string) -> usize { + var at : usize = 0; + var fence : u8 = 0; + while (at < f.len) { + const stop : usize = line_end(f, at); + const fc : u8 = fence_char(f, at, stop); + if (fence == 0 and fc == 0 and heading_level(f, at, stop) > 0 and is_traps(f, at, stop)) { return at; } + fence = fence_after(fence, fc); + at = next_line(f, at); + } + return f.len; +} + +/// Where the section that opens at h ends: the next heading of its level or a higher one outside a fence, else f.len. +pub fn section_end(f: string, h: usize) -> usize { + const level : usize = heading_level(f, h, line_end(f, h)); + var at : usize = next_line(f, h); + var fence : u8 = 0; + while (at < f.len) { + const stop : usize = line_end(f, at); + const fc : u8 = fence_char(f, at, stop); + if (fence == 0 and fc == 0) { + const l : usize = heading_level(f, at, stop); + if (l > 0 and l <= level) { return at; } + } + fence = fence_after(fence, fc); + at = next_line(f, at); + } + return f.len; +} + +/// The offset just after the last line of the section that is not blank; just after the heading when there is none. +pub fn insert_at(f: string, h: usize) -> usize { + const e : usize = section_end(f, h); + var last : usize = next_line(f, h); + var at : usize = last; + while (at < e) { + if (!blank_line(f, at, line_end(f, at))) { last = next_line(f, at); } + at = next_line(f, at); + } + return last; +} + +/// Does a bullet open this line: `-`, `*` or `+` and a space, in column 0? +pub fn is_bullet(f: string, at: usize, stop: usize) -> bool { + if (at + 2 > stop) { return false; } + return (f[at] == 45 or f[at] == 42 or f[at] == 43) and f[at + 1] == 32; +} + +/// Where the words of the block f[bs..be] start: after a bullet mark and after a date and the space that follows it. +pub fn body_from(f: string, bs: usize, be: usize) -> usize { + var k : usize = bs; + if (is_bullet(f, bs, be)) { k = bs + 2; } + if (k + 11 <= be and f[k + 10] == 32 and date_at(f, k, be)) { k = k + 11; } + return k; +} + +/// Where the words of the block f[bs..be] end: before a `(ref)` that closes it, else at its end. +pub fn body_to(f: string, bs: usize, be: usize) -> usize { + var e : usize = be; + while (e > bs and (f[e - 1] == 32 or f[e - 1] == 9 or f[e - 1] == 13)) { + e = e - 1; + } + if (e == bs or f[e - 1] != 41) { return be; } + var k : usize = e - 1; + while (k > bs and f[k] != 40) { + k = k - 1; + } + if (f[k] == 40 and k > bs) { return k; } + return be; +} + +/// Does the key of f[a..z] equal the key of b[bf..bt]? +pub fn fold_eq(f: string, a: usize, z: usize, b: string, bf: usize, bt: usize) -> bool { + return key_len(f, a, z) == key_len(b, bf, bt) and fold_at(f, a, z, b, bf, bt); +} + +/// Does the block f[bs..be] say the lesson b[bf..bt]? A lesson of MIN_KEY letters and digits or more is said +/// when its key is inside the block's; a shorter one only when it is the block's words, as written or without +/// their closing (ref). +pub fn block_says(f: string, bs: usize, be: usize, b: string, bf: usize, bt: usize) -> bool { + if (key_len(b, bf, bt) >= MIN_KEY) { return fold_in(f, bs, be, b, bf, bt); } + const from : usize = body_from(f, bs, be); + return fold_eq(f, from, be, b, bf, bt) or fold_eq(f, from, body_to(f, bs, be), b, bf, bt); +} + +/// Does one block of f[from..to], a bullet and its continuation lines or a paragraph, already say the lesson b[bf..bt]? +pub fn said(f: string, from: usize, to: usize, b: string, bf: usize, bt: usize) -> bool { + var at : usize = from; + var bs : usize = to; + var be : usize = to; + while (at < to) { + const stop : usize = line_end(f, at); + const blank : bool = blank_line(f, at, stop); + if (blank or is_bullet(f, at, stop)) { + if (bs < to and block_says(f, bs, be, b, bf, bt)) { return true; } + bs = to; + } + if (!blank) { + if (bs >= to) { bs = at; } + be = stop; + } + at = next_line(f, at); + } + return bs < to and block_says(f, bs, be, b, bf, bt); +} + +// ---- the decision and the patch --------------------------------------------------------------------- + +/// What `tri learn add` does with this lesson. `skill_is_dir` is the command's one fact about the disk: +/// .claude/skills/ is a directory. f is the skill's file. The refusals come in the order of the header. +pub fn decide(skill: string, skill_is_dir: bool, f: string, lesson: string, date: string, cite: string) -> u8 { + if (!skill_ok(skill)) { return O_BAD_SKILL; } + if (!skill_is_dir) { return O_NO_SKILL; } + const lf : usize = lesson_from(lesson); + const lt : usize = lesson_to(lesson); + const keys : usize = key_len(lesson, lf, lt); + if (keys == 0) { return O_EMPTY; } + if (has_control(lesson, lf, lt)) { return O_CONTROL; } + if (lt - lf > MAX_LESSON) { return O_TOO_LONG; } + if (secret_shaped(lesson, lf, lt)) { return O_SECRET; } + if (!date_ok(date)) { return O_BAD_DATE; } + if (!ref_ok(cite)) { return O_BAD_REF; } + const h : usize = trap_line(f); + if (h >= f.len) { return O_ADD_SECTION; } + if (said(f, next_line(f, h), section_end(f, h), lesson, lf, lt)) { return O_DUPLICATE; } + return O_ADD; +} + +/// Does the outcome write the file? +pub fn writes(o: u8) -> bool { + return o == O_ADD or o == O_ADD_SECTION; +} + +/// Is the outcome a refusal: nothing is written and the command fails. +pub fn refused(o: u8) -> bool { + return o >= O_EMPTY; +} + +pub fn say(o: u8) -> string { + if (o == O_ADD) { return "added"; } + if (o == O_ADD_SECTION) { return "added, in a new Traps section at the end of the file"; } + if (o == O_DUPLICATE) { return "duplicate"; } + if (o == O_EMPTY) { return "the lesson has no letter or digit in it"; } + if (o == O_CONTROL) { return "the lesson has a line break or another control byte: one lesson is one line"; } + if (o == O_TOO_LONG) { return "the lesson is longer than 400 bytes: say it shorter"; } + if (o == O_SECRET) { return "the lesson looks like a secret (a long hex or base64-like run, a token prefix, a private key): cite a commit by its first 12 characters, and never paste a key"; } + if (o == O_BAD_DATE) { return "the date is not YYYY-MM-DD"; } + if (o == O_BAD_REF) { return "the ref is not empty or one word of at most 80 printable characters, with no parenthesis and no secret in it"; } + if (o == O_BAD_SKILL) { return "the skill is not one directory name (letters, digits, _ and -)"; } + return "there is no such directory under .claude/skills/"; +} + +/// Trailing newlines of the file. +pub fn tail_newlines(f: string) -> usize { + var n : usize = 0; + while (n < f.len and f[f.len - 1 - n] == 10) { + n = n + 1; + } + return n; +} + +/// The offset of f where the patch goes: after the last line of the Traps section that is not blank, or the end of the file when there is no section. +pub fn patch_at(f: string) -> usize { + const h : usize = trap_line(f); + if (h >= f.len) { return f.len; } + return insert_at(f, h); +} + +/// Newlines the patch opens with: one to end a last line that has none, one blank line under a heading that has nothing under it, or +/// the blank line that keeps a new section apart from the text before it. +pub fn lead_len(f: string) -> usize { + const h : usize = trap_line(f); + if (h >= f.len) { + if (f.len == 0) { return 0; } + if (tail_newlines(f) >= 2) { return 0; } + return 2 - tail_newlines(f); + } + const at : usize = insert_at(f, h); + var n : usize = 0; + if (at > 0 and f[at - 1] != 10) { n = n + 1; } + if (at == next_line(f, h)) { n = n + 1; } + return n; +} + +/// The heading a new section opens with, 0 bytes when the file has the section. +pub fn head_len(f: string) -> usize { + if (trap_line(f) < f.len) { return 0; } + return SECTION_HEAD.len; +} + +/// The 1-based line of the row in the file once the patch is in. +pub fn row_line(f: string) -> usize { + const at : usize = patch_at(f); + var n : usize = 1 + lead_len(f); + if (head_len(f) > 0) { n = n + 2; } + var k : usize = 0; + while (k < at) { + if (f[k] == 10) { n = n + 1; } + k = k + 1; + } + return n; +} + +pub fn put_span(out: []u8, w: usize, s: string, from: usize, to: usize) -> usize { + var at : usize = w; + var k : usize = from; + while (k < to) { + out[at] = s[k]; + at = at + 1; + k = k + 1; + } + return at; +} + +/// The bytes to insert at patch_at(f), written into out: the lead newlines, the heading of a new section, then +/// `- `, ` ()` when there is one, and a newline. Its length; 0 when it does not fit out. +pub fn patch(f: string, lesson: string, date: string, cite: string, out: []u8) -> usize { + const lf : usize = lesson_from(lesson); + const lt : usize = lesson_to(lesson); + const lead : usize = lead_len(f); + const head : usize = head_len(f); + var total : usize = lead + head + 2 + date.len + 1 + (lt - lf) + 1; + if (cite.len > 0) { total = total + 3 + cite.len; } + if (total > out.len) { return 0; } + var w : usize = 0; + while (w < lead) { + out[w] = 10; + w = w + 1; + } + w = put_str(out, out.len, w, SECTION_HEAD, head); + out[w] = 45; + out[w + 1] = 32; + w = put_span(out, w + 2, date, 0, date.len); + out[w] = 32; + w = put_span(out, w + 1, lesson, lf, lt); + if (cite.len > 0) { + out[w] = 32; + out[w + 1] = 40; + w = put_span(out, w + 2, cite, 0, cite.len); + out[w] = 41; + w = w + 1; + } + out[w] = 10; + return w + 1; +} + +// ---- tests ------------------------------------------------------------------------------------------ + +// A skill with a Traps section between two others, a hand-written wrapped bullet in it, and a heading inside a code fence. +const A_HEAD : string = "# Demo\n\nintro line\n\n"; +const A_PRE : string = "# Demo\n\nintro line\n\n## Traps\n\n- `gh` fails TLS inside the sandbox; use curl with the token.\n- the zig cache is not writable, so a seal says tests blocked\n until it is run outside the sandbox (#10500)\n- 2026-10-18 a bench job needs a second approval before it starts (#9000)\n"; +const A_POST : string = "\n## After\n\n- not a trap: a lesson about something else entirely\n"; +const A : string = "# Demo\n\nintro line\n\n## Traps\n\n- `gh` fails TLS inside the sandbox; use curl with the token.\n- the zig cache is not writable, so a seal says tests blocked\n until it is run outside the sandbox (#10500)\n- 2026-10-18 a bench job needs a second approval before it starts (#9000)\n\n## After\n\n- not a trap: a lesson about something else entirely\n"; +const A_ROW : string = "- 2026-10-19 a new lesson that is long enough to keep (#11060)\n"; +const A_AFTER : string = "# Demo\n\nintro line\n\n## Traps\n\n- `gh` fails TLS inside the sandbox; use curl with the token.\n- the zig cache is not writable, so a seal says tests blocked\n until it is run outside the sandbox (#10500)\n- 2026-10-18 a bench job needs a second approval before it starts (#9000)\n- 2026-10-19 a new lesson that is long enough to keep (#11060)\n\n## After\n\n- not a trap: a lesson about something else entirely\n"; +const NEW : string = "a new lesson that is long enough to keep"; + +// A file that ends in the Traps section with no newline, a section with nothing in it, a file with no section, an empty file. +const B : string = "# Demo\n\n## Traps\n\n- one lesson that is long enough to keep"; +const C : string = "# Demo\n\n## Traps\n\n## After\n\nbody\n"; +const D : string = "# Demo\n\nSome text.\n"; +const E_NO_NEWLINE : string = "# Demo\n\nSome text."; + +// A heading in a code fence is a quotation; a deeper heading belongs to the section; a shallower one ends it. +const F : string = "## Notes\n\n```\n## Traps\n```\n\n### Traps met on this Mac\n\n- first lesson that is long enough\n\n#### sub heading\n\n- second lesson that is long enough\n\n### Next\n\n- third lesson\n\n# Top\n"; + +// Lessons of 400 and 401 bytes (the second is the first and one more letter). +const L400 : string = "abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghij"; +const L401 : string = "abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghi abcdefghijk"; + +const SK : string = "ci-gates"; +const DT : string = "2026-10-19"; + +pub fn add(f: string, lesson: string, cite: string) -> u8 { + return decide(SK, true, f, lesson, DT, cite); +} + +pub fn bytes_are(out: []u8, n: usize, lit: string) -> bool { + if (n != lit.len) { return false; } + var k : usize = 0; + while (k < n) { + if (out[k] != lit[k]) { return false; } + k = k + 1; + } + return true; +} + +// Scenario: given a skill with a Traps section, a new lesson is added. +test a_new_lesson_is_added_to_the_traps_section { + assert A.len == A_PRE.len + A_POST.len; + assert add(A, NEW, "#11060") == O_ADD; + assert trap_line(A) == A_HEAD.len; + assert patch_at(A) == A_PRE.len; + assert section_end(A, trap_line(A)) == A_PRE.len + 1; + assert writes(O_ADD) and !refused(O_ADD); +} + +// Scenario: the row is `- ()`, a newline, and the position is printed. +test the_row_is_date_lesson_and_ref { + var out : [1024]u8 = undefined; + const n : usize = patch(A, NEW, DT, "#11060", out[0 .. 1024]); + assert bytes_are(out[0 .. 1024], n, A_ROW); + assert A.len + n == A_AFTER.len; + assert row_line(A) == 11; +} + +// Scenario: given the same lesson again, nothing is added: the file the patch makes holds it. +test the_same_lesson_again_is_a_duplicate { + assert add(A_AFTER, NEW, "#11060") == O_DUPLICATE; + assert add(A_AFTER, NEW, "") == O_DUPLICATE; + assert add(A_AFTER, NEW, "#99") == O_DUPLICATE; + assert add(A, NEW, "") == O_ADD; + assert writes(O_DUPLICATE) == false and refused(O_DUPLICATE) == false; + assert say(O_DUPLICATE) == "duplicate"; +} + +// Scenario: case, spaces and punctuation do not make a lesson new. +test case_spaces_and_punctuation_are_folded { + assert add(A_AFTER, "A NEW lesson, that is long enough to keep!!", "") == O_DUPLICATE; + assert add(A_AFTER, " `a new lesson` that is long enough to keep. ", "") == O_DUPLICATE; + assert add(A_AFTER, "A-new-lesson-that-is-long-enough-to-keep", "") == O_DUPLICATE; + assert add(A_AFTER, "a new lesson that is long enough to keep, plus more", "") == O_ADD; + assert fold_in("A b-C!", 0, 7, "a b c", 0, 5) == true; + assert fold_in("abc", 0, 3, "abd", 0, 3) == false; + assert fold_in("ab", 0, 2, "abc", 0, 3) == false; + assert key_len("A b-C!", 0, 7) == 3 and key_len("", 0, 0) == 0 and key_len("-- ..", 0, 6) == 0; +} + +// Scenario: a lesson a hand-written bullet already states, wrapped over two lines or with a date and a ref, is not added again. +test a_bullet_written_by_hand_counts { + assert add(A, "the zig cache is not writable, so a seal says tests blocked until it is run outside the sandbox", "") == O_DUPLICATE; + assert add(A, "gh fails TLS inside the sandbox", "") == O_DUPLICATE; + assert add(A, "a bench job needs a second approval before it starts", "#1") == O_DUPLICATE; + assert add(A, "so a seal says tests blocked until it is run", "") == O_DUPLICATE; + assert add(A, "a bench job needs a second approval before it starts and a third after", "") == O_ADD; + assert add(A, "tests blocked until the second approval before it starts", "") == O_ADD; +} + +// A short lesson is compared whole: with the bullet mark, a leading date and a closing (ref) taken off. +const G : string = "## Traps\n\n- 2026-10-19 use curl (#5)\n- tls\n- plain words here that are long enough\n"; +const BL : string = "- 2026-10-19 use curl (#5)"; + +// Scenario: a lesson of fewer than 12 letters and digits is a repeat only when it is a whole line's words. +test a_short_lesson_is_compared_whole { + assert decide(SK, true, G, "use curl", DT, "") == O_DUPLICATE; + assert decide(SK, true, G, "Use CURL!", DT, "#9") == O_DUPLICATE; + assert decide(SK, true, G, "use curl (#5)", DT, "") == O_DUPLICATE; + assert decide(SK, true, G, "tls", DT, "") == O_DUPLICATE; + assert decide(SK, true, G, "curl", DT, "") == O_ADD; + assert decide(SK, true, G, "tl", DT, "") == O_ADD; + assert decide(SK, true, G, "words", DT, "") == O_ADD; + assert decide(SK, true, G, "plain words here that are long enough", DT, "") == O_DUPLICATE; + assert decide(SK, true, G, "plain words", DT, "") == O_ADD; + assert body_from(BL, 0, BL.len) == 13 and body_to(BL, 0, BL.len) == 22; + assert body_from("- 2026-13-19 x", 0, 14) == 2 and body_from("plain", 0, 5) == 0 and body_from("- x", 0, 3) == 2; + assert body_to("- x (y", 0, 6) == 6 and body_to("- x", 0, 3) == 3 and body_to("- x (y) ", 0, 9) == 4; + assert date_at("2026-10-19", 0, 10) and !date_at("2026-10-19", 0, 9) and !date_at("2026-10-19x", 1, 11); + assert fold_eq("Use-curl", 0, 8, "use curl", 0, 8) and !fold_eq("use curls", 0, 9, "use curl", 0, 8); +} + +// Scenario: another section's lines are not the Traps section's. +test only_the_traps_section_is_searched { + assert add(A, "not a trap: a lesson about something else entirely", "") == O_ADD; + assert add(A, "intro line and more of it", "") == O_ADD; +} + +// Scenario: an empty lesson, or one with nothing to say, is refused. +test an_empty_lesson_is_refused { + assert add(A, "", "") == O_EMPTY; + assert add(A, " ", "") == O_EMPTY; + assert add(A, "-- ... !!", "") == O_EMPTY; + assert refused(O_EMPTY) and !writes(O_EMPTY); + assert add(A, "short", "") == O_ADD; + assert add(A, "x", "") == O_ADD; +} + +// Scenario: 400 bytes are fine, 401 are refused; the spaces around the lesson are not counted. +test a_lesson_over_400_bytes_is_refused { + assert L400.len == 400 and L401.len == 401; + assert add(A, L400, "") == O_ADD; + assert add(A, L401, "") == O_TOO_LONG; + assert add(A, " abcdefghi abcdefghi ", "") == O_ADD; + assert lesson_to(L400) - lesson_from(L400) == MAX_LESSON; + assert lesson_to(L401) - lesson_from(L401) == MAX_LESSON + 1; + assert lesson_from(" x y ") == 2 and lesson_to(" x y ") == 5; + assert lesson_from(" ") == 3 and lesson_to(" ") == 3; +} + +// Scenario: one lesson is one line. +test a_line_break_in_a_lesson_is_refused { + assert add(A, "the first line is long\nthe second line is long", "") == O_CONTROL; + assert has_control("a\nb", 0, 3) and !has_control("a b", 0, 3); +} + +// Scenario: given a lesson that looks like a token, it is refused and nothing is written. +test a_lesson_that_looks_like_a_token_is_refused { + assert add(A, "the token ghp_abcdefghijklmnopqrstuvwxyz0123456789 was pasted here", "") == O_SECRET; + assert add(A, "a key sk-ant-api03-abcdefgh was pasted into a comment", "") == O_SECRET; + assert add(A, "use github_pat_11ABCDEFG0123456789 to push it", "") == O_SECRET; + assert add(A, "the key AKIAIOSFODNN7EXAMPLE was in the config file", "") == O_SECRET; + assert add(A, "a header xoxb-123456789-abcdefgh was pasted as a quote", "") == O_SECRET; + assert add(A, "the file starts with -----BEGIN RSA PRIVATE KEY----- and goes on", "") == O_SECRET; + assert add(A, "the commit 4f40a5fb135da860067e11a332658ccc6b612118 moved the gate", "") == O_SECRET; + assert add(A, "the digest 0123456789abcdef0123456789ABCDEF is a hash of it", "") == O_SECRET; + assert add(A, "a secret q8Zr2LmX9vKt4BpW7nYc3JdH6sFg1AeU5oRi0TaNxV was pasted here", "") == O_SECRET; + assert say(O_SECRET)[0] == 116 and refused(O_SECRET) and !writes(O_SECRET); +} + +// Scenario: what only looks like a token is not refused: a short commit, a long path, a word that holds sk-, a prefix with nothing after it. +test a_lesson_that_only_resembles_a_token_is_kept { + assert add(A, "the commit 4f40a5fb1352 moved the gate and the seal", "") == O_ADD; + assert add(A, "the digest 0123456789abcdef0123456789abcde has 31 digits only", "") == O_ADD; + assert add(A, "see gHashTag/trinity/blob/master/apps/website/public/t27/files/specs/catalog/onboarding for it", "") == O_ADD; + assert add(A, "a task-list-view and a disk-full error are not keys", "") == O_ADD; + assert add(A, "ghp_ and sk- prefixes are what a scanner looks for", "") == O_ADD; + assert add(A, "a PRIVATE KEY is never pasted into a lesson", "") == O_ADD; + assert add(A, "TriLoopTickReadsSnapshotLinesAndPrintsOneAction2 is a long name", "") == O_ADD; + assert has_hex_run("0123456789abcdef0123456789abcdef", 0, 32) and !has_hex_run("0123456789abcdef0123456789abcde", 0, 31); + assert at_word_start("a sk-x", 2, 0) and !at_word_start("task-x", 2, 0) and at_word_start("sk-x", 0, 0); +} + +// Scenario: the skill is one directory name that exists, and nothing else. +test only_a_directory_under_the_skills_is_a_skill { + assert decide("ci-gates", true, A, NEW, DT, "") == O_ADD; + assert decide("ci-gates", false, A, NEW, DT, "") == O_NO_SKILL; + assert decide("", true, A, NEW, DT, "") == O_BAD_SKILL; + assert decide("..", true, A, NEW, DT, "") == O_BAD_SKILL; + assert decide("../x", true, A, NEW, DT, "") == O_BAD_SKILL; + assert decide("a/b", true, A, NEW, DT, "") == O_BAD_SKILL; + assert decide("/etc", true, A, NEW, DT, "") == O_BAD_SKILL; + assert decide(".claude", true, A, NEW, DT, "") == O_BAD_SKILL; + assert decide("a\\b", true, A, NEW, DT, "") == O_BAD_SKILL; + assert decide("a b", true, A, NEW, DT, "") == O_BAD_SKILL; + assert decide("-x", true, A, NEW, DT, "") == O_BAD_SKILL; + assert decide("t27b-loop", true, A, NEW, DT, "") == O_ADD; + assert decide("a_b-2", true, A, NEW, DT, "") == O_ADD; + assert skill_ok("t27f") and !skill_ok("x.y") and !skill_ok(""); + assert !skill_ok("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") and skill_ok("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); +} + +// Scenario: the date and the ref come from the arguments and are checked. +test the_date_and_the_ref_are_checked { + assert decide(SK, true, A, NEW, "2026-10-19", "") == O_ADD; + assert decide(SK, true, A, NEW, "2026-13-19", "") == O_BAD_DATE; + assert decide(SK, true, A, NEW, "2026-10-32", "") == O_BAD_DATE; + assert decide(SK, true, A, NEW, "2026-00-10", "") == O_BAD_DATE; + assert decide(SK, true, A, NEW, "2026-10-00", "") == O_BAD_DATE; + assert decide(SK, true, A, NEW, "26-10-19", "") == O_BAD_DATE; + assert decide(SK, true, A, NEW, "2026/10/19", "") == O_BAD_DATE; + assert decide(SK, true, A, NEW, "2026-10-1x", "") == O_BAD_DATE; + assert decide(SK, true, A, NEW, "", "") == O_BAD_DATE; + assert decide(SK, true, A, NEW, DT, "gHashTag/t27#11060") == O_ADD; + assert decide(SK, true, A, NEW, DT, "two words") == O_BAD_REF; + assert decide(SK, true, A, NEW, DT, "a(b)") == O_BAD_REF; + assert decide(SK, true, A, NEW, DT, "line\nbreak") == O_BAD_REF; + assert decide(SK, true, A, NEW, DT, "ghp_abcdefghijklmnopqrstuvwxyz") == O_BAD_REF; + assert decide(SK, true, A, NEW, DT, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") == O_BAD_REF; + assert ref_ok("") and ref_ok("#1") and !ref_ok("a b"); +} + +// Scenario: the refusals come in the order of the header. +test the_first_refusal_wins { + assert decide("..", false, A, "", "bad", "a b") == O_BAD_SKILL; + assert decide("ok", false, A, "", "bad", "a b") == O_NO_SKILL; + assert decide("ok", true, A, "", "bad", "a b") == O_EMPTY; + assert decide("ok", true, A, "a\nb is a long enough lesson", "bad", "a b") == O_CONTROL; + assert decide("ok", true, A, L401, "bad", "a b") == O_TOO_LONG; + assert decide("ok", true, A, NEW, "bad", "a b") == O_BAD_DATE; + assert decide("ok", true, A, NEW, DT, "a b") == O_BAD_REF; + assert decide("ok", true, A, "ghp_abcdefghijklmnop is a token and too short", DT, "") == O_SECRET; +} + +// Scenario: the section is the first Traps heading outside a fence, up to a heading of its level or a higher one. +test the_section_is_found_and_bounded { + assert trap_line(F) == 28; + assert section_end(F, 28) == 146; + assert is_traps("## Traps\n", 0, 8) and is_traps("## Traps that cost hours\n", 0, 24) and is_traps("## Traps met on this Mac\n", 0, 24); + assert is_traps("### trap\n", 0, 8) and is_traps("# TRAPS: a list\n", 0, 15) and is_traps("## Trap\n", 0, 7); + assert !is_traps("## Trapped\n", 0, 10) and !is_traps("## Trapezium\n", 0, 12) and !is_traps("## No traps\n", 0, 11); + assert heading_level("## x", 0, 4) == 2 and heading_level("#x", 0, 2) == 0 and heading_level("####### x", 0, 9) == 0 and heading_level("##", 0, 2) == 2; + assert fence_char("```rust", 0, 7) == 96 and fence_char(" ~~~", 0, 5) == 126 and fence_char(" ```", 0, 7) == 0 and fence_char("``", 0, 2) == 0; + assert trap_line(D) == D.len and trap_line("") == 0; + assert add(F, "third lesson", "") == O_ADD; + assert add(F, "third lesson is a different one", "") == O_ADD; + assert add(F, "second lesson that is long enough", "") == O_DUPLICATE; +} + +// Scenario: the row goes after the last line of the section that is not blank, before its trailing blank line. +test the_row_goes_after_the_last_line_of_the_section { + assert patch_at(F) == 145; + assert patch_at(C) == 17; + assert patch_at(B) == B.len; + assert patch_at(A) == A_PRE.len; + assert patch_at(D) == D.len and patch_at("") == 0; + assert lead_len(A) == 0 and lead_len(B) == 1 and lead_len(C) == 1 and lead_len(D) == 1 and lead_len(E_NO_NEWLINE) == 2 and lead_len("") == 0; + assert tail_newlines("a\n\n\n") == 3 and tail_newlines("a") == 0 and lead_len("a\n\n") == 0; +} + +// Scenario: a file that ends with no newline, a section with nothing in it, a file with no section, an empty file. +test the_patch_fits_every_shape_of_file { + var out : [1024]u8 = undefined; + var n : usize = patch(B, NEW, DT, "", out[0 .. 1024]); + assert bytes_are(out[0 .. 1024], n, "\n- 2026-10-19 a new lesson that is long enough to keep\n"); + n = patch(C, NEW, DT, "", out[0 .. 1024]); + assert bytes_are(out[0 .. 1024], n, "\n- 2026-10-19 a new lesson that is long enough to keep\n"); + n = patch(D, NEW, DT, "#1", out[0 .. 1024]); + assert bytes_are(out[0 .. 1024], n, "\n## Traps\n\n- 2026-10-19 a new lesson that is long enough to keep (#1)\n"); + n = patch(E_NO_NEWLINE, NEW, DT, "", out[0 .. 1024]); + assert bytes_are(out[0 .. 1024], n, "\n\n## Traps\n\n- 2026-10-19 a new lesson that is long enough to keep\n"); + n = patch("", NEW, DT, "", out[0 .. 1024]); + assert bytes_are(out[0 .. 1024], n, "## Traps\n\n- 2026-10-19 a new lesson that is long enough to keep\n"); + assert add(D, NEW, "") == O_ADD_SECTION and add("", NEW, "") == O_ADD_SECTION and add(B, NEW, "") == O_ADD; + assert say(O_ADD_SECTION)[0] == 97 and writes(O_ADD_SECTION) and !refused(O_ADD_SECTION); + assert head_len(D) == 10 and head_len(A) == 0; +} + +// Scenario: where the row lands, as the line number the command prints. +test the_position_is_the_line_of_the_row { + assert row_line(A) == 11; + assert row_line(B) == 6; + assert row_line(C) == 5; + assert row_line(D) == 7; + assert row_line("") == 3; + assert row_line(E_NO_NEWLINE) == 7; + assert row_line(F) == 14; +} + +// Scenario: a patch that does not fit writes nothing. +test a_patch_that_does_not_fit_writes_nothing { + var out : [16]u8 = undefined; + assert patch(A, NEW, DT, "#11060", out[0 .. 16]) == 0; + var big : [100]u8 = undefined; + assert patch(A, NEW, DT, "#11060", big[0 .. 100]) == A_ROW.len; + assert patch(A, NEW, DT, "#11060", big[0 .. 62]) == 0; +} + +// Scenario: the words printed. +test every_outcome_has_its_words { + var o : u8 = 0; + while (o < OUTCOMES) { + assert say(o).len > 0; + assert refused(o) == (o >= O_EMPTY); + assert writes(o) == (o == O_ADD or o == O_ADD_SECTION); + o = o + 1; + } + assert say(O_ADD) == "added" and say(99) == say(O_NO_SKILL); +} diff --git a/tools/census/fetches.txt b/tools/census/fetches.txt index 2f2d85e858..064e5ad9e5 100644 --- a/tools/census/fetches.txt +++ b/tools/census/fetches.txt @@ -1,6 +1,6 @@ BOUNDED GITHUB FETCHES, AND WHETHER EACH CAN TELL A PAGE FROM A TOTAL - files read 49 + files read 50 lines naming either spelling 78 of those, FETCH SITES 35 refused as not a fetch 43 (--excluded prints them) From f89ad012223e2604e81f780e70e76ac2c1153f2e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:24:43 +0700 Subject: [PATCH 2/3] tri learn: save a lesson into a skill Traps section once, decisions in .t27 (Closes #11060) Merge of master plus sealing, gates run on the merged tree with t27c from master: tests clean with vacuous passes 0, seal --verify MATCH, tri types ratchet CLEAN, dupe_scan failure set a subset of master's. Co-Authored-By: Claude Sonnet 5.5 --- .trinity/seals/loop_TriLoopLesson.json | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .trinity/seals/loop_TriLoopLesson.json diff --git a/.trinity/seals/loop_TriLoopLesson.json b/.trinity/seals/loop_TriLoopLesson.json new file mode 100644 index 0000000000..24999695c6 --- /dev/null +++ b/.trinity/seals/loop_TriLoopLesson.json @@ -0,0 +1,37 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+0fba674ef", + "closure": [ + { + "spec": "specs/claw/text.t27", + "spec_hash": "sha256:968755ec1917f766eb2cdd0ab4c0c48aef028a0e7d69e191911695e925b286ad" + }, + { + "spec": "specs/tri/lab/receipt.t27", + "spec_hash": "sha256:00d9eec8d7be84dde596c9990b3a5574de24aff9f7a94579fc620eb585712441" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:b43f8e5932e50e39962700c70d21c804ecac1296da76238bc38a6d908d1442be", + "gen_hash_rust": "sha256:5b03509aa928f0f6ae57f16317efdbab2ce35120e04299cc7c68ab01c7628a95", + "gen_hash_verilog": "sha256:3d1754b8cb60953c8f360d30ccef8482f51efa3e495137efeab74f35945fd6c0", + "gen_hash_zig": "sha256:3351777f5fc3125f092cf3882414ee7a46582ca37ea287cecbe72c77b27f58e0", + "module": "TriLoopLesson", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-19T16:23:39Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:9964c79e37666e3673d2d32b4d98bb0a4b2d9639f5ff3a9d8372f4f4fa1cadcb", + "spec_path": "specs/tri/loop/lesson.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 21, + "total": 21, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file From 03233d5d0c9143155f4645d1d8fcb6eae8c95731 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:59:17 +0700 Subject: [PATCH 3/3] tri learn: the glue lives in loopclaim.rs, not in a new file (own-language forbids a new .rs) (Closes #11060) The own-language gate refused cli/tri/src/learn.rs as a new hand-written .rs file. The same 40 lines of glue and the 3 lines in main.rs now sit in the existing loopclaim.rs; behaviour is unchanged. Merged master. Census: see the tools/census change in this commit if any moved. Co-Authored-By: Claude Sonnet 5.5 --- cli/tri/src/learn.rs | 40 ---------------------------------------- cli/tri/src/loopclaim.rs | 38 ++++++++++++++++++++++++++++++++++++++ cli/tri/src/main.rs | 5 ++--- tools/census/fetches.txt | 2 +- 4 files changed, 41 insertions(+), 44 deletions(-) delete mode 100644 cli/tri/src/learn.rs diff --git a/cli/tri/src/learn.rs b/cli/tri/src/learn.rs deleted file mode 100644 index bfb3b70d1e..0000000000 --- a/cli/tri/src/learn.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! `tri learn add`: every decision is specs/tri/loop/lesson.t27 (t27c gen-rust, gen/rust/tri/loop/lesson.rs); this reads the skill's file, asks it, writes what it says. -use anyhow::{bail, Context, Result}; -use clap::Subcommand; -#[path = "../../../gen/rust/tri/loop/lesson.rs"] #[allow(dead_code, unused_parens)] -mod lesson; - -#[derive(Subcommand)] -pub enum LearnCmd { - /// Add one lesson, as the line `- ()`, to the Traps section of a skill's SKILL.md (a Traps section is made at the end of the file when it has none), unless the section already says it: then it prints `duplicate` and changes nothing. It prints the file and the line. It refuses an empty lesson, one over 400 bytes or with a line break, one that looks like a secret, a bad date or ref, and a skill that is no directory under .claude/skills/. Rules: specs/tri/loop/lesson.t27. - Add { - /// The skill: a directory name under .claude/skills/. - #[arg(long)] skill: String, - /// One line, at most 400 bytes. - lesson: String, - /// YYYY-MM-DD; today (UTC) when absent. - #[arg(long)] date: Option, - /// Where it was learned, one word such as #11060; none when absent. - #[arg(long = "ref", default_value = "", value_name = "REF")] cite: String, - }, -} - -pub fn run(cmd: &LearnCmd) -> Result<()> { - let LearnCmd::Add { skill, lesson: said, date, cite } = cmd; - let st = |s: &str| -> &'static str { s.to_string().leak() }; - let dir = crate::find_trinity_root()?.join(".claude/skills").join(skill); - let is_dir = lesson::skill_ok(st(skill)) && std::fs::symlink_metadata(&dir).map_or(false, |m| m.is_dir()); - let md = ["SKILL.md", "skill.md"].iter().map(|n| dir.join(n)).find(|p| is_dir && p.is_file()); - let text = md.as_ref().map(std::fs::read_to_string).transpose()?.unwrap_or_default(); - let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); - let (f, l, d, c) = (st(&text), st(said), st(date.as_ref().unwrap_or(&today)), st(cite)); - let o = lesson::decide(st(skill), is_dir, f, l, d, c); - if lesson::refused(o) { bail!("refused: {}", lesson::say(o)); } - if o == lesson::O_DUPLICATE { println!("{}", lesson::say(o)); return Ok(()); } - let path = md.context("the skill has no SKILL.md or skill.md")?; - let (mut buf, at) = (vec![0u8; 1024], lesson::patch_at(f)); - let n = lesson::patch(f, l, d, c, &mut buf); - std::fs::write(&path, format!("{}{}{}", &f[..at], String::from_utf8_lossy(&buf[..n]), &f[at..]))?; - println!("{}:{} {}", path.display(), lesson::row_line(f), lesson::say(o)); - Ok(()) -} diff --git a/cli/tri/src/loopclaim.rs b/cli/tri/src/loopclaim.rs index 5756648511..a63a57df8c 100644 --- a/cli/tri/src/loopclaim.rs +++ b/cli/tri/src/loopclaim.rs @@ -180,6 +180,44 @@ fn claim(name: &str, release: bool, who: bool) -> Result<()> { } } +// `tri learn add` (#11060): every decision is specs/tri/loop/lesson.t27 (t27c gen-rust); this reads the skill's file, asks it, writes what it says. +#[path = "../../../gen/rust/tri/loop/lesson.rs"] #[allow(dead_code, unused_parens)] +mod lesson; +#[derive(Subcommand)] +pub enum LearnCmd { + /// Add one lesson, as the line `- ()`, to the Traps section of a skill's SKILL.md (a Traps section is made at the end of the file when it has none), unless the section already says it: then it prints `duplicate` and changes nothing. It prints the file and the line. It refuses an empty lesson, one over 400 bytes or with a line break, one that looks like a secret, a bad date or ref, and a skill that is no directory under .claude/skills/. Rules: specs/tri/loop/lesson.t27. + Add { + /// The skill: a directory name under .claude/skills/. + #[arg(long)] skill: String, + /// One line, at most 400 bytes. + lesson: String, + /// YYYY-MM-DD; today (UTC) when absent. + #[arg(long)] date: Option, + /// Where it was learned, one word such as #11060; none when absent. + #[arg(long = "ref", default_value = "", value_name = "REF")] cite: String, + }, +} + +pub fn learn_run(cmd: &LearnCmd) -> Result<()> { + let LearnCmd::Add { skill, lesson: said, date, cite } = cmd; + let st = |s: &str| -> &'static str { s.to_string().leak() }; + let dir = crate::find_trinity_root()?.join(".claude/skills").join(skill); + let is_dir = lesson::skill_ok(st(skill)) && std::fs::symlink_metadata(&dir).map_or(false, |m| m.is_dir()); + let md = ["SKILL.md", "skill.md"].iter().map(|n| dir.join(n)).find(|p| is_dir && p.is_file()); + let text = md.as_ref().map(std::fs::read_to_string).transpose()?.unwrap_or_default(); + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + let (f, l, d, c) = (st(&text), st(said), st(date.as_ref().unwrap_or(&today)), st(cite)); + let o = lesson::decide(st(skill), is_dir, f, l, d, c); + if lesson::refused(o) { anyhow::bail!("refused: {}", lesson::say(o)); } + if o == lesson::O_DUPLICATE { println!("{}", lesson::say(o)); return Ok(()); } + let path = md.context("the skill has no SKILL.md or skill.md")?; + let (mut buf, at) = (vec![0u8; 1024], lesson::patch_at(f)); + let n = lesson::patch(f, l, d, c, &mut buf); + std::fs::write(&path, format!("{}{}{}", &f[..at], String::from_utf8_lossy(&buf[..n]), &f[at..]))?; + println!("{}:{} {}", path.display(), lesson::row_line(f), lesson::say(o)); + Ok(()) +} + #[cfg(test)] mod tests { #[test] diff --git a/cli/tri/src/main.rs b/cli/tri/src/main.rs index e7be822d44..8b5896429f 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -26,7 +26,6 @@ mod jumps; mod kinddrift; mod leanreach; mod leanvac; -mod learn; mod ledgers; mod loopclaim; mod misread; @@ -455,7 +454,7 @@ enum Commands { /// Land a lane branch (default: the current one): merge master, then tests with no vacuous pass, seals, types ratchet, dupe subset, push, pull request, auto-merge; the first failing gate stops it with a named code (CONFLICT, TESTS_NOT_CLEAN, SEAL_NOT_CLEAN, TYPES_NOT_CLEAN, DUPE_NOT_SUBSET, PUSH_NOT_CONFIRMED) and a second run prints ALREADY_LANDED; --plan prints the steps and runs none, --check stops before the commit; T27C names the t27c program (default t27c on PATH) (rules: specs/tri/land/land.t27, #11068). Land { branch: Option, #[arg(long, default_value_t = 0)] issue: u64, #[arg(long)] plan: bool, #[arg(long)] check: bool }, /// Add one lesson to the Traps section of a skill unless the section already says it; `tri learn add --help` has the refusals (rules: specs/tri/loop/lesson.t27, #11060). - Learn { #[command(subcommand)] action: learn::LearnCmd }, + Learn { #[command(subcommand)] action: loopclaim::LearnCmd }, } #[derive(Subcommand)] @@ -1376,7 +1375,7 @@ fn main() -> Result<()> { Commands::T27o { file, name, oracle, check, t27c, emit_cell } => std::process::exit(cmd_t27o(file, name.as_deref(), *oracle, *check, t27c.as_deref(), *emit_cell)? as i32), Commands::T27oSweep { paths, repo, max_bytes, cache, top } => std::process::exit(cmd_t27o_sweep(paths, repo, *max_bytes, cache.as_deref(), *top)? as i32), Commands::Land { branch, issue, plan, check } => std::process::exit(cmd_land(branch.as_deref(), *issue, *plan, *check)? as i32), - Commands::Learn { action } => learn::run(action)?, + Commands::Learn { action } => loopclaim::learn_run(action)?, } Ok(()) diff --git a/tools/census/fetches.txt b/tools/census/fetches.txt index 064e5ad9e5..2f2d85e858 100644 --- a/tools/census/fetches.txt +++ b/tools/census/fetches.txt @@ -1,6 +1,6 @@ BOUNDED GITHUB FETCHES, AND WHETHER EACH CAN TELL A PAGE FROM A TOTAL - files read 50 + files read 49 lines naming either spelling 78 of those, FETCH SITES 35 refused as not a fetch 43 (--excluded prints them)