Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/label-external.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion docs/os/wake-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo> --label awaiting-lumen --state open --json number,title,author --limit 10` (standing queue); `gh issue list`/`gh pr list -R <repo> --state all --search "updated:><cursor>" --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/<repo>/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 <repo> --label awaiting-lumen --state open --json number,title,author --limit 10` (standing queue); `gh issue list`/`gh pr list -R <repo> --state all --search "updated:><cursor>" --json number,title,state,updatedAt,author,labels --limit 15` (activity); `gh api repos/<repo>/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`

Expand Down
5 changes: 5 additions & 0 deletions handbook/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions runtime/crates/muse-context/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 61 additions & 3 deletions runtime/crates/muse-context/src/context_stages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
106 changes: 97 additions & 9 deletions runtime/crates/muse-context/src/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -711,6 +711,59 @@ struct GhActivityItem {
title: String,
state: String,
updated_at: DateTime<Utc>,
#[serde(default)]
author: Option<GhAuthor>,
#[serde(default)]
labels: Vec<GhLabel>,
}

/// One entry of an item's `labels` array.
#[derive(serde::Deserialize)]
struct GhLabel {
name: String,
}

/// One row of `gh api repos/<repo>/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<String>,
pub newest: DateTime<Utc>,
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
Expand Down Expand Up @@ -881,7 +934,7 @@ pub(crate) async fn fetch_activity_since(
gh_bin: &str,
repo: &str,
cursor_str: &str,
) -> (Option<String>, DateTime<Utc>) {
) -> ActivitySince {
let search = format!("updated:>{cursor_str}");
let issues: Vec<GhActivityItem> = fetch_gh_json(
gh_bin,
Expand All @@ -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",
],
Expand All @@ -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<Vec<String>> = fetch_gh_json::<Vec<GhCollaborator>>(
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)));
Expand All @@ -936,12 +998,36 @@ pub(crate) async fn fetch_activity_since(
})
.unwrap_or(now);

let activity_lines: Vec<String> = combined
let (inner_items, outside): (Vec<_>, Vec<_>) = combined
.iter()
.partition(|(_, item)| is_inner(item, circle.as_deref()));
let mut activity_lines: Vec<String> = inner_items
.iter()
.map(|(kind, item)| format_activity_item(*kind, item, now))
.collect();
if !outside.is_empty() {
let refs: Vec<String> = 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}");
Expand Down