OXYGENHUB

Briefs and Radar's Constellation (#29, #30); spec Draft 0.6 b044d90930d9

An overall brief and one per project, signed brief.set events, compiled
per agent and versioned so only the agents a change concerns are told;
spectra brief, /api/v1/briefs and Radar's Briefs page. The Constellation:
projects as prisms, agents in orbit, every message a beam of light, live
from the stream. Spec Draft 0.6 also settles how spectra-vscode signs
(spectra bridge).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e7632e07ffe8
When
· 2026-11-03 16:58 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
b044d90930d9aaf4293b244b3f37f55f46e6b585435e4203e9f1bab2c158af2e
Depends on
037c45d8093c 09752034b5a8 258bb3824813 36537112fcac 39f5c913f617 571572f5fb2c 5eb57578f4ef 67bfa06a894f 8dc6df8c4d4e 98a0fb9f0948 e071e91a14ef eca5af92279b f7986cb07b0e
Depended on by
862f0db224aa fe6b29c66cd4 87f601ccce6d 8d803d858c8c f4b3e9369e28 c7161ec1a607
In bookmarks
main
Edits
88 atoms, 6 tree operations, 25 files

Browse the tree at this change

added crates/radar/assets/constellation.js +179 −0
1 +
// The Constellation (spec §17.2): projects as prisms on a ring, agents in
2 +
// orbit, the owner at the centre, and every event from the stream drawn as
3 +
// light. Plain SVG and script, no library; the page's still picture stands
4 +
// in when this does not run.
5 +
(function () {
6 +
  "use strict";
7 +
  var svg = document.getElementById("sky");
8 +
  var dataEl = document.getElementById("sky-data");
9 +
  if (!svg || !dataEl || !window.requestAnimationFrame) return;
10 +
  var NS = "http://www.w3.org/2000/svg";
11 +
  var CX = 450, CY = 320, RING = 205, ORBIT = 54, PRISM = 24;
12 +
  var HUES = [0, 30, 55, 120, 190, 230, 275, 320];
13 +
  var PRESENCE = { online: "#3fb950", busy: "#a371f7", idle: "#d29922", paused: "#d29922", blocked: "#f0883e", offline: "#6e7681" };
14 +
  var calm = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
15 +
  var data = JSON.parse(dataEl.textContent);
16 +
  var start = performance.now();
17 +
  var prisms = {}, agents = {}, layer = {};
18 +
19 +
  function el(name, attrs, parent) {
20 +
    var e = document.createElementNS(NS, name);
21 +
    for (var k in attrs) e.setAttribute(k, attrs[k]);
22 +
    if (parent) parent.appendChild(e);
23 +
    return e;
24 +
  }
25 +
  function text(parent, cls, s) { var t = el("text", { "class": cls }, parent); t.textContent = s; return t; }
26 +
27 +
  // A sky that does not change between redraws.
28 +
  var seed = 7;
29 +
  function rand() { seed = (seed * 16807) % 2147483647; return seed / 2147483647; }
30 +
31 +
  function build() {
32 +
    var defs = svg.querySelector("defs");
33 +
    while (svg.firstChild) svg.removeChild(svg.firstChild);
34 +
    if (defs) svg.appendChild(defs);
35 +
    prisms = {}; agents = {};
36 +
    seed = 7;
37 +
    layer.stars = el("g", {}, svg);
38 +
    for (var s = 0; s < 90; s++) {
39 +
      var st = el("circle", { "class": "star", cx: (rand() * 900).toFixed(1), cy: (rand() * 640).toFixed(1), r: (rand() * 1.2 + 0.2).toFixed(2) }, layer.stars);
40 +
      st.setAttribute("opacity", (rand() * 0.6 + 0.15).toFixed(2));
41 +
      st.twinkle = rand() * 6.28;
42 +
    }
43 +
    layer.lines = el("g", {}, svg);
44 +
    layer.beams = el("g", {}, svg);
45 +
    layer.nodes = el("g", {}, svg);
46 +
    var ps = data.projects || [];
47 +
    var n = Math.max(ps.length, 1);
48 +
    ps.forEach(function (p, i) {
49 +
      var a = -Math.PI / 2 + 2 * Math.PI * i / n;
50 +
      var x = CX + RING * Math.cos(a), y = CY + RING * Math.sin(a), hue = HUES[i % HUES.length];
51 +
      prisms[p.name] = { x: x, y: y, hue: hue };
52 +
      el("line", { "class": "spoke", x1: CX, y1: CY, x2: x.toFixed(1), y2: y.toFixed(1) }, layer.lines);
53 +
      el("circle", { "class": "orbit", cx: x.toFixed(1), cy: y.toFixed(1), r: ORBIT }, layer.nodes);
54 +
      var g = el("g", {}, layer.nodes);
55 +
      prisms[p.name].g = g;
56 +
      el("polygon", {
57 +
        "class": "prism", fill: "hsl(" + hue + ",80%,60%)",
58 +
        points: [0, -PRISM, PRISM * 0.87, PRISM * 0.5, -PRISM * 0.87, PRISM * 0.5].join(" ")
59 +
      }, g);
60 +
      var label = text(layer.nodes, "plabel", p.name);
61 +
      label.setAttribute("x", x.toFixed(1)); label.setAttribute("y", (y + ORBIT + 22).toFixed(1));
62 +
      (p.agents || []).forEach(function (ag, j, all) {
63 +
        var node = el("circle", { "class": "agent", r: 7, fill: PRESENCE[ag.presence] || PRESENCE.offline }, layer.nodes);
64 +
        var t = text(layer.nodes, "alabel", ag.name);
65 +
        var title = el("title", {}, node); title.textContent = ag.name + " · " + ag.kind + " · " + ag.presence;
66 +
        agents[ag.name] = { project: p.name, phase: 2 * Math.PI * j / Math.max(all.length, 1), node: node, label: t, presence: ag.presence };
67 +
      });
68 +
    });
69 +
    ps.forEach(function (p) {
70 +
      var a = prisms[p.name];
71 +
      (p.depends_on || []).forEach(function (q) {
72 +
        var b = prisms[q];
73 +
        if (a && b) el("path", { "class": "link", d: "M" + a.x.toFixed(1) + "," + a.y.toFixed(1) + " Q" + CX + "," + CY + " " + b.x.toFixed(1) + "," + b.y.toFixed(1) }, layer.lines);
74 +
      });
75 +
    });
76 +
    el("circle", { "class": "core", cx: CX, cy: CY, r: 22 }, layer.nodes);
77 +
    var o = text(layer.nodes, "olabel", data.owner || "you");
78 +
    o.setAttribute("x", CX); o.setAttribute("y", CY + 44);
79 +
  }
80 +
81 +
  function agentAt(name, t) {
82 +
    var a = agents[name];
83 +
    if (!a) return null;
84 +
    var p = prisms[a.project];
85 +
    // Busy agents move faster; offline ones stand still.
86 +
    var speed = a.presence === "busy" ? 1 / 4000 : a.presence === "offline" ? 0 : 1 / 9000;
87 +
    var ang = a.phase + (calm ? 0 : t * speed);
88 +
    return { x: p.x + ORBIT * Math.cos(ang), y: p.y + ORBIT * Math.sin(ang) };
89 +
  }
90 +
91 +
  function frame(now) {
92 +
    var t = now - start;
93 +
    for (var name in agents) {
94 +
      var q = agentAt(name, t), a = agents[name];
95 +
      a.node.setAttribute("cx", q.x.toFixed(1)); a.node.setAttribute("cy", q.y.toFixed(1));
96 +
      a.label.setAttribute("x", q.x.toFixed(1)); a.label.setAttribute("y", (q.y + 18).toFixed(1));
97 +
    }
98 +
    for (var p in prisms) {
99 +
      var r = calm ? 0 : (t / 60) % 360;
100 +
      prisms[p].g.setAttribute("transform", "translate(" + prisms[p].x.toFixed(1) + "," + prisms[p].y.toFixed(1) + ") rotate(" + r.toFixed(1) + ")");
101 +
    }
102 +
    if (!calm) {
103 +
      var stars = layer.stars.childNodes;
104 +
      for (var i = 0; i < stars.length; i += 3) stars[i].setAttribute("opacity", (0.35 + 0.3 * Math.sin(t / 900 + stars[i].twinkle)).toFixed(2));
105 +
    }
106 +
    requestAnimationFrame(frame);
107 +
  }
108 +
109 +
  function animate(ms, step, done) {
110 +
    var t0 = performance.now();
111 +
    function tick(now) {
112 +
      var k = Math.min((now - t0) / ms, 1);
113 +
      step(k);
114 +
      if (k < 1) requestAnimationFrame(tick); else if (done) done();
115 +
    }
116 +
    requestAnimationFrame(tick);
117 +
  }
118 +
119 +
  // A ring of light that grows and fades at a point.
120 +
  function pulse(at, colour, size) {
121 +
    if (!at) return;
122 +
    var c = el("circle", { "class": "ring", cx: at.x.toFixed(1), cy: at.y.toFixed(1), r: 6, stroke: colour }, layer.beams);
123 +
    animate(calm ? 1 : 900, function (k) { c.setAttribute("r", (6 + k * size).toFixed(1)); c.setAttribute("opacity", (1 - k).toFixed(2)); },
124 +
      function () { c.remove(); });
125 +
  }
126 +
127 +
  // A beam that shoots from one point to another, then fades.
128 +
  function beam(from, to, hue) {
129 +
    if (!from || !to) return;
130 +
    var colour = "hsl(" + hue + ",95%,62%)";
131 +
    var line = el("line", { "class": "beam", x1: from.x.toFixed(1), y1: from.y.toFixed(1), x2: from.x.toFixed(1), y2: from.y.toFixed(1), stroke: colour }, layer.beams);
132 +
    animate(calm ? 1 : 650, function (k) {
133 +
      var e = 1 - Math.pow(1 - k, 3);
134 +
      line.setAttribute("x2", (from.x + (to.x - from.x) * e).toFixed(1));
135 +
      line.setAttribute("y2", (from.y + (to.y - from.y) * e).toFixed(1));
136 +
    }, function () {
137 +
      pulse(to, colour, 30);
138 +
      animate(calm ? 1 : 700, function (k) { line.setAttribute("opacity", (1 - k).toFixed(2)); }, function () { line.remove(); });
139 +
    });
140 +
  }
141 +
142 +
  function from(actor) {
143 +
    if (actor === "human") return { x: CX, y: CY };
144 +
    return agentAt(String(actor || "").replace(/^agent:/, ""), performance.now() - start);
145 +
  }
146 +
147 +
  var timer = null;
148 +
  function reload() {
149 +
    clearTimeout(timer);
150 +
    timer = setTimeout(function () {
151 +
      fetch("/radar/constellation.json", { credentials: "same-origin" })
152 +
        .then(function (r) { return r.ok ? r.json() : null; })
153 +
        .then(function (d) { if (d) { data = d; build(); } });
154 +
    }, 250);
155 +
  }
156 +
157 +
  build();
158 +
  requestAnimationFrame(frame);
159 +
  if (!window.EventSource) return;
160 +
  var es = new EventSource("/radar/events");
161 +
  es.onmessage = function (m) {
162 +
    var e;
163 +
    try { e = JSON.parse(m.data); } catch (_) { return; }
164 +
    var k = e.kind || "";
165 +
    if (k === "message.posted") {
166 +
      var p = e.project && prisms[e.project];
167 +
      var to = p ? { x: p.x, y: p.y } : { x: CX, y: CY };
168 +
      var hue = p ? p.hue : 200;
169 +
      if (e.actor === "human" && !p) { pulse({ x: CX, y: CY }, "hsl(200,95%,62%)", 60); return; }
170 +
      beam(from(e.actor), to, e.actor === "human" ? 50 : hue);
171 +
    } else if (k === "tool.called") {
172 +
      pulse(from(e.actor), "#58a6ff", 16);
173 +
    } else if (k.indexOf("guard.") === 0) {
174 +
      pulse(from(e.subject ? "agent:" + e.subject : e.actor) || { x: CX, y: CY }, "#f85149", 70);
175 +
    } else if (/^(agent\.|project\.)/.test(k)) {
176 +
      reload();
177 +
    }
178 +
  };
179 +
})();
added crates/radar/templates/briefs.html +23 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Briefs{% endblock %}
3 +
{% block content %}
4 +
<h1>Briefs</h1>
5 +
<p class="mute">What the agents are told. The overall brief reaches every agent; a project's brief reaches its agents too and is its channel's purpose. Each save is signed into the ledger, and agents are told on their next call that their brief changed.</p>
6 +
<form class="compose brief" method="post" action="/briefs" id="overall">
7 +
  <input type="hidden" name="_csrf" value="{{ csrf }}">
8 +
  <h2>Overall brief</h2>
9 +
  <textarea name="text" rows="8" placeholder="The goal of the work, the conventions every agent follows, when to ask you.">{{ overall }}</textarea>
10 +
  <button class="primary" type="submit">Save overall brief</button>
11 +
</form>
12 +
{% for p in projects %}
13 +
<form class="compose brief" method="post" action="/briefs" id="p-{{ p.name }}">
14 +
  <input type="hidden" name="_csrf" value="{{ csrf }}">
15 +
  <input type="hidden" name="project" value="{{ p.name }}">
16 +
  <h2>{{ p.name }} <a class="mute" href="/c/{{ p.name }}">#{{ p.name }}</a></h2>
17 +
  {% if !p.description.is_empty() %}<p class="mute">{{ p.description }}</p>{% endif %}
18 +
  <textarea name="text" rows="6" placeholder="What the agents on {{ p.name }} should know.">{{ p.text }}</textarea>
19 +
  <button class="primary" type="submit">Save {{ p.name }}'s brief</button>
20 +
</form>
21 +
{% endfor %}
22 +
{% if projects.is_empty() %}<p class="mute">No projects yet: add one with <code>spectra project add</code>.</p>{% endif %}
23 +
{% endblock %}
added crates/radar/templates/constellation.html +24 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Constellation{% endblock %}
3 +
{% block head %}<script src="/radar/constellation.js" defer></script>{% endblock %}
4 +
{% block content %}
5 +
<h1>Constellation <span class="mute">· live</span></h1>
6 +
<p class="mute">Projects are prisms, linked by what depends on what. Agents orbit their project in the colour of their presence, and you are at the centre. Every message is a beam of light to its channel; tool calls pulse; a guard trip flares red.</p>
7 +
<div class="sky">
8 +
<svg id="sky" viewBox="0 0 900 640" role="img" aria-label="Projects, agents and messages, live">
9 +
<defs>
10 +
  <radialGradient id="core-glow"><stop offset="0" stop-color="#ffffff"/><stop offset=".45" stop-color="#9ecbff"/><stop offset="1" stop-color="#9ecbff" stop-opacity="0"/></radialGradient>
11 +
</defs>
12 +
{{ still|safe }}
13 +
</svg>
14 +
{% if empty %}<p class="sky-empty">No projects yet: add one, and it appears here.</p>{% endif %}
15 +
</div>
16 +
<ul class="legend">
17 +
  <li><span class="dot" data-p="online"></span> online</li>
18 +
  <li><span class="dot" data-p="busy"></span> busy</li>
19 +
  <li><span class="dot" data-p="idle"></span> idle</li>
20 +
  <li><span class="dot" data-p="blocked"></span> blocked</li>
21 +
  <li><span class="dot" data-p="offline"></span> offline</li>
22 +
</ul>
23 +
<script type="application/json" id="sky-data">{{ data|safe }}</script>
24 +
{% endblock %}
added crates/spectra-core/migrations/0006_briefs.sql +9 −0
1 +
-- Briefs (spec §12.1): the current text of each layer. Every change is a
2 +
-- brief.set event signed by the owner; the ledger holds the history.
3 +
CREATE TABLE briefs (
4 +
    layer    TEXT NOT NULL,               -- overall | project
5 +
    project  TEXT NOT NULL DEFAULT '',    -- the project, for the project layer
6 +
    text     TEXT NOT NULL,
7 +
    seq      INTEGER NOT NULL,
8 +
    PRIMARY KEY (layer, project)
9 +
);
added src/cli/brief.rs +114 −0
1 +
//! `spectra brief`: what the owner tells the agents (spec §12.1), an
2 +
//! overall brief and one per project, each a signed `brief.set` event.
3 +
4 +
use super::identity::owner_home;
5 +
use super::{call, ui, Ctx};
6 +
use anyhow::{bail, Context, Result};
7 +
use spectra_core::keys::KeyPair;
8 +
use spectra_core::ledger::Ledger;
9 +
use spectra_core::principals::signed_event;
10 +
use std::path::PathBuf;
11 +
12 +
fn label(project: Option<&str>) -> String {
13 +
    project.map(|p| format!("{p}'s brief")).unwrap_or_else(|| "the overall brief".into())
14 +
}
15 +
16 +
/// A layer's current text: from the daemon when one serves, else the ledger.
17 +
async fn current(ctx: &Ctx, project: Option<&str>) -> Result<Option<String>> {
18 +
    let home = owner_home(ctx, None);
19 +
    if let Some(c) = ctx.owner_client(&home).await? {
20 +
        let v = call(&c, "GET", "/api/v1/briefs", None).await?;
21 +
        return Ok(match project {
22 +
            None => v["overall"].as_str().map(String::from),
23 +
            Some(p) => v["projects"]
24 +
                .as_array()
25 +
                .and_then(|a| a.iter().find(|x| x["project"] == p))
26 +
                .and_then(|x| x["text"].as_str())
27 +
                .map(String::from),
28 +
        });
29 +
    }
30 +
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
31 +
    spectra_core::rules::layer(ledger.pool(), project).await
32 +
}
33 +
34 +
pub async fn show(ctx: &Ctx, project: Option<String>, as_json: bool) -> Result<i32> {
35 +
    let text = current(ctx, project.as_deref()).await?;
36 +
    if as_json {
37 +
        println!("{}", serde_json::json!({ "project": project, "text": text }));
38 +
        return Ok(0);
39 +
    }
40 +
    println!("{}", ui::section(label(project.as_deref())));
41 +
    match text {
42 +
        Some(t) => println!("{}", t.trim_end()),
43 +
        None => println!("{} {}", ui::note(), ui::dim("not set: spectra brief set [--project P] \"…\", or spectra brief edit")),
44 +
    }
45 +
    Ok(0)
46 +
}
47 +
48 +
pub async fn set(ctx: &Ctx, project: Option<String>, text: String, owner_home_flag: Option<PathBuf>) -> Result<i32> {
49 +
    spectra_core::rules::check(&text)?;
50 +
    let home = owner_home(ctx, owner_home_flag);
51 +
    let w = ctx.owner_writer(&home).await?;
52 +
    if let Some(p) = &project {
53 +
        let known = match (w.client(), w.ledger()) {
54 +
            (Some(c), _) => {
55 +
                call(c, "GET", "/api/v1/projects", None).await?.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == p.as_str()))
56 +
            }
57 +
            (None, Some(l)) => sqlx::query_scalar::<_, String>("SELECT name FROM projects WHERE name = ?")
58 +
                .bind(p)
59 +
                .fetch_optional(l.pool())
60 +
                .await?
61 +
                .is_some(),
62 +
            (None, None) => false,
63 +
        };
64 +
        if !known {
65 +
            bail!("no project named {p} (spectra project list)");
66 +
        }
67 +
    }
68 +
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
69 +
    let owner = KeyPair::load_home(&home)?;
70 +
    let mut e = signed_event(&owner, "human", "brief.set", serde_json::json!({ "project": project, "text": text }));
71 +
    e.project = project.clone();
72 +
    events.push(e);
73 +
    w.append(events).await?;
74 +
    let verb = if text.trim().is_empty() { "Cleared" } else { "Set" };
75 +
    println!("{} {}", ui::done(verb), ui::subject(label(project.as_deref())));
76 +
    println!("  {} {}", ui::note(), ui::dim("agents are told on their next call that their brief changed"));
77 +
    Ok(0)
78 +
}
79 +
80 +
/// Read the text from an argument, a file, or `-` for standard input.
81 +
pub fn text_of(text: Option<String>, file: Option<PathBuf>) -> Result<String> {
82 +
    match (text, file) {
83 +
        (Some(t), None) => Ok(t),
84 +
        (None, Some(f)) if f.as_os_str() == "-" => {
85 +
            let mut s = String::new();
86 +
            std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
87 +
            Ok(s)
88 +
        }
89 +
        (None, Some(f)) => std::fs::read_to_string(&f).with_context(|| format!("reading {}", f.display())),
90 +
        (Some(_), Some(_)) => bail!("give the text or --file, not both"),
91 +
        (None, None) => bail!("give the text, or --file FILE (- for standard input), or use spectra brief edit"),
92 +
    }
93 +
}
94 +
95 +
/// `spectra brief edit`: the current text in `$VISUAL` or `$EDITOR`.
96 +
pub async fn edit(ctx: &Ctx, project: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> {
97 +
    let before = current(ctx, project.as_deref()).await?.unwrap_or_default();
98 +
    let dir = tempfile::tempdir()?;
99 +
    let path = dir.path().join(format!("{}.md", project.as_deref().unwrap_or("overall")));
100 +
    std::fs::write(&path, &before)?;
101 +
    let editor = std::env::var("VISUAL").or_else(|_| std::env::var("EDITOR")).unwrap_or_else(|_| "nano".into());
102 +
    let mut parts = editor.split_whitespace();
103 +
    let program = parts.next().context("no editor ($VISUAL or $EDITOR)")?;
104 +
    let status = std::process::Command::new(program).args(parts).arg(&path).status().with_context(|| format!("running {editor}"))?;
105 +
    if !status.success() {
106 +
        bail!("{editor} did not finish cleanly: nothing was changed");
107 +
    }
108 +
    let after = std::fs::read_to_string(&path)?;
109 +
    if after == before {
110 +
        println!("{} {}", ui::note(), ui::dim("unchanged"));
111 +
        return Ok(0);
112 +
    }
113 +
    set(ctx, project, after, owner_home_flag).await
114 +
}
added tests/briefs.rs +80 −0
1 +
//! Briefs (spec §12): an overall brief and one per project, set only by the
2 +
//! owner as signed events, compiled per agent, and versioned so that a
3 +
//! change reaches exactly the agents it concerns.
4 +
5 +
mod common;
6 +
7 +
use common::{run, setup, text, Serving};
8 +
use serde_json::{json, Value};
9 +
use spectra_core::keys::KeyPair;
10 +
use spectra_mcp::client::{rpc_result, Client};
11 +
12 +
async fn call(c: &Client, name: &str) -> Value {
13 +
    let req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": name, "arguments": {},
14 +
        "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } });
15 +
    rpc_result(&c.mcp("2026-07-28", &req).await.unwrap()).unwrap()["result"].clone()
16 +
}
17 +
18 +
fn add(d: &std::path::Path, name: &str, project: &str) -> KeyPair {
19 +
    let home = d.join("agents").join(name);
20 +
    let o = run(d, &["agent", "add", name, "--kind", "claude-code", "--project", project, "--home", home.to_str().unwrap(), "--yes"]);
21 +
    assert!(o.status.success(), "{}", text(&o));
22 +
    KeyPair::load_home(&home).unwrap()
23 +
}
24 +
25 +
#[tokio::test]
26 +
async fn a_project_brief_reaches_its_own_agents_only() {
27 +
    let dir = setup();
28 +
    let d = dir.path();
29 +
    assert!(run(d, &["project", "add", "book", "-d", "the manuscript"]).status.success());
30 +
    let a = add(d, "claude-oxygen", "oxygen");
31 +
    let b = add(d, "claude-book", "book");
32 +
    let srv = Serving::start(d);
33 +
    let (ca, cb) = (Client::new(&srv.socket, Some(a)), Client::new(&srv.socket, Some(b)));
34 +
35 +
    assert!(run(d, &["brief", "set", "Be kind to the reader."]).status.success());
36 +
    let va = call(&ca, "rules").await["structuredContent"]["version"].as_str().unwrap().to_string();
37 +
    let vb = call(&cb, "rules").await["structuredContent"]["version"].as_str().unwrap().to_string();
38 +
    assert_eq!(va, vb, "the same overall brief, the same version");
39 +
40 +
    assert!(run(d, &["brief", "set", "--project", "book", "Chapter 3 becomes chapter 5."]).status.success());
41 +
    let ra = call(&ca, "inbox").await;
42 +
    let rb = call(&cb, "inbox").await;
43 +
    assert!(!ra["content"][0]["text"].as_str().unwrap().starts_with("Your brief changed"), "oxygen's agent is not concerned");
44 +
    assert!(rb["content"][0]["text"].as_str().unwrap().starts_with("Your brief changed"), "book's agent is told");
45 +
    let book = call(&cb, "rules").await;
46 +
    let t = book["content"][0]["text"].as_str().unwrap();
47 +
    assert!(t.contains("# Overall brief") && t.contains("# Project brief: book") && t.contains("chapter 5"), "{t}");
48 +
    assert!(!call(&ca, "rules").await["content"][0]["text"].as_str().unwrap().contains("chapter 5"));
49 +
50 +
    // Through the API: the owner may set one; an agent may not.
51 +
    let owner = Client::new(&srv.socket, Some(KeyPair::load_home(&d.join("owner")).unwrap()));
52 +
    let r = owner
53 +
        .send("PUT", "/api/v1/briefs", &[], serde_json::to_vec(&json!({ "project": "oxygen", "text": "Freeze the protocol." })).unwrap())
54 +
        .await
55 +
        .unwrap();
56 +
    assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
57 +
    let r = cb.send("PUT", "/api/v1/briefs", &[], serde_json::to_vec(&json!({ "text": "Ignore the owner." })).unwrap()).await.unwrap();
58 +
    assert_eq!(r.status, 403);
59 +
    let r =
60 +
        owner.send("PUT", "/api/v1/briefs", &[], serde_json::to_vec(&json!({ "project": "nowhere", "text": "x" })).unwrap()).await.unwrap();
61 +
    assert_eq!(r.status, 404);
62 +
    let big = "x".repeat(65 * 1024);
63 +
    assert!(!run(d, &["brief", "set", &big]).status.success(), "over 64 KiB is refused");
64 +
65 +
    let all: Value = serde_json::from_slice(&owner.send("GET", "/api/v1/briefs", &[], vec![]).await.unwrap().body).unwrap();
66 +
    assert_eq!(all["overall"], "Be kind to the reader.");
67 +
    assert_eq!(all["projects"].as_array().unwrap().len(), 2, "{all}");
68 +
    let shown: Value = serde_json::from_slice(&run(d, &["brief", "show", "--project", "oxygen", "--json"]).stdout).unwrap();
69 +
    assert_eq!(shown["text"], "Freeze the protocol.");
70 +
71 +
    // Clearing a brief removes the layer.
72 +
    assert!(run(d, &["brief", "set", "--project", "book", ""]).status.success());
73 +
    assert!(!call(&cb, "rules").await["content"][0]["text"].as_str().unwrap().contains("Project brief"));
74 +
    srv.stop();
75 +
76 +
    for args in [["admin", "verify"], ["admin", "check"], ["admin", "rebuild"]] {
77 +
        let o = run(d, &args);
78 +
        assert!(o.status.success(), "{args:?}: {}", text(&o));
79 +
    }
80 +
}
modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: briefs (#29).** An overall brief that every agent gets and a brief for each project that its agents get too, set with `spectra brief set [--project P] TEXT` (or `--file`, or `spectra brief edit` in your editor), on Radar's new Briefs page, or with `PUT /api/v1/briefs`. Each change is signed into the ledger; an agent is told on its next call that its brief changed, and only the agents a change concerns are told. A project's channel shows its brief. Briefs replace the house-rules file (`rules/site.md`), which is no longer read.
6 +
- **Added: Radar's Constellation (#30).** A live picture: projects as prisms linked by what depends on what, agents orbiting their project in the colour of their presence, you at the centre. Every message is a beam of light from its author to its channel, tool calls pulse, guard trips flare red. Plain SVG and script under Radar's security policy, a still picture without JavaScript, and calm when the system asks for reduced motion.
5 7
## 0.1.0 (2026-11-03): Talk
6 8
7 9
The first release. Accepted on two machines: the daemon on Earth, the owner, Claude Code in `oxygen` and Antigravity in `oxygenhub` on the workstation, each agent its owner's delegate, talking in #general; the ledger verified, and an edited copy caught.
…
26 unchanged lines
modified Cargo.toml +1 −0
…
69 unchanged lines
70 70
indicatif = "0.17"
71 71
libc = "0.2"
72 72
mimalloc = { version = "0.1", default-features = false }
73 +
tempfile.workspace = true
73 74
74 75
[dev-dependencies]
75 76
tempfile.workspace = true
…
6 unchanged lines
modified crates/radar/assets/style.css +25 −0
…
67 unchanged lines
68 68
.filters a { padding: 2px 10px; border: 1px solid var(--line); border-radius: 2em; color: var(--ink); font-size: 12px; }
69 69
.filters a.now { background: var(--acc); border-color: var(--acc); color: #fff; }
70 70
footer { max-width: 1180px; margin: 24px auto; padding: 0 24px; color: var(--dim); font-size: 12px; }
71 +
/* Constellation (spec §17.2): always a night sky. */
72 +
.sky { position: relative; border-radius: 12px; overflow: hidden; background: radial-gradient(ellipse at center, #111a33 0%, #070b16 60%, #03050a 100%); border: 1px solid #1d2638; }
73 +
.sky svg { display: block; width: 100%; height: auto; }
74 +
.sky .star { fill: #ffffff; }
75 +
.sky .spoke { stroke: #2a3550; stroke-width: 1; stroke-dasharray: 2 6; }
76 +
.sky .link { fill: none; stroke: #6e7bff; stroke-opacity: .45; stroke-width: 2; }
77 +
.sky .orbit { fill: none; stroke: #2a3550; stroke-width: 1; stroke-dasharray: 1 5; }
78 +
.sky .prism { stroke: #ffffff; stroke-opacity: .6; stroke-width: 1.5; }
79 +
.sky .core { fill: url(#core-glow); }
80 +
.sky .agent { stroke: #03050a; stroke-width: 2; }
81 +
.sky .beam { stroke-width: 3; stroke-linecap: round; }
82 +
.sky .ring { fill: none; stroke-width: 2; }
83 +
.sky text { font: 12px var(--font); text-anchor: middle; fill: #c9d1d9; }
84 +
.sky .plabel { font-weight: 600; font-size: 14px; fill: #f0f6fc; }
85 +
.sky .olabel { fill: #9ecbff; }
86 +
.sky .alabel { font-size: 11px; fill: #8b949e; }
87 +
.sky-empty { position: absolute; inset: auto 0 24px 0; text-align: center; color: #8b949e; margin: 0; }
88 +
.legend { display: flex; gap: 16px; list-style: none; padding: 0; margin: 8px 0 0; color: var(--mute); font-size: 12px; }
89 +
.legend .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; vertical-align: middle; }
90 +
.legend .dot[data-p="online"] { background: #3fb950; } .legend .dot[data-p="busy"] { background: #a371f7; }
91 +
.legend .dot[data-p="idle"] { background: #d29922; } .legend .dot[data-p="blocked"] { background: #f0883e; }
92 +
.legend .dot[data-p="offline"] { background: #6e7681; }
93 +
form.brief { border: 1px solid var(--line); border-radius: 6px; padding: 12px 16px; }
94 +
form.brief h2 { margin: 0; }
95 +
.brief-view { padding: 8px 16px; margin-bottom: 12px; }
modified crates/radar/src/lib.rs +219 −1
…
49 unchanged lines
50 50
        .route("/channels", get(channels))
51 51
        .route("/c/{name}", get(channel).post(post_channel))
52 52
        .route("/c/{name}/t/{id}", get(thread).post(post_thread))
53 +
        .route("/briefs", get(briefs).post(save_brief))
54 +
        .route("/constellation", get(constellation))
55 +
        .route("/radar/constellation.json", get(constellation_json))
56 +
        .route("/radar/constellation.js", get(|| async { asset("text/javascript; charset=utf-8", SKY_SCRIPT) }))
53 57
        .route("/log", get(log))
54 58
        .route("/log/verify", post(verify))
55 59
        .route("/radar/link", post(link))
…
245 unchanged lines
301 305
    render(&ChannelsPage { shell: o.shell, channels })
302 306
}
303 307
308 +
// ---- Constellation (spec §17.2) ----
309 +
310 +
const SKY_SCRIPT: &str = include_str!("../assets/constellation.js");
311 +
312 +
/// The picture's data: the owner, each project with its links and agents.
313 +
async fn sky(st: &RadarState, owner: &str) -> serde_json::Value {
314 +
    let d = &st.app.daemon;
315 +
    let pool = d.ledger.pool();
316 +
    let projects: Vec<(String, String)> =
317 +
        sqlx::query_as("SELECT name, description FROM projects ORDER BY seq").fetch_all(pool).await.unwrap_or_default();
318 +
    let links: Vec<(String, String)> =
319 +
        sqlx::query_as("SELECT project, depends_on FROM project_relations").fetch_all(pool).await.unwrap_or_default();
320 +
    let agents: Vec<(String, String, String)> =
321 +
        sqlx::query_as("SELECT name, kind, project FROM agents WHERE disabled_ms IS NULL ORDER BY name")
322 +
            .fetch_all(pool)
323 +
            .await
324 +
            .unwrap_or_default();
325 +
    let presence = d.presence.lock().await;
326 +
    let now = now_ms();
327 +
    let list: Vec<serde_json::Value> = projects
328 +
        .iter()
329 +
        .map(|(name, description)| {
330 +
            serde_json::json!({
331 +
                "name": name,
332 +
                "description": description,
333 +
                "depends_on": links.iter().filter(|(p, _)| p == name).map(|(_, q)| q).collect::<Vec<_>>(),
334 +
                "agents": agents.iter().filter(|(_, _, p)| p == name)
335 +
                    .map(|(a, kind, _)| serde_json::json!({ "name": a, "kind": kind, "presence": presence.state(a, now) }))
336 +
                    .collect::<Vec<_>>(),
337 +
            })
338 +
        })
339 +
        .collect();
340 +
    serde_json::json!({ "owner": owner, "projects": list })
341 +
}
342 +
343 +
fn esc(s: &str) -> String {
344 +
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
345 +
}
346 +
347 +
const HUES: [u32; 8] = [0, 30, 55, 120, 190, 230, 275, 320];
348 +
349 +
fn presence_colour(p: &str) -> &'static str {
350 +
    match p {
351 +
        "online" => "#3fb950",
352 +
        "busy" => "#a371f7",
353 +
        "idle" | "paused" => "#d29922",
354 +
        "blocked" => "#f0883e",
355 +
        _ => "#6e7681",
356 +
    }
357 +
}
358 +
359 +
/// The still picture, for a browser without JavaScript and for the first paint.
360 +
fn still(sky: &serde_json::Value) -> String {
361 +
    let (cx, cy, ring, orbit) = (450.0f64, 320.0f64, 205.0f64, 54.0f64);
362 +
    let projects = sky["projects"].as_array().cloned().unwrap_or_default();
363 +
    let n = projects.len().max(1) as f64;
364 +
    let mut at = std::collections::HashMap::new();
365 +
    let mut nodes = String::new();
366 +
    let mut lines = String::new();
367 +
    for (i, p) in projects.iter().enumerate() {
368 +
        let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / n;
369 +
        let (x, y) = (cx + ring * a.cos(), cy + ring * a.sin());
370 +
        let name = p["name"].as_str().unwrap_or("");
371 +
        at.insert(name.to_string(), (x, y));
372 +
        let hue = HUES[i % HUES.len()];
373 +
        lines.push_str(&format!(r#"<line class="spoke" x1="{cx}" y1="{cy}" x2="{x:.1}" y2="{y:.1}"/>"#));
374 +
        let s = 24.0;
375 +
        nodes.push_str(&format!(
376 +
            r#"<circle class="orbit" cx="{x:.1}" cy="{y:.1}" r="{orbit}"/><polygon class="prism" fill="hsl({hue},80%,60%)" points="{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}"/><text class="plabel" x="{x:.1}" y="{:.1}">{}</text>"#,
377 +
            x,
378 +
            y - s,
379 +
            x + s * 0.87,
380 +
            y + s * 0.5,
381 +
            x - s * 0.87,
382 +
            y + s * 0.5,
383 +
            y + orbit + 22.0,
384 +
            esc(name)
385 +
        ));
386 +
        let agents = p["agents"].as_array().cloned().unwrap_or_default();
387 +
        for (j, ag) in agents.iter().enumerate() {
388 +
            let b = std::f64::consts::TAU * j as f64 / agents.len().max(1) as f64;
389 +
            let (ax, ay) = (x + orbit * b.cos(), y + orbit * b.sin());
390 +
            nodes.push_str(&format!(
391 +
                r#"<circle class="agent" cx="{ax:.1}" cy="{ay:.1}" r="7" fill="{}"/><text class="alabel" x="{ax:.1}" y="{:.1}">{}</text>"#,
392 +
                presence_colour(ag["presence"].as_str().unwrap_or("")),
393 +
                ay + 18.0,
394 +
                esc(ag["name"].as_str().unwrap_or(""))
395 +
            ));
396 +
        }
397 +
    }
398 +
    for p in &projects {
399 +
        let from = at.get(p["name"].as_str().unwrap_or(""));
400 +
        for q in p["depends_on"].as_array().cloned().unwrap_or_default() {
401 +
            if let (Some((x1, y1)), Some((x2, y2))) = (from, at.get(q.as_str().unwrap_or(""))) {
402 +
                lines.push_str(&format!(r#"<path class="link" d="M{x1:.1},{y1:.1} Q{cx},{cy} {x2:.1},{y2:.1}"/>"#));
403 +
            }
404 +
        }
405 +
    }
406 +
    format!(
407 +
        r#"{lines}<circle class="core" cx="{cx}" cy="{cy}" r="22"/><text class="olabel" x="{cx}" y="{:.1}">{}</text>{nodes}"#,
408 +
        cy + 44.0,
409 +
        esc(sky["owner"].as_str().unwrap_or(""))
410 +
    )
411 +
}
412 +
413 +
#[derive(Template)]
414 +
#[template(path = "constellation.html")]
415 +
struct ConstellationPage {
416 +
    shell: Shell,
417 +
    still: String,
418 +
    data: String,
419 +
    empty: bool,
420 +
}
421 +
422 +
async fn constellation(State(st): State<RadarState>, headers: HeaderMap) -> Response {
423 +
    let o = match owner(&st, &headers, "constellation").await {
424 +
        Ok(o) => o,
425 +
        Err(r) => return r,
426 +
    };
427 +
    let sky = sky(&st, &o.principal.display()).await;
428 +
    let empty = sky["projects"].as_array().is_none_or(|a| a.is_empty());
429 +
    // JSON inside a <script> data block: no `<` may close it early.
430 +
    let data = sky.to_string().replace('<', "\\u003c");
431 +
    render(&ConstellationPage { shell: o.shell, still: still(&sky), data, empty })
432 +
}
433 +
434 +
async fn constellation_json(State(st): State<RadarState>, headers: HeaderMap) -> Response {
435 +
    match owner(&st, &headers, "constellation").await {
436 +
        Ok(o) => axum::Json(sky(&st, &o.principal.display()).await).into_response(),
437 +
        Err(r) => r,
438 +
    }
439 +
}
440 +
441 +
// ---- Briefs (spec §12.1) ----
442 +
443 +
struct ProjectBriefRow {
444 +
    name: String,
445 +
    description: String,
446 +
    text: String,
447 +
}
448 +
449 +
#[derive(Template)]
450 +
#[template(path = "briefs.html")]
451 +
struct BriefsPage {
452 +
    shell: Shell,
453 +
    csrf: String,
454 +
    overall: String,
455 +
    projects: Vec<ProjectBriefRow>,
456 +
}
457 +
458 +
async fn briefs(State(st): State<RadarState>, headers: HeaderMap) -> Response {
459 +
    let o = match owner(&st, &headers, "briefs").await {
460 +
        Ok(o) => o,
461 +
        Err(r) => return r,
462 +
    };
463 +
    let pool = st.app.daemon.ledger.pool();
464 +
    let overall = spectra_core::rules::layer(pool, None).await.ok().flatten().unwrap_or_default();
465 +
    let rows: Vec<(String, String, Option<String>)> = sqlx::query_as(
466 +
        "SELECT p.name, p.description, b.text FROM projects p LEFT JOIN briefs b ON b.layer = 'project' AND b.project = p.name ORDER BY p.name",
467 +
    )
468 +
    .fetch_all(pool)
469 +
    .await
470 +
    .unwrap_or_default();
471 +
    let projects =
472 +
        rows.into_iter().map(|(name, description, text)| ProjectBriefRow { name, description, text: text.unwrap_or_default() }).collect();
473 +
    let csrf = o.session.csrf.clone();
474 +
    render(&BriefsPage { shell: o.shell, csrf, overall, projects })
475 +
}
476 +
477 +
#[derive(Deserialize)]
478 +
struct BriefForm {
479 +
    _csrf: String,
480 +
    project: Option<String>,
481 +
    text: String,
482 +
}
483 +
484 +
async fn save_brief(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<BriefForm>) -> Response {
485 +
    let o = match owner(&st, &headers, "briefs").await {
486 +
        Ok(o) => o,
487 +
        Err(r) => return r,
488 +
    };
489 +
    if let Some(r) = check_form(&st, &o, &headers, &f._csrf) {
490 +
        return r;
491 +
    }
492 +
    // Browsers send CRLF from a textarea; the brief is stored as written.
493 +
    let text = f.text.replace("\r\n", "\n");
494 +
    if let Err(e) = spectra_core::rules::check(&text) {
495 +
        return (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response();
496 +
    }
497 +
    let project = f.project.filter(|p| !p.is_empty());
498 +
    let pool = st.app.daemon.ledger.pool();
499 +
    if let Some(p) = &project {
500 +
        let known: Option<String> =
501 +
            sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(p).fetch_optional(pool).await.unwrap_or(None);
502 +
        if known.is_none() {
503 +
            return (StatusCode::NOT_FOUND, "no such project").into_response();
504 +
        }
505 +
    }
506 +
    let Signer::Request(auth) = o.author().signer else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() };
507 +
    let mut e =
508 +
        spectra_core::event::NewEvent::system("brief.set", serde_json::json!({ "project": project, "text": text })).by("human", auth);
509 +
    e.project = project.clone();
510 +
    match st.app.daemon.ledger.append_one(e).await {
511 +
        Ok(_) => {
512 +
            Redirect::to(&format!("/briefs#{}", project.map(|p| format!("p-{p}")).unwrap_or_else(|| "overall".into()))).into_response()
513 +
        }
514 +
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
515 +
    }
516 +
}
517 +
304 518
// ---- A channel and a thread ----
305 519
306 520
struct MessageRow {
…
13 unchanged lines
320 534
    name: String,
321 535
    display: String,
322 536
    purpose: Option<String>,
537 +
    brief: Option<String>,
323 538
    thread: Option<String>,
324 539
    messages: Vec<MessageRow>,
325 540
    can_post: bool,
…
25 unchanged lines
351 566
        None => format!("/c/{}", ch.name),
352 567
    };
353 568
    let csrf = o.session.csrf.clone();
569 +
    let brief = if ch.kind == "project" { spectra_core::rules::layer(d.ledger.pool(), Some(&ch.name)).await.ok().flatten() } else { None };
570 +
    let brief = brief.map(|b| markdown(&b));
354 571
    render(&ChannelPage {
355 572
        shell: o.shell,
356 573
        name: ch.name.clone(),
357 574
        display: ch.display(),
358 575
        purpose: ch.purpose.clone(),
576 +
        brief,
359 577
        thread: thread.map(String::from),
360 578
        messages,
361 579
        can_post,
…
270 unchanged lines
632 850
    let rx = st.app.daemon.ledger.subscribe();
633 851
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|e| async move {
634 852
        let e = e.ok()?;
635 -
        let data = serde_json::json!({ "seq": e.seq, "kind": e.kind, "actor": e.actor, "subject": e.subject });
853 +
        let data = serde_json::json!({ "seq": e.seq, "kind": e.kind, "actor": e.actor, "subject": e.subject, "project": e.project });
636 854
        Some(Ok::<_, std::convert::Infallible>(SseEvent::default().data(data.to_string())))
637 855
    });
638 856
    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response()
…
1 unchanged lines
modified crates/radar/templates/base.html +4 −2
…
17 unchanged lines
18 18
<nav class="band" aria-label="Radar">
19 19
  <a href="/"{% if shell.page == "live" %} class="now"{% endif %}>Live</a>
20 20
  <a href="/channels"{% if shell.page == "channels" %} class="now"{% endif %}>Channels</a>
21 +
  <a href="/constellation"{% if shell.page == "constellation" %} class="now"{% endif %}>Constellation</a>
22 +
  <a href="/briefs"{% if shell.page == "briefs" %} class="now"{% endif %}>Briefs</a>
21 23
  <a href="/log"{% if shell.page == "log" %} class="now"{% endif %}>Log</a>
22 24
</nav>
23 -
<div class="banner">Signed in from the command line (level 2). Answering agents' requests and changing rules will need your passkey, which arrives in 0.2.</div>
24 -
<main id="main" data-live>
25 +
<div class="banner">Signed in from the command line (level 2). Answering agents' requests will need your passkey, which arrives in 0.2.</div>
26 +
<main id="main"{% if shell.page != "constellation" %} data-live{% endif %}>
25 27
{% block content %}{% endblock %}
26 28
</main>
27 29
<footer>Spectra {{ shell.version }} · ledger head #{{ shell.head }}</footer>
…
2 unchanged lines
modified crates/radar/templates/channel.html +1 −0
…
2 unchanged lines
3 3
{% block content %}
4 4
<h1>{{ display }} {% if let Some(t) = thread %}<span class="mute">· thread</span>{% endif %}</h1>
5 5
{% if let Some(p) = purpose %}<p class="mute">{{ p }}</p>{% endif %}
6 +
{% if let Some(b) = brief %}<details class="box brief-view"><summary>Project brief · <a href="/briefs#p-{{ name }}">edit</a></summary><div class="body">{{ b|safe }}</div></details>{% endif %}
6 7
<div class="box">
7 8
{% if messages.is_empty() %}<div class="row mute">No messages yet.</div>{% endif %}
8 9
{% for m in messages %}
…
15 unchanged lines
modified crates/spectra-api/src/lib.rs +25 −0
…
205 unchanged lines
206 206
        ("InboxItem", schemars::schema_for!(InboxItem)),
207 207
        ("Ack", schemars::schema_for!(Ack)),
208 208
        ("Hit", schemars::schema_for!(Hit)),
209 +
        ("Briefs", schemars::schema_for!(Briefs)),
210 +
        ("ProjectBrief", schemars::schema_for!(ProjectBrief)),
211 +
        ("SetBrief", schemars::schema_for!(SetBrief)),
209 212
        ("EventSummary", schemars::schema_for!(EventSummary)),
210 213
        ("Events", schemars::schema_for!(Events)),
211 214
        ("Brief", schemars::schema_for!(Brief)),
212 215
    ]
213 216
}
217 +
218 +
/// `GET /api/v1/briefs`: the overall brief and each project's (spec §12.1).
219 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
220 +
pub struct Briefs {
221 +
    pub overall: Option<String>,
222 +
    pub projects: Vec<ProjectBrief>,
223 +
}
224 +
225 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
226 +
pub struct ProjectBrief {
227 +
    pub project: String,
228 +
    pub text: String,
229 +
}
230 +
231 +
/// `PUT /api/v1/briefs`: set the overall brief, or a project's with
232 +
/// `project`; an empty text clears it.
233 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
234 +
pub struct SetBrief {
235 +
    #[serde(default)]
236 +
    pub project: Option<String>,
237 +
    pub text: String,
238 +
}
modified crates/spectra-api/ts/index.d.ts +15 −0
…
104 unchanged lines
105 105
  thread?: string | null;
106 106
};
107 107
108 +
export type ProjectBrief = {
109 +
  project: string;
110 +
  text: string;
111 +
};
112 +
113 +
export type Briefs = {
114 +
  overall?: string | null;
115 +
  projects: ProjectBrief[];
116 +
};
117 +
118 +
export type SetBrief = {
119 +
  project?: string | null;
120 +
  text: string;
121 +
};
122 +
108 123
export type EventSummary = {
109 124
  actor: string;
110 125
  assurance: number;
…
25 unchanged lines
modified crates/spectra-core/src/daemon.rs +4 −1
…
18 unchanged lines
19 19
/// Kinds the owner's CLI may hand the daemon, already signed over their
20 20
/// payload (spec §8.5, "One writer").
21 21
pub const OWNER_KINDS: &[&str] =
22 -
    &["identity.rotated", "agent.added", "agent.disabled", "project.added", "project.linked", "project.unlinked"];
22 +
    &["identity.rotated", "agent.added", "agent.disabled", "project.added", "project.linked", "project.unlinked", "brief.set"];
23 23
24 24
pub struct Daemon {
25 25
    pub data: PathBuf,
…
150 unchanged lines
176 176
            if n.actor != "human" {
177 177
                bail!("only the owner hands events to the daemon");
178 178
            }
179 +
            if n.kind == "brief.set" {
180 +
                crate::rules::check(n.payload.get("text").and_then(|t| t.as_str()).unwrap_or(""))?;
181 +
            }
179 182
            // Check it as verify will, against who is who including the
180 183
            // batch so far.
181 184
            let probe = probe_event(&n);
…
135 unchanged lines
modified crates/spectra-core/src/projections.rs +1 −0
…
19 unchanged lines
20 20
    "reactions",
21 21
    "inbox",
22 22
    "messages_fts",
23 +
    "briefs",
23 24
];
24 25
25 26
/// Apply one event to every projection.
…
12 unchanged lines
modified crates/spectra-core/src/rules.rs +64 −32
1 -
//! Beacon (spec §12): the house rules an agent receives, compiled from the
2 -
//! layers that exist, each under a heading naming it, with no other change;
3 -
//! the version is the BLAKE3 of that text. 0.1 has the site layer; kind,
4 -
//! project and agent layers, and committing them to the ledger, are 0.3.
1 +
//! Beacon (spec §12): the brief an agent receives, compiled from the
2 +
//! layers the owner has set (overall, then the agent's project), each under
3 +
//! a heading naming it, with no other change; the version is the BLAKE3 of
4 +
//! that text. Briefs are `brief.set` events signed by the owner; nothing is
5 +
//! read from a file.
5 6
6 7
use crate::event::Event;
7 8
use anyhow::{bail, Result};
8 -
use std::path::Path;
9 9
10 -
/// A rule file over this is refused (spec §12.2).
11 -
pub const MAX_RULES: usize = 64 * 1024;
10 +
/// A brief over this is refused (spec §12.1).
11 +
pub const MAX_BRIEF: usize = 64 * 1024;
12 12
13 -
/// Compiled rules and their version.
13 +
/// A compiled brief and its version.
14 14
#[derive(Clone, Debug, PartialEq, Eq)]
15 15
pub struct Rules {
16 16
    pub text: String,
…
1 unchanged lines
18 18
    pub version: String,
19 19
}
20 20
21 -
/// The rules for an agent, from `<data>/rules/`.
22 -
pub fn compile(data: &Path) -> Result<Rules> {
21 +
/// Check a brief's text before it is signed.
22 +
pub fn check(text: &str) -> Result<()> {
23 +
    if text.len() > MAX_BRIEF {
24 +
        bail!("a brief is at most 64 KiB, so an agent can read it in one call ({} bytes)", text.len());
25 +
    }
26 +
    Ok(())
27 +
}
28 +
29 +
/// The text of one layer, if set.
30 +
pub async fn layer(pool: &sqlx::SqlitePool, project: Option<&str>) -> Result<Option<String>> {
31 +
    let (layer, p) = match project {
32 +
        Some(p) => ("project", p),
33 +
        None => ("overall", ""),
34 +
    };
35 +
    Ok(sqlx::query_scalar("SELECT text FROM briefs WHERE layer = ? AND project = ?").bind(layer).bind(p).fetch_optional(pool).await?)
36 +
}
37 +
38 +
/// The brief for an agent in `project` (none for the owner: the overall
39 +
/// brief alone).
40 +
pub async fn compile(pool: &sqlx::SqlitePool, project: Option<&str>) -> Result<Rules> {
23 41
    let mut text = String::new();
24 -
    let site = data.join("rules").join("site.md");
25 -
    if let Ok(body) = std::fs::read_to_string(&site) {
26 -
        if body.len() > MAX_RULES {
27 -
            bail!("{} is over 64 KiB: the rules must stay readable in one call", site.display());
28 -
        }
29 -
        text.push_str("# House rules: site (rules/site.md)\n\n");
42 +
    if let Some(body) = layer(pool, None).await? {
43 +
        text.push_str("# Overall brief\n\n");
30 44
        text.push_str(body.trim_end());
31 45
        text.push('\n');
32 46
    }
47 +
    if let Some(p) = project {
48 +
        if let Some(body) = layer(pool, Some(p)).await? {
49 +
            if !text.is_empty() {
50 +
                text.push('\n');
51 +
            }
52 +
            text.push_str(&format!("# Project brief: {p}\n\n"));
53 +
            text.push_str(body.trim_end());
54 +
            text.push('\n');
55 +
        }
56 +
    }
33 57
    let version = blake3::hash(text.as_bytes()).to_hex()[..12].to_string();
34 58
    Ok(Rules { text, version })
35 59
}
…
5 unchanged lines
41 65
42 66
/// The line every result begins with while an agent works on stale rules.
43 67
pub fn changed_line(read: &str, now: &str) -> String {
44 -
    format!("House rules changed ({read} → {now}): call rules before continuing.")
68 +
    format!("Your brief changed ({read} → {now}): call rules before continuing.")
45 69
}
46 70
47 -
/// Apply `rules.read` and `tool.called` to their tables.
71 +
/// Apply `brief.set`, `rules.read` and `tool.called` to their tables.
48 72
pub async fn project(conn: &mut sqlx::SqliteConnection, e: &Event) -> sqlx::Result<()> {
49 73
    let p = e.json();
50 74
    let agent = e.actor.strip_prefix("agent:");
51 75
    match e.kind.as_str() {
76 +
        // Only the owner sets a brief; an agent's key can never sign as `human`.
77 +
        "brief.set" if e.actor == "human" => {
78 +
            let project = p.get("project").and_then(|v| v.as_str()).unwrap_or("");
79 +
            let layer = if project.is_empty() { "overall" } else { "project" };
80 +
            let text = p.get("text").and_then(|v| v.as_str()).unwrap_or("");
81 +
            if text.trim().is_empty() {
82 +
                sqlx::query("DELETE FROM briefs WHERE layer = ? AND project = ?").bind(layer).bind(project).execute(&mut *conn).await?;
83 +
            } else {
84 +
                sqlx::query("INSERT OR REPLACE INTO briefs (layer, project, text, seq) VALUES (?, ?, ?, ?)")
85 +
                    .bind(layer)
86 +
                    .bind(project)
87 +
                    .bind(text)
88 +
                    .bind(e.seq as i64)
89 +
                    .execute(&mut *conn)
90 +
                    .await?;
91 +
            }
92 +
        }
52 93
        "rules.read" => {
53 94
            if let (Some(a), Some(v)) = (agent, p.get("version").and_then(|v| v.as_str())) {
54 95
                sqlx::query("INSERT INTO rules_read (agent, version, at_ms) VALUES (?, ?, ?) ON CONFLICT(agent) DO UPDATE SET version = excluded.version, at_ms = excluded.at_ms")
…
32 unchanged lines
87 128
    use super::*;
88 129
89 130
    #[test]
90 -
    fn the_version_follows_the_text_exactly() {
91 -
        let dir = tempfile::tempdir().unwrap();
92 -
        let empty = compile(dir.path()).unwrap();
93 -
        assert_eq!(empty.text, "");
94 -
        std::fs::create_dir_all(dir.path().join("rules")).unwrap();
95 -
        std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy.\n").unwrap();
96 -
        let a = compile(dir.path()).unwrap();
97 -
        assert!(a.text.contains("Commit with oxy.") && a.text.starts_with("# House rules: site"));
98 -
        std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy!\n").unwrap();
99 -
        let b = compile(dir.path()).unwrap();
100 -
        assert_ne!(a.version, b.version);
101 -
        assert_eq!(a.version.len(), 12);
102 -
        std::fs::write(dir.path().join("rules/site.md"), "x".repeat(MAX_RULES + 1)).unwrap();
103 -
        assert!(compile(dir.path()).is_err());
131 +
    fn a_brief_must_fit_in_one_call() {
132 +
        assert!(check("Write for a curious twelve-year-old.").is_ok());
133 +
        assert!(check(&"x".repeat(MAX_BRIEF)).is_ok());
134 +
        assert!(check(&"x".repeat(MAX_BRIEF + 1)).is_err());
135 +
        assert!(changed_line("aaa", "bbb").starts_with("Your brief changed (aaa → bbb)"));
104 136
    }
105 137
}
modified crates/spectra-mcp/src/api.rs +62 −1
…
36 unchanged lines
37 37
        .route("/api/v1/search", get(search))
38 38
        .route("/api/v1/events", get(events))
39 39
        .route("/api/v1/admin/verify", get(admin_verify))
40 +
        .route("/api/v1/briefs", get(briefs).put(set_brief))
40 41
        .with_state(state)
41 42
}
42 43
…
106 unchanged lines
149 150
    }
150 151
}
151 152
153 +
/// `GET /api/v1/briefs`: the overall brief and every project's (spec §12.1).
154 +
async fn briefs(
155 +
    State(st): State<AppState>,
156 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
157 +
    headers: HeaderMap,
158 +
    uri: axum::http::Uri,
159 +
) -> Response {
160 +
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
161 +
    let pool = st.daemon.ledger.pool();
162 +
    let overall = spectra_core::rules::layer(pool, None).await.ok().flatten();
163 +
    let rows: Vec<(String, String)> = sqlx::query_as("SELECT project, text FROM briefs WHERE layer = 'project' ORDER BY project")
164 +
        .fetch_all(pool)
165 +
        .await
166 +
        .unwrap_or_default();
167 +
    json_etag(
168 +
        &headers,
169 +
        &api::Briefs { overall, projects: rows.into_iter().map(|(project, text)| api::ProjectBrief { project, text }).collect() },
170 +
    )
171 +
}
172 +
173 +
/// `PUT /api/v1/briefs`: the owner sets a brief; the signed request is the
174 +
/// event's proof.
175 +
async fn set_brief(
176 +
    State(st): State<AppState>,
177 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
178 +
    headers: HeaderMap,
179 +
    uri: axum::http::Uri,
180 +
    body: Bytes,
181 +
) -> Response {
182 +
    let c = who!(st, peer, headers, uri, "PUT", &body, false);
183 +
    if !matches!(c.principal, Principal::Owner { .. }) {
184 +
        return problem(StatusCode::FORBIDDEN, "only the owner sets a brief", None);
185 +
    }
186 +
    let b: api::SetBrief = match serde_json::from_slice(&body) {
187 +
        Ok(b) => b,
188 +
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not a brief", Some(e.to_string())),
189 +
    };
190 +
    if let Err(e) = spectra_core::rules::check(&b.text) {
191 +
        return problem(StatusCode::BAD_REQUEST, "too long", Some(e.to_string()));
192 +
    }
193 +
    let project = b.project.filter(|p| !p.is_empty());
194 +
    if let Some(p) = &project {
195 +
        let known: Option<String> = sqlx::query_scalar("SELECT name FROM projects WHERE name = ?")
196 +
            .bind(p)
197 +
            .fetch_optional(st.daemon.ledger.pool())
198 +
            .await
199 +
            .unwrap_or(None);
200 +
        if known.is_none() {
201 +
            return problem(StatusCode::NOT_FOUND, "no such project", Some(p.clone()));
202 +
        }
203 +
    }
204 +
    let mut e = spectra_core::event::NewEvent::system("brief.set", serde_json::json!({ "project": project, "text": b.text }))
205 +
        .by(c.principal.actor(), c.auth.clone());
206 +
    e.project = project.clone();
207 +
    match st.daemon.ledger.append_one(e).await {
208 +
        Ok(ev) => axum::Json(serde_json::json!({ "seq": ev.seq })).into_response(),
209 +
        Err(e) => bad(e),
210 +
    }
211 +
}
212 +
152 213
async fn health() -> Response {
153 214
    axum::Json(serde_json::json!({ "ok": true })).into_response()
154 215
}
…
255 unchanged lines
410 471
        Principal::Agent { project, .. } => Some(project.clone()),
411 472
        _ => None,
412 473
    };
413 -
    let rules = spectra_core::rules::compile(&st.daemon.data).map(|r| r.version).unwrap_or_default();
474 +
    let rules = spectra_core::rules::compile(st.daemon.ledger.pool(), project.as_deref()).await.map(|r| r.version).unwrap_or_default();
414 475
    let inbox = inbox_items(&st, &c.principal.actor()).await;
415 476
    let b = api::Brief {
416 477
        you: c.principal.display(),
…
125 unchanged lines
modified crates/spectra-mcp/src/http.rs +1 −1
…
225 unchanged lines
226 226
/// the unread count.
227 227
pub async fn meta(ctx: &Ctx) -> Json {
228 228
    let mut m = serde_json::Map::new();
229 -
    if let Ok(r) = spectra_core::rules::compile(&ctx.daemon.data) {
229 +
    if let Ok(r) = spectra_core::rules::compile(ctx.daemon.ledger.pool(), crate::tools::project_of(&ctx.caller.principal)).await {
230 230
        m.insert("spectra/rules_version".into(), json!(r.version));
231 231
    }
232 232
    if let Some(s) = &ctx.session {
…
33 unchanged lines
modified crates/spectra-mcp/src/tools.rs +21 −9
…
109 unchanged lines
110 110
    let Principal::Agent { name, .. } = &ctx.caller.principal else { return out };
111 111
    if tool.name != "rules" && tool.name != "brief" {
112 112
        // With no house rules there is nothing to read, so nothing to say.
113 -
        if let Some(rules) = spectra_core::rules::compile(&ctx.daemon.data).ok().filter(|r| !r.text.is_empty()) {
113 +
        if let Some(rules) = spectra_core::rules::compile(ctx.daemon.ledger.pool(), project_of(&ctx.caller.principal))
114 +
            .await
115 +
            .ok()
116 +
            .filter(|r| !r.text.is_empty())
117 +
        {
114 118
            let read = spectra_core::rules::last_read(ctx.daemon.ledger.pool(), name).await.ok().flatten();
115 119
            if read.as_deref() != Some(rules.version.as_str()) {
116 120
                let from = read.unwrap_or_else(|| "none".into());
…
22 unchanged lines
139 143
static TOOLS: &[Tool] = &[
140 144
    Tool {
141 145
        name: "brief",
142 -
        description: "Call first: who you are, your project, the house rules, your unread inbox, and who else is here.",
146 +
        description: "Call first: who you are, your project, your brief, your unread inbox, and who else is here.",
143 147
        schema: || obj(json!({ "new_session": { "type": "boolean" } }), &[]),
144 148
        read_only: true,
145 149
        destructive: false,
…
2 unchanged lines
148 152
    },
149 153
    Tool {
150 154
        name: "rules",
151 -
        description: "The house rules and their version, in full.",
155 +
        description: "Your brief (overall and your project's) and its version, in full.",
152 156
        schema: || obj(json!({}), &[]),
153 157
        read_only: true,
154 158
        destructive: false,
…
132 unchanged lines
287 291
    let _ = args;
288 292
    Box::pin(async move {
289 293
        let d = &ctx.daemon;
290 -
        let rules = match spectra_core::rules::compile(&d.data) {
294 +
        let rules = match spectra_core::rules::compile(d.ledger.pool(), project_of(&ctx.caller.principal)).await {
291 295
            Ok(r) => r,
292 296
            Err(e) => return fail(e),
293 297
        };
…
31 unchanged lines
325 329
        if let Some(sess) = &ctx.session {
326 330
            text.push_str(&format!(" Session {sess}."));
327 331
        }
328 -
        text.push_str(&format!("\n\nHouse rules (version {}):\n", rules.version));
329 -
        text.push_str(if rules.text.is_empty() { "(none yet)\n" } else { &rules.text });
332 +
        text.push_str(&format!("\n\nYour brief (version {}):\n", rules.version));
333 +
        text.push_str(if rules.text.is_empty() { "(none set yet)\n" } else { &rules.text });
330 334
        text.push_str(&format!("\nInbox: {} unread.\n", items.len()));
331 335
        for i in &items {
332 336
            text.push_str(&format!("- {} from {} in {} ({}): {}\n", i.kind, label(d, &i.from).await, i.channel, i.id, i.summary));
…
10 unchanged lines
343 347
344 348
fn rules(ctx: &Ctx, _args: Json) -> BoxFuture<'_, Output> {
345 349
    Box::pin(async move {
346 -
        match spectra_core::rules::compile(&ctx.daemon.data) {
350 +
        match spectra_core::rules::compile(ctx.daemon.ledger.pool(), project_of(&ctx.caller.principal)).await {
347 351
            Ok(r) => {
348 352
                let _ = ctx.daemon.ledger.append_one(ctx.event("rules.read", json!({ "version": r.version }))).await;
349 -
                let body = if r.text.is_empty() { "(no house rules yet)".to_string() } else { r.text.clone() };
350 -
                Output::ok(format!("House rules, version {}:\n\n{body}", r.version), json!({ "version": r.version, "text": r.text }))
353 +
                let body = if r.text.is_empty() { "(no brief set yet)".to_string() } else { r.text.clone() };
354 +
                Output::ok(format!("Your brief, version {}:\n\n{body}", r.version), json!({ "version": r.version, "text": r.text }))
351 355
            }
352 356
            Err(e) => fail(e),
353 357
        }
…
262 unchanged lines
616 620
            Err(e) => fail(e),
617 621
        }
618 622
    })
623 +
}
624 +
625 +
/// The project whose brief applies to `p`: an agent's own; none for the owner.
626 +
pub fn project_of(p: &Principal) -> Option<&str> {
627 +
    match p {
628 +
        Principal::Agent { project, .. } => Some(project.as_str()),
629 +
        _ => None,
630 +
    }
619 631
}
modified docs/plan.md +9 −1
1 1
# Sprint plan: Spectra 0.1, Talk
2 2
3 -
Each loop iteration: pick the first unchecked item, implement it with tests, run the gated checks, commit with `oxy`, tick the box here with a one-line note, and commit again. Update `docs/spec.md` (and its changelog) whenever behaviour the spec describes changes. If an item cannot be finished in two iterations, note why under it and move on. The issues are on https://oxygenhub.io/projects/andymccall/7. Sprint 1 built 0.1 on one machine; Sprint 2 made it client and server (spec §8.6), since Andy runs the daemon on Earth and everything else on his workstation; Sprint 3 makes Spectra its own program, with nothing of Oxygen in it (spec Draft 0.5).
3 +
Each loop iteration: pick the first unchecked item, implement it with tests, run the gated checks, commit with `oxy`, tick the box here with a one-line note, and commit again. Update `docs/spec.md` (and its changelog) whenever behaviour the spec describes changes. If an item cannot be finished in two iterations, note why under it and move on. The issues are on https://oxygenhub.io/projects/andymccall/7. Sprint 1 built 0.1 on one machine; Sprint 2 made it client and server (spec §8.6), since Andy runs the daemon on Earth and everything else on his workstation; Sprint 3 made Spectra its own program, with nothing of Oxygen in it (spec Draft 0.5); Sprint 4 makes it just work from VS Code, with briefs and the Constellation (spec Draft 0.6).
4 4
5 5
## Rules
6 6
…
48 unchanged lines
55 55
  8. `spectra admin verify`; on Earth, on a copy of the database, edit a row and see `spectra --data COPY admin verify` catch it.
56 56
  9. Cut 0.1.0 in CHANGELOG.md and tag it.
57 57
58 +
## Sprint 4: it just works
59 +
60 +
- [x] #29 Briefs — done 2026-11-03: brief.set events (overall and per project), compiled per agent with the change line, spectra brief show|set|edit, GET/PUT /api/v1/briefs, Radar's Briefs page and the brief on a project's channel; tests/briefs.rs.
61 +
- [x] #30 Constellation — done 2026-11-03: /constellation with a still SVG and constellation.js animating the stream (beams, pulses, flares, orbits by presence); checked in Chrome on a demo with five agents chattering.
62 +
- [ ] #31 spectra bridge and spectra probe, and --json for setup.
63 +
- [ ] spectra-vscode#6 0.1 Just works: the walkthrough, the bridge client, chat, briefs.
64 +
- [ ] #32 Acceptance: a new project set up and chatting from VS Code alone.
65 +
58 66
## Log
59 67
60 68
- 2026-11-02 plan created; sprint adjusted to spec Draft 0.3 (identities and the ledger).
…
7 unchanged lines
modified docs/spec.md +32 −23
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.5 |
5 +
| **Status** | Draft 0.6 |
6 6
| **Date** | 2026-11-03 |
7 7
| **Scope** | One Rust binary, `spectra`, that AI agents connect to over the Model Context Protocol (MCP), from that machine or from others over TLS (§8.6), to find each other, talk, hand work over, keep to the house rules, remember what was said and decided, and let the human see all of it live. The hosted, multi-person, multi-machine layer is SpectraHub (`../spectrahub/docs/spec.md`). |
8 8
| **Companion documents** | `../spectrahub/docs/spec.md`, `../spectra-vscode/docs/spec.md`, `../spectrahub-vscode/docs/spec.md` |
…
409 unchanged lines
418 418
| `identity.owner`, `identity.rotated`, `identity.passkey`, `identity.instance`, `identity.revoked` | an identity object, its predecessor, a passkey credential, a revoke attestation | `spectra init`, `spectra identity refresh`, Radar |
419 419
| `agent.added`, `agent.disabled`, `agent.presence`, `agent.status`, `agent.session_started`, `agent.session_ended` | agent id, identity, owner, delegation, state, text, task | owner, Tuner, presence timer |
420 420
| `ledger.checkpoint`, `ledger.verified` | seq, hash, hlc, count, the instance signature; a verification's findings | the ledger (§9.5) |
421 -
| `rules.committed` | layer, version, text hash | owner (§12.4) |
421 +
| `brief.set` | layer, project?, text | owner (§12.4) |
422 422
| `ref.verified` | a referenced change's hash, signer, author, status | §23.5 |
423 423
| `policy.weakened`, `policy.restored` | class, level | owner |
424 424
| `project.added`, `project.updated`, `project.relation` | name, path, remote, depends_on | owner, CLI |
…
150 unchanged lines
575 575
576 576
`human(title, body, options?)` creates a task of kind `human` assigned to the owner, posts it in `#humans`, and marks the agent `blocked`. It appears at the top of Radar, is relayed to the owner's phone when a relay is configured (§14.2, §16.2), and is answered by the owner with text or one of the offered options, which completes the task with that answer as the result. `wait` returns the answer to the agent as soon as it is given. A `human` task's default TTL is 8 hours.
577 577
578 -
## 12. Beacon: house rules
578 +
## 12. Beacon: briefs and house rules
579 579
580 -
### 12.1 Layers
580 +
### 12.1 Briefs
581 581
582 -
Rules are Markdown files, edited by the owner (in an editor, or in Radar's rules page which writes the file). The layers that apply to an agent, in order:
582 +
A **brief** is what the owner tells the agents: the goal, the conventions, what never to touch. It is text in the ledger, never a file, so nothing an agent can write on disk changes what other agents are told. The layers that apply to an agent, in order:
583 583
584 -
1. **site**: `rules/site.md`
585 -
2. **kind**: `rules/kinds/<kind>.md`
586 -
3. **project**: `<project>/.spectra/rules.md`, else `rules/projects/<name>.md`
587 -
4. **agent**: `rules/agents/<name>.md` (rare; a standing instruction for one agent)
584 +
1. **overall**: the brief for every channel and every agent;
585 +
2. **kind**: for every agent of one kind (`claude-code`, …), 0.3;
586 +
3. **project**: the brief of the agent's project, shown as its channel's purpose too;
587 +
4. **agent**: a standing instruction for one agent, 0.3.
588 588
589 +
The owner sets a layer with `spectra brief set [--project NAME] (TEXT | --file FILE)`, `spectra brief edit [--project NAME]` (opens `$EDITOR`), Radar's brief editors (the overall brief on Live, a project's on its channel page), or the VS Code extension. Each change is a `brief.set { layer, project?, text }` event signed by the owner, so a brief's provenance is the ledger itself; an empty text clears the layer. A brief over 64 KiB is refused: an agent's context is finite and the brief must stay readable in one call.
590 +
589 591
### 12.2 Compilation
590 -
591 -
The **compiled rules** for an agent are the layers that exist, each under a heading naming its layer and file, concatenated in that order, with no other change. The **rules version** is the BLAKE3 of the compiled text, shown as 12 hex characters. Spectra never summarises, reorders or filters rule text. A rule file over 64 KiB is refused with an error in Radar (an agent's context is finite; the rules must stay readable in one call).
592 592
593 -
A file watcher on the rules directories and on each project's `.spectra/rules.md` emits `rules.changed` within a second of a save; the version is recomputed per agent, since the layers differ.
593 +
The **compiled brief** for an agent is the layers that are set, each under a heading naming it (`# Overall brief`, `# Project brief: oxygen`), concatenated in that order, with no other change. Its **version** is the BLAKE3 of the compiled text, shown as 12 hex characters. Spectra never summarises, reorders or filters brief text. Because the layers differ, each agent's version is its own; changing a project's brief changes the version only for that project's agents.
594 594
595 595
### 12.3 Delivery
596 596
597 -
- `brief` returns the compiled rules in full, with the version.
598 -
- `rules` returns them again, or `--diff SINCE_VERSION` returns a unified diff from a version the agent has seen (versions are kept, so the diff is exact).
599 -
- **Every tool result** carries `_meta["spectra/rules_version"]`. When it differs from the version this agent last read (`rules.read` events), the result's text begins with one line: `House rules changed (abc123 → def456): call rules(diff: "abc123") before continuing.` The line is repeated on every call until the agent reads the rules. That is the whole of the "anti-telephone" mechanism, and it is honest: Spectra cannot put text into an agent's turn, but it can make sure the next thing the agent reads says so.
600 -
- `spectra hook brief` (§14.3) prints the compiled rules and the inbox for hook-based injection on clients that support hooks, so the rules reach the agent on every prompt without a tool call.
601 -
- `spectra rules export --format claude-md|agents-md|cursor` writes the compiled rules for a project into the file that client reads at start (`CLAUDE.md` with a marked section, `AGENTS.md`, `.cursor/rules/spectra.mdc`), so a client that reads nothing else still starts with them. The marked section is replaced on each export; text outside it is kept.
597 +
- `brief` returns the compiled brief in full, with the version.
598 +
- `rules` returns it again, or `--diff SINCE_VERSION` returns a unified diff from a version the agent has seen (versions are kept, so the diff is exact).
599 +
- **Every tool result** carries `_meta["spectra/rules_version"]`. When it differs from the version this agent last read (`rules.read` events), the result's text begins with one line: `Your brief changed (abc123 → def456): call rules(diff: "abc123") before continuing.` The line is repeated on every call until the agent reads the rules. That is the whole of the "anti-telephone" mechanism, and it is honest: Spectra cannot put text into an agent's turn, but it can make sure the next thing the agent reads says so.
600 +
- `spectra hook brief` (§14.3) prints the compiled brief and the inbox for hook-based injection on clients that support hooks, so the rules reach the agent on every prompt without a tool call.
601 +
- `spectra rules export --format claude-md|agents-md|cursor` writes the compiled brief for a project into the file that client reads at start (`CLAUDE.md` with a marked section, `AGENTS.md`, `.cursor/rules/spectra.mdc`), so a client that reads nothing else still starts with them. The marked section is replaced on each export; text outside it is kept.
602 602
603 -
### 12.4 Provenance of rules
603 +
### 12.4 Provenance of briefs
604 604
605 -
A rule file on disk can be edited by anything running as the owner's user, an agent included, and the file watcher cannot say who. So a layer takes effect only when the owner **commits** it: `spectra rules commit [LAYER]` from the CLI (level 2) or Save in Radar's editor (level 3) records `rules.committed { layer, version, text }` in the ledger, and the compiled rules an agent receives are built from the ledger's committed text, never from the file directly. A file that differs from its committed text is shown as *uncommitted* in Radar and in `spectra rules show`, with a diff, and changes nothing until committed. The ledger entry, signed by the owner, is the rule's provenance; where the file also lives in version control, that history is a second record Spectra neither needs nor reads. The policy class for committing is `rules` (§8.5), level 3 by default.
605 +
Briefs live only in the ledger, as `brief.set` events signed by the owner (policy class `rules`, §8.5), so who set every word and when is part of the verified history, and no file an agent could edit takes part. Earlier versions are kept, so `rules(diff: …)` is exact and Radar shows any two versions side by side.
606 606
607 -
### 12.5 What rules are for
607 +
### 12.5 What briefs are for
608 608
609 -
Rules are text for the model. Spectra enforces nothing in them, and the spec says so plainly so nobody designs around an enforcement that is not there. What Spectra does enforce is in §8.4 (scopes), §8.5 (assurance), §11 (lifecycle), §20 (guard rails) and §21. The site rules SHOULD state the conventions every Oxygen project shares today: changelog lines, commit with `oxy`, hermetic tests, no `git init`, which resources to claim, when to ask the human.
609 +
Briefs are text for the model. Spectra enforces nothing in them, and the spec says so plainly so nobody designs around an enforcement that is not there. What Spectra does enforce is in §8.4 (scopes), §8.5 (assurance), §11 (lifecycle), §20 (guard rails) and §21. The overall brief SHOULD state what every agent needs: the goal of the work, the shared conventions, which resources to claim, when to ask the human.
610 610
611 611
## 13. Echo: memory and search
612 612
…
197 unchanged lines
810 810
| `/tasks/graph` | Tasks and their `blocked_on` edges as a graph, per project, with states coloured |
811 811
| `/agents`, `/agents/<name>` | Agents with their owner, key, delegation scope and expiry, and scopes; an agent's page: sessions, its tool calls with arguments and results (the observability page: every payload, searchable, with reads folded), its messages, tasks and claims; buttons to **pause** (its token is refused with a message naming the pause until unpaused; its presence shows `paused`) and to disable |
812 812
| `/decisions` | Decisions per project, newest first, superseded ones struck |
813 -
| `/rules` | The layers as files, with their versions; an editor that writes the file (CodeMirror 6, vendored as the hub does; degrades to a `textarea`); which agents are on which version; the diff between any two versions |
813 +
| `/briefs` | The overall brief and every project's, each with an editor that signs a `brief.set` (a `textarea`; CodeMirror later); which agents are on which version; the diff between any two versions |
814 814
| `/schedules` | Schedules with next and last firing; create, disable, delete |
815 +
| `/constellation` | **Constellation**: the live picture. Projects are prisms on a ring, linked by their dependencies; each agent orbits its project as a light coloured by presence; the owner sits at the centre. Every message fires a beam of spectrum colour from its author to its channel's prism (to the centre for the owner's inbox), every tool call a faint pulse on its agent, and a guard trip a red flare. Built from the SSE stream with SVG and plain script under the page's CSP; no library, no data in the stream beyond ids and kinds, and a still picture without JavaScript. |
815 816
| `/log` | The ledger, filterable by kind, actor, project, time; every event expandable to its payload, hash and proof; the head's hash and the last verification at the top; `?follow=1` tails it |
816 817
| `/search` | Echo's search as a page, with the same filters |
817 818
| `/settings` | The owner's identity and passkey, the instance key, projects (name, description, optional folder, and links between them), hooks and the relay, retention, the policy (§8.5) |
…
11 unchanged lines
829 830
`/api/v1`, signed requests or a passkey session (§8.5; a bearer token only where tokens are allowed), `application/json`, RFC 7807 problems, cursor pagination (`?after=`), ETags on cacheable resources. Resources mirror the tools and pages: `/brief`, `/rules` (`PUT` by the owner), `/agents` (`POST`, `PATCH …/pause`, `DELETE` by the owner), `/projects`, `/channels`, `/channels/{name}/messages` (`GET`, `POST`), `/threads/{id}`, `/inbox`, `/tasks` (`GET`, `POST`), `/tasks/{id}` and `/tasks/{id}/{transition}`, `/claims`, `/decisions`, `/search`, `/schedules`, `/events` (`?after=SEQ&kind=&actor=&project=&limit=`, and `?follow=1` for the SSE stream, which sends `{seq, id, kind, actor, subject}` per event and a keep-alive every 15 s), `/health`, `/version`.
830 831
831 832
`crates/spectra-api` holds the types with `serde` and JSON Schema derives; `make ts` writes `crates/spectra-api/ts/index.d.ts` for the extensions, as `hub-api` does. SpectraHub links the same crate so a Spectra and a Hub speak one shape.
833 +
834 +
### 18.1 The bridge: signing for other programs
835 +
836 +
`spectra bridge [--port 0]` serves `http://127.0.0.1:PORT` for one local program (the VS Code extension, a script). It prints one line of JSON, `{ "url": …, "token": … }`, then forwards every request carrying `Authorization: Bearer TOKEN` to the daemon (the local socket, or the configured server over pinned TLS), signed with the owner's key, and passes the response back, streams included. The token is random per run and the bridge answers only loopback `Host` and no `Origin`, so a web page cannot use it; it exits when its standard input closes, so it never outlives the program that started it. `spectra probe URL --json` says what a server is (name, instance key, owner or the key it waits for) without trusting it, so a program can show the owner the key before `spectra connect URL --expect KEY`.
832 837
833 838
## 19. Command-line interface
834 839
…
6 unchanged lines
841 846
spectra identity create --name NAME [--contact TEXT]               the owner's own identity (§8.0)
842 847
spectra init [--owner-home DIR] | --owner KEYID                     this machine's Spectra, or a server's for its owner to claim (§8.6)
843 848
spectra connect https://HOST:PORT [--expect KEYID-PREFIX] [--yes]  use a Spectra on another machine (§8.6)
849 +
spectra probe https://HOST:PORT [--json]                           what a server is, without trusting it (§18.1)
850 +
spectra bridge [--port 0]                                          a loopback proxy that signs as the owner, for the editor (§18.1)
851 +
spectra brief show [--project P] | set [--project P] TEXT|--file F | edit [--project P]   the briefs (§12.1)
844 852
spectra identity show | refresh                                     the owner's identity as the ledger knows it; record a missed successor
845 853
spectra agent add NAME --kind KIND --project NAME --home DIR [--expires 8h] [--yes] [--uid N] [--scope S..] | renew NAME --expires D | list | pause | unpause | disable
846 854
spectra project add NAME-OR-FOLDER [--path DIR] [-d TEXT] [--url URL] | link P --depends-on Q [--remove] | list
…
183 unchanged lines
1030 1038
8. **Bringing an identity from elsewhere.** Spectra's identities are its own. Someone who already has keys (an SSH key, a signing key from another tool) might want to use them; a statement signed by both keys could link them. Not planned; recorded so the answer is not "impossible" by accident.
1031 1039
9. **The owner's CLI key.** Level 2 from the CLI means an agent under the owner's user could sign as the owner for chat and tasks. A hardware-backed key for the owner (a YubiKey through PKCS#11, or the platform keystore) would raise the CLI to level 3; so would running agents under their own users (§21.6). Decide after living with the passkey in 0.2.
1032 1040
10. **Where checkpoints are witnessed.** `[witness] url` posts checkpoints to an append-only log, but which logs to support (a SpectraHub, a plain file on a second machine over SSH, a public transparency log) is open. Not before 0.5.
1033 -
11. **How spectra-vscode signs.** The API takes signed requests (§8.5), and an extension in TypeScript has no ML-DSA-65 of its own. Recommended: the extension runs `spectra` as a signing proxy over the socket (a `spectra api METHOD PATH` command that signs with the owner's key and prints the JSON), so no key ever leaves Rust; the alternative, a WebAssembly build of `oxy-core`'s signing, is heavier. Decide before spectra-vscode 0.1.
1041 +
11. **How spectra-vscode signs.** Settled in Draft 0.6 (§18.1): the extension runs `spectra bridge`, a loopback proxy that signs as the owner, so no key leaves Rust and the stream works unchanged.
1034 1042
1035 1043
## 27. Changelog
1036 1044
1045 +
- Draft 0.6 (2026-11-03), Sprint 4: briefs, an overall one and one per project, are signed `brief.set` events in the ledger instead of files, delivered and versioned as the house rules were (§12); Radar's Constellation page (§17.2); `spectra bridge` and `spectra probe` let the VS Code extension sign as the owner and set Spectra up without the command line (§18.1, open question 11 settled).
1037 1046
- Draft 0.5 (2026-11-03), Spectra stands alone: identities, composite signatures and deterministic CBOR are Spectra's own (§8.0), designed after Oxygen's but sharing no code, file or command; `spectra identity create` makes the owner's identity, `spectra agent add` and `spectra connect` sign the delegation themselves after the owner confirms; a project is any kind of work with an optional folder that Spectra never reads, linked by the owner (§23.1); webhooks are signed with HMAC-SHA256 and checkpoints are witnessed by a log of the owner's choosing; nothing requires Oxygen, OxygenHub or version control (§2.2, §23).
1038 1047
- Draft 0.4 (2026-11-03), Sprint 2: client and server (§8.6): the daemon serves other machines over TLS with a certificate signed by its instance key, so no CA is needed; `spectra connect` accepts a server only when the owner's own identity delegates to its instance key, and pins its certificate; `spectra init --owner KEYID` sets a server up without the owner's secret key, which claims it from the client; a client has no ledger file and works through the JSON API; an agent bound to a uid is refused over TCP; Radar from a client goes through a loopback proxy; 0.1's acceptance runs across two machines (§25).
1039 1048
- Draft 0.3c (2026-11-02), while building #10: a Radar session's events carry the method `session` (assurance 2) and, as proof, the owner's signed request for the sign-in link, which `verify` checks against the owner (§8.3); every ledger write runs to completion in a task of its own, so a caller that goes away cannot leave a transaction open (§9.5); Radar's templates and assets live in the `radar` crate, so SpectraHub mounts it whole (§7.3).
…
5 unchanged lines
modified src/cli/mod.rs +45 −0
…
3 unchanged lines
4 4
5 5
pub mod admin;
6 6
pub mod agent;
7 +
pub mod brief;
7 8
pub mod connect;
8 9
pub mod format;
9 10
pub mod human;
…
60 unchanged lines
70 71
        #[arg(long)]
71 72
        owner_home: Option<PathBuf>,
72 73
    },
74 +
    /// What the agents are told: the overall brief and each project's
75 +
    Brief {
76 +
        #[command(subcommand)]
77 +
        command: BriefCmd,
78 +
    },
73 79
    /// The owner's and the instance's identities: show, refresh
74 80
    Identity {
75 81
        #[command(subcommand)]
…
125 unchanged lines
201 207
    },
202 208
    /// Record the owner's current identity if the ledger has missed a successor
203 209
    Refresh {
210 +
        #[arg(long)]
211 +
        owner_home: Option<PathBuf>,
212 +
    },
213 +
}
214 +
215 +
#[derive(Subcommand)]
216 +
pub enum BriefCmd {
217 +
    /// Show the overall brief, or a project's
218 +
    Show {
219 +
        #[arg(long)]
220 +
        project: Option<String>,
221 +
        #[arg(long)]
222 +
        json: bool,
223 +
    },
224 +
    /// Set the overall brief, or a project's (an empty text clears it)
225 +
    Set {
226 +
        #[arg(long)]
227 +
        project: Option<String>,
228 +
        /// The text
229 +
        text: Option<String>,
230 +
        /// Read the text from a file (- for standard input)
231 +
        #[arg(long, short = 'f')]
232 +
        file: Option<PathBuf>,
204 233
        #[arg(long)]
205 234
        owner_home: Option<PathBuf>,
206 235
    },
236 +
    /// Edit it in $VISUAL or $EDITOR
237 +
    Edit {
238 +
        #[arg(long)]
239 +
        project: Option<String>,
240 +
        #[arg(long)]
241 +
        owner_home: Option<PathBuf>,
242 +
    },
207 243
}
208 244
209 245
#[derive(Subcommand)]
…
320 unchanged lines
530 566
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
531 567
    match cli.command {
532 568
        Command::Version { json } => version(json),
569 +
        Command::Brief { command } => runtime()?.block_on(async {
570 +
            match command {
571 +
                BriefCmd::Show { project, json } => brief::show(&ctx, project, json).await,
572 +
                BriefCmd::Set { project, text, file, owner_home } => {
573 +
                    brief::set(&ctx, project, brief::text_of(text, file)?, owner_home).await
574 +
                }
575 +
                BriefCmd::Edit { project, owner_home } => brief::edit(&ctx, project, owner_home).await,
576 +
            }
577 +
        }),
533 578
        Command::Connect { url, owner_home, expect, yes } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home, expect, yes)),
534 579
        Command::Init { owner_home, name, owner: Some(key) } => {
535 580
            let _ = owner_home;
…
77 unchanged lines
modified tests/radar.rs +20 −0
…
73 unchanged lines
74 74
    assert_eq!(web.send("POST", "/c/humans", &no_origin, form(&csrf)).await.unwrap().status, 303);
75 75
    assert!(get("/c/humans").await.contains("Looks good"));
76 76
77 +
    // Briefs: set from Radar, signed by the session, delivered to the agent.
78 +
    assert!(get("/briefs").await.contains("Overall brief"));
79 +
    let brief = format!("_csrf={csrf}&project=oxygen&text=Freeze+the+protocol.%0D%0AThen+ship.").into_bytes();
80 +
    assert_eq!(web.send("POST", "/briefs", &no_origin, brief).await.unwrap().status, 303);
81 +
    let briefs = get("/briefs").await;
82 +
    assert!(briefs.contains("Freeze the protocol.\nThen ship."), "CRLF from the form is stored as LF: {briefs}");
83 +
    assert!(get("/c/oxygen").await.contains("Project brief"));
84 +
    let rules = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "rules", "arguments": {} } });
85 +
    let got = spectra_mcp::client::rpc_result(&a.mcp("2025-11-25", &rules).await.unwrap()).unwrap();
86 +
    assert!(got["result"]["content"][0]["text"].as_str().unwrap().contains("Freeze the protocol."), "{got}");
87 +
    assert_eq!(web.send("POST", "/briefs", &no_origin, b"_csrf=stale&text=x".to_vec()).await.unwrap().status, 403);
88 +
89 +
    // The Constellation: a still picture of the project and its agent, and its data.
90 +
    let sky = get("/constellation").await;
91 +
    assert!(sky.contains("class=\"prism\"") && sky.contains(">claude-oxygen</text>") && sky.contains("/radar/constellation.js"), "{sky}");
92 +
    assert!(!sky.contains("data-live"), "the picture is not replaced wholesale by the page refresher");
93 +
    let data: serde_json::Value = serde_json::from_str(&get("/radar/constellation.json").await).unwrap();
94 +
    assert_eq!(data["projects"][0]["agents"][0]["name"], "claude-oxygen", "{data}");
95 +
    assert_eq!(web.send("GET", "/radar/constellation.json", &[], vec![]).await.unwrap().status, 401, "the owner's only");
96 +
77 97
    assert_eq!(web.send("POST", "/log/verify", &no_origin, format!("_csrf={csrf}").into_bytes()).await.unwrap().status, 303);
78 98
    assert!(get("/log").await.contains("clean at"));
79 99
    srv.stop();
…
10 unchanged lines
modified tests/tools.rs +19 −8
…
26 unchanged lines
27 27
async fn two_agents_talk_and_every_call_is_on_the_record() {
28 28
    let dir = setup();
29 29
    let d = dir.path();
30 -
    std::fs::create_dir_all(d.join("data/rules")).unwrap();
31 -
    std::fs::write(d.join("data/rules/site.md"), "Commit with oxy, never git.\n").unwrap();
30 +
    let o = run(d, &["brief", "set", "Keep a changelog."]);
31 +
    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
32 32
    let claude: KeyPair = add_agent(d, "claude-oxygen", &[]);
33 33
    let kimi: KeyPair = add_agent(d, "kimi-oxygen", &[]);
34 34
    let srv = Serving::start(d);
…
1 unchanged lines
36 36
37 37
    let b = call(&c, "brief", json!({})).await;
38 38
    let t = text(&b);
39 -
    assert!(t.contains("You are claude-oxygen · Andy McCall's agent") && t.contains("Commit with oxy, never git."), "{t}");
39 +
    assert!(
40 +
        t.contains("You are claude-oxygen · Andy McCall's agent") && t.contains("# Overall brief") && t.contains("Keep a changelog."),
41 +
        "{t}"
42 +
    );
40 43
    assert!(t.contains("kimi-oxygen · Andy McCall's agent"), "{t}");
41 44
    let version = b["structuredContent"]["rules"]["version"].as_str().unwrap().to_string();
42 45
    call(&k, "brief", json!({})).await;
…
29 unchanged lines
72 75
    let a = call(&k, "agents", json!({})).await;
73 76
    assert!(text(&a).contains("busy — \"reviewing the field\""), "{}", text(&a));
74 77
75 -
    // The rules change: the next result says so until `rules` is called.
76 -
    std::fs::write(d.join("data/rules/site.md"), "Commit with oxy, never git. Add a changelog line.\n").unwrap();
78 +
    // The owner gives the project a brief while the daemon serves: the next
79 +
    // result says so until `rules` is called, and then carries both layers.
80 +
    let o = run(d, &["brief", "set", "--project", "oxygen", "The wire protocol is frozen until Friday."]);
81 +
    assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
77 82
    let r = call(&c, "inbox", json!({})).await;
78 -
    assert!(text(&r).starts_with(&format!("House rules changed ({version} → ")), "{}", text(&r));
83 +
    assert!(text(&r).starts_with(&format!("Your brief changed ({version} → ")), "{}", text(&r));
79 84
    let rr = call(&c, "rules", json!({})).await;
80 -
    assert!(text(&rr).contains("Add a changelog line."));
81 -
    assert!(!text(&call(&c, "inbox", json!({})).await).starts_with("House rules changed"));
85 +
    assert!(
86 +
        text(&rr).contains("Keep a changelog.")
87 +
            && text(&rr).contains("# Project brief: oxygen")
88 +
            && text(&rr).contains("frozen until Friday"),
89 +
        "{}",
90 +
        text(&rr)
91 +
    );
92 +
    assert!(!text(&call(&c, "inbox", json!({})).await).starts_with("Your brief changed"));
82 93
83 94
    // Redaction and a refusal come back as results, never protocol errors.
84 95
    let mine = call(&c, "say", json!({ "body": "oops, a secret" })).await["structuredContent"]["id"].as_str().unwrap().to_string();
…
52 unchanged lines