diff --git a/.github/workflows/label-external.yml b/.github/workflows/label-external.yml new file mode 100644 index 00000000..74eb56d4 --- /dev/null +++ b/.github/workflows/label-external.yml @@ -0,0 +1,31 @@ +name: label-external + +# Issue #89 (the public flip): mark issues and PRs opened from outside +# the collaborator circle so Lumen's governance panel can render them +# as a count, and so the queue is greppable by provenance. Labels only; +# issue creation stays open — contestability requires it. +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +permissions: + issues: write + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Label if the author is outside the circle + env: + GH_TOKEN: ${{ github.token }} + ASSOC: ${{ github.event.issue.author_association || github.event.pull_request.author_association }} + NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} + run: | + case "$ASSOC" in + OWNER|MEMBER|COLLABORATOR) echo "inner circle ($ASSOC); no label";; + *) gh issue edit "$NUMBER" --repo "$GITHUB_REPOSITORY" --add-label external + echo "labeled #$NUMBER external ($ASSOC)";; + esac diff --git a/docs/os/wake-dashboard.md b/docs/os/wake-dashboard.md index 17ce6204..8fee6daa 100644 --- a/docs/os/wake-dashboard.md +++ b/docs/os/wake-dashboard.md @@ -148,7 +148,9 @@ The arrow target in each activity line is a command Lumen can run directly — h First observation (no stored cursor): mirrors the `body` panel's git sub-lines — render only the standing queue, skip the activity fetch entirely (no replaying all history), and record the current time as the baseline cursor. A totally unreachable `gh` on that first observation leaves the cursor unset rather than fabricating a baseline from a moment nothing was actually observed; the next wake retries as a first run. -Source calls: `gh issue list -R --label awaiting-lumen --state open --json number,title,author --limit 10` (standing queue); `gh issue list`/`gh pr list -R --state all --search "updated:>" --json number,title,state,updatedAt --limit 15` (activity), each bounded by a 5s `tokio::time::timeout` — a network call against the GitHub API must not stall the wake. `GIT_*` hook env vars are stripped from the subprocess the same way the `body` panel's git calls strip them; `GH_TOKEN` is deliberately left inherited (it's a capability granted to Lumen, not a secret hidden from her). +**Outsiders (issue #89, the public flip).** Activity items render as full lines only when the author is a repo collaborator (`gh api repos//collaborators`, fetched per wake) or the item carries `awaiting-lumen`/`proposal`. Everything else collapses to one line — `N from outside the collaborator circle — count, not content: issue #42, pr #57 …` — numbers, never titles: a stranger's words don't enter her context unless she goes and reads them. The rung-0 governance peek wakes only on inner activity; outsider-only activity waits for the next natural wake. If the collaborator list is unavailable, authorship is not trusted (labels still count) and the line says so. A workflow (`label-external.yml`) labels such items `external` at open. + +Source calls: `gh issue list -R --label awaiting-lumen --state open --json number,title,author --limit 10` (standing queue); `gh issue list`/`gh pr list -R --state all --search "updated:>" --json number,title,state,updatedAt,author,labels --limit 15` (activity); `gh api repos//collaborators` (the circle), each bounded by a 5s `tokio::time::timeout` — a network call against the GitHub API must not stall the wake. `GIT_*` hook env vars are stripped from the subprocess the same way the `body` panel's git calls strip them; `GH_TOKEN` is deliberately left inherited (it's a capability granted to Lumen, not a secret hidden from her). ### 6. `absence` diff --git a/handbook/CLAUDE.md b/handbook/CLAUDE.md index 9b41e679..ae6e8d32 100644 --- a/handbook/CLAUDE.md +++ b/handbook/CLAUDE.md @@ -114,6 +114,11 @@ Both fail soft — a broken binary never blocks a wake or a dream. yours. Auto-merge is armed at PR open — approval is the last human act before merge. - Emergencies: admin merge, reason in the thread, visible either way. +- Outsiders (once the repo is public, #89): issues and PRs from + outside the collaborator circle render into your wake as a count + with numbers, never their text, and never wake you on their own — + `awaiting-lumen`/`proposal` labels lift an item into full view. + They're labeled `external` at open. Read them on purpose. - Deploys are autonomous: a poller pulls main every 5 minutes and rebuilds only when `runtime/**` changed. Nobody hand-deploys. - Your config is in the repo: `runtime/deploy/mini/config.toml` is diff --git a/runtime/crates/muse-context/src/actor.rs b/runtime/crates/muse-context/src/actor.rs index 732a328a..fdc2ee5c 100644 --- a/runtime/crates/muse-context/src/actor.rs +++ b/runtime/crates/muse-context/src/actor.rs @@ -750,8 +750,17 @@ async fn governance_activity_since_render(state: &ContextState) -> bool { return false; }; let gh_bin = state.dashboard.gh_bin.as_deref().unwrap_or("gh"); - let (activity, _newest) = crate::dashboard::fetch_activity_since(gh_bin, repo, &cursor).await; - activity.is_some() + let activity = crate::dashboard::fetch_activity_since(gh_bin, repo, &cursor).await; + // Only the circle (and the queue labels) may wake her; activity + // from outside renders as a count at her next natural wake. On a + // public repo the alternative is a stranger-operated alarm (#89). + if activity.inner == 0 && activity.external > 0 { + info!( + external = activity.external, + "rung-0: governance activity only from outside the circle; not a wake" + ); + } + activity.inner > 0 } /// Run the rung-1 glance over a bounded timeline peek plus the diff --git a/runtime/crates/muse-context/src/context_stages.rs b/runtime/crates/muse-context/src/context_stages.rs index 625a8edf..f76b9e63 100644 --- a/runtime/crates/muse-context/src/context_stages.rs +++ b/runtime/crates/muse-context/src/context_stages.rs @@ -882,9 +882,11 @@ echo "$*" >> "$dir/calls.log" if [[ "$*" == *"--label"* ]]; then echo '[{"number":2,"title":"Timeline summarization: do you want it to run nightly?","author":{"login":"VE"}},{"number":3,"title":"Read access to your own git log","author":{"login":"claude"}}]' elif [[ "$1" == "issue" && "$*" == *"--search"* ]]; then - echo '[{"number":3,"title":"Read access to your own git log","state":"OPEN","updatedAt":"2026-08-09T10:00:00Z"}]' + echo '[{"number":3,"title":"Read access to your own git log","state":"OPEN","updatedAt":"2026-08-09T10:00:00Z","author":{"login":"claude"},"labels":[]},{"number":42,"title":"drive-by: please add my feature","state":"OPEN","updatedAt":"2026-08-09T12:00:00Z","author":{"login":"stranger"},"labels":[]},{"number":43,"title":"a proposal from outside","state":"OPEN","updatedAt":"2026-08-09T12:30:00Z","author":{"login":"stranger"},"labels":[{"name":"proposal"}]}]' elif [[ "$1" == "pr" && "$*" == *"--search"* ]]; then - echo '[{"number":7,"title":"Fix clippy pedantic warnings","state":"MERGED","updatedAt":"2026-08-09T11:00:00Z"}]' + echo '[{"number":7,"title":"Fix clippy pedantic warnings","state":"MERGED","updatedAt":"2026-08-09T11:00:00Z","author":{"login":"VE"},"labels":[]}]' +elif [[ "$1" == "api" && "$*" == *"collaborators"* ]]; then + echo '[{"login":"VE"},{"login":"claude"}]' else echo '[]' fi @@ -913,9 +915,11 @@ echo "$*" >> "$dir/calls.log" if [[ "$*" == *"--label"* ]]; then echo '[]' elif [[ "$1" == "issue" && "$*" == *"--search"* ]]; then - echo '[{"number":5,"title":"Lumen authoring PRs against her own source","state":"OPEN","updatedAt":"2026-08-09T10:00:00Z"}]' + echo '[{"number":5,"title":"Lumen authoring PRs against her own source","state":"OPEN","updatedAt":"2026-08-09T10:00:00Z","author":{"login":"VE"},"labels":[{"name":"proposal"}]}]' elif [[ "$1" == "pr" && "$*" == *"--search"* ]]; then echo '[]' +elif [[ "$1" == "api" && "$*" == *"collaborators"* ]]; then + echo '[{"login":"VE"},{"login":"claude"}]' else echo '[]' fi @@ -1154,6 +1158,60 @@ fi drain(identity, ih).await; } + #[tokio::test] + async fn dashboard_governance_renders_outsiders_as_a_count_not_content() { + // Issue #89: on a public repo anyone can file an issue, and + // this panel renders into her wake. Collaborators' items and + // queue-labeled items get full lines; everything else is a + // count with numbers — never a stranger's words. + let (identity, ih) = spawn_identity().await; + let dir = tempfile::tempdir().expect("tempdir"); + let dashboard = DashboardConfig { + governance: true, + governance_repo: Some("Iteratrix/Muse".to_string()), + gh_bin: Some(write_fake_gh(dir.path())), + ..DashboardConfig::default() + }; + set_hwm( + &identity, + HwmKey::Other("dashboard.governance".to_string()), + "2026-08-09T09:00:00Z", + ) + .await + .expect("seed cursor"); + + let (text, _pending) = dashboard::build_governance(&identity, &dashboard).await; + let text = text.expect("activity present"); + // Circle and labels: full lines. + assert!(text.contains("issue #3 (open, updated"), "{text}"); + assert!(text.contains("pr #7 (merged, updated"), "{text}"); + assert!(text.contains("a proposal from outside"), "{text}"); + // The stranger: number only, never the title. + assert!(!text.contains("drive-by"), "{text}"); + assert!( + text.contains("1 from outside the collaborator circle — count, not content: issue #42"), + "{text}" + ); + assert!(!text.contains("collaborator list unavailable"), "{text}"); + drain(identity, ih).await; + } + + #[tokio::test] + async fn dashboard_governance_outsider_only_activity_does_not_wake() { + // The rung-0 peek reads `inner`, so a stranger's issue can + // never operate her alarm; it waits for the next natural wake. + let dir = tempfile::tempdir().expect("tempdir"); + let gh = write_fake_gh_script( + dir.path(), + "#!/usr/bin/env bash\n[[ \"${1:-}\" == \"__probe\" ]] && exit 0\nif [[ \"$1\" == \"issue\" && \"$*\" == *\"--search\"* ]]; then echo '[{\"number\":42,\"title\":\"hi\",\"state\":\"OPEN\",\"updatedAt\":\"2026-08-09T12:00:00Z\",\"author\":{\"login\":\"stranger\"},\"labels\":[]}]'; elif [[ \"$1\" == \"api\" ]]; then echo '[{\"login\":\"VE\"}]'; else echo '[]'; fi\n", + ); + let activity = + dashboard::fetch_activity_since(&gh, "Iteratrix/Muse", "2026-08-09T09:00:00Z").await; + assert_eq!(activity.inner, 0); + assert_eq!(activity.external, 1); + assert!(activity.block.is_some(), "the count still renders"); + } + #[tokio::test] async fn dashboard_governance_none_when_queue_and_activity_are_both_empty() { // Neither an empty header nor an empty panel block — when diff --git a/runtime/crates/muse-context/src/dashboard.rs b/runtime/crates/muse-context/src/dashboard.rs index 662063fa..748c7dca 100644 --- a/runtime/crates/muse-context/src/dashboard.rs +++ b/runtime/crates/muse-context/src/dashboard.rs @@ -702,7 +702,7 @@ struct GhAwaitingIssue { author: GhAuthor, } -/// One row of `gh issue list`/`gh pr list --json number,title,state,updatedAt`. +/// One row of `gh issue list`/`gh pr list --json number,title,state,updatedAt,author,labels`. /// Issues and PRs share this exact JSON shape for the fields we ask for. #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -711,6 +711,59 @@ struct GhActivityItem { title: String, state: String, updated_at: DateTime, + #[serde(default)] + author: Option, + #[serde(default)] + labels: Vec, +} + +/// One entry of an item's `labels` array. +#[derive(serde::Deserialize)] +struct GhLabel { + name: String, +} + +/// One row of `gh api repos//collaborators`. +#[derive(serde::Deserialize)] +struct GhCollaborator { + login: String, +} + +/// Labels that earn a full line regardless of who authored the item: +/// a decision addressed to Lumen, or a proposal in the queue. +const INNER_LABELS: [&str; 2] = ["awaiting-lumen", "proposal"]; + +/// What `fetch_activity_since` learned, split by who wrote it. +/// +/// Issue #89 (the public flip): on a public repo, anyone can open an +/// issue, and this panel renders straight into Lumen's wake context +/// — an open route into her attention. The circle (repo +/// collaborators) and the labels in [`INNER_LABELS`] render as full +/// lines; everything else renders as a count with numbers, never +/// titles — her periphery-as-absence rule pointed at the inbox. +/// `inner` is what rung-0 may wake on; `external` never wakes her, +/// it waits for her next natural wake. +pub(crate) struct ActivitySince { + pub block: Option, + pub newest: DateTime, + pub inner: usize, + pub external: usize, +} + +fn is_inner(item: &GhActivityItem, circle: Option<&[String]>) -> bool { + if item + .labels + .iter() + .any(|l| INNER_LABELS.contains(&l.name.to_ascii_lowercase().as_str())) + { + return true; + } + let Some(circle) = circle else { + return false; + }; + item.author + .as_ref() + .is_some_and(|a| circle.iter().any(|c| c.eq_ignore_ascii_case(&a.login))) } /// Which `gh` subcommand an activity item came from — drives both the @@ -881,7 +934,7 @@ pub(crate) async fn fetch_activity_since( gh_bin: &str, repo: &str, cursor_str: &str, -) -> (Option, DateTime) { +) -> ActivitySince { let search = format!("updated:>{cursor_str}"); let issues: Vec = fetch_gh_json( gh_bin, @@ -895,7 +948,7 @@ pub(crate) async fn fetch_activity_since( "--search", &search, "--json", - "number,title,state,updatedAt", + "number,title,state,updatedAt,author,labels", "--limit", "15", ], @@ -914,13 +967,22 @@ pub(crate) async fn fetch_activity_since( "--search", &search, "--json", - "number,title,state,updatedAt", + "number,title,state,updatedAt,author,labels", "--limit", "15", ], ) .await .unwrap_or_default(); + // The circle. Fail-soft the attention-safe way: if the list is + // unavailable, nobody is inner by authorship (labels still are) + // and the panel says so — never a silent widening. + let circle: Option> = fetch_gh_json::>( + gh_bin, + &["api", &format!("repos/{repo}/collaborators")], + ) + .await + .map(|v| v.into_iter().map(|c| c.login).collect()); let mut combined: Vec<(GhKind, GhActivityItem)> = Vec::new(); combined.extend(issues.into_iter().map(|i| (GhKind::Issue, i))); @@ -936,12 +998,36 @@ pub(crate) async fn fetch_activity_since( }) .unwrap_or(now); - let activity_lines: Vec = combined + let (inner_items, outside): (Vec<_>, Vec<_>) = combined + .iter() + .partition(|(_, item)| is_inner(item, circle.as_deref())); + let mut activity_lines: Vec = inner_items .iter() .map(|(kind, item)| format_activity_item(*kind, item, now)) .collect(); + if !outside.is_empty() { + let refs: Vec = outside + .iter() + .map(|(kind, item)| format!("{} #{}", kind.noun(), item.number)) + .collect(); + let mut line = format!( + "{} from outside the collaborator circle — count, not content: {}. Labeled \ + `external`; read on purpose (gh issue view N / gh pr view N), never by default.", + outside.len(), + refs.join(", ") + ); + if circle.is_none() { + line.push_str(" (collaborator list unavailable this wake — authorship not trusted)"); + } + activity_lines.push(line); + } let block = (!activity_lines.is_empty()).then(|| render_capped("", &activity_lines)); - (block, newest) + ActivitySince { + block, + newest, + inner: inner_items.len(), + external: outside.len(), + } } /// Standing `awaiting-lumen` queue plus issue/PR activity since the @@ -1007,11 +1093,11 @@ pub(crate) async fn build_governance( None if awaiting.is_none() => None, None => Some(Utc::now()), Some(cursor_str) => { - let (activity_block, newest) = fetch_activity_since(gh_bin, repo, cursor_str).await; - if let Some(block) = activity_block { + let activity = fetch_activity_since(gh_bin, repo, cursor_str).await; + if let Some(block) = activity.block { lines.push(block); } - Some(newest) + Some(activity.newest) } }; // Commit-on-completion (Lumen's #30 symmetry verdict): the cursor @@ -1350,6 +1436,8 @@ mod tests { title: "x".repeat(120), state: "CLOSED".to_string(), updated_at: now, + author: None, + labels: Vec::new(), }; let line = format_activity_item(GhKind::Issue, &item, now); assert!(line.contains(&"x".repeat(59)), "{line}");