diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index 58b63f5..ecd560c 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -158,8 +158,9 @@ pub const SESSION_NAMESPACE: Uuid = Uuid::NAMESPACE_OID; /// What the child is told at the start. The contract with the `eureka` /// skill: it names the skill, the instance and the run id. pub fn start_message(instance: &Slug, run: &PipelineRef, plan_quota: &[PlanQuota], now_ms: u64) -> String { + let closing_rule = inbox::CLOSING_RULE; format!( - "Use the eureka skill. You are Self for the instance `{}`; your run is `{}`. Admit as faculty SelfFaculty, agent {AGENT}.\n\nThis session stays open after you answer: briefs from your Persona arrive while you work and between tasks. When you answer a brief, resolve follow-up Recorded with your answer as rationale; if you decline it, resolve it Withdrawn with why. Work you take on beyond an answer is a follow-up of your own. What you say when you end a turn reaches your Persona as a reply, and your Persona's answers come as briefs. Briefs reach you one conversation at a time. More briefs of the conversation you are serving may arrive while you work; another conversation's brief is told only once you are idle, with no turn running and no background task of yours still running or waiting for its turn. What you say ending any turn, the turns that report your background tasks included, reaches your Persona in the conversation you are serving, so a long background task keeps other members waiting.\n\n{}", + "Use the eureka skill. You are Self for the instance `{}`; your run is `{}`. Admit as faculty SelfFaculty, agent {AGENT}.\n\nThis session stays open after you answer: briefs from your Persona arrive while you work and between tasks. {closing_rule} Work you take on beyond an answer is a follow-up of your own. What you say when you end a turn reaches your Persona as a reply, and your Persona's answers come as briefs. Briefs reach you one conversation at a time. More briefs of the conversation you are serving may arrive while you work; another conversation's brief is told only once you are idle, with no turn running and no background task of yours still running or waiting for its turn. What you say ending any turn, the turns that report your background tasks included, reaches your Persona in the conversation you are serving, so a long background task keeps other members waiting.\n\n{}", instance.0, run.id.0, render_plan(plan_quota, now_ms) @@ -223,7 +224,7 @@ fn flush_text(why: Why) -> String { Why::Rotation => " (This process ends soon: the unit's time is nearly up. If your next brief comes within the hour, the next process continues this conversation.)", Why::Idle => " (This session ends because nothing is waiting and its cache would lapse.)", }; - format!("{FLUSH_MESSAGE}{cause} Resolve each brief you answered (Recorded, your answer as rationale); a brief you are still working stays open and the next session continues it.") + format!("{FLUSH_MESSAGE}{cause} Close each brief that is done by the closing rule; a brief whose change is not on its default branch stays open, or is superseded by your own follow-up when it waits on someone else, and the next session continues it.") } /// The session id of a run: UUIDv5 over the run's id. Derived, never stored. diff --git a/crates/mind-body/src/self_run/inbox.rs b/crates/mind-body/src/self_run/inbox.rs index dd367a9..750c03b 100644 --- a/crates/mind-body/src/self_run/inbox.rs +++ b/crates/mind-body/src/self_run/inbox.rs @@ -121,9 +121,13 @@ pub fn render(briefs: &[Brief], plan: &str) -> String { blocks.join("\n\n") } -/// What her Self does when she answers or declines a brief. +/// How a brief closes: the one owner of what done means for her Self. The start message tells it +/// once; every told brief and the flush point back to it. +pub(crate) const CLOSING_RULE: &str = "A brief is done when what it asks for exists. A brief that asks for an answer, a review or a read is done when you answer it: resolve it Recorded with the answer as rationale. A brief that asks for a change is done when the change is on its repo's default branch, and live where the brief asks for a deploy: resolve it Fixed with that commit. An open PR, a pushed branch or a local commit is not done, and the brief stays open. In a repo you steward the merge is yours and needs no one's leave: have your Soul check the work, then merge it, then resolve the brief (rulings steward-is-maintainer and merge-gate-own-soul). Land what you have open before you start more. When what is left waits on someone else (a review in a repo you do not steward, a ruling, a deploy you do not hold), admit a follow-up of your own whose item names the PR or branch and whom it waits on, resolve the brief Superseded by it, and say so in your reply. If you decline a brief, resolve it Withdrawn with why."; + +/// The reminder under each told brief: the closing rule in short, for this brief's id. pub fn resolving_sentence(id: &str) -> String { - format!("When you answer this brief, resolve follow-up {id} Recorded with your answer as rationale; if you decline it, resolve it Withdrawn with why.") + format!("Close follow-up {id} by the closing rule: Recorded with your answer, Fixed by its commit on the default branch, Superseded by your own follow-up while it waits on someone else, or Withdrawn with why. An open PR is not done.") } #[cfg(test)] diff --git a/crates/mind-body/src/self_run/inbox/tests.rs b/crates/mind-body/src/self_run/inbox/tests.rs index 36298fe..86e50a2 100644 --- a/crates/mind-body/src/self_run/inbox/tests.rs +++ b/crates/mind-body/src/self_run/inbox/tests.rs @@ -104,7 +104,12 @@ fn a_tell_names_each_brief_the_plan_and_how_she_resolves_it() { let brief = Brief { id: "eureka-body:follow_up:b1".into(), campaign: "eureka-body".into(), repo: "GameCult/Huginn".into(), item: "Wire the thing.".into(), ordinal: 1, conversation: persona::Conversation::Public }; let text = render(&[brief], "Plan: 5h at 40%."); assert!(text.contains("eureka-body:follow_up:b1") && text.contains("Wire the thing."), "{text}"); - assert!(text.contains("resolve follow-up eureka-body:follow_up:b1 Recorded") && text.contains("Withdrawn"), "she is told to resolve it: {text}"); + let reminder = resolving_sentence("eureka-body:follow_up:b1"); + assert!(text.contains(&reminder), "{text}"); + for word in ["eureka-body:follow_up:b1", "Recorded", "Fixed", "default branch", "Superseded", "Withdrawn", "An open PR is not done"] { + assert!(reminder.contains(word), "{word}: {reminder}"); + } + assert!(!text.contains(CLOSING_RULE), "the rule is told once, at start: {text}"); assert!(text.ends_with("Plan: 5h at 40%."), "{text}"); assert!(!render(&[], "").contains("brief"), "no brief, no plan, nothing"); } diff --git a/crates/mind-body/src/self_run/tests.rs b/crates/mind-body/src/self_run/tests.rs index a2819b0..d3e3c44 100644 --- a/crates/mind-body/src/self_run/tests.rs +++ b/crates/mind-body/src/self_run/tests.rs @@ -1398,7 +1398,7 @@ fn a_brief_derived_while_a_turn_runs_is_written_at_once_counted_at_its_echo_and_ let told = tells(&ran); assert_eq!(told.len(), 1, "{:?}", ran.written()); assert!(told[0].contains(&brief_ref("b1").id.0) && told[0].contains("Survey the noise functions."), "{}", told[0]); - assert!(told[0].contains("resolve follow-up") && told[0].contains("Recorded"), "she is told how to close it: {}", told[0]); + assert!(told[0].contains(&inbox::resolving_sentence(&brief_ref("b1").id.0)) && !told[0].contains(inbox::CLOSING_RULE), "she is reminded of the closing rule, not told it again: {}", told[0]); // The plan line is restated under every brief for the run's life, so it names the instant of the reading, never an age that stops being true. assert!(told[0].contains("read at 2026-10-29T20:33Z") && !told[0].contains(" ago"), "{}", told[0]); assert_eq!(ran.written().len(), 2, "the start message and one tell: {:?}", ran.written()); @@ -1419,6 +1419,25 @@ fn a_brief_derived_while_the_session_is_idle_is_written_and_the_session_is_busy_ assert!(resolutions_of(&mind, &brief_ref("b2")).is_empty()); } +#[test] +fn the_start_message_carries_the_closing_rule_once_and_names_what_closes_a_change() { + let text = start_message(&Slug("yggdrasil".into()), &run_ref("selfrun-a"), &[], 0); + assert_eq!(text.matches(inbox::CLOSING_RULE).count(), 1, "{text}"); + for word in ["default branch", "Recorded", "Fixed", "Superseded", "Withdrawn", "merge-gate-own-soul", "An open PR, a pushed branch or a local commit is not done"] { + assert!(text.contains(word), "{word}: {text}"); + } +} + +#[test] +fn the_flush_keeps_a_brief_whose_change_has_not_landed_open() { + for why in [Why::Ceiling, Why::Rotation, Why::Idle] { + let text = flush_text(why); + assert!(text.starts_with(FLUSH_MESSAGE), "{text}"); + assert!(text.contains("stays open") && text.contains("default branch") && text.contains("closing rule"), "{text}"); + assert!(!text.contains("Recorded, your answer"), "{text}"); + } +} + #[test] fn a_brief_open_at_start_is_in_the_start_message_with_how_to_resolve_it_and_is_not_told_again() { let mind = live_mind(); @@ -1426,7 +1445,8 @@ fn a_brief_open_at_start_is_in_the_start_message_with_how_to_resolve_it_and_is_n let ran = mind_run(&mind, vec![Out(echo()), Idle(31), Idle(31)]); let start = &ran.written()[0]; assert!(start.contains("Also open for you") && start.contains(&brief_ref("p1").id.0) && start.contains("Still open."), "{start}"); - assert!(start.contains(&format!("resolve follow-up {} Recorded", brief_ref("p1").id.0)), "{start}"); + assert!(start.contains(&inbox::resolving_sentence(&brief_ref("p1").id.0)), "{start}"); + assert_eq!(start.matches(inbox::CLOSING_RULE).count(), 1, "the rule is told once, beside the reminder: {start}"); assert_eq!(ran.written().len(), 1, "never told again: {:?}", ran.written()); } @@ -1493,7 +1513,7 @@ fn the_session_ends_where_the_cache_would_be_evicted_with_a_flush_and_a_waiting_ let closed_at = hour.events.iter().position(|event| *event == Event::InputClosed).unwrap(); assert!(flush_at < closed_at && hour.count(&Event::InputClosed) == 1, "{:?}", hour.events); assert!(hour.out.contains("decision Write { tells: false, switch: false, flush: Some(Idle) } prefix 10 ttl_s 3600 idle_s 3540 inbox false written 2 consumed 1\n"), "{}", hour.out); - assert!(hour.written().iter().any(|text| text.contains("nothing is waiting and its cache would lapse") && text.contains("Resolve each brief you answered")), "{:?}", hour.written()); + assert!(hour.written().iter().any(|text| text.contains("nothing is waiting and its cache would lapse") && text.contains("by the closing rule")), "{:?}", hour.written()); // Five minutes' cache: the flush at 240 s. let short = go(vec![Out(echo()), Out(five_minute_request("m1", 10)), Out(ok_result()), Idle(239), Idle(1), Out(echo()), Out(ok_result()), Idle(1)]); assert_eq!(flushes(&short), 1); diff --git a/docs/eureka-body-map.md b/docs/eureka-body-map.md index 160d2c4..00c8f86 100644 --- a/docs/eureka-body-map.md +++ b/docs/eureka-body-map.md @@ -836,7 +836,10 @@ in-force `operated_by Mind` Self run of its instance, which admission keeps uniq echo; the grace interrupt (earlier of 15 min after the ask and the cap less the ladder) is the backstop. A brief is an in-force `self-run` follow-up her Persona turn admitted (the receipt's agent, not the source the writer named); the actuator closes none, and a brief left open is - told to the next session. Compaction is + told to the next session. Briefs close by + `inbox::CLOSING_RULE`, told once at start (Recorded for answers, Fixed by a default-branch commit for + changes, Superseded by her own follow-up while a change waits on someone else); each told brief and + the flush carry a reminder that points at it. Compaction is off in the CLI (`DISABLE_COMPACT=1` in the child environment). - With `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` set the CLI forces the permission mode to default: `--permission-mode bypassPermissions` has no effect there and only the seed's allow list decides