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('&', "&").replace('<', "<").replace('>', ">").replace('"', """) |
|
| 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 |